machbus
machbus is a Rust library for building agricultural CAN and ISOBUS-style applications. It includes protocol codecs, protocol state machines, helper surfaces, examples, and C/Python bindings.
It is tested locally by a large suite of unit, fixture, integration, binding, trace, and fuzz-smoke checks. In plain words: machbus is not certified. It ships with no ISO, SAE, NMEA, or AEF certification. Certification and deployment approval still require the official standards, real hardware, and interoperability evidence.
Fast path
If you are new to the project, read in this order:
First, read conformity boundary material so every API example is interpreted inside the correct evidence and certification limits. Then learn ISOBUS basics, build your first node, and pick tutorial material for the role you are building.
- Claim boundary
- The standards, end to end — the story of how ISOBUS works, or ISOBUS in plain words for a quicker primer.
- Build and verify
- The session facade — the recommended API for new code.
- Pick the tutorial for the role you are building.
The application surface
machbus gives you one application surface over the protocol codecs:
the session facade (machbus::session). You compose
a node from plugins, drive a pure sans-IO core, and use a driver/handle split. It
is the recommended hosted API and the same feed/tick/drain shape is the embedded
API. In hosted/default builds you also get the high-level plugin stack, C/Python
bindings, virtual-bus adapters, and richer geo helpers. In embedded builds you
disable defaults and use the no_std + alloc surface with board-owned time, CAN
I/O, and storage. Every guided walkthrough and tutorial page teaches the hosted
shape first; MCU users should also read
no_std on microcontrollers.
What machbus gives you
- J1939 and ISOBUS-oriented CAN identifiers, address claiming, requests, and transport helpers.
- Higher-level services for Virtual Terminal, Task Controller, File Server, diagnostics, Sequence Control, tractor/implement facilities, GNSS, and NMEA.
- A session surface that lets multiple roles talk over a virtual bus or real SocketCAN endpoints.
- Rust APIs first, with C ABI and Python bindings for integration.
What you still own
- Choosing device identities and safe addresses for your machine.
- Testing with your actual CAN hardware and wiring.
- Validating behavior with real Virtual Terminals, Task Controllers, service tools, and other ECUs.
- Official conformance/certification work.
Where the old audit docs went
The previous audit-oriented documentation was moved to book/src/reference/audit/. Use it as a
migration reference when maintaining the book, but write new user-facing content
in this book/ mdBook.
Conformity and evidence
This book starts with conformity because agricultural networks are safety- and interoperability-sensitive. It is not enough for code to compile. Users need to know what was tested, what evidence exists, and where the boundary is.
In this book:
- Conformity means the implementation follows known protocol behavior and has local evidence.
- Certification means official or third-party validation. machbus does not claim that.
The rest of the book is practical. You will see tutorials and examples, but those tutorials should always be read with this boundary in mind.
Evidence categories
machbus uses several kinds of evidence:
| Evidence | Purpose |
|---|---|
| Unit tests | Validate small codecs and state transitions. |
| Golden fixtures | Pin exact bytes for known messages. |
| Property tests | Feed broad input ranges and hostile bytes. |
| Session/role tests | Prove roles work together over a bus abstraction. |
| Binding tests | Check Rust, C, and Python surfaces. |
| Trace replay | Check captured or fixture CAN logs. |
| Hardware evidence | Required before real deployment claims. |
Start with Claim boundary before using the protocol tutorials.
Claim boundary
machbus is a protocol implementation and development library. It is not an officially certified ISOBUS product. Put another way: machbus is not an officially certified ISOBUS product.
Safe wording
It is fine to say:
- machbus implements codecs and session behavior for many ISOBUS/J1939/NMEA workflows.
- machbus has local tests, fixtures, examples, and binding smoke tests.
- machbus is designed for agricultural CAN applications.
Do not say:
- machbus is ISO certified.
- machbus is AEF certified.
- machbus is guaranteed to work with every VT, TC, implement, or tractor.
- local unit tests are the same as formal conformance.
What local tests prove
Local tests prove that the checked code paths behave as expected for the fixtures, generated inputs, examples, and session scenarios in this repository.
They can catch:
- byte layout regressions
- malformed payload handling mistakes
- panics in public decode paths
- state-machine transition mistakes
- C/Python binding drift
- examples that no longer build
What local tests cannot prove
Local tests cannot prove:
- that all official standard requirements have been reviewed
- that every vendor device accepts the behavior
- that timing on real CAN hardware is always suitable
- that a machine is safe to deploy
- that AEF conformance has been achieved
Why standards text is not copied here
ISO 11783, SAE J1939, and NMEA 2000 are not fully public in the way normal open source documentation is public. This book teaches concepts and documents this library, but it does not reproduce normative standard text.
For product certification, use the official standards and the applicable AEF or vendor validation process.
Evidence model
machbus uses layered evidence. Each layer catches different failures.
| Level | Catches | Misses |
|---|---|---|
| Static API shape | Missing public functions, feature drift | Runtime behavior |
| Unit tests | Local codec/state mistakes | Cross-role interaction |
| Golden byte fixtures | Exact wire byte regressions | Untested byte variants |
| Property/fuzz smoke | Panic and bounds bugs | Semantic conformance gaps |
| Session/role tests | Multi-role workflows | Real hardware timing |
| Binding tests | C/Python wrapper drift | Every host platform |
| Standards-text review | Rules never implemented right | Anything not written down |
| Trace replay | Captured log regressions | Devices not in captures |
| Hardware capture | Real bus behavior | Official certification |
| AEF/PlugFest-style validation | Interoperability evidence | Future regressions |
Why standards-text review earns its own row
Every layer above it takes the implementation’s own assumptions as the baseline. Unit tests prove the code does what its author meant; fixtures pin the bytes it already produces; session tests run both halves of a conversation against each other. None of that can catch a wire rule that was reconstructed rather than read, because both sides of the test share the reconstruction.
That is not hypothetical. The standards-text audit found nine defects, seven of which only misbehave against a conformant peer — over-strict receive paths and uniform defaults where the standard varies. Testing a stack against itself could not have surfaced any of them.
The layer’s blind spot is the mirror image: it sees only what the documents write down. Much of ISO 11783 deliberately does not — PGN assignments, DDIs and NAMEs all live in the electronic database at isobus.net rather than in the standards, so no PGN constant in this crate is checkable this way.
Repository evidence map
tests/protocol_fixtures.rs: protocol fixture checks.tests/standard/session_harness.rs: multi-node session integration behavior.examples/c_abi/: C ABI demos plus surface/layout checks (make c-demo/c-full-demo).examples/c_abi: C examples and layout probe.examples/python_binding: Python wheel/demo/regression smoke.tests/fuzz_targets.rs: bounded fuzz/property smoke.book/src/reference/audit/: previous audit evidence and rationale.book/src/reference/audit/standards-text-audit.md: what was checked against the ISO/AEF/NMEA text, what it found, and what it cannot show.
The new book should stay readable, but it should link back to evidence when a chapter makes a strong claim.
What is tested
The project currently verifies a broad local surface through make verify.
Major tested areas include:
- Address claim and NAME management.
- J1939 diagnostic messages and diagnostic request behavior.
- Transport Protocol and Extended Transport Protocol boundaries.
- NMEA 2000 Fast Packet and NMEA 0183 serial GNSS parsing.
- Virtual Terminal client/server handshake, object pool transfer, typed object bodies, updates, and server-side semantic state cache.
- Task Controller client/server workflows, DDOP parsing/helpers, process data, measurement triggers, and TC-GEO helpers.
- File Server client/server operations.
- Sequence Control master/client workflows.
- Tractor, implement, guidance, powertrain, and facility helpers.
- Rust session presets and plugins.
- C ABI and Python bindings.
For exact rows and evidence links, see Protocol matrix.
What is not certified
machbus does not include an official certificate for:
- ISO 11783
- SAE J1939
- NMEA 2000
- AEF ISOBUS conformance
The code can be useful before certification. It can help build prototypes, tools, tests, simulators, gateways, and product code. But shipping on real machines requires a separate validation process.
Treat these as open deployment responsibilities
- Run with the actual CAN interface and bitrate.
- Test against the actual VT, TC, service tool, and peer ECUs.
- Capture and review traces.
- Validate machine safety behavior.
- Use official standards and AEF processes where required.
The repository may contain external-oracle rows in its evidence matrix. Those rows mean “implemented locally but still needs external proof”, not “certified”.
Hardware and AEF path
The practical path from local tests to deployment usually looks like this:
- Run
make verify. - Run examples over a virtual CAN interface.
- Capture and replay traffic with SocketCAN/candump.
- Test with a small hardware bench.
- Test with real VTs, TCs, service tools, tractors, and implements.
- Compare traces against expected behavior.
- Use official AEF or customer-required conformance processes.
Hardware evidence should record
- repository commit
- CAN interface
- bitrate
- device list
- test steps
- raw capture path
- expected result
- observed result
- pass/fail decision
Hardware tests should never be vague. If a trace is used as evidence, keep the trace and a short report with enough metadata that someone else can understand what was connected.
ISOBUS in plain words
ISOBUS is the agricultural-machine family of CAN-based communication built on SAE J1939 ideas and ISO 11783 application layers. A tractor, implement, Virtual Terminal, Task Controller, GNSS receiver, file server, service tool, and specialized controllers may all share the same physical network.
If you are new to it, do not start with the whole protocol surface. Start with the mental model:
- every communicating role is a control function;
- every control function has a stable NAME;
- the NAME is used to claim a temporary source address;
- normal traffic is grouped by PGN;
- short payloads fit in one CAN frame;
- long payloads use TP, ETP, or Fast Packet;
- application services such as VT, TC, FS, SC, TIM, diagnostics, and GNSS build workflows on top of those lower layers.
machbus groups those layers into modules and stack helpers so application code can work with typed messages instead of raw bytes most of the time.
Mental model
Application role
└─ machbus session/plugins
└─ ISOBUS/J1939/NMEA services
└─ transport protocols
└─ CAN frames
Two ways to look at the stack
From the bus upward:
| Layer | Question it answers |
|---|---|
| CAN | Which frame won arbitration, and what bytes arrived? |
| J1939 identifier | What priority, PGN, source, and destination are encoded in the identifier? |
| Address claim | Which NAME currently owns which source address? |
| Transport | Is this one payload or a reassembled multi-frame payload? |
| Application protocol | Is this diagnostics, VT, TC, FS, GNSS, TIM, or another service? |
| Your application | What should the machine or UI do with that information? |
From an application downward:
| Application thought | Protocol reality |
|---|---|
| “I am an implement.” | Build an implement node (plug Implement / presets::implement(...)) with a NAME and preferred address. |
| “I need a terminal.” | Find a VT partner by NAME/function and connect the VT client. |
| “I need a task controller.” | Find a TC partner, upload a DDOP, then exchange process data. |
| “I need to send 2 kB.” | Let TP/ETP split and reassemble the payload. |
| “I need to react to an ISB.” | Subscribe to the Shortcut Button state and move to the application safe state. |
Where to go next
This page is the gentle on-ramp. For the full story — every part explained with diagrams and a path from concept to code — read The standards, end to end, which builds the picture from the wire up:
- The networking foundation — CAN, J1939, NAME and address claim, and transport (the spine everything else stands on).
- The Virtual Terminal — how an implement borrows the cab’s screen.
- The Task Controller — documented work and the shared DDI vocabulary.
- Application services — implement control, the tractor ECU, diagnostics, File Server, sequence control, and TIM.
- Positioning: NMEA and GNSS — getting the fix onto the bus.
When you want to see this on a real bus, jump to Reading candump traces; for the source documents, see Further reading. When you are ready to build, start with The session facade.
What this section is not
This section is a practical orientation guide. It is not a copy of ISO 11783, and it does not replace official protocol documents or external certification work. The goal is to give you enough shape to understand the machbus API and to debug traces without drowning in every part of the standard on day one.
Reading candump traces
candump is the fastest way to see what actually crossed a SocketCAN
interface. The text it prints is short, but every line packs a 29-bit
identifier whose fields you have to take apart by hand before the bytes make
sense. This page teaches you to read a line cold: which token is which, how to
pull priority, PGN, source, and destination out of the hex identifier, and how
to recognize the handful of traffic shapes you will see over and over. Every
example line here is one we made up to illustrate the math — none is copied
from a real capture.
Why this exists
When something on the bus misbehaves, the trace is the ground truth. A node that “isn’t responding” might be answering to the wrong destination; an upload that “hangs” might be stuck waiting for a clear-to-send. You cannot see any of that from the application side — you have to read the wire. Knowing how to decode a raw line turns a wall of hex into a conversation you can follow.
Anatomy of a candump line
A compact candump -L style line looks like this (illustrative):
(0.842301) can0 18EF2280#0102030405060708
Read it left to right:
| Token | Example | Meaning |
|---|---|---|
| timestamp | (0.842301) | When the frame was observed; present with -t options. |
| interface | can0 | The SocketCAN device (can0, vcan0, …). |
| identifier | 18EF2280 | The CAN identifier in hex. Eight hex digits means an extended 29-bit ID. |
# | # | Separator between identifier and payload. |
| payload | 0102030405060708 | The data bytes, two hex digits each. |
The common bracketed format carries the same information differently:
(0.842301) can0 18EF2280 [8] 01 02 03 04 05 06 07 08
Here [8] is the DLC (data length code, the byte count), and the payload
bytes are spaced out. Both shapes describe one frame; machbus’s
candump_replay example parses either one.
A quick reflex: eight hex digits in the identifier means extended (29-bit),
which is what ISOBUS and J1939 use. A two- or three-digit identifier is an
11-bit standard frame and is not ISOBUS/J1939 application traffic — the
candump_replay example deliberately rejects those rather than guess at them.
Decoding the identifier by hand
The 29-bit identifier is not the PGN. It is a packed word, and the PGN is only part of it. The layout, most-significant bit first:
bits 28..26 | 25 | 24 | 23..16 | 15..8 | 7..0
priority | EDP | DP | PF | PS | SA
- priority (3 bits): lower number wins arbitration; it does not change meaning, only urgency.
- EDP / DP (2 bits): the data-page selectors. Together with PF and PS they form the 18-bit PGN.
- PF (PDU Format, 8 bits): the byte that decides PDU1 vs PDU2.
- PS (PDU Specific, 8 bits): either a destination address (PDU1) or a group extension that is part of the PGN (PDU2).
- SA (Source Address, 8 bits): who sent the frame.
The PF byte is the fork in the road:
- PF < 240 → PDU1 (destination-specific). PS is the destination address and is not part of the PGN. The PGN’s low byte is zero.
- PF ≥ 240 → PDU2 (broadcast). PS is part of the PGN (the group extension), and the frame has no single destination — it is for everyone.
Worked example: 18EF2280
Take the identifier 18EF2280 and write it as 32 bits, then drop the top three
(only 29 are used):
hex 1 8 E F 2 2 8 0
bits 0001 1000 1110 1111 0010 0010 1000 0000
Slice it along the field boundaries above:
| Field | Bits | Value |
|---|---|---|
| priority | 110 | 6 |
| EDP | 0 | 0 |
| DP | 0 | 0 |
| PF | 1110 1111 | 0xEF = 239 |
| PS | 0010 0010 | 0x22 |
| SA | 1000 0000 | 0x80 |
PF is 0xEF = 239, which is below 240, so this is PDU1. That means:
- PGN = EDP·DP·PF·
00=0x00EF00(the PS byte is not in the PGN; the low byte is forced to zero). - Destination = PS =
0x22. - Source = SA =
0x80. - Priority = 6.
So 18EF2280 is “node 0x80 sends a proprietary-A message to node 0x22 at
priority 6.” If PF had been 0xF0 or higher, PS would have folded into the PGN
and the frame would be a broadcast with no specific destination.
machbus does exactly this decode in net::Identifier: priority(),
pgn(), source(), and destination() return the same fields, and
is_pdu2() / is_broadcast() answer the PF≥240 question for you.
A few PGNs you can compute
You do not need a dictionary to read most traffic — compute the PGN from PF/PS and a short table covers the common cases. These values come from the machbus PGN helpers and are illustrative, not exhaustive:
| PGN | PF / PS | Kind | What it is |
|---|---|---|---|
0xEE00 | EE / dst | PDU1 | Address claimed (announcing NAME ↔ address). |
0xEA00 | EA / dst | PDU1 | Request for a PGN. |
0xE800 | E8 / dst | PDU1 | Acknowledgement. |
0xEC00 | EC / dst | PDU1 | Transport connection management (RTS/CTS/BAM/abort). |
0xEB00 | EB / dst | PDU1 | Transport data transfer (the numbered packets). |
0xFECA | FE / CA | PDU2 | A diagnostic message, broadcast. |
Notice the pattern: PF below 0xF0 (EE, EA, E8, EC, EB) is PDU1, so the PS slot
in the identifier is a destination, not part of the PGN. PF of 0xFE is PDU2,
so PS (0xCA) is baked into the PGN and the frame goes to everyone.
Recognizing traffic on sight
After a little practice you can name most lines without decoding every bit.
Address claim. PF 0xEE, eight payload bytes — that payload is a 64-bit
NAME. Seeing several EE00 frames right after power-up is normal: nodes are
sorting out who owns which address. See
NAME and address claim.
(0.010) can0 18EEFF80#00112233445566AA ; node 0x80 claims, broadcast
A request. PF 0xEA, three payload bytes — those three bytes are the
requested PGN, little-endian (low byte first). A request for PGN 0xEE00
(address claimed) shows up as payload 00 EE 00.
(0.020) can0 18EA80FF#00EE00 ; "everyone, please claim again"
A transport sequence. Watch for 0xEC (control) and 0xEB (data) between
the same source/destination pair. A directed transfer runs:
(0.100) can0 1CEC2210#10 14 00 03 FF 00 EF 00 ; RTS: I have 0x14 bytes / 3 pkts, target PGN 0xEF00
(0.101) can0 1CEC1022#11 03 01 FF FF 00 EF 00 ; CTS: send 3 packets starting at 1
(0.102) can0 1CEB2210#01 ... ; DT packet 1
(0.103) can0 1CEB2210#02 ... ; DT packet 2
(0.104) can0 1CEB2210#03 ... ; DT packet 3
(0.105) can0 1CEC1022#13 14 00 03 FF 00 EF 00 ; EoMA: got all 0x14 bytes
The first payload byte of an EC00 frame is the control byte: 0x10 is RTS
(request-to-send), 0x11 is CTS (clear-to-send), 0x13 is end-of-message
ack, 0x20 is BAM (broadcast announce), and 0xFF is abort. The key insight:
the PGN on the wire is the transport PGN, not the application PGN — the real
target PGN rides inside the control message (the last three payload bytes of the
RTS, little-endian). See Transport protocol.
A broadcast. PF 0xF0 or higher, or PDU1 sent to destination 0xFF. No
reply is expected; the data is for whoever cares.
(0.200) can0 18FEF142#... ; PDU2 broadcast from node 0x42
Tips for capturing and replaying
To capture on a live or virtual interface:
candump -td -L can0 > capture.candump # -td: delta timestamps, -L: log format
candump -l can0 # writes a timestamped candump-*.log file
To replay or summarize a capture with machbus, the candump_replay example
parses each line, rebuilds the frame, rejects anything that is not a valid
extended ISOBUS/J1939 shape, and prints a decoded one-liner per accepted frame:
cargo run --example candump_replay -- path/to/capture.candump
Each accepted line prints the raw identifier, the decoded PGN, source,
destination, length, and payload — the same fields you sliced out by hand
above — followed by a parsed/accepted/rejected summary. It accepts both the
compact # form and the bracketed [8] form, and it ignores CAN FD, error, and
flagged frames rather than guessing them into classic CAN. To push a real
capture onto a vcan interface and watch a node react, see the
SocketCAN replay tutorial.
Common confusions
- The hex identifier is not the PGN.
18EF2280is the whole 29-bit word. The PGN (0xEF00here) is only the EDP/DP/PF part — and for PDU1 the PS byte is a destination, not PGN content. Strip priority and source first. - The PDU1 destination byte hides in the identifier. For PF < 240, the PS slot is the destination. If you compute the PGN with PS still in it, you will invent PGNs that do not exist and miss who the frame was addressed to.
- Byte order inside the payload. The PGN carried inside a request or a
transport RTS is little-endian: low byte first.
00 EE 00means PGN0x00EE00, not0x00EE00read big-endian. The same applies to most multi-byte numeric fields. - Standard vs extended. A short identifier (two or three hex digits) is an 11-bit standard frame and is not ISOBUS/J1939 application traffic. Do not decode it with the PF/PS/SA layout.
- DLC versus actual bytes. In the bracketed form,
[8]is the declared length. If the spelled-out bytes do not match it, the line is malformed and machbus’s parser drops it instead of padding or truncating.
See also
- PGN, priority, source, destination — the field-by-field primer behind this decoding.
- CAN and J1939 — where the 29-bit identifier comes from.
- Transport protocol — the RTS/CTS/data dance you will see in traces.
- SocketCAN replay — capture and replay on a virtual bus, end to end.
This is a reading guide, not a conformance claim: machbus is not certified, and real deployment still needs official standards, hardware, and interoperability evidence.
Further reading
These public resources are useful while learning the concepts in this section. They are references for orientation, not a substitute for official protocol documents when building a product.
AgIsoStack++
The public AgIsoStack++ docs have a clear beginner path that influenced the shape of these chapters:
- Concepts overview: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Concepts.rst
- Tutorial index: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials.rst
- Hello world tutorial: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials/The%20ISOBUS%20Hello%20World.rst
- Adding a destination: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials/Adding%20a%20Destination.rst
- Receiving messages: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials/Receiving%20Messages.rst
- Transport layer: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials/Transport%20Layer.rst
- Virtual Terminal basics: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/blob/main/sphinx/source/Tutorials/Virtual%20Terminal%20Basics.rst
- Task Controller basics/client docs: https://github.com/Open-Agriculture/AgIsoStack-plus-plus/tree/main/sphinx/source/Tutorials
Public lookup databases
- ISOBUS.net landing page and lookup categories: https://www.isobus.net/isobus/
- Manufacturer codes: https://www.isobus.net/isobus/manufacturerCode
- Device class/function: https://www.isobus.net/isobus/nameFunction
- Source addresses: https://www.isobus.net/isobus/sourceAddress
- PGN/SPN lookup: https://www.isobus.net/isobus/pGNAndSPN/?type=PGN
- Process Data DDI lookup: https://www.isobus.net/isobus/dDEntity
machbus reference pages
- Protocol coverage:
book/src/reference/protocol-coverage.md - Hardware evidence:
book/src/reference/hardware-evidence.md - Claim boundary:
book/src/reference/audit/conformance.md - Binding contracts:
book/src/reference/audit/bindings.md
When a public resource and machbus behavior differ, trust the executable tests for what this repository currently does, and use the resource to decide what should be improved next.
The standards, end to end
This section is the story of how an ISOBUS machine works — not a clause-by-clause recital, but the narrative an engineer needs to hold in their head. By the end you should be able to look at any frame on a tractor bus and know which layer produced it, why, and what happens next.
We build the picture from the wire up: a single twisted pair, then the language spoken on it, then the agreement that lets a tractor and a stranger’s implement cooperate the first time they are bolted together.
Throughout, standards are named only at the part level — “ISO 11783-6 (Virtual Terminal)”, “SAE J1939”, “NMEA 2000”. The explanations are written from how machbus implements the behavior; consult the official documents for normative wording.
The one-paragraph version
A tractor and an implement are two computers that have never met. They share two copper wires. CAN lets them put bits on those wires without electrocuting each other’s messages. SAE J1939 turns those bits into named messages (PGNs) sent between addresses. ISO 11783 takes J1939 and adds everything farming needs: a way to claim an address by identity so two strange devices never collide, a way to move data bigger than 8 bytes, a screen-sharing protocol so an implement can draw its own controls on the tractor’s terminal, a job/data protocol so a field computer can run a prescription, plus diagnostics, files, guidance, and safety interlocks. AEF is the club that certifies all of this actually interoperates; NMEA 2000 is the cousin protocol that carries the GPS fix.
The layer cake
Everything below sits on the layer above’s shoulders. Read it bottom-to-top: each layer only worries about its own job and trusts the one beneath it.
┌────────────────────────────────────────────────────────────────────┐
│ application services VT · TC · File Server · Sequence Control · │
│ TIM · diagnostics · guidance · GNSS feed │
│ (ISO 11783-6, -7, -9 … -14) │
├────────────────────────────────────────────────────────────────────┤
│ messages & transport PGN requests/acks · TP / ETP · Fast Packet ·│
│ interconnect routing │
│ (ISO 11783-3, -4 · SAE J1939) │
├────────────────────────────────────────────────────────────────────┤
│ identity & addressing NAME · address claim · who-may-talk │
│ (ISO 11783-5) │
├────────────────────────────────────────────────────────────────────┤
│ naming 29-bit ID = priority + PGN + source + dest │
│ (SAE J1939) │
├────────────────────────────────────────────────────────────────────┤
│ wire 250 kbit/s CAN, 29-bit extended frames │
│ (ISO 11783-2, ISO 11898) │
└────────────────────────────────────────────────────────────────────┘
For a one-screen index of every part — what it does, where machbus implements it, and which chapter covers it — see Standards capability map.
The deep-dive chapters in this section follow these bands:
- The networking foundation — the bottom four bands: CAN, J1939 naming, address claiming, and transport. This is the spine; nothing else works until a node has claimed an address and can move data.
- The Virtual Terminal — ISO 11783-6, the screen-sharing protocol and the most elaborate application service.
- The Task Controller and the data dictionary — ISO 11783-10 and -11: documented work, process data, and the DDI vocabulary.
- Implement control, the tractor ECU, and the rest — ISO 11783-7/-9 plus diagnostics (-12), File Server (-13), Sequence Control (-14), and TIM.
- Positioning: NMEA and GNSS — how the fix gets onto the bus.
Who is on the bus
A useful mental model before the protocols: an ISOBUS network is a small town of control functions (CFs). Each CF is one participant with one job and one identity. A physical box (an ECU) may host several CFs. The cast of characters on a typical tractor-plus-implement bus:
The bus — control functions side by side:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Tractor │ │ Virtual │ │ Task │ │ Implement │ │ GNSS │
│ ECU (TECU) │ │ Terminal │ │ Controller │ │ ECU │ │ receiver │
│ speed · hitch │ │ the screen, │ │ runs the job, │ │ VT + TC client, │ │ position, │
│ PTO · GNSS │ │ soft keys │ │ logs work │ │ the actuators │ │ COG / SOG │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
The tractor side mostly serves (it has the screen, the speed, the hitch). The implement side mostly requests (it borrows the screen, asks for the job, reports what it did). Many roles come in client/server pairs — a VT server (the terminal) and VT clients (each implement), a TC server and TC clients. machbus implements both halves of each pair.
A machine waking up: the first ten seconds
The single most important sequence to internalize is what happens when you turn the key. Every protocol above the wire depends on it.
t0 Power on. The CF has a NAME (its 64-bit identity) and a *preferred* address,
but is not yet allowed to send application traffic.
t1 It broadcasts an Address Claim for its preferred address, putting its NAME
on the wire as the tie-breaker.
t2 Silence for the contention window? → the address is ours. Claimed.
Someone else claims the same address? → the *lower NAME wins*. The loser
either grabs another address (if self-configurable) or goes quiet.
t3 Now addressed, the CF starts its periodic chores: a TECU broadcasts wheel
speed and hitch state; a CF may begin its heartbeat.
t4 An implement's VT client goes looking for a terminal: "any VT out there?"
It discovers the VT's address and version.
t5 The VT client uploads its *object pool* — the entire description of its UI —
in one big transport-protocol transfer, then asks the VT to activate it.
t6 In parallel, the TC client uploads its *device description* (the DDOP) to the
Task Controller and the two begin trading process data.
t7 Steady state: status broadcasts tick along, the operator presses soft keys,
setpoints flow down, as-applied values flow up, and the GNSS receiver feeds
position the whole time.
machbus mirrors exactly this order. In the session facade you plug the subsystems
you need, start() the claim, and drive poll(); address claim runs first and the
application plugins only act once an address is held. See
The session facade.
Why identity-based addressing is the clever bit
J1939 alone assigns addresses more or less by convention. That is fine for a truck built by one manufacturer. It falls apart the moment a random implement from a different manufacturer is hitched to a tractor it has never seen — both might want the same address.
ISO 11783-5 (network management) solves this with NAME-based arbitration. Every CF carries a 64-bit NAME encoding what it is (manufacturer, device class, function, instance, and a self-configurable flag), not where it sits. When two CFs want the same address, the numerically lower NAME wins the address and the other moves. Because NAMEs are unique by construction, the network always converges — no human, no DIP switches, no central authority. This is the property that makes “hitch any implement to any tractor” actually work, and it is why address claim is the first thing every node does.
Conflict on address 0x80:
CF-A NAME = 0x00A0_0000_0000_1234 ─┐
CF-B NAME = 0x00C0_0000_0000_5678 ─┤ compare NAMEs
▼
0x00A0… < 0x00C0… → CF-A keeps 0x80
CF-B is self-configurable → claims 0x81 instead
Two sizes of message, and why transport exists
A raw CAN frame carries at most 8 bytes. Plenty for “engine speed = 1500 rpm”; hopeless for “here is a 40 KB object pool describing my user interface.” ISO 11783 inherits J1939’s answer and extends it:
- Single frame — ≤ 8 bytes, one shot.
- Transport Protocol (TP) — up to 1785 bytes, broken into 7-byte packets with flow control (a destination can say “send me packets 1–16, then pause”).
- Extended Transport Protocol (ETP) — megabyte-scale transfers for big object pools and files.
- Fast Packet — NMEA 2000’s lighter multi-frame scheme for things like a GNSS position record.
The deep dive covers the handshakes. The key idea: every layer above transport gets to pretend messages are arbitrarily large; transport quietly chops and reassembles.
The application services, in one breath
Once a node can claim an address and move data, the farming-specific services are just well-defined conversations on top:
| Service (part) | The conversation, in plain words |
|---|---|
| Virtual Terminal (-6) | “Here is my whole UI. Draw it. Tell me what the operator touches; I’ll tell you what to change.” |
| Implement messages (-7) | “Here is my hitch/PTO/section/speed state” — and the commands to change it. |
| Tractor ECU (-9) | The tractor’s promise of which facilities (speed, hitch, PTO, guidance) it offers. |
| Task Controller (-10) | “Here is what I am and what I can measure/control (my DDOP). Now let’s trade process data for this job.” |
| Data dictionary (-11) | The shared vocabulary (DDIs) so “application rate” means the same number to everyone. |
| Diagnostics (-12) | “Here are my active faults” — and the service-tool requests to read and clear them. |
| File Server (-13) | A shared filesystem on the bus: open/read/write/close, volumes and directories. |
| Sequence Control (-14) | “Run this saved sequence of steps” — headland automation and the like. |
| TIM (AEF) | “May I, the implement, command the tractor’s hitch/PTO?” — authority with safety interlocks. |
Each has a dedicated codec and a session plugin in machbus. The deep-dive chapters tell each story properly.
Where AEF and NMEA fit
- AEF (Agricultural Industry Electronics Foundation) does not invent wire formats; it defines functionalities and a certification process that proves two vendors’ boxes actually interoperate. TIM (Tractor Implement Management) is an AEF-driven capability layered on the ISO messages. machbus models the functionality advertisement and the TIM authority guard, but ships no certification — see Conformity first.
- NMEA 2000 is a separate but CAN-compatible standard. On an ISOBUS machine it is how the GNSS receiver publishes position, course, speed, and attitude. machbus decodes the relevant PGNs so guidance and TC-GEO have a fix to work with.
How to read the rest of this section
The four deep-dive chapters are written to be read in order but stand alone. Each follows the same arc: the field problem, the mental model, the message anatomy, the lifecycle (with diagrams), how machbus expresses it, and the failure modes that bite in practice.
- The networking foundation
- The Virtual Terminal
- The Task Controller and the data dictionary
- Implement control, the tractor ECU, and the rest
- Positioning: NMEA and GNSS
If you would rather learn by building, jump to The session facade and come back here when a frame surprises you.
Standards capability map
The narrative chapters tell the story; this page is the map. Every standard that matters to an ISOBUS machine has a row here: what it does in one line, where machbus implements it, and which deep-dive chapter (or concept primer) covers it. Use it to jump straight to what you need, or to confirm that a given standard area exists in machbus at all.
Standards are named at the part level only. “Where in machbus” points at the source module and, where it exists, the session-facade plugin; “Read more” points at the chapter that explains the behavior.
The foundation
| Standard | What it does, in one line | Where in machbus | Read more |
|---|---|---|---|
| ISO 11898 (CAN) | The two-wire bus, bit timing, and non-destructive arbitration that everything rides on. | validated by net CAN-config checks | The networking foundation |
| SAE J1939 | Turns CAN bits into named messages (PGNs) sent between addresses; the parent of ISOBUS. | net (identifiers, PGNs), j1939 | The networking foundation |
| NMEA 2000 | CAN-based positioning/instrument standard; carries the GNSS fix on the bus. | nmea, session::plugins::Gnss | Positioning |
| NMEA 0183 | Older serial GNSS sentences (GGA, RMC, …) for simpler receivers and benches. | nmea | Positioning |
ISO 11783 — the ISOBUS parts
| Part | What it does, in one line | Where in machbus | Read more |
|---|---|---|---|
| -1 General & device classes | The overall architecture and the device-class/role vocabulary. | net (NAME, roles) | Role boundaries |
| -2 Physical layer | The 250 kbit/s bus profile: cabling, termination, bit timing, sample point. | net CAN-config validation | The networking foundation |
| -3 Data link layer | Framing plus the multi-packet Transport Protocol (TP) and Extended TP (ETP). | net (TP/ETP engines) | The networking foundation |
| -4 Network layer | Joining CAN segments: which PGNs forward across a router, and loop/clash guards. | net::niu (interconnect) | The networking foundation |
| -5 Network management | NAME-based address claiming — plug-and-play between strangers. | net (address claimer) | The networking foundation |
| -6 Virtual Terminal | Screen sharing: an implement ships its UI to the cab terminal and drives it. | isobus::vt, VtClient / VtServer | The Virtual Terminal |
| -7 Implement messages | Hitch, PTO, aux valves, speed/distance, lighting — status and commands. | isobus::implement, Implement | Implement & services |
| -8 Power train messages | Engine/transmission/powertrain status used across the machine. | j1939 (engine/powertrain), Powertrain | Implement & services |
| -9 Tractor ECU | The TECU and its classes: which facilities (speed, hitch, PTO, guidance) a tractor offers. | isobus::implement (facilities), TECU persona | Implement & services |
| -10 Task Controller | Documented work: upload a device description, then trade process data for a job. | isobus::tc, TcClient / TcServer | The Task Controller |
| -11 Data dictionary (DDI) | The shared vocabulary so “application rate” means the same number to everyone. | isobus::tc::ddi_database | The Task Controller |
| -12 Diagnostics services | Active/previous faults, clears, freeze frames, memory access, identity strings. | j1939::diagnostic, Diagnostics / DmMemory | Implement & services |
| -13 File Server | A shared filesystem on the bus: volumes, directories, open/read/write/close. | isobus::fs, FsClient / FsServer | Implement & services |
| -14 Sequence Control | Run saved sequences of steps — headland automation and the like. | isobus::sc, ScMaster / ScClient | Implement & services |
AEF and certified capabilities
| Capability | What it does, in one line | Where in machbus | Read more |
|---|---|---|---|
| AEF functionalities | Vendor-interoperability functionalities + the certification process (machbus ships the mechanism, not certification). | isobus::functionalities, ControlFunctionalities | Conformity first |
| TIM (Tractor Implement Management) | Lets an implement command the tractor’s hitch/PTO under authority + safety interlocks. | isobus::tim, Tim | TIM (AEF) |
The supporting cast (ISOBUS/J1939 services)
These are smaller but real parts of a working node, each an machbus plugin:
| Service | One line | Plugin |
|---|---|---|
| Heartbeat | Periodic “I’m alive” for liveness detection. | Heartbeat |
| Maintain Power | Ask the tractor to keep power after key-off to finish safely. | MaintainPower |
| Shortcut Button / ISB | The cab “stop everything” safe-state signal. | ShortcutButton |
| Language Command | Broadcast locale and unit preferences. | LanguageCommand |
| Auxiliary (AUX-O / AUX-N) | Joystick/switch-bank inputs assigned to implement functions. | Auxiliary |
| Group Function / Request2 / NAME management | Request/response and dynamic-NAME plumbing that keeps the network self-describing. | GroupFunction, Request2, NameManagement |
Suggested reading order
- The standards, end to end — the landscape and the wake-up timeline.
- The networking foundation — until this clicks, nothing above it makes sense.
- The service you are building: VT, TC, or implement & the rest.
- Positioning if guidance or TC-GEO is in scope.
- Then build: The session facade.
For where each part lives in code, see the Crate map; for exactly which messages are implemented and tested, see Protocol coverage.
The networking foundation
Before any implement can borrow a screen or report a sprayed litre, four things must already work: the wire must carry bits, those bits must form named messages, the node must own an address, and it must be able to move data larger than one frame. That spine is ISO 11783 parts 1–5 standing on SAE J1939 and CAN. This page is the map; each layer has its own chapter.
Why this is the spine
A farmer buys a tractor from one company and a planter from another, ten years apart, and expects them to cooperate the moment the hitch pin drops — no installer, no network admin. Plug-and-play between strangers on a noisy two-wire bus is the demand that shapes every choice below. Get this layer right and everything above it is just conversation; get it wrong and nothing works at all.
The layer cake
Each layer trusts the one beneath it (read bottom-up):
ISO 11783-5 network management address claiming (lowest NAME wins)
ISO 11783-4 network layer joining segments, routing, loop guard
ISO 11783-3 data link TP / ETP / Fast Packet (big payloads)
SAE J1939 naming 29-bit identifier · PGN · PDU1/PDU2
ISO 11783-1 general the NAME · device classes · layering
ISO 11783-2 physical 250 kbit/s CAN · arbitration
Read it layer by layer
- ISO 11783-1 — general and device classes — the architecture and the NAME (with its bit-field).
- ISO 11783-2 — the physical layer — the CAN bus and non-destructive arbitration.
- SAE J1939 — the heritage — the identifier, PGNs, and the PDU1/PDU2 trap.
- ISO 11783-3 — data link and transport — moving more than 8 bytes (TP/ETP/Fast Packet) with the RTS/CTS handshake.
- ISO 11783-4 — the network layer — joining CAN segments safely.
- ISO 11783-5 — network management and address claiming — the plug-and-play handshake that runs first.
The two ideas to carry away
- Lowest number wins, everywhere. CAN arbitration (lowest identifier wins the bus), message priority (lowest value wins), and address claiming (lowest NAME wins the address) are the same rule applied at three levels. Once it clicks, the whole foundation feels inevitable.
- Layers above pretend messages are any size and addresses are stable. Transport quietly chops and reassembles; address claiming quietly resolves conflicts. The application services get to ignore both.
How machbus expresses the foundation
You almost never touch it directly — that is the point. Build a node with a NAME and a
preferred address; the claim runs first. Send a payload of any size; the network layer
picks single-frame / Fast Packet / TP / ETP. Receive fully reassembled Messages,
routed to the subsystem that cares. React to AddressClaim and bus/confinement events
on the unified stream. See The session facade.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Claiming an address | Session::start() + drive poll() | Address claim |
| Sending any-size data | Session::send_raw / codec send (transport automatic) | Transport Protocol |
| Routing across segments | net::niu | Network routing |
| Seeing it on the wire | candump + replay | Reading candump traces |
Failure modes worth knowing (across the foundation)
- PDU1 vs PDU2 confusion — a frame that looks right but routes wrong.
- Talking before claimed — application sends are rejected until the claim completes.
- Transport timeouts/aborts — a stalled CTS or missed packet kills a transfer; retry.
- Address loss mid-run — a lower-NAME newcomer can take your address.
- Bus-off / error confinement — a flood of CAN errors takes a node off the bus.
See also
- The standards, end to end and Standards capability map.
- The Virtual Terminal — the first big service built on this spine.
ISO 11783-1 — general and device classes
Part 1 is the table of contents for the whole family: it lays out the layered architecture, defines the roles a participant can play, and — most importantly for day-to-day code — establishes the NAME, the 64-bit identity every control function carries. Everything else in this section is a specialization of the picture Part 1 paints.
Why this exists
ISOBUS is a system standard, not a single protocol. Part 1 exists so that the dozen later parts agree on the same vocabulary: what a “control function” is, what a “device class” means, how identity is structured, and how the layers stack. Without that shared frame, the VT part and the TC part would describe incompatible worlds.
The layered picture
application services VT · TC · FS · SC · diagnostics · implement · GNSS
───────────────────────────────────────────────────────────────
identity & messaging NAME · address claim · requests · transport
───────────────────────────────────────────────────────────────
naming PGN · source · destination · priority (J1939)
───────────────────────────────────────────────────────────────
wire 250 kbit/s CAN (Part 2)
Part 1 owns the top-to-bottom shape; the other parts fill in each band.
Roles and device classes
A participant is a control function (CF) — one job, one identity. A physical box (an ECU) may host several CFs. Part 1’s device-class vocabulary lets a CF say, in its NAME, roughly what kind of machine part it is (a tractor ECU, a planter, a fertilizer system, a positioning device, …). Other nodes use that to decide whether they want to talk to it.
ECU "ACME-Box"
├─ CF: Tractor ECU (device class = tractor, function = TECU)
└─ CF: Navigation (device class = positioning)
machbus models roles in net (the NAME and its fields) and surfaces the
role/ownership mapping in Role boundaries.
The NAME, field by field
The NAME answers “what is this?”, not “where is it?”. It is 64 bits, and every field
is exposed on net::Name:
NAME (64 bits) — what a control function IS:
┌──────┬──────────┬──────────────┬──────────┬──────────┬──────────┬──────────────┐
│ self │ industry │ device class │ function │ function │ ECU │ manufacturer │
│ cfg │ group │ (+ instance) │ code │ instance │ instance │ + identity # │
└──────┴──────────┴──────────────┴──────────┴──────────┴──────────┴──────────────┘
self-cfg = "if I lose my address, I may pick another"
manufacturer + identity number make every NAME globally unique
Two properties make the NAME the keystone of the whole network:
- Globally unique — manufacturer code plus identity number guarantee no two CFs share a NAME.
- Totally ordered — the 64-bit value is directly comparable, so any two CFs can deterministically decide who outranks whom.
Address claiming (Part 5) needs exactly those two properties and nothing more. The self-configurable bit decides a CF’s fate in a conflict: a self-configurable CF that loses its preferred address may take another; one that is not must go silent.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Constructing a NAME | net::Name (with_function_code, with_identity_number, with_self_configurable, …) | NAME management |
| Roles a node plays | the session-facade plugins you plug | The session facade |
| Where roles map to code | — | Role boundaries |
See also
- ISO 11783-5 — address claiming — where the NAME earns its keep.
- The standards, end to end — the whole-system story.
ISO 11783-2 — the physical layer
Part 2 is the copper. It fixes the bus that every higher layer assumes exists: a single twisted pair running at a defined speed, terminated correctly, with bit timing chosen so dozens of ECUs spread across a long machine all agree on where each bit starts and stops.
Why this exists
A field machine is an electrically brutal place — long harnesses, big motors, welding repairs in a shed. The physical layer is the agreement that makes a robust, deterministic bus out of that environment, and that lets a stranger’s ECU plug in and just work at the same bitrate and timing as everyone else.
The profile
CAN_H ──┬───────┬───────┬───────┬──
CAN_L ──┴───────┴───────┴───────┴──
│ │ │ │
ECU 1 ECU 2 ECU 3 GNSS
• 250 kbit/s • terminated at both ends
• twisted pair • defined bit timing + sample point
machbus does not toggle pins — a transport/driver owns the hardware — but it validates the profile: a wrong bitrate, an out-of-range sample point, or inconsistent explicit bit-timing segments are rejected before they can be mistaken for a compliant bus.
The one idea that matters upward: arbitration
CAN’s defining trick is non-destructive, priority-based arbitration. When two
nodes transmit simultaneously, the bus ANDs their bits: a dominant 0 overwrites a
recessive 1. The node sending the lower identifier wins and continues; the loser
detects the mismatch and backs off — no frame is destroyed.
ECU X transmits 1 0 1 1 0 …
ECU Y transmits 1 0 1 0 … ← differs at bit 4
bus carries 1 0 1 0 … ← Y's dominant 0 wins
▲
X sees bus ≠ what it sent → X yields, retries later
This “lowest number wins, losers retry cleanly” behavior is the hardware foundation for two things higher up: message priority (low priority value = wins the bus) and NAME-based address claiming (low NAME = wins the address). The same rule, all the way up the stack.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Validating the bus profile | net CAN-config checks | CAN interface problems |
| Putting frames on real copper | a Transport (e.g. EndpointTransport over SocketCAN) | SocketCAN |
| Watching real frames | candump + replay | Reading candump traces |
See also
- SAE J1939 heritage — what those arbitrated bits mean.
- ISO 11783-3 — data link & transport — moving more than 8 bytes over this wire.
SAE J1939 — the heritage
ISOBUS did not start from scratch. It took SAE J1939 — the heavy-duty vehicle network already proven on trucks and buses — and built farming on top. Understanding what came from J1939 (and what ISO 11783 added) explains a lot of otherwise-arbitrary detail, especially the shape of the identifier and the PDU1/PDU2 split that trips up newcomers.
What J1939 contributed
J1939 turns raw CAN bits into a disciplined messaging system:
- The 29-bit identifier layout — priority, parameter group, and source address.
- PGNs (Parameter Group Numbers) — every message is a named group of parameters.
- The transport protocol — multi-packet BAM and RTS/CTS (see Part 3).
- The diagnostic “DM” family — DM1 active faults, DM2 history, and the rest, which ISO 11783-12 reuses almost verbatim.
- Address-based addressing — which ISO 11783-5 then upgraded to NAME-based claiming.
The identifier, decoded
29-bit extended CAN identifier
┌────────┬─────┬───────────┬────────────────┐
│ prio │ EDP │ PF · PS │ source address │
│ 3 bits │ DP │ (the PGN) │ 8 bits │
└────────┴─────┴───────────┴────────────────┘
- Priority — lower wins arbitration (safety low, status high).
- PGN — the message identity, assembled from the PF (PDU format) and, sometimes, the PS byte.
- Source address — who sent it.
PDU1 vs PDU2 — the classic confusion
The single most error-prone J1939 detail, inherited directly by ISOBUS:
PF < 240 → PDU1 (point-to-point)
the PS byte IS the destination address
e.g. "send this to address 0x26"
PF ≥ 240 → PDU2 (broadcast)
the PS byte is part of the PGN itself
e.g. "wheel speed, to everyone"
Get this wrong and you produce a frame that looks valid but routes to the wrong place
— a destination-specific PGN sent as broadcast, or a broadcast PGN “addressed” to a
node. machbus’s net::Identifier encodes/decodes this correctly; the failure mode is
worth knowing because it shows up in hand-built frames.
What ISO 11783 added on top
| J1939 gave… | ISO 11783 added… |
|---|---|
| address-based addressing | NAME-based address claiming (plug-and-play) |
| BAM + RTS/CTS transport | Extended TP for megabyte object pools and files |
| diagnostic DM family | ISOBUS diagnostics wrinkles (a sixth ID field, functionalities) |
| generic vehicle messages | the farming services: VT, TC, FS, SC, implement control, TIM |
In short: J1939 is the grammar; ISO 11783 is the agricultural language written in it.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Identifiers, PGNs, PDU1/PDU2 | net::Identifier, net::Pgn | PGN request |
| J1939 diagnostics | j1939::diagnostic | ISO 11783-12 — diagnostics |
| Engine/powertrain messages | j1939 engine codecs, Powertrain plugin | ISO 11783-9 — the tractor ECU |
See also
- The networking foundation — the overview of parts 1–5 on top of this heritage.
- ISO 11783-2 — physical layer — the CAN bus underneath.
ISO 11783-3 — data link and transport
A CAN frame holds at most 8 bytes. An object pool, a DDOP, or a file is far bigger. Part 3 (carrying SAE J1939’s transport) is how ISOBUS pretends messages can be any size: it chops a large payload into 7-byte packets, ships them with flow control, and reassembles them on the far side. Master this and the rest of the stack stops being mysterious — almost every “describe yourself” service rides this layer.
Why this exists
The services that make ISOBUS valuable — sending a whole UI to a terminal, a whole device description to a task controller, a whole file to a server — are all bulk transfers. Without a transport, none of them could exist on an 8-byte bus.
The three (plus one) ways to move data
≤ 8 bytes ─────────────► single frame (one shot)
≤ 1785 bytes ──────────► Transport Protocol (TP) (7-byte packets + flow control)
up to megabytes ───────► Extended TP (ETP) (big counters; pools, files)
modest multi-frame ────► Fast Packet (NMEA 2000's lighter scheme)
machbus picks automatically: hand it a payload and it chooses single-frame, Fast
Packet, TP, or ETP; inbound, you receive the fully reassembled Message.
Broadcast transport (BAM): fire and forget
For data addressed to everyone, the sender announces the size, then streams packets at a fixed pace with no feedback. Listeners reassemble what they catch.
sender ── BAM "1024 bytes / 147 packets, PGN X" ──► (everyone)
sender ── DT 1 ─► DT 2 ─► … ─► DT 147 ─► (paced, no acks)
│
receivers reassemble independently
Connection-mode transport (RTS/CTS): the receiver sets the pace
Point-to-point transfers let the destination throttle, so a small ECU is never flooded. This is the handshake to know cold:
SENDER (drives) RECEIVER (paces)
── RTS: "1024 bytes, 147 packets, PGN X" ──────► request to send
◄─ CTS: "send 16 packets, starting at #1" ───── clear-to-send window
── DT #1 … #16 ─────────────────────────────────► one window of data
◄─ CTS: "send 16 packets, starting at #17" ────
── DT #17 … #32 ────────────────────────────────►
… repeat until all 147 packets sent …
── DT … #147 ───────────────────────────────────►
◄─ EndOfMsgAck: "got all 1024 bytes" ───────── done
◄─ (or) Abort: "stop — reason R" ──────────── failure, any point
Things that bite in practice and that machbus’s TP/ETP engines handle for you:
- Windows and holds. A receiver may send a CTS with a zero count to say “hold — I’m busy”; the sender waits and resumes on the next non-zero CTS.
- Timeouts. If a CTS or a data packet does not arrive within the protocol window, the session aborts; robust callers retry the whole transfer.
- One session per peer-pair-and-PGN. Starting a second transfer to the same peer for the same PGN while one is active is an error; machbus queues it behind the active one.
- Abort. Either side can abort with a reason at any point; the half-sent payload is discarded.
Extended TP and Fast Packet
ETP is the same shape as RTS/CTS with larger counters for megabyte payloads (big object pools, files). Fast Packet is NMEA 2000’s lighter multi-frame format for records like a GNSS position — a header frame plus a few continuation frames, no windowed flow control. machbus reassembles all of them beneath the message layer.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Sending any-size payloads | Session::send_raw / codec send — transport is automatic | Transport Protocol |
| Fast Packet (GNSS) | Plugin::fast_packet_pgns registers reassembly | Fast Packet |
| Watching a transfer | examples/transport_demo.rs | Reading candump traces |
See also
- ISO 11783-4 — network layer — moving these messages across joined segments.
- The Virtual Terminal and The Task Controller — the biggest users of transport.
ISO 11783-4 — the network layer
Most machines are one CAN segment and can ignore this part. But a big machine — a tractor with its own bus joined to a large implement with its own bus — is two segments bridged by a router. Part 4 defines how a message crosses that boundary safely: which messages forward, in which direction, and how to avoid loops and address clashes between the two sides.
Why this exists
One CAN segment has electrical and load limits. When a machine outgrows them, you split it and join the halves with an interconnect (a network interconnection unit, NIU). The moment you do, two new problems appear: a broadcast could echo back and forth forever (a loop), and two devices on opposite segments might claim the same address. Part 4 is the rulebook that prevents both.
The mental model
Tractor segment Implement segment
(TECU · VT · GNSS) (rate ctrl · sections · sensors)
│ │
└──────────────► ┌───────┐ ◄─────────┘
│ NIU │ router: forwards selected PGNs by
└───────┘ rule, drops echoes, guards addresses
The NIU is not a dumb repeater. It applies forwarding rules: a given PGN may be allowed to cross in one direction, both, or neither, optionally filtered by source or destination NAME. A loop guard ensures a forwarded frame is not forwarded back.
What machbus models
machbus implements the interconnect in net::niu: forwarding policies per PGN,
direction control, NAME-based source/destination filters, and the loop guard. It is
the piece that lets a large machine scale without every ECU having to know it lives
on a multi-segment network.
For single-segment applications — the common case — none of this is in your way; you build one node on one bus and the network layer is invisible.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Routing PGNs across segments | net::niu forwarding rules + loop guard | Network routing |
| Single-segment apps | nothing — it just works | The session facade |
See also
- ISO 11783-3 — data link & transport — what is being routed.
- ISO 11783-5 — address claiming — the address clashes the NIU must guard against.
ISO 11783-5 — network management and address claiming
This is the part that makes “hitch any implement to any tractor” actually work. Part 5 turns two strange boxes that both want the same address, with no installer and no central authority into a deterministic, self-healing assignment. It is the first thing every node does after power-on, and nothing above it may speak until it finishes.
Why this exists
SAE J1939 mostly assigns addresses by convention — fine for one manufacturer’s truck. On a farm, a random implement from another vendor, built years apart, may want the same address as something already on the bus. There is no human to resolve it. Part 5 resolves it automatically using the one thing that is guaranteed unique and comparable: the NAME.
NAME arbitration: lowest NAME wins
When two CFs contend for an address, they compare 64-bit NAMEs and the numerically lower one keeps the address. Because NAMEs are unique, the contest always has a winner and the network always converges.
Both want address 0x80:
CF-A NAME = 0x00A0_0000_0000_1234 ┐
CF-B NAME = 0x00C0_0000_0000_5678 ┘ compare as 64-bit numbers
│
0x00A0… < 0x00C0…
▼
CF-A keeps 0x80 · CF-B must move (if self-configurable) or go silent
This is the same “lowest number wins” rule as CAN arbitration — applied to identity instead of priority.
The state machine
POWER ON
│ have NAME + preferred address; may not send app traffic yet
▼
CLAIMING ── broadcast "I claim 0x80, NAME = N" ──► bus
│
│ wait the contention window, then:
│
├─ no contender ──────────────► CLAIMED (may now send app traffic)
├─ contender, higher NAME ────► CLAIMED (the contender yields)
└─ contender, lower NAME ─────► lose 0x80:
├─ self-configurable ─► claim another address (→ CLAIMING)
└─ not ───────────────► SILENT (cannot operate)
Rules machbus enforces:
- No application traffic before CLAIMED. The session core refuses app sends until the claim completes — this is why “drive the claim loop first” is the cardinal rule.
- Request for Address Claim. Any node can ask everyone to re-announce; a CF replies with its current claim.
- Address violation → diagnostic. Detecting another node on your claimed address is surfaced as a DTC (SPN derived from the offending address).
- Loss mid-run. A lower-NAME newcomer can take your address while you are running; a self-configurable CF moves, and the change is reported on the event stream.
- Convergence. Because the tie-break is numeric and NAMEs are unique, the network reaches a stable assignment with no oscillation.
NAME management (dynamic identity)
A further wrinkle: a CF can be commanded to adopt a new NAME (NAME management). The
node applies the new identity and re-claims. machbus models this with the
NameManagement plugin, which answers the management requests, adopts a commanded
NAME, and triggers a fresh claim.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Claiming an address | Session::start() then drive poll(); watch for the Claimed event | Address claim |
| Runtime “is it claimed?” | controls.is_claimed() (or watch for the Claimed event) | Address claim |
| Dynamic NAME adoption | session::plugins::NameManagement | NAME management |
| Address-conflict debugging | — | Address conflicts |
See also
- ISO 11783-1 — the NAME — the identity this part arbitrates on.
- The networking foundation — the overview that ties parts 1–5 together.
The Virtual Terminal
The Virtual Terminal — ISO 11783-6 — is the most elaborate service on the bus, and the one that makes ISOBUS visible to a farmer. It is a screen-sharing protocol: an implement with no display of its own ships a complete description of its user interface to the terminal in the cab, and from then on the two cooperate to show controls and react to the operator. This chapter tells that story end to end.
Why this exists
A modern cab cannot grow a new dial and a new screen layout every time a different implement is attached. So the cab terminal is deliberately dumb about implements: it is a general-purpose renderer that knows how to draw rectangles, numbers, strings, bars, and buttons, and how to report touches and key presses. The implement is the one that knows what its UI should look like. The VT protocol is the contract that lets the implement describe its UI once, hand it over, and then drive it — so any implement can present rich controls on any certified terminal.
IMPLEMENT (VT client) CAB (VT server)
"knows what the UI means" "knows how to draw"
── object pool (the whole UI) ──────────────► uploaded once
── change_numeric_value(rate, 42) ──────────► field redraws
◄─ soft key 3 pressed ─────────────────────── operator input
Mental model: the object pool
The implement’s UI is a pool of objects, each with a numeric ID, that reference
one another to form a tree. A working set object is the root; it points at data
masks (full-screen layouts); masks contain containers, output fields, input fields,
buttons, bar graphs, and so on; fonts, colours, and pictures are shared leaf
objects. machbus models all ~48 object types in isobus::vt.
WorkingSet ──► DataMask "main" ──► OutputNumber "rate"
│ │ └─► SoftKeyMask ──► Key 1, Key 2, Key 3
│ └─► OutputString "status"
└──► DataMask "settings" ──► InputNumber, InputList …
shared: Font, Colour, Picture, Macro, … (referenced by ID)
The pool is data, not code. The implement builds it (often exported from a design
tool as an .iop file), and machbus’s codec serializes it to the exact wire layout
the terminal expects and parses it back — including the per-type length walk that
makes the byte stream unambiguous.
The lifecycle: find, upload, activate, run
A VT client moves through a connect state machine. The shape:
DISCONNECTED
│ who is a VT? (look for VT status)
▼
DISCOVERED ── learns the VT's address + version
│ send working-set announcement
▼
UPLOADING ── transfer the whole object pool (big → Transport Protocol)
│ "End of Object Pool"; VT validates it
▼
ACTIVATING ── ask the VT to make this pool the active one
│ activation OK
▼
CONNECTED ── steady state: push value changes, receive input events
Two realities make this interesting:
- The pool is large. The upload rides the Transport Protocol from the foundation chapter — often hundreds or thousands of bytes in one connection-mode transfer. A failed transfer means no UI.
- Versions differ. Terminals advertise a VT version and capabilities (screen size, colour depth, soft-key count, fonts). A good client adapts; machbus exposes the version handshake and capabilities so you can.
Anatomy of the runtime conversation
Once CONNECTED, the protocol is a steady two-way stream:
Client → VT (commands). The implement keeps the displayed UI in sync with its internal state. The common commands, all on the machbus VT client:
| Command | Effect on screen |
|---|---|
| change numeric value | update a number/bar/gauge |
| change string value | update text |
| hide / show, enable / disable | toggle visibility / interactivity |
| change active mask | switch the whole screen |
| change soft-key mask | switch the row of soft keys |
| change attribute / size / colour / position | restyle or move an object |
| select input object, lock/unlock mask | drive focus and modality |
VT → client (events). The terminal reports what the operator did and what it decided:
| Event | Meaning |
|---|---|
| soft-key / button activation | operator pressed a key |
| numeric / string value changed | operator edited an input field |
| input object selected | focus moved / edit started |
| pool error | the VT rejected something in the pool |
| language / units changed | operator changed locale; reload if needed |
| active working set changed | another implement took the screen |
machbus surfaces these as VtEvent variants on the unified event stream.
Doing it with machbus
On the recommended facade, the whole lifecycle is a plugin:
Session::builder(name, addr)
.plug(VtClient::new(config, pool, working_set))
.spawn(transport)
│
▼ driver.poll() each cycle
┌────────────────────────────────────────────────────────┐
│ FSM: discover → upload → activate → run │
│ inbound VT frames → VtEvent on the event stream │
│ your commands (set_value, …) → buffered, sent next tick│
└────────────────────────────────────────────────────────┘
You point the client at a VT (connect_to), and once connected push updates with
set_value / set_string / etc. through fine control. Soft-key and value-change
events arrive on poll() (or drain::<VtEvent>()). The full walkthrough is in the
Virtual Terminal client tutorial; the
server side (acting as the terminal) is in
Virtual Terminal server.
machbus also ships a renderer and a VT server: it can play the terminal, maintain the active pool, apply runtime commands, run macros, and report operator input — useful for simulation, testing, and headless terminals.
Auxiliary control (AUX)
Beyond the screen, ISO 11783-6 defines auxiliary inputs — physical joysticks and switch banks that an operator assigns to implement functions. There are an older (AUX-O) and a newer type-2 (AUX-N) scheme. machbus decodes the status messages and, on the VT-version-5 path, the capability discovery that lets a client learn what auxiliary channels a terminal offers.
Failure modes worth knowing
- Pool too big or malformed — the VT rejects the upload or returns a pool
error; nothing draws. Validate the pool and watch for
PoolError. - Version mismatch — commands or objects the terminal does not support fail silently or with a pool error; check the advertised version/capabilities first.
- Lost activation — another working set can become active; handle
ActiveWorkingSetso you do not fight for the screen. - Editing races — the operator may be editing a field while you push a new value to it; the protocol and a careful client reconcile who wins.
- Reconnect — if the VT drops, the client must re-discover and re-upload; the pool is not persisted on the terminal across a real power cycle.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| VT client (implement side) | session::plugins::VtClient | Virtual Terminal client |
| VT server (the terminal) | session::plugins::VtServer | Virtual Terminal server |
| Building an object pool | isobus::vt (or an .iop export) | VT object pools |
| Pushing UI updates | VtClient::set_value / set_string / … | VT updates |
| Auxiliary discovery | VtClient aux capabilities | VT auxiliary capabilities |
What this proves / does not prove
machbus implements the VT client, server, renderer, and the object-pool codec, and
exercises them against real .iop pools and reassembled uploads. That proves the
protocol behavior locally; it does not prove pixel-accurate rendering on a
specific commercial terminal or AEF VT certification — see
Conformity first.
See also
- The Task Controller and the data dictionary — the other big implement-side service, usually running alongside the VT.
- VT object pools, VT updates, VT auxiliary capabilities.
The Task Controller and the data dictionary
If the Virtual Terminal is how a machine talks to the operator, the Task Controller is how it does documented work. ISO 11783-10 (Task Controller) defines the conversation between a field computer and an implement so a prescription can be executed, section by section, and an accurate as-applied record produced. It only works because ISO 11783-11 gives everyone a shared vocabulary — the data dictionary (DDIs). This chapter covers both.
Why this exists
Precision agriculture is bookkeeping at speed. A sprayer must apply the right rate at the right place, turn individual booms off over already-covered ground, and log exactly what it did for compliance and analytics. None of that is possible unless the field computer and the implement agree, to the litre and the centimetre, on:
- what the implement is — how many booms, sections, and tanks; what it can measure and control;
- what each number means — that “application rate” is this DDI, in these units, at this resolution;
- where the implement is — so coverage and section control line up with the map.
The TC protocol plus the DDI dictionary plus a geometry description make that agreement machine-readable.
The two halves
TASK CONTROLLER (server) IMPLEMENT (TC client)
the field computer / job log the sprayer / seeder
◄──── DDOP upload ───────────────── "here is what I am" (device desc.)
───── activate ───────────────────►
───── setpoint: rate = 200 L/ha ──► process data DOWN (commands)
◄──── measured: actual = 198 ───── process data UP (as-applied)
◄──── section 3 worked ───────────
machbus implements both: a TC client (the implement side) and a TC server (the controller side), plus the DDOP builder, the process-data helpers, peer control, and TC-GEO geometry/prescription conversion.
The device description: the DDOP
Before any work, the implement uploads a Device Object Pool (DDOP) — a tree that describes the machine. It is conceptually similar to a VT object pool, but instead of describing a screen it describes a machine’s structure and capabilities:
Device "ACME Sprayer"
└─ DeviceElement "boom" (a functional part)
├─ DeviceProcessData DDI=ActualRate (a runtime value)
├─ DeviceProcessData DDI=SetpointRate
├─ DeviceProperty DDI=WorkingWidth (a fixed attribute)
└─ DeviceElement "section 1" (offset X/Y/Z, width)
DeviceElement "section 2"
…
- DeviceElement — a part of the machine (the device, a boom, a section), with a geometry (offsets from a reference point).
- DeviceProcessData — a runtime value the TC can read or write (rate, speed, section state), tagged with a DDI.
- DeviceProperty — a definition-time constant (working width, capacity).
machbus builds DDOPs fluently and serializes them to the wire layout. The upload itself rides the Transport Protocol — a DDOP is far bigger than one frame.
The shared vocabulary: DDIs (ISO 11783-11)
A DDI — Data Dictionary Identifier — is a number that means a specific quantity, in specific units, at a specific resolution, agreed across the whole industry. “Setpoint volume per area application rate” is one DDI; “actual working width” is another. The dictionary is what stops one vendor’s “rate” being another vendor’s “rate ÷ 10.”
machbus ships the dictionary as a generated, sorted, fingerprinted table with:
- lookup by DDI number, distinguishing a known entry from the unknown sentinel;
- resolution-aware engineering conversion (raw integer ↔ real units), saturating on invalid input;
- explicit handling of the proprietary DDI range so custom values never collide with the unknown sentinel.
Crucially, the data dictionary is a public vocabulary; machbus references named DDI
constants (e.g. ddi::ACTUAL_WORKING_WIDTH) rather than magic numbers, and its
helpers carry the named references through so geometry, rate, and total semantics
stay correct. The dictionary has its own deep-dive:
ISO 11783-11 — the data dictionary.
The lifecycle
DISCONNECTED
│ who is a TC? (TC status broadcast)
▼
DISCOVERED ── learns TC address + version
│ working-set master + version handshake
▼
UPLOADING ── transfer the DDOP (Transport Protocol)
│ activate
▼
CONNECTED ── trade process data for the life of the task
│ setpoints down, measurements up, section reports
▼
(task ends / disconnect)
This mirrors the VT lifecycle deliberately — discover, upload a big description, activate, then run — because both are “describe yourself, then cooperate” services. machbus drives the FSM and ships the frames; you supply the DDOP and react to value requests and commands.
Process data in practice
Once connected, process data flows continuously:
- Down (TC → implement): setpoints and triggers — “set rate to 200 L/ha”, “enable section 4”.
- Up (implement → TC): measured values and state — “actual rate 198”, “section 4 on”, “12.4 ha worked” — sometimes on change, sometimes on a time/distance trigger the TC requests.
The TC can ask for values on a trigger (every N ms, every N cm, on change, on threshold) so the network is not flooded. machbus exposes the process-data value helpers and the request/command path.
Priority follows the command, not the PGN
All of this rides one parameter group (0xCB00), but Annex B.2 does not give it one priority. It splits three ways by the command nibble in byte 1:
| Priority | Commands | What they are |
|---|---|---|
| 3 | 3, A, E, F | value, set-value-and-ack, TC status, client task |
| 4 | D | process data acknowledge (PDACK) |
| 5 | 0, 1, 2, 4–9 | capabilities, DDOP transfer, request value, measurement setup, peer control |
The split arrived in version 4 “giving higher priority to control and connection
maintenance messages versus request and acknowledgement messages”. It matters
because the TC Status (E) and Client Task (F) messages are the heartbeats
each side declares the other dead over after six seconds — sending them at the
same priority as a bulk DDOP transfer is how they get starved on a loaded bus.
ProcessDataCommands::priority() carries the mapping.
Peer control and TC-GEO
Two higher-order capabilities sit on the TC foundation:
- Peer control — the TC can hand control of a specific element/DDI from one CF to another (for example, letting a guidance or rate controller drive a section directly). machbus models the peer-control assignment messages and surfaces them as events.
- TC-GEO — geographic/prescription work: turning a position fix plus a prescription map into per-section setpoints, with DDI-aware engineering conversion for the rates. This is where the GNSS feed (see Positioning) meets the DDOP geometry.
prescription map + GNSS fix + DDOP geometry (section offsets/width)
│ │ │
└───────────────┴─────────────────┘
▼
per-section setpoint rates (TC-GEO)
▼
process data DOWN to the implement
Doing it with machbus
On the session facade, plug TcClient (implement) or TcServer (controller):
Session::builder(name, addr)
.plug(TcClient::new(config, ddop))
.spawn(transport)
│
▼ driver.poll() each cycle
FSM: discover → announce → upload DDOP → activate → trade data
state changes → Event::Tc(TcEvent::StateChanged(..))
The full hands-on path is in the Task Controller client tutorial and Task Controller server tutorial; DDOP construction in DDOP; the geographic side in TC-GEO prescription.
Failure modes worth knowing
- DDOP rejected — a structural or DDI error makes the TC refuse activation; validate the DDOP and watch the state machine.
- Magic DDI numbers — using a raw number instead of a named, dictionary-checked DDI is how units quietly drift; machbus’s helpers guard against it.
- Trigger storms vs starvation — too-frequent triggers flood the bus; too-rare ones lose resolution in the as-applied log. Match the trigger to the work.
- Geometry mistakes — wrong section offsets/widths make section control and coverage misalign with reality even when every message is “valid.”
- Version gaps — older TCs support fewer features; negotiate on the advertised version.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| TC client (implement side) | session::plugins::TcClient | Task Controller client |
| TC server (controller side) | session::plugins::TcServer | Task Controller server |
| Building a device description | isobus::tc DDOP builder | DDOP |
| The DDI vocabulary | isobus::tc::ddi_database | ISO 11783-11 |
| Position → setpoint | TC-GEO helpers + Gnss | TC-GEO prescription |
What this proves / does not prove
machbus implements the TC client/server, DDOP, process data, peer control, TC-GEO conversion, and the DDI dictionary, tested locally. That proves protocol and conversion behavior; it does not prove interoperability with a specific commercial TC or AEF TC certification — see Conformity first.
See also
- The Virtual Terminal — usually running alongside the TC.
- Positioning: NMEA and GNSS — the fix that feeds TC-GEO.
- ISO 11783-11 — the data dictionary — the shared DDI vocabulary.
ISO 11783-11 — the data dictionary
The Task Controller can only trade numbers if everyone agrees what each number means. Part 11 delegates that agreement to a public dictionary of DDIs — Data Dictionary Identifiers — each pinning a quantity to specific units and resolution. It is the vocabulary that makes one vendor’s “application rate” equal another vendor’s.
Part 11 does not contain the dictionary. The document is three pages. §4.1 says the process-data variables “shall be as defined in the ISOBUS Data Dictionary, accessible at the ISOBUS website” — maintained by VDMA as the maintenance agency appointed by the ISO Technical Management Board — and §4.2 fixes only the shape of an entry: “identification number; process data element definition; range of the process data element; resolution of the process data element; units of the process data element.”
So the DDI values below are sourced from the online database, and no document in the ISO 11783 series can be used to check them. What is checkable is that every entry carries those five attributes, which
DDIDefinitiondoes. See the standards-text audit.
Why this exists
“Rate = 200” is meaningless without units and scale. Is that litres per hectare, or kilograms, or millilitres per square metre? At what resolution? Part 11 removes the ambiguity: a DDI number is the definition. Pick the DDI for “setpoint volume per area application rate” and both ends know the units and the integer-to-real conversion.
What a DDI carries
DDI 0x0001 ─► "Setpoint Volume per Area Application Rate"
unit: mm³/m² · resolution: 0.01 · range: …
DDI 0x0043 ─► "Actual Working Width" · unit: mm · resolution: 1
… (a public, industry-wide table)
Two ranges matter:
- the standard range — the published dictionary everyone shares;
- the proprietary range — vendor-private DDIs, which must never collide with the unknown sentinel the lookup returns for unrecognized numbers.
How machbus expresses it
machbus ships the dictionary as a generated, sorted, fingerprinted table with:
- lookup by number, distinguishing a known entry from the unknown sentinel;
- engineering conversion — raw integer ↔ real units using the entry’s resolution, saturating cleanly on invalid input;
- proprietary-range handling so custom DDIs never alias the unknown sentinel.
Crucially, the code refers to named DDI constants (e.g. ddi::ACTUAL_WORKING_WIDTH)
rather than magic numbers, and the DDOP/process-data helpers carry those named
references through so geometry, rate, and total semantics stay correct end to end.
raw 12_345 ── ddi_to_engineering(DDI, raw) ──► 123.45 (real units)
123.45 ── ddi_from_engineering(DDI, x) ──► 12_345 (raw, saturating)
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| DDI lookup + conversion | isobus::tc::ddi_database (ddi::* constants) | DDOP |
| Using DDIs in a DDOP | isobus::tc DDOP builder | The Task Controller |
| Geographic rate conversion | TC-GEO helpers | TC-GEO prescription |
Failure modes worth knowing
- Magic numbers — a raw DDI literal instead of a named, dictionary-checked constant is how units silently drift.
- Resolution mistakes — applying the wrong scale turns 200 L/ha into 20 or 2000.
- Proprietary collisions — a custom DDI that overlaps the unknown sentinel becomes invisible.
See also
- The Task Controller — the protocol that uses this vocabulary.
Application services: implement, tractor, and the rest
The Virtual Terminal and Task Controller get the spotlight, but a working machine runs on a handful of quieter services: the messages that move a hitch and spin a PTO, the tractor’s promise of what it offers, diagnostics, the on-bus filesystem, saved automation sequences, and the AEF authority that lets an implement command the tractor. This page is the map; each service has its own chapter.
The services at a glance
ISO 11783-7 implement messages hitch · PTO · aux valves · speed · lighting
ISO 11783-9 tractor ECU facilities advertisement + status source
ISO 11783-12 diagnostics DM1 faults, clears, freeze frames, memory, IDs
ISO 11783-13 File Server a shared filesystem on the bus
ISO 11783-14 sequence control headland automation and saved step sequences
AEF / TIM authority implement commands the tractor, under interlocks
Read each service
- ISO 11783-7 — implement messages — the physical-control vocabulary (hitch, PTO, aux valves, speed/distance, lighting).
- ISO 11783-9 — the tractor ECU — facilities, classes, and the capability contract.
- ISO 11783-12 — diagnostics — the DM fault family and service-tool access.
- ISO 11783-13 — the File Server — files, volumes, and the TAN-matched request/response model.
- ISO 11783-14 — sequence control — master/client step automation.
- TIM (AEF) — authority with safety interlocks.
The supporting cast
A few more services round out a real node, each an machbus plugin:
| Service | One line | Plugin |
|---|---|---|
| Heartbeat | Periodic “I’m alive” for liveness detection. | Heartbeat |
| Maintain Power | Keep tractor power after key-off to finish safely. | MaintainPower |
| Shortcut Button / ISB | The cab “stop everything” safe-state signal. | ShortcutButton |
| Language Command | Broadcast locale and unit preferences. | LanguageCommand |
| Auxiliary (AUX-O / AUX-N) | Joystick / switch-bank inputs assigned to functions. | Auxiliary |
| Functionalities / Group fn / Request2 / NAME mgmt | Advertisement and request/response plumbing that keeps the network self-describing. | ControlFunctionalities, GroupFunction, Request2, NameManagement |
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| A curated tractor node | session::presets::tractor() | Tractor ECU |
| A curated implement node | session::presets::implement(pool, ws, ddop) | Implement ECU |
| The small responders | the matching session::plugins | The session facade |
See also
- The networking foundation — what these services stand on.
- The Task Controller and The Virtual Terminal — the two big implement-side services.
- Standards capability map — the one-screen index.
ISO 11783-7 — implement messages
Part 7 is the vocabulary of physical machine control: the frames that report and command hitches, power take-offs, auxiliary valves, ground/wheel speed, distance, and lighting. These are the network’s steady heartbeat of “what the iron is doing” and “what I want it to do.”
Why this exists
A tractor and an implement must continuously agree on the physical situation: how fast the ground is moving, where the hitch is, whether the PTO is turning, which booms are on. Part 7 standardizes those signals so a rate controller, a guidance system, or a section controller from any vendor reads the same numbers.
The message families
TRACTOR ── wheel-based speed + distance ──► bus (Class 1+)
TRACTOR ── ground-based speed (radar) ────► bus (Class 2+)
TRACTOR ── machine-selected speed ────────► bus
TRACTOR ── front/rear hitch status ───────► bus
TRACTOR ── front/rear PTO status ─────────► bus
IMPLEMENT ── aux-valve command ────────────► tractor
either ── lighting command / data ───────► bus
Two ideas to hold
Speed has provenance. Wheel-based, ground-based (radar), and machine-selected
speed are different signals with different trust. A controller must choose the right
one — radar for true ground speed, wheel for driveline. machbus decodes each with
0xFF-tail “not available” handling, and offers a wheel-slip helper derived from
wheel vs ground speed.
wheel speed 10.0 km/h ─┐
ground speed 9.2 km/h ─┴─► slip = (10.0 − 9.2)/10.0 = 8%
Status and command are symmetric. The same structures describe “the hitch is at
62%” and “move the hitch to 80%”; front vs rear is carried by which PGN delivered it.
machbus’s Implement plugin decodes the status/command families into a cache plus
events, and offers broadcast_* / command_* helpers.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Hitch / PTO / aux / speed / lighting | session::plugins::Implement (isobus::implement) | Implement ECU |
| Engine/transmission alongside | session::plugins::Powertrain | Powertrain |
| Commanding the tractor under authority | session::plugins::Tim | TIM (AEF) |
Failure modes worth knowing
- Wrong speed source — using wheel speed where radar ground speed is meant skews rate and coverage.
- Front/rear mix-up — acting on the wrong hitch/PTO because the PGN was misread.
- Ignoring “not available” — treating a
0xFF-filled signal as a real zero.
See also
- ISO 11783-9 — the tractor ECU — what the tractor advertises it can do.
- TIM (AEF) — guarded command of the tractor by the implement.
ISO 11783-9 — the tractor ECU
Part 9 defines the Tractor ECU (TECU) — the tractor’s representative on the bus — and, crucially, its classes. A tractor advertises which facilities it offers and at what class level, so an implement knows up front whether the tractor can do what it needs before it asks.
Why this exists
An implement that needs ground speed, rear-hitch control, and guidance readiness must not blindly send commands and hope. Part 9 makes the tractor publish a contract of capability: “here is what I provide, at this class.” The implement reads it and adapts — or warns the operator that this tractor cannot run this implement fully.
Facilities and classes
TECU advertisement ──► "I provide:
• ground + wheel speed
• rear hitch control
• rear PTO
• guidance readiness"
▲
implement reads it, then only requests facilities the tractor actually offers
Higher tractor classes provide more facilities (more speed signals, more hitch/PTO
control, guidance). machbus models the facility set, the class matrix, and the
tractor() preset plus the Implement facilities broadcast that advertises them;
maintain-power and guidance-readiness sit here too.
Steering, speed and motion are three separate classes
Base class 1/2/3 says nothing about whether a tractor can be driven over the bus. That is carried by letter addenda, and §4.4.2 gives them separately:
| Addendum | Clause | Meaning |
|---|---|---|
| G | §4.4.2.7 | “shall support the external control of the guidance system” — curvature command, estimated curvature, readiness, lockout |
| P | §4.4.2.8 | “capable of accepting speed and/or drive strategy commands from an implement controller” |
| M | §4.4.2.9 | accepts “commands to initiate motion of the vehicle (forward or reverse)” |
A class 2G tractor steers on command and need not accept a speed command at all.
Even with P, §4.4.2.8 says outright that bringing the tractor to a stop
(speed 0.0) is optional and “can be determined by an implement via the
tractor facilities response message”.
The handshake is not optional for the implement either
Two PGNs, and the second one is the part that surprises people:
- 0xFE09 Tractor Facilities Response — what the TECU has installed.
- 0xFE0A Required Tractor Facilities — what the implement needs.
§4.4.2: “An implement CF can send the required tractor facilities message to the Tractor ECU to enable the transmission of the messages that provide the required facilities. A facility is not required if its corresponding bits are set to 0 in the implement CF required tractor facilities message. The Tractor ECU can then stop the transmission of this implement message to reduce bandwidth.”
So a node that never declares what it needs may simply never receive it. That is
why AutoDrive
broadcasts the request on a cycle rather than only listening.
The TECU’s other duties
Beyond the advertisement, a TECU is the source of the part-7 status broadcasts (speed,
hitch, PTO) and the relay for some tractor-side services. In machbus a tractor node is
typically the Implement plugin (for the status/command messages) plus the facilities
advertisement, optionally MaintainPower and guidance.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Advertising facilities | session::presets::tractor() + Implement | Tractor ECU |
| The status broadcasts | session::plugins::Implement | Implement ECU |
| Keeping power after key-off | session::plugins::MaintainPower | The session facade |
| A curated tractor node | session::presets::tractor() | The session facade |
See also
- ISO 11783-7 — implement messages — the signals the TECU produces.
- TIM (AEF) — how an implement borrows the tractor’s facilities under authority.
ISO 11783-12 — diagnostics
When a sensor reads out of range, a valve stops responding, or a supply voltage sags, the ECU that noticed needs to tell the network, and a service technician needs to read that fault back later. Part 12 is the shared language for that. It reuses the SAE J1939 “DM” diagnostic family almost verbatim and adds a few ISOBUS-specific wrinkles.
Why this exists
A fault only one ECU knows about is useless to everyone else. Network diagnostics make a fault visible (other nodes and the terminal can show it) and durable (a service tool can read and clear it long after it occurred).
The anatomy of a fault
a DTC = SPN (which parameter is wrong)
+ FMI (how it is wrong: too high, too low, open circuit, …)
+ occurrence count (how many times)
plus a lamp panel: malfunction / warning / protect / amber-red status
The DM family
active faults ──► DM1 broadcast, periodic
previous faults ──► DM2 on request
clear ──► DM3 / DM11 (clear all) · DM22 (clear one, with ack/nack)
freeze frame ──► DM25 captured conditions at fault time
memory access ──► DM14 / DM15 / DM16 service-tool read/write
identity ──► ECU / software / product identification strings
The DM1 broadcast is the heartbeat of health: a node periodically announces its active faults; clearing one moves it to the “previously active” list. ISOBUS adds a sixth ECU-identification field and the control-function functionalities advertisement on top of the J1939 base.
ISOBUS DM1 and DM2 have no lamp bytes. Annex B.6 and B.7 define bytes 1–2 as “Reserved, set to FF16” and put SPN/FMI/occurrence in bytes 3–6; the word “lamp” does not appear in ISO 11783-12 at all. J1939-73 does put lamp status there, which is why
DmDtcListkeeps two encoders —encode()for the J1939 form andencode_iso()for the ISOBUS one. The diagnostics plugin broadcasts the ISOBUS form.
Advertising functionalities is forward-compatible by design
The Control Function Functionalities message (PGN 0xFC8E) is how a node says which roles it implements and at which generation. Annex B.9 is unusually explicit that a reader must tolerate what it does not recognise:
“Functionality characteristics values reserved for ISO assignment shall be parsed without generating an error.” … “If the number of option bytes is larger than specified in this document for a functionality, the receiving CF shall ignore the undefined functionality option bytes and parse the known option bytes for this functionality only.”
Both halves matter, because A.10 keeps the 0–255 functionality list in the online database — it grows between revisions. A decoder that rejects the message over one unknown code throws away every functionality it did understand, so machbus skips unknown blocks using their own declared option length and keeps the rest.
How machbus expresses it
Two plugins cover the family:
Diagnostics— owns the active/previous lists, the periodic DM1 broadcast, and request handling (DM1/DM2 requests, DM3/DM11 clears, DM22 individual clears). Youraise/clearfaults through fine control; inbound peer faults arrive asEvent::Diag(DiagEvent::Dm1Received { .. }).DmMemory— the service-tool messages (DM14/15/16) and the identity strings, with automatic answers to identification requests.
ctrl.with_mut::<Diagnostics, _>(|d| d.raise(dtc)); // active → goes out on DM1
// peer DM1 → Event::Diag(DiagEvent::Dm1Received { source, active, lamps })
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Active/previous faults, DM1 | session::plugins::Diagnostics | Diagnostics |
| Service-tool memory + identity | session::plugins::DmMemory | Diagnostics |
| The DM codecs directly | j1939::diagnostic | Diagnostics |
Failure modes worth knowing
- Silent faults — forgetting to enable diagnostics means a real fault never reaches the bus.
- Stale active list — clearing must move a DTC to previously-active, not just drop it.
- Identity gaps — service tools expect identification responses; missing them looks like a dead ECU.
See also
- SAE J1939 — the heritage — where the DM family comes from.
- Implement control, the tractor ECU, and the rest — the services overview.
ISO 11783-13 — the File Server
Part 13 puts a filesystem on the bus. A File Server offers volumes and directories; clients open, seek, read, write, and close files, manage attributes, and query free space — each as a request/response exchange. It is how implements persist configuration, prescriptions, and logs without storage of their own.
Why this exists
A small implement ECU may have little or no non-volatile memory, yet it needs to load a prescription, save an as-applied log, or keep settings across power cycles. Rather than give every ECU a card slot, ISOBUS lets one node be the server and everyone else share it.
The conversation
Every operation is a request tagged with a transaction number (TAN); the matching response carries the same TAN, so a client can have several in flight:
CLIENT SERVER
│ ── open "/presc.bin", read ──► (TAN 7)
│ ◄── handle 3, ok ────────────
│ ── read handle 3, 512 bytes ─► (TAN 8)
│ ◄── 512 bytes ─────────────── (large reads ride Transport Protocol)
│ ── write handle 3, <data> ──► (TAN 9)
│ ◄── 512 bytes written ───────
│ ── close handle 3 ──────────► (TAN 10)
│ ◄── ok ──────────────────────
The full surface: connect (a CCM handshake), open/close, read/write/seek, get/set attributes, get date-time, current/change directory, move/delete, volume init and status, plus free-space queries.
How machbus expresses it
machbus implements both halves with an async-style request → event model: each
request method returns its TAN immediately, and the matching response arrives later as
an FsEvent. The FsClient plugin drives the client; FsServer serves in-memory
files and directories.
let tan = ctrl.with_mut::<FsClient, _>(|fs| fs.open("/presc.bin", flags))??;
// … later …
// Event::Fs(FsEvent::OpenResponse { tan, result: Ok(handle) })
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Reading/writing files (client) | session::plugins::FsClient | File Server |
| Serving files (server) | session::plugins::FsServer | File Server |
| The FS codecs directly | isobus::fs | File Server |
What counts as malformed
Narrower than it looks. §4.9 scopes the malformed-request error to one thing:
“The file server shall respond with Error Code 47 Malformed Request, if it receives a message, which is shorter than expected.”
Length — not a surprising flag value. The volume flags (B.29) and volume mode (B.30) each describe their spare bits as “Reserved, send as 000000”, which binds the sender; nothing licenses a server to refuse a request over one. Refusing would also mean a later revision that defines one of those bits gets turned away rather than ignored, against the general rule that undefined bits are “received as ‘don’t care’”. machbus therefore masks them on receive while still refusing to transmit them set.
Failure modes worth knowing
- TAN confusion — matching a response to the wrong request if TANs are not tracked.
- Not connected — issuing file ops before the CCM handshake completes.
- Big transfers — large reads/writes ride the transport protocol and inherit its timeout/abort behavior.
See also
- ISO 11783-3 — data link & transport — what carries the big reads and writes.
- Implement control, the tractor ECU, and the rest.
ISO 11783-14 — sequence control
Part 14 automates sequences of actions. Its most familiar face is headland management: at the end of a pass, the machine raises the hitch, folds a marker, disengages sections, and lifts the implement — in a defined order, from one operator action or a position trigger. Sequence Control is the protocol that coordinates that.
Why this exists
Turning at the headland is a choreography of half a dozen actuator actions that must happen in the right order every time. Doing it by hand, every pass, is tiring and error-prone. Sequence Control lets the machine record the choreography once and replay it reliably, with each participating ECU executing the steps it owns.
Master and client
MASTER (runs the saved sequence) CLIENTS (execute their steps)
│ ── start sequence ──────────────────► (all)
│ ── step 3: "raise rear hitch" ──────► hitch ECU executes
│ ◄── step 3 complete ───────────────── hitch ECU reports
│ ── step 4: "disengage section bank" ► section ECU executes
│ ◄── step 4 complete ─────────────────
│ … pause / resume / abort as needed …
│ ── sequence complete ───────────────►
The master drives ordering and timeouts; each client executes the steps addressed to it and reports progress. The protocol carries pause, resume, and abort so a sequence can be interrupted safely.
How machbus expresses it
machbus implements both roles: ScMaster runs and broadcasts the sequence;
ScClient executes steps and reports completion. Both surface their lifecycle as
ScEvent variants (state changes, step started/completed, sequence complete, timeout,
pause/resume/abort).
ctrl.with_mut::<ScMaster, _>(|m| { m.add_step(step)?; m.start() })?;
// progress arrives as Event::Sc(ScEvent::MasterStepCompleted { step_id }) …
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Running a sequence (master) | session::plugins::ScMaster | Sequence Control |
| Executing steps (client) | session::plugins::ScClient | Sequence Control |
Failure modes worth knowing
- Step never completes — a client that cannot perform a step must report, or the master times out; do not hang the sequence.
- Unsafe abort — aborting mid-sequence must leave the machine in a safe state, not half-folded.
- Ordering assumptions — steps run in the master’s order; clients should not reorder.
See also
- TIM (AEF) — the authority model often paired with automated motion.
- Implement control, the tractor ECU, and the rest.
TIM — Tractor Implement Management (AEF)
TIM lets an implement command the tractor — adjust speed, work the hitch, spin the PTO — so the implement can optimize the whole operation rather than just react to it. That is powerful and dangerous, so TIM is built around authority with safety interlocks. It is an AEF-driven capability layered on the ISO messages.
Why this exists
Often the implement knows best. A baler knows when to slow the tractor for a dense windrow; a precision planter knows when to lift. Letting the implement make those adjustments directly is more accurate and less tiring than the operator relaying them. But handing a stranger’s box control of a moving tractor demands a hard safety model — hence authority that must be explicitly granted and can be revoked the instant an interlock trips.
The authority handshake
IMPLEMENT ── request authority over {rear hitch, rear PTO} ──► TRACTOR
◄── grant (only if interlocks are clear) ──────────
IMPLEMENT ── command: rear PTO engage CW ──► (allowed only while granted)
◄── status: PTO engaged ──────────
…
── any interlock trips → authority revoked → further commands refused
The guard is the whole point: a guarded command is refused before any frame goes on the wire unless authority is currently granted and the local interlocks are clear. A revoked grant immediately blocks subsequent commands.
How machbus expresses it
The Tim plugin is a local authority/interlock guard plus the guarded command helpers
and the decoded PTO/hitch/aux status stream:
ctrl.with_mut::<Tim, _>(|t| {
t.request_authority(options)?; // ask
t.grant_authority()?; // (tractor side) grant if interlocks clear
t.command_pto_engage(Pto::Rear, true) // refused unless granted + clear
});
// a blocked command surfaces as Event::Tim(TimEvent::CommandBlocked { .. })
// an interlock change surfaces as Event::Tim(TimEvent::AuthorityStateChanged(..))
set_interlocks updates the local safety state; if it revokes authority, the change is
reported and further guarded commands fail until authority is re-granted.
Certification
TIM is defined and certified by AEF. machbus ships the mechanism — the authority guard, the guarded commands, the status decoding — but no certification. Shipping a TIM-capable product to market is a separate AEF process; see Conformity first.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Authority + guarded commands | session::plugins::Tim | TIM and automation |
| The hitch/PTO messages it guards | session::plugins::Implement | ISO 11783-7 — implement messages |
| The certification boundary | — | Conformity first |
Failure modes worth knowing
- Commanding without authority — refused by design; check the grant first.
- Ignoring revocation — an interlock can revoke mid-operation; watch for the authority-state event and stop commanding.
- Treating the mechanism as certification — it is not.
See also
- ISO 11783-9 — the tractor ECU — the facilities TIM borrows.
- ISO 11783-14 — sequence control — automated motion that often pairs with TIM.
Automatic guidance
The first question everyone asks about ISOBUS guidance is: do I send points, angles, or velocities? The answer is none of those — you send a desired path curvature.
One capability, several names. Automatic guidance, agricultural guidance and autosteer all mean the same thing: this page. ISO 11783-7 calls the messages “Agricultural Guidance System Command” and “Agricultural Guidance Machine Info”; “autosteer” is the colloquial name for using them. There is no separate autosteer facility, message or plugin.
In machbus one plugin speaks these messages:
AutoDrive, which carries steering and speed behind a single engage lifecycle.Everything else you may see is supporting code, not another concept:
isobus::implement::guidanceis the wire codecs,geo::guidanceis pure path-to-curvature maths, andmachbus driveis a tool subcommand that drives a plugin.
Points vs. angles vs. curvature
Three things you might imagine sending to make a tractor steer itself, and why only one of them is right:
- Not waypoints. You do not stream a list of latitude/longitude points and let the tractor figure out the geometry. The bus has no message for “drive to these coordinates.”
- Not a raw steering angle. You do not command the wheels directly to N degrees. The right wheel angle depends on the machine’s geometry and changes with speed; an angle that holds a line at 4 km/h would oversteer at 12 km/h.
- Curvature. You send the desired curvature of the path: how tightly the
machine should be turning, expressed in 1/km — the inverse of the turn radius.
Zero curvature is dead straight. A larger magnitude is a tighter turn, and the
sign tells the steering which way to turn. A 50 m radius is
1000 / 50 = 201/km.
Curvature is the natural interface because it is independent of speed and of the machine’s steering linkage. The tractor’s steering ECU takes the commanded curvature and closes the loop on its own wheels — it owns the actuator, the mechanical limits, and the speed-dependent geometry. The guidance controller only has to say how hard to turn, not how to move the wheels.
Speed is a separate concern. A curvature command says nothing about how fast the machine travels. The tractor owns its speed. Keep the two ideas apart: guidance is geometry, not throttle.
If you also want to influence speed, there are two different facilities, and they are not interchangeable:
- Machine Selected Speed Command (PGN 0xFD43) — the ISO 11783-7 message, sent
as a plain broadcast with no authority handshake. This is what the
AutoDriveplugin uses alongside the curvature command. - TIM — a separate protocol on its own PGNs, where speed is a function that must be explicitly assigned and authenticated before you may command it.
Which one a given tractor acts on is a property of that tractor. An AEF-certified machine will generally guard speed behind TIM; a retrofit or bench system will generally take the native message. See AutoDrive → Do I need TIM? for the full comparison.
Turning a path into curvature is the application’s job. Each control cycle, something has to look at the planned line, the current GNSS position and heading, and the cross-track error, then compute the single curvature value that steers the machine back onto the line. That control law — a pure-pursuit or Stanley tracker, typically — lives in your application. machbus does not plan paths or run the tracker; it carries the resulting curvature command onto the wire and decodes what comes back.
Thinking in (v, ω): the robotics twist
If you come from mobile robotics, you command a body with a twist: a linear
velocity v and an angular (yaw) velocity ω. Curvature is not a rejection of that
model — it is that model with the speed factored out:
κ = ω / v
A robot uses (v, ω) because one controller owns both steering and throttle.
ISOBUS deliberately splits them: the guidance message carries only the
speed-independent geometry (curvature), because on a tractor the steering authority
and the speed authority are usually different systems — and the operator often keeps
speed. Dividing ω by v is exactly what removes the speed dependence: a 50 m
circle stays a 50 m circle whether you crawl or fly. (This is also why a raw yaw
rate ω alone would be the wrong thing to send — the same ω is a different arc at
every speed.)
You can still command in twist terms when it is convenient. machbus’s (v, ω) call
computes κ = ω / v and sends it as the Guidance System Command (PGN 0xAD00), and
also sends v as a Machine Selected Speed Command (PGN 0xFD43) — so the two
separate authorities each receive the message they expect, from one call. A
near-zero v cannot define a forward path curvature, so it commands straight. See
the Guidance tutorial.
The two messages
Guidance is a two-way conversation between the guidance controller (the thing deciding where to go) and the tractor’s steering ECU (the thing that moves the wheels). Two messages carry it, each in one direction.
guidance controller steering ECU (tractor)
(your app: path + GNSS → curvature) (actuator + safety logic)
│ │
│ Guidance System Command (PGN 0xAD00) │
│ commanded curvature + intent to steer ───► │ inside limits?
│ │ engaged / allowed?
│ │
│ ◄─── Agricultural Guidance Machine Info │
│ (PGN 0xAC00): estimated curvature, │
│ steering readiness, limit status │
▼ ▼
adjust the request next cycle steer, refuse, or drop out
Guidance System Command — PGN 0xAD00
Direction: guidance controller → tractor. This is the command. It carries the commanded curvature plus a small readiness/intent signal that says whether the controller actually wants to steer right now, or is merely reporting a curvature without asking to take the wheel. A curvature number on its own is never a request to steer — the intent flag is the engage signal. The controller resends this message on a fixed cadence; it is a heartbeat, not a one-shot.
Agricultural Guidance Machine Info — PGN 0xAC00
Direction: steering ECU → bus. This is the feedback. The steering system broadcasts:
- its estimated actual curvature — what the wheels are really producing now, which is not always what you asked for;
- its steering-system readiness — whether the system is engaged and in a state that allows an external command to steer it;
- a guidance limit status — whether the command is being clamped, the system is at a limit, or there is a fault.
You command through 0xAD00 and you always verify through 0xAC00. Never assume the machine reached the curvature you requested — read back the estimated curvature.
What each signal means, in plain terms
Two messages, a handful of fields. Here is what each field is actually telling you — no jargon.
On the command (PGN 0xAD00), controller → tractor:
| Field | What it means |
|---|---|
| Commanded curvature | How hard to turn, in 1/km (0 = straight, sign = direction). |
| Curvature Command Status | The engage request. Intended to steer = “take the wheel”; not intended to steer = “I’m only reporting a number, don’t steer.” This single flag is the difference between a suggestion and a request — it is exactly what engage() and disengage() flip. |
On the feedback (PGN 0xAC00), tractor → bus. The steering ECU broadcasts this every 100 ms the whole time it is powered. Seeing this message on the bus at all is the first signal that the ECU can be steered. Inside it:
| Field | What it means | Value that means “good to steer” |
|---|---|---|
| Steering System Readiness State | The headline “am I ready?” flag. | On / active = ready and engaged. Off/passive = not ready. |
| Mechanical Lockout | A physical safety cut-out (e.g. a lockout switch). | Not active. If it is Active, you cannot engage at all. |
| Remote Engage Switch Status | The operator’s arm switch — most systems need the person in the seat to flip a switch or hold a button before guidance is allowed to take the wheel. | On / active (operator has armed it). |
| Steering Input Position Status | Whether the operator’s steering wheel is being moved — the basis for override detection. | (informational) |
| Guidance Limit Status | Whether your command is being clamped, the system is at a limit, or has a non-recoverable fault. | Not limited. |
| Exit / reason code | Why the system is refusing or last dropped out (a diagnostic — see below). | No reason / all clear. |
| Estimated curvature | What the wheels are actually producing right now — not necessarily what you asked for. | (always read it back; never assume) |
is_steering_ready() is exactly the “Steering System Readiness State == on/active”
check. For everything else, read the full record with latest_machine_info().
When it refuses: the exit / reason code
When the steering ECU will not — or will no longer — accept your commands, it says why in the exit/reason code. The reasons a real system reports (from the AEF automation guideline’s external-guidance table) include:
- required level of operator presence/awareness not detected
- operator override of function — someone touched the wheel
- operator control not in a valid position
- remote command timeout — your command heartbeat stalled
- remote command out of range / invalid
- system not calibrated
- alternate guidance system active
- vehicle speed too high / too low
- transmission gear does not allow remote commands (park, etc.)
Treat any non-clear reason as “stop asserting intent and tell the operator,” not as something to retry blindly.
The lifecycle: when each thing happens
Read the two messages in order and a normal engage → steer → release cycle looks like this:
- Power on. The steering ECU starts broadcasting Agricultural Guidance Machine Info (0xAC00) at 100 ms with readiness = not ready. Its mere presence tells your controller the machine is steerable. A deeper, up-front capability check is the ISO 11783-9 tractor-facilities advertisement, which you can read before any guidance traffic to know the tractor supports being steered at all.
- Operator arms it. The person in the seat flips the remote-engage switch (or holds the button). Remote Engage Switch Status goes on, and the system moves toward ready. Until this happens, nothing your code sends will steer.
- Controller asks for the wheel. Your app calls
engage()and starts sending the Guidance System Command (0xAD00) carrying the curvature and Curvature Command Status = intended to steer, on a fixed ~100 ms heartbeat. - System engages. With the operator armed, no lockout, speed in range, and the command stream alive, the steering ECU engages: readiness reports on/active and the estimated curvature begins tracking your command.
- Steady state. Every cycle you recompute a curvature from your path + GNSS and resend it, and you read back readiness, limit status, and estimated curvature to confirm the machine is actually following.
- Drop out. The instant the operator touches the wheel — or speed leaves the
window, or your heartbeat stalls — the system leaves automatic mode, readiness
drops to not ready, and the exit/reason code says why. Your controller must
disengage()and stop asserting intent at once. It does not fight the operator. - Release. When you are done, call
disengage(): the next command goes out with not intended to steer, and the operator has the wheel back.
The rule under all of it: the steering ECU is the authority on its own safety. Your controller asks, the tractor decides, and a dropout is honoured immediately — every cycle.
Two layers of “is it allowed?”
The fields above are the raw ISO 11783-7 handshake. On top of them sits the AEF TIM / automation layer, which treats steering as an authority-controlled automated function: authority to steer must be granted, and it comes with the same operator-override interlocks formalised as part of the automation contract. Both layers say the same thing — the operator and the tractor stay in charge — at different levels of formality. See TIM for how that authority is granted and revoked.
machbus is not a safety system
machbus encodes and decodes these messages. It does not plan paths, does not close the steering loop, does not supervise the operator, and carries no ISO/SAE/AEF certification. Nothing here makes a machine safe for unattended steering. Real deployment needs official standards, functional-safety engineering, qualified hardware, and interoperability evidence this crate does not provide.
From concept to code
The session::plugins::AutoDrive plugin is the whole surface: arm and
engage (assert intent to steer), disengage, command a curvature and
optionally a speed, and read back the steering system’s estimated curvature,
readiness and limit status. It is also exposed in the C and Python bindings
(session-level autodrive_* functions/methods, behind an enable_autodrive
flag).
| You read about… | Build it with… | See… |
|---|---|---|
| Asking for / releasing the wheel (intent to steer) | AutoDrive::arm + engage / disengage (sets the Curvature Command Status on 0xAD00) | AutoDrive tutorial |
| Commanding a path by curvature | AutoDrive::command(DriveCommand::steer(k)) | AutoDrive tutorial |
| Converting a radius or a (v, ω) twist to curvature | geo::guidance::curvature_per_km_from_radius / curvature_per_km_from_twist | AutoDrive tutorial |
| Commanding steering and speed together | AutoDrive::command(DriveCommand { speed_mps, curvature_km_inv }) | AutoDrive tutorial |
| Reading the steering ECU’s feedback | AutoDrive::estimated_curvature, steering_readiness_state, machine_info, Event::Guidance | AutoDrive tutorial |
| The tractor advertising it can steer | ISO 11783-9 facilities | ISO 11783-9 — the tractor ECU |
| Steering as a granted, revocable authority | session::plugins::Tim | TIM (AEF) |
See also
- AutoDrive tutorial — the plugin, end to end.
- ISO 11783-9 — the tractor ECU — what the tractor advertises.
- TIM (AEF) — steering under granted authority.
- Positioning: NMEA and GNSS — where the position and heading that feed your path tracker come from.
Positioning: NMEA and GNSS
Guidance, section control, and TC-GEO all need one thing the rest of ISOBUS does not provide: an accurate, fresh answer to “where am I, which way am I pointed, and how fast am I going?” That answer comes from a GNSS receiver, and on a modern machine it usually arrives over NMEA 2000 — a CAN-based standard that is a cousin, not a child, of ISOBUS. This chapter explains how the fix gets onto the bus and into a prescription.
Why this is a separate world
ISO 11783 is about machine control; positioning is a measurement domain with its own committee, its own message catalog, and its own history (the older serial NMEA 0183 sentences and the CAN-based NMEA 2000). Rather than reinvent it, ISOBUS machines simply carry NMEA 2000 traffic on the same wire and let agricultural software consume it. machbus treats positioning as a first-class but bounded subset: it decodes the PGNs guidance and TC-GEO actually need.
GNSS receiver ──(NMEA 2000 PGNs)──► bus ──► guidance / TC-GEO / logging
│ │
position, COG/SOG, heading, attitude, turns a fix + a map into
dilution-of-precision, system time per-section setpoints
What a fix actually contains
A position is more than a latitude/longitude pair. The signals that matter for agriculture, each its own NMEA 2000 PGN that machbus decodes:
| Signal | Why a machine cares |
|---|---|
| Rapid position (lat/lon) | the basic fix, updated frequently for guidance |
| Detailed GNSS position | full fix with quality/satellite info |
| COG / SOG (course & speed over ground) | direction and speed for guidance and logging |
| Heading / track control | where the vehicle points (not always the same as COG) |
| Attitude (yaw/pitch/roll) | terrain compensation for accurate ground position |
| Rate of turn | smoothing and prediction |
| DOPs (dilution of precision) | how much to trust the fix |
| System time / date, local-time offset | timestamping the as-applied log |
The recurring theme: a raw lat/lon is not enough. Accurate section control needs heading and attitude (the GNSS antenna is not at ground level, and a tilted machine projects its position sideways), and the DOP values tell the software when the fix is too poor to act on.
Fast Packet: the right-sized transport
A full GNSS position record does not fit in 8 bytes, but it is far smaller than an object pool. NMEA 2000 uses Fast Packet — a lightweight multi-frame scheme — for these modest records, rather than the heavier ISOBUS Transport Protocol. machbus’s network layer reassembles Fast Packet PGNs (the GNSS position-data message among them) so the decoder sees a complete record.
Fast Packet: frame 0 (header + first bytes) · frame 1 · frame 2 …
→ reassembled into one GNSS position record → decoded
NMEA 0183, briefly
Some receivers still speak the older serial NMEA 0183 — comma-separated ASCII
sentences (GGA, RMC, VTG, …) over a UART rather than CAN. machbus can parse
these too, which is useful for bench setups and simpler receivers. The two worlds
carry the same underlying information in very different envelopes; machbus normalizes
both into the same position types.
From a fix to a setpoint
The payoff is the loop that closes guidance and variable-rate work:
NMEA fix (lat/lon, heading, attitude, quality)
│ normalize, check quality (DOP)
▼
machine ground position (antenna offset + tilt corrected)
│ + DDOP geometry (section offsets, working width)
│ + prescription map
▼
per-section coverage + setpoint rates (TC-GEO)
│
▼
process data DOWN to the implement · as-applied logged UP
This is why the positioning chapter sits next to the Task Controller: the fix is an input to TC-GEO, and the DDOP geometry is what turns “the machine is here” into “boom section 7 is over this square metre.”
Doing it with machbus
On the session facade, plug Gnss. It decodes the NMEA 2000 GNSS/navigation PGNs
into cached state plus GnssEvents, and can broadcast a few of them:
Session::builder(name, addr)
.plug(Gnss::new(NMEAConfig::default().with_all(true)))
.spawn(transport)
│
▼ driver.poll()
inbound NMEA PGNs → Event::Gnss(GnssEvent::Position / Cog / Sog / …)
cached fix available via get::<Gnss>().latest_position()
The hands-on paths are NMEA 2000 and Serial GNSS.
From concept to code
| You read about… | Build it with… | See… |
|---|---|---|
| Decoding NMEA 2000 GNSS PGNs | session::plugins::Gnss | NMEA 2000 |
| Serial NMEA 0183 receivers | nmea parser | Serial GNSS |
| Steering on top of the fix | guidance helpers | Guidance |
| Position → setpoint | TC-GEO + DDOP geometry | TC-GEO prescription |
Scope and honesty
machbus implements the GNSS/navigation PGNs that agricultural guidance and TC-GEO need, decoded and tested locally; it is deliberately a subset of the full NMEA 2000 catalog (it is not a marine chartplotter). It does not provide RTK correction, a positioning engine, or certified positioning accuracy — that is the receiver’s job. What machbus guarantees is faithful decoding of the records the receiver puts on the bus.
Failure modes worth knowing
- Trusting a bad fix — acting on a position without checking DOP/quality leads to misapplied product; gate work on fix quality.
- Ignoring attitude — on slopes, an uncorrected antenna position is metres off at ground level.
- COG ≠ heading — at low speed, course-over-ground is noisy; heading is the reliable signal for orientation.
- Stale fix — if the receiver drops out, the last position must be treated as stale, not current.
See also
- The Task Controller and the data dictionary — where the fix becomes a setpoint.
- The networking foundation — Fast Packet and the transport family.
- Guidance — steering and curvature on top of the fix.
Decode without ISOBUS
machbus is best known as an ISOBUS stack. But underneath the session facade, the implement personas, and the Virtual Terminal client, there is a smaller, sharper tool: a CAN / J1939 / NMEA 2000 decoder. You can use it on its own.
This is deliberate. Most of the value of being on an agricultural bus is simply understanding what is on it. A tractor, a GNSS receiver, an engine ECU, and a planter all chatter constantly. Before you ever claim an address or send a command, you usually want to listen — to take a raw 29-bit identifier and eight bytes of payload and turn it into something you can reason about: “engine speed, 1450 rpm, from source 0x00.”
machbus exposes exactly that capability as three standalone public modules. You do not need to start a session, claim an address, or run a network manager to use them.
The three public modules
machbus
├── net ── CAN frames, identifiers, PGNs, the wire layer
├── j1939 ── SAE J1939 PGN codecs (engine, diagnostics, …)
└── nmea ── NMEA 2000 PGN codecs + an NMEA 0183 serial parser
machbus::net — the wire layer
This is the foundation. It knows nothing about the meaning of a message — only its shape on the bus. Here you find:
Identifier— the 29-bit extended CAN identifier, with structured accessors for priority, PGN, source address, and destination. It does the PDU1/PDU2 arithmetic for you (more on that trap in the next page).Message— an identifier paired with up to eight data bytes; the unit a node actually sends and receives.Pgnand the PGN helpers — the Parameter Group Number, plus utilities to extract the PF/PS fields, decide whether a PGN is broadcast, and look up known PGNs.
If all you want is to read a candump line and answer “who sent this, to whom, at what
priority, and which parameter group is it?”, net alone is enough.
machbus::j1939 — named messages
net gives you a PGN number. j1939 tells you what that number means. It is a
collection of wire codecs, one family per concern: engine controllers, fuel and
temperature, hours, transmission, speed and distance, the whole diagnostic “DM” family,
acknowledgements, requests, and proprietary messages. Each codec takes the raw bytes for
its PGN and produces a typed Rust struct with named, scaled fields.
machbus::nmea — positioning and the marine heritage
NMEA 2000 rides on the same 29-bit CAN bus as J1939 and ISOBUS. In agriculture it is
how GNSS receivers, compasses, and weather sensors report position, heading, course, and
environment. The nmea module decodes a focused subset of N2K PGNs through its
NMEAInterface, handles the N2K network-management PGNs, and — as a bonus — ships an
NMEA 0183 parser for the older serial-text sentences a GNSS puck might emit over a
UART.
The decode pipeline as a mental model
Every decode, no matter the protocol, follows the same four steps. Hold this picture in your head and the rest of these pages will slot into place.
┌──────────────────────────────────────────────────────────────┐
│ 1. RAW FRAME │
│ 29-bit identifier + 0–8 data bytes │
│ e.g. 0x0CF00400 [ FF FF 68 13 FF FF FF FF ] │
└───────────────────────────┬──────────────────────────────────┘
│ net::Identifier::from_raw(...)
▼
┌──────────────────────────────────────────────────────────────┐
│ 2. STRUCTURED IDENTIFIER │
│ priority = 3 PGN = 0xF004 source = 0x00 dst = global │
│ "broadcast, electronic engine controller #1" │
└───────────────────────────┬──────────────────────────────────┘
│ match on the PGN
▼
┌──────────────────────────────────────────────────────────────┐
│ 3. PICK THE CODEC FOR THAT PGN │
│ PGN 0xF004 → j1939 EEC1 codec │
│ PGN 129025 → nmea rapid-position codec │
└───────────────────────────┬──────────────────────────────────┘
│ decode the data bytes
▼
┌──────────────────────────────────────────────────────────────┐
│ 4. TYPED STRUCT │
│ Eec1 { engine_speed: 620.0 rpm, … } │
└──────────────────────────────────────────────────────────────┘
Read it left to right:
- Raw frame. Whatever your CAN driver hands you: an identifier and a byte slice. This is the same shape whether the frame is J1939, NMEA 2000, or plain ISOBUS — they all share the 29-bit CAN format.
- Structured identifier.
net::Identifiercracks the 29 bits into priority, PGN, source, and destination. This step is protocol-agnostic; it is pure bit arithmetic. - Pick the codec. The PGN is the routing key. You look at it and decide which decoder applies — a J1939 engine codec, an NMEA navigation codec, or none (an unknown or proprietary PGN you choose to ignore).
- Typed struct. The chosen codec interprets the data bytes — applying scale factors, offsets, bit masks, and “not available” sentinels — and gives you a struct with real units.
The crucial insight: steps 1–2 are universal, steps 3–4 are protocol-specific. That
is exactly why machbus splits net (universal) from j1939 and nmea (specific).
A note on multi-frame messages
Step 1 above assumes one frame carries one whole message. Often it does — most J1939 and N2K signals fit in eight bytes. But some messages are larger: a long diagnostic list, a detailed GNSS fix with every satellite. Those are split across many frames and reassembled by a transport protocol (J1939 TP/BAM/ETP) or Fast Packet (NMEA 2000).
For the basics, treat reassembly as a black box that sits between steps 1 and 2: many frames go in, one logical payload comes out, and from there the pipeline is unchanged. The J1939 and NMEA 2000 pages explain how those mechanisms work conceptually.
When to use this vs the full session facade
Reach for the decode modules when you want to observe the bus:
- You have a candump trace, a log file, or a live socket and you want to know what is on it.
- You are building a dashboard, a logger, a black-box recorder, or a diagnostics viewer.
- You only care about reading engine, GNSS, or diagnostic data — you are not a participant, just a listener.
- You want a tiny dependency: no address claiming, no network manager, no timers.
Reach for the session facade when you want to participate:
- You need a claimed address and a NAME on the network.
- You must respond to requests, send acknowledgements, or run a Virtual Terminal or Task Controller client.
- You need the network manager to track partners, handle address conflicts, and run transport sessions for you.
A good rule of thumb: if you are read-only, stay in net / j1939 / nmea. The
moment you need to talk back in a way the network must track, move up to the session
facade.
Where to go next
Three conceptual pages build the picture from the bottom up:
- Anatomy of a CAN frame — the bus, the 29-bit identifier, and the PDU1/PDU2 split that trips up everyone.
- J1939 messages and PGNs — from frames to named messages, the
transport protocols, the DM diagnostic family, and what
j1939decodes. - NMEA 2000 on the bus — how N2K reuses J1939, Fast Packet, the GNSS
PGNs, and what
nmeadecodes.
Then three hands-on tutorials show the actual code:
For the deep theory behind the wire — why the identifier looks the way it does, how address claiming works, how transport is specified — see the standards section, in particular SAE J1939: the heritage and Positioning: NMEA and GNSS.
Anatomy of a CAN frame
Everything on an agricultural bus — J1939 engine data, NMEA 2000 position, ISOBUS commands — is, at the very bottom, a CAN frame. If you understand the frame, you understand the substrate that all three protocols share. This page builds that picture from the wire up, and ends with the single detail that confuses every newcomer: the PDU1/PDU2 split.
You will use machbus::net to work at this layer. Its Identifier type is the
structured form of what this page describes.
The bus
CAN — Controller Area Network — is a two-wire, multi-drop, broadcast bus. Every node is wired to the same pair of differential wires (CAN-H and CAN-L), terminated at both ends.
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ ECU │ │ GNSS │ │ Term │ │ Disp │
│ A │ │ │ │ inal │ │ lay │
└──┬───┘ └──┬───┘ └──┬───┘ └──┬───┘
120Ω │ │ │ │ 120Ω
──┳━━━━━┷━━━━━━━━━━━┷━━━━━━━━━━━┷━━━━━━━━━━━┷━━━━┳── CAN-H
┃ ┃
──┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻── CAN-L
Two consequences shape everything else:
- There are no addresses on the wire. Every node hears every frame. Filtering by who-is-it-for happens in software, above CAN. This is why a decoder can simply listen and see the whole bus.
- Frames carry an identifier, not a destination. The identifier names the message, and arbitration uses it to decide who transmits when two nodes start at once. A lower numeric identifier wins; the loser backs off and retries. This is how “priority” gets baked into the identifier — lower value, higher priority.
Two identifier sizes: 11-bit vs 29-bit
CAN comes in two flavours of identifier:
| Form | ID width | Common name | Used by |
|---|---|---|---|
| CAN 2.0A | 11 bits | Standard / base | Simple automotive, appliances |
| CAN 2.0B | 29 bits | Extended | J1939, ISOBUS, NMEA 2000 |
An 11-bit identifier gives you 2048 possible message IDs — fine for a small closed system. Agriculture and heavy vehicles need far more structure than that: a priority, a message identity, and the address of the sender, all packed into the identifier itself. Eleven bits cannot hold all of it.
So agriculture uses 29-bit (extended) identifiers. The extra 18 bits are exactly what lets J1939 — and therefore ISOBUS and NMEA 2000 — carry priority, a Parameter Group Number, and a source address in every single frame. When you see a 29-bit ID on a farm bus, it is almost certainly one of these three protocols, all of which share the same layout.
The 29-bit identifier layout
Here is the layout J1939 defined and ISOBUS and NMEA 2000 inherited. Bit 28 is the most significant.
bit 28 26 25 24 23 16 15 8 7 0
┌─────────┬───┬───┬───────────────┬───────────────┬─────────────┐
│ priority│EDP│ DP│ PF │ PS │ source │
│ 3 bits │ 1 │ 1 │ PDU format │ PDU specific │ address │
│ │ │ │ 8 bits │ 8 bits │ 8 bits │
└─────────┴───┴───┴───────────────┴───────────────┴─────────────┘
\_______/ \_____________________________________/ \___________/
priority the PGN lives here who sent it
Field by field:
- Priority (3 bits). 0–7. Lower wins arbitration. Safety-critical messages get low numbers; routine status gets higher ones. A common default is 6.
- EDP — Extended Data Page (1 bit). Selects which PGN page the message lives on. J1939/ISOBUS messages use EDP = 0. NMEA 2000 also lives in the standard pages. EDP = 1 is reserved for other ISO 15765 uses.
- DP — Data Page (1 bit). A second page-selector bit. Together EDP and DP extend the PGN number space.
- PF — PDU Format (8 bits). The high byte of the message identity. Its value decides whether the frame is point-to-point or broadcast (see below).
- PS — PDU Specific (8 bits). The low byte. Its meaning depends on PF — it is either a destination address or part of the message identity. This is the trap.
- Source address (8 bits). Who sent the frame. 0–253 are real node addresses, 254 is the null address (used during address claiming), and 255 is the global/broadcast address.
The middle 18 bits — EDP, DP, PF, and (sometimes) PS — together form the PGN, the Parameter Group Number that names the message. The next page is all about PGNs.
net::Identifier gives you each of these directly: priority(), pdu_format(),
pdu_specific(), source(), data_page(), extended_data_page(), plus the derived
pgn(), destination(), and is_broadcast().
The data field: DLC and up to 8 bytes
After the identifier comes the payload. Classic CAN frames carry a DLC (Data Length Code) and 0 to 8 data bytes.
┌──────────────────────┬─────┬───────────────────────────────┐
│ 29-bit identifier │ DLC │ data: 0–8 bytes │
└──────────────────────┴─────┴───────────────────────────────┘
└ byte 0 … byte 7 ┘
Eight bytes is a hard ceiling for classic CAN. That single constraint explains a huge amount of how J1939 and NMEA 2000 are designed:
- Signals are packed tightly — a field might be one byte, or two bytes little-endian, or even a handful of bits — to fit the budget.
- Anything larger than eight bytes must be split across multiple frames and reassembled. That is what transport protocols (J1939 TP/BAM/ETP) and Fast Packet (NMEA 2000) exist to do. Each gets covered on its protocol’s page.
Two more details worth knowing as a decoder:
- Byte order is little-endian. Multi-byte signals put the least-significant byte
first. A two-byte value in bytes 3–4 is
byte3 + (byte4 << 8). - 0xFF means “not available.” A byte (or all bytes of a signal) set to all-ones is the standard “no data” sentinel. A scaled signal reading its maximum raw value almost always means the sensor has nothing to report, not a real reading. Treat these as missing, never as data.
Bit timing and bus speed (briefly)
You rarely touch this as a decoder, but it is worth one paragraph.
The ISOBUS / NMEA 2000 baseline runs at 250 kbit/s. Every node on the segment must agree on this rate, or nothing communicates. The bit rate is built from a nominal bit time divided into time quanta, with a sample point typically placed around 75–87% of the bit. The sample point and synchronization-jump-width let nodes stay in lock-step despite clock drift and propagation delay along the cable. J1939 buses also commonly run at 250 kbit/s, with some segments at 500 kbit/s.
The practical takeaway: if you are capturing a farm bus, configure your interface for 250 kbit/s, extended (29-bit) identifiers, and you will see the traffic. The deeper electrical and timing rules live in ISO 11783-2: the physical layer.
The PDU1 vs PDU2 split — read this twice
This is the one rule that catches everyone. The meaning of the PS byte — and therefore how you compute the PGN and the destination — depends entirely on the PF byte.
┌─────────────────────────────────────────────────────────────┐
│ if PF < 240 → PDU1 (point-to-point) │
│ │
│ PS = DESTINATION ADDRESS │
│ "send PGN <PF·00> to node <PS>" │
│ the PGN's low byte is forced to 0 │
│ │
│ if PF ≥ 240 → PDU2 (broadcast) │
│ │
│ PS = GROUP EXTENSION (part of the PGN) │
│ "broadcast PGN <PF·PS> to everyone" │
│ there is no destination — it is for all │
└─────────────────────────────────────────────────────────────┘
Why it matters: the same 8-bit PS field is a destination in one case and message identity in the other. If you blindly fold PS into the PGN, you will compute nonsense PGNs for every peer-to-peer message. If you blindly read PS as a destination, you will think broadcast messages are addressed to phantom nodes.
PDU1 example — a request to one node
identifier bytes (PF=0xEA, the Request PGN):
prio EDP/DP PF=0xEA PS=0x26 src=0x80
└ destination! ─┘
→ PGN = 0xEA00 (PS is NOT part of it; low byte is 0)
→ dest = 0x26 (the PS byte)
→ "address 0x80 is requesting something from address 0x26"
PDU2 example — a broadcast signal
identifier bytes (PF=0xF0, an engine controller PGN):
prio EDP/DP PF=0xF0 PS=0x04 src=0x00
└ group extension ┘
→ PGN = 0xF004 (PS IS part of it)
→ dest = global (broadcast, no specific node)
→ "address 0x00 is broadcasting EEC1 to everyone"
The mechanics in one table
| Condition | PDU type | PS byte means | PGN low byte | Destination |
|---|---|---|---|---|
| PF < 240 | PDU1 | destination address | forced to 0 | the PS value |
| PF ≥ 240 | PDU2 | group extension | equals PS | global (all) |
net::Identifier implements all of this for you. is_pdu2() returns true when PF ≥ 240;
pgn() folds PS in only for PDU2; destination() returns the PS value for PDU1 and the
broadcast address for PDU2. You never have to do the masking by hand — but you must
understand why the same bits decode two different ways, because the trap reappears the
moment you read a raw trace without the helper.
Putting it together: reading one frame
Take a single candump-style line: an identifier and some bytes. To decode it as a human:
- Split the 29 bits into priority, EDP/DP, PF, PS, source.
- Look at PF. Is it
< 240(PDU1, point-to-point) or≥ 240(PDU2, broadcast)? - Compute the PGN accordingly, and the destination accordingly.
- Now you have the routing key (PGN) and can hand off to a J1939 or NMEA codec.
That hand-off — PGN to named message — is the subject of the next two pages.
Cross-links
- ISO 11783-2: the physical layer — the electrical bus, termination, bit timing, and 250 kbit/s in depth.
- SAE J1939: the heritage — where the 29-bit identifier and the PDU1/PDU2 split came from, and what ISOBUS added.
J1939 messages and PGNs
The previous page took a raw 29-bit frame and cracked it into priority, PGN, source, and
destination. The PGN is the routing key. This page is about what that key unlocks:
turning a PGN into a named message with named, scaled signals — the job of the
machbus::j1939 module.
SAE J1939 is the heavy-vehicle networking standard that ISOBUS was built on. The engine, transmission, and diagnostic messages an agricultural bus carries are, overwhelmingly, J1939 messages. Decode them and you can read a tractor’s powertrain without ever joining the network.
PGNs name the message; SPNs name the signal
Two acronyms carry the whole model.
- PGN — Parameter Group Number. A number that identifies a group of related parameters — one message type. “Electronic Engine Controller 1” is a PGN. “Engine Hours” is a PGN. The PGN is what you matched on in the identifier.
- SPN — Suspect Parameter Number. A number that identifies a single signal within (or referenced by) a PGN. “Engine speed” is an SPN. “Engine coolant temperature” is an SPN. SPNs are how individual values get named, and they matter most in diagnostics, where a fault points at a specific SPN.
PGN 0xF004 "Electronic Engine Controller 1" ← one message
├── SPN 190 engine speed (2 bytes, 0.125 rpm/bit)
├── SPN 513 actual torque (1 byte, 1 %/bit, -125 offset)
├── SPN 512 driver demand torque (1 byte, …)
└── …
A J1939 codec’s whole job is this mapping: given the PGN’s data bytes, pull out each SPN
at its byte/bit position, apply its scale and offset, honour the not-available
sentinel, and hand you a typed value in real units. machbus does this inside each codec
in j1939, so you receive a struct like Eec1 { engine_speed, … } rather than raw
bytes.
Scaling, offset, and “not available”
Every SPN has three things you must respect:
- Resolution (scale). Raw bits times a factor. Engine speed is
raw × 0.125 rpm. - Offset. Added after scaling. A percent-torque signal might be
raw × 1 − 125, letting one byte cover −125% to +130%. - Not-available sentinel. The top of the raw range (all-ones) means “no reading.”
A two-byte signal of
0xFFFFis missing, not 8191.875 rpm.
Get any of these wrong and your decode is silently, plausibly wrong — which is worse than
crashing. The codecs in j1939 encode them once, correctly, so you do not re-derive them
per project.
Single-frame vs multi-frame messages
Most J1939 messages fit in the eight-byte CAN payload, so the pipeline is direct: one frame in, one decoded struct out. But some messages are larger than eight bytes — a long list of active faults, a block of vehicle-identification text. Those need a transport protocol to break the payload into numbered packets and reassemble it on the far side.
J1939 defines three transport mechanisms. Conceptually:
┌──────────────────────────────────────────────────────────────┐
│ TP — Transport Protocol (up to 1785 bytes) │
│ │
│ BAM Broadcast Announce Message │
│ "I'm about to send N bytes of PGN X to everyone" │
│ then a stream of numbered data packets. No handshake. │
│ Best-effort, one-to-all. │
│ │
│ RTS/CTS Request-To-Send / Clear-To-Send │
│ A point-to-point handshake: sender asks, receiver │
│ grants a window, data flows, receiver acknowledges. │
│ Flow-controlled, one-to-one. │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ ETP — Extended Transport Protocol (very large transfers) │
│ Same idea as RTS/CTS but with a wider length field, │
│ for payloads beyond the TP ceiling. │
└──────────────────────────────────────────────────────────────┘
How to think about it as a decoder:
- BAM is the one you will see most when listening, because it is broadcast — you can reassemble it without participating. Watch for the announce frame, collect the numbered data frames, concatenate, then decode the resulting payload as if it had arrived in one piece.
- RTS/CTS and ETP are directed and handshaked. To receive them you generally have to be a claimed participant sending CTS/acknowledgement frames — which is when you graduate from the decode modules to the session facade.
For the basics, picture transport as a black box between “many frames” and “one payload.” Once the payload is whole, the PGN-to-struct decode is identical to the single-frame case. The full handshake rules live in the standards section; see SAE J1939: the heritage.
The “DM” diagnostic family
Diagnostics deserve their own mention because they are a whole sub-language built on PGNs, and ISOBUS reuses them almost verbatim. The “DM” (Diagnostic Message) family reports faults as DTCs — Diagnostic Trouble Codes — each a bundle of an SPN (which parameter), an FMI (Failure Mode Identifier — how it failed: short, open, out-of-range, …), and an occurrence count, alongside lamp status (the warning lights).
The members you meet most:
| DM | Role |
|---|---|
| DM1 | Active diagnostic trouble codes — faults happening now |
| DM2 | Previously active DTCs — the fault history |
| DM3 | Clear previously-active DTCs |
| DM11 | Clear active DTCs |
| DM13 | Stop/start broadcast — quiet the bus during service |
| DM14/15/16 | Memory access — read/write ECU memory (the dm_memory codec) |
The mental model: DM1 is the present, DM2 is the past. A diagnostics viewer listens for DM1 to show what is wrong right now, and can request DM2 to show what has been wrong. Each carries one or more DTCs you then expand into SPN + FMI + count + lamp state.
What machbus::j1939 decodes
The j1939 module ships a codec per PGN family. You match the PGN from the identifier,
pick the matching codec, and get a typed struct. The table below maps the families the
module covers to the kinds of PGN they decode.
| Family / module | Covers (representative PGNs) | What you get |
|---|---|---|
engine — EEC1/2/3 | 0xF004 EEC1, 0xF003 EEC2, 0xFEC0 EEC3 | Engine speed, torque, demand, retarder, friction torque |
engine — fuel/econ | 0xFEF2 fuel economy, 0xFEE9 fuel consumption | Fuel rate, instantaneous & average economy |
engine — temps | engine temperature 1 & 2 | Coolant, oil, fuel, intercooler temperatures |
engine — hours | 0xFEE5 engine hours | Total engine hours and revolutions |
engine — other | ambient conditions, fluid levels, dash display, aftertreatment | Pressures, levels, ambient air, DEF/SCR data |
speed_distance | speed & distance PGNs | Wheel/ground speed, trip & total distance |
transmission | ETC1 and transmission parameters | Selected/current gear, output shaft speed |
diagnostic | 0xFECA DM1, 0xFECB DM2, DM3–DM12, DM20–DM25 | DTC lists (SPN+FMI+count), lamps, freeze frames, IDs |
dm_memory | 0xD900 DM14, 0xD800 DM15, 0xD700 DM16 | Memory-access request/response/transfer |
diag_monitor | DTC delta tracking over DM1 | Appeared/cleared fault deltas |
heartbeat | the J1939 heartbeat PGN | Liveness sequence, jump/loss detection |
language | 0xFE0F language command | Units, date/time/decimal format, unit system |
maintain_power | 0xFE47 maintain power | Key-switch state, power-down hold requests |
acknowledgment | 0xE800 ACK/NACK | Positive/negative acknowledgement and reason |
pgn_request | 0xEA00 Request | Which PGN is being asked for |
request2 | 0xC900 Request2 / transfer | Request-2 query, reply, and transfer |
proprietary | proprietary A / proprietary B ranges | Raw manufacturer-specific payloads + helpers |
A few notes on reading this table:
- EEC1 (0xF004) is the workhorse — engine speed lives here, broadcast continuously. If you decode exactly one J1939 PGN, decode this.
- The diagnostic family is the largest single codec because the DM messages are
numerous and structured; it expands DTC lists into typed
Dtc { spn, fmi, count }records with lamp status. pgn_request(0xEA00) is a PDU1 message — recall from the previous page that its PS byte is a destination, not part of the PGN. The payload then carries the PGN being requested.- Proprietary PGNs are, by definition, manufacturer-defined; the codec gives you the raw payload and the addressing helpers so you can route them, but it cannot name the signals for you.
The decode loop in words
Putting the whole J1939 picture together, the listener’s loop is:
- Receive a frame; build a
net::Identifier. - If it is a transport control/data frame, feed it to reassembly; continue until a full payload is ready.
- Take the PGN. Match it against the families above.
- Hand the payload to the matching
j1939codec. - Receive a typed struct with real units; act on it (log, display, alarm).
Nothing here requires a claimed address as long as you only listen and only reassemble broadcast (BAM) transports. The moment you must request directed data or acknowledge RTS/CTS, move up to the session facade.
Cross-links
- SAE J1939: the heritage — the identifier, PGN/SPN model, the transport handshakes, and the DM family in depth.
- Anatomy of a CAN frame — the PDU1/PDU2 split you need before matching any PGN.
- NMEA 2000 on the bus — the same machinery, applied to positioning.
NMEA 2000 on the bus
If you have read the J1939 page, you already understand most of NMEA 2000. That is not an accident: NMEA 2000 is built directly on J1939. Same 29-bit CAN identifier, same 250 kbit/s wire, same PGN/source/priority model, same address-claiming mechanism. N2K took the heavy-vehicle networking stack and pointed it at the marine and positioning world — depth, heading, wind, and above all GNSS position.
In agriculture, NMEA 2000 is how the GNSS receiver, the compass, and weather sensors put
their data on the bus, and it frequently shares the same physical bus as ISOBUS. So a
decoder that wants position has to speak N2K. That is what machbus::nmea is for.
What N2K reuses from J1939
┌─────────────────────────────────────────────────────────────┐
│ NMEA 2000 (positioning / marine) │
│ GNSS · heading · attitude · wind · depth · environment │
├─────────────────────────────────────────────────────────────┤
│ SAE J1939 │
│ 29-bit identifier · PGN/SPN · source addr · priority │
│ address claiming (NAME) · transport for big messages │
├─────────────────────────────────────────────────────────────┤
│ CAN 2.0B @ 250 kbit/s │
└─────────────────────────────────────────────────────────────┘
Concretely, N2K inherits:
- The identifier layout — priority, EDP/DP, PF, PS, source — decoded by the same
net::Identifier. A GNSS PGN cracks open exactly like an engine PGN. - PGNs as message identity. N2K PGNs are large numbers (129025, 127250, …) because many live in the broadcast (PDU2) range, where the PS byte folds into the PGN.
- Address claiming and NAME. N2K devices claim an address and announce a NAME just as J1939/ISOBUS nodes do. As a pure listener you ignore this; as a participant you do not.
- Little-endian byte order and not-available sentinels. Same rules: LSB first,
all-ones means “no data.” A latitude of all-
0xFFis no fix, not a real coordinate.
What N2K adds is mostly a different catalogue of PGNs and a different multi-frame mechanism.
Fast Packet — N2K’s multi-frame transport
J1939 carries big messages with TP/BAM/ETP. NMEA 2000 has its own scheme for the moderately-large records it sends constantly — most importantly a detailed GNSS fix that does not fit in eight bytes. That scheme is Fast Packet.
Fast Packet is lighter than full transport: no announce, no handshake. It steals a few bits at the start of the payload for sequencing, so the frames self-describe how to reassemble.
First frame of a Fast Packet sequence
┌──────────┬──────────┬──────────────────────────────────────┐
│ seq/frame│ total len│ data bytes (start of the record) │
│ counter │ (1 byte) │ │
└──────────┴──────────┴──────────────────────────────────────┘
Continuation frames
┌──────────┬─────────────────────────────────────────────────┐
│ seq/frame│ more data bytes │
│ counter │ │
└──────────┴─────────────────────────────────────────────────┘
counter encodes [ sequence id | frame number ]
→ group frames by sequence id, order by frame number, drop the
header bytes, concatenate → one whole record.
The mental model:
- The first frame carries the total byte count and the first chunk.
- Continuation frames carry an incrementing frame number within the same sequence id.
- You group by sequence id, sort by frame number, strip the counter bytes, and concatenate to recover the full record. Then you decode it like any single-frame PGN.
Because Fast Packet is broadcast and needs no handshake, a pure listener can reassemble it — exactly the property that makes N2K position decodable without joining the network.
The navigation and GNSS PGNs
This is the heart of why you would decode N2K in a field. A GNSS receiver does not send one “position” message; it spreads position, velocity, orientation, and quality across several PGNs at different rates. A decoder typically fuses them.
| PGN | Name | What it carries |
|---|---|---|
| 129025 | GNSS Position, Rapid Update | Latitude / longitude only, sent fast (the steering feed) |
| 129026 | COG & SOG, Rapid Update | Course over ground + speed over ground, sent fast |
| 129029 | GNSS Position Data | The full fix: lat/lon/alt, fix type, sats, ref station |
| 129539 | GNSS DOPs | Dilution of precision (HDOP/VDOP/PDOP) — fix quality |
| 129540 | GNSS Satellites in View | Per-satellite detail (PRN, elevation, azimuth, SNR) |
| 127250 | Vessel/Vehicle Heading | Heading (true or magnetic), deviation, variation |
| 127251 | Rate of Turn | Angular rate |
| 127257 | Attitude | Yaw, pitch, roll |
How to think about them together:
- 129025 (rapid position) and 129026 (rapid COG/SOG) are the fast, lightweight feeds — what a guidance controller leans on. They fit in single frames.
- 129029 (detailed GNSS) is the rich, slower record — it is a Fast Packet message because it carries altitude, fix type, satellite count, and reference-station data all at once. This is the one that needs reassembly.
- 129539 (DOPs) tells you whether to trust the fix. A great-looking latitude with a terrible HDOP is not a usable position. Always read quality alongside coordinates.
- 127250 (heading) and 127257 (attitude) come from a compass/IMU, not the GNSS engine, but a navigation consumer fuses them with position to know orientation, not just location.
machbus’s positioning layer collapses these into a coherent GNSSPosition /
GNSSBatch, so you reason about “a fix” rather than five disjoint PGNs. The deeper
positioning theory — datums, fix types, RTK — is in
Positioning: NMEA and GNSS.
What machbus::nmea decodes
The nmea module is deliberately focused. NMEA 2000 has a large PGN catalogue; machbus
decodes a selected navigation / environment / engine subset that covers what an
agricultural consumer actually needs, plus the management PGNs and an NMEA 0183 parser.
The selected N2K PGN subset (via NMEAInterface)
Around two dozen PGNs, grouped by concern:
| Group | Representative PGNs | What you get |
|---|---|---|
| GNSS / nav | 129025, 129026, 129029, 129539, plus position-delta and XTE | Position, COG/SOG, detailed fix, DOPs, deviation |
| Orientation | 127250 heading, 127251 rate of turn, 127257 attitude, mag var | Heading, turn rate, yaw/pitch/roll, variation |
| Time | 126992 system time | Date/time on the bus |
| Marine-ish | rudder, speed through water, water depth | (present for completeness; less used on land) |
| Engine | engine parameters rapid, fluid level, battery status | Quick engine RPM/status, tank levels, voltage |
| Environment | wind data, outside environmental, temperature, humidity, | Wind, ambient temp/humidity/pressure |
| pressure |
The NMEAInterface is the dispatcher: it knows which of these PGNs to recognise, runs
Fast-Packet reassembly where needed, and produces the typed records.
The management PGNs
Three N2K network-management PGNs round out the picture. These are the N2K analogues of the housekeeping every J1939 network does:
| PGN | Name | Role |
|---|---|---|
| 126993 | Heartbeat | Periodic liveness — “I am still here” |
| 126996 | Product Information | Device model, software version, identity |
| 126998 | Configuration Info | Installation / configuration text fields |
The N2KManagement helper handles these: it tracks heartbeats for liveness, and
collects product/config information about devices on the bus.
The NMEA 0183 serial parser
Older GNSS receivers — and many cheap GNSS pucks — do not speak N2K at all. They emit
NMEA 0183: comma-separated ASCII sentences over a serial line ($GPGGA,…,
$GPRMC,…, and friends), not CAN frames. It is a different physical and framing world,
but the information overlaps heavily with the N2K GNSS PGNs.
nmea’s SerialGNSS parses that 0183 sentence stream into the same kind of typed
position/time records, so a consumer can take GNSS from either source — a CAN-bus N2K
receiver or a serial 0183 puck — and treat the result uniformly.
The event / callback decode model
A small but important conceptual point: NMEA data is a stream, not a request/response. The receiver broadcasts position several times a second whether anyone is listening or not. So the natural shape for consuming it is event-driven, not poll-driven.
frames arrive ──► reassemble (Fast Packet) ──► decode PGN
│
▼
┌──────────────────────────────────────┐
│ your callback fires with a typed │
│ record: "new GNSS fix", "heading" │
└──────────────────────────────────────┘
You register interest once; as frames flow, the interface reassembles, decodes, and invokes your handler with each completed record. You react to fixes as they happen rather than asking “where are we?” on a timer. This matches the pump-style architecture machbus uses throughout — you feed frames in, decoded events come out — and it is exactly the listener-friendly model that lets you read position off a shared ISOBUS/N2K bus without ever claiming an address.
Cross-links
- Positioning: NMEA and GNSS — GNSS fundamentals, fix types, datums, and how positioning fits the wider stack.
- J1939 messages and PGNs — the PGN/SPN model and transport that N2K inherits.
- Anatomy of a CAN frame — the shared 29-bit identifier and the PDU1/PDU2 split.
Tutorial: inspect CAN identifiers
Every frame on a J1939, ISOBUS, or NMEA 2000 bus carries a 29-bit extended identifier. That identifier is not an opaque number — it is a packed structure holding the message priority, the Parameter Group Number (PGN), the source address, and (sometimes) a destination address. Before you decode a single data byte you can already learn who sent the frame, who it is for, and roughly what it contains, purely from the identifier.
This tutorial walks through examples/can_inspect.rs, a tiny program that
pulls a 29-bit identifier apart using nothing but [machbus::net]. There is no
protocol stack here, no address claim, no session, and no IO — just bit
arithmetic wrapped in a typed API. If all you want is to sniff a bus and label
the traffic, this is the smallest possible starting point.
The one import you need
#![allow(unused)]
fn main() {
use machbus::net::Identifier;
}
Identifier is the typed view over a raw 29-bit CAN identifier. You build one
from a u32 and then ask it questions. It owns no data buffer and performs no
allocation; constructing one and reading a field is just masking and shifting,
so you can call it on every frame in a high-rate capture without worrying about
cost.
Decomposing one identifier
Here is the heart of the program — the describe function that takes a raw
u32 and prints everything the identifier encodes:
#![allow(unused)]
fn main() {
{{#include ../../../examples/can_inspect.rs:decode}}
}
Walk through it call by call.
-
Identifier::from_raw(raw)takes the raw 29-bit value as a plainu32and wraps it. This is infallible: anyu32is a valid identifier as far as the bit layout is concerned (the upper 3 bits are simply ignored — only 29 bits are meaningful). There is noOptionand no error here; the fallible step comes later when you try to decode the payload. -
id.priority()returns the message priority. Lower numbers are higher priority on the bus (priority 0 wins arbitration over priority 7). The return type is a smallPrioritynewtype rather than a bare integer, which is why the example writesu8::from(id.priority())to get a number it can print. Typical values you will see: 3 for fast control messages like engine speed, 6 for slower informational and network-management traffic, 2 for some high-rate NMEA 2000 navigation PGNs. -
id.pgn()returns the Parameter Group Number — the identity of the message. The PGN is what tells you “this is engine speed” versus “this is wheel speed”. The example prints it twice, once as hex (0x{:04X}) and once as decimal ({:>6}), because lookup tables in different specifications quote it in different bases. -
id.source()returns the source address: the 8-bit address of the ECU that transmitted the frame. On a live bus this is assigned dynamically through address claiming, but in the identifier itself it is always the low byte. -
id.is_pdu2()distinguishes the two addressing formats (explained in detail below). The example uses it to pick the label and to decide whether a destination address even exists. -
id.destination()returns the destination address — but only meaningful for PDU1 frames. For a PDU2 (broadcast) frame there is no destination, so the example never callsdestination()in that branch; it printsALLinstead.
The samples it decodes
The program runs describe over a list of real-world 29-bit identifiers:
#![allow(unused)]
fn main() {
{{#include ../../../examples/can_inspect.rs:samples}}
}
These are not toys. 0x0CF00400 is the classic EEC1 engine-speed broadcast.
0x18EAFF00 is a PGN request sent to the global address. 0x18EEFF80 is an
Address Claimed announcement. 0x09F80180 is an NMEA 2000 rapid-position
frame. Each one exercises a different corner of the identifier layout, which is
exactly why they make a good demonstration set.
The PDU1 / PDU2 trap
This is the single most important rule when reading CAN identifiers on these buses, and it is the one people get wrong. The relevant field inside the PGN is the PDU Format byte, conventionally called PF:
-
PF < 240 → this is PDU1, a peer-to-peer format. The byte that would otherwise be part of the PGN is actually the destination address. The frame is directed at one specific ECU (or at
0xFF, the global address, meaning everyone). Useis_pdu2()→falseand readdestination(). -
PF ≥ 240 → this is PDU2, a broadcast format. That same byte (now called the PDU Specific, PS) is part of the PGN itself, not an address. There is no destination; the frame goes to the whole bus.
is_pdu2()→true, andis_broadcast()→true.
The example asserts exactly this distinction so the behaviour is pinned down in code:
#![allow(unused)]
fn main() {
{{#include ../../../examples/can_inspect.rs:pdu}}
}
Read it carefully:
-
0x18EA26EEis a PGN request. Its PF is below 240, so it is PDU1. The low byte0x26is therefore a destination address, and the assertions confirmis_pdu2()isfalseanddestination()is0x26. The request is aimed at the ECU at address0x26. -
0x0CF00400is EEC1. Its PF is0xF0(240), which is ≥ 240, so it is PDU2. The assertions confirmis_pdu2()andis_broadcast()are bothtrue. The0x04low byte is part of the PGN, not an address.
If you ever find yourself reading a destination address of “4” or “0” off a
broadcast message and wondering why nobody is listening, this rule is why: you
read a PGN byte as if it were an address. is_pdu2() is the guard that keeps
you honest.
Run it
$ cargo run --example can_inspect
The program prints the decoded view of each sample identifier:
0x0CF00400 prio=3 PGN=0xF004 ( 61444) src=0x00 dst=ALL PDU2 (broadcast)
0x18EAFF00 prio=6 PGN=0xEA00 ( 59904) src=0x00 dst=0xFF PDU1 (peer-to-peer)
0x18EEFF80 prio=6 PGN=0xEE00 ( 60928) src=0x80 dst=0xFF PDU1 (peer-to-peer)
0x09F80180 prio=2 PGN=0x1F801 (129025) src=0x80 dst=ALL PDU2 (broadcast)
Match the output against the rule. The two EA00/EE00 lines are PDU1 (their
PF is below 240), so they show a real destination byte — here 0xFF, the
global address, meaning “broadcast to all” as a PDU1 message addressed to
everyone, which is subtly different from a PDU2 broadcast. The F004 and
1F801 lines are PDU2, so their destination is reported as ALL and the low
byte stays part of the PGN.
Note 0x09F80180 decodes to PGN 0x1F801 (129025) — a five-hex-digit PGN.
That is expected: PDU2 PGNs can exceed 16 bits because the Data Page and
Extended Data Page bits extend the range. NMEA 2000 lives heavily in this upper
PGN space.
What to change for real bus data
This example feeds in hard-coded identifiers so the output is reproducible. To
point it at a live bus, replace the samples array with identifiers you read
off the wire. With SocketCAN, for instance, you would receive a frame, take its
id field (the 29-bit extended identifier), and hand it straight to
Identifier::from_raw. Nothing else changes — Identifier does not care where
the u32 came from. Once you have the PGN you can branch to the right payload
decoder, which is exactly what the next two tutorials do.
See also
- Anatomy of a CAN frame — the structure behind the identifier, in prose.
- J1939 messages and PGNs — what those PGNs actually mean.
- The networking foundation — how the addressing model fits the larger stack.
- SAE J1939: the heritage — where PDU1/PDU2 comes from.
- Tutorial: decode J1939 PGNs — the natural next step: turn the PGN you just identified into a typed struct.
Tutorial: decode J1939 PGNs
Identifying a PGN tells you what a frame is; decoding it tells you what it
says. machbus ships the J1939 wire codecs as plain functions on typed structs
in [machbus::j1939]. Each message group — engine controllers, diagnostics,
hours meters, language preferences, and more — has a struct with three uniform
operations: decode bytes into the struct, from_message decode from an
assembled Message, and encode the struct back to bytes. No protocol stack,
no session, no IO.
This tutorial walks through examples/j1939_decode.rs. It builds two messages —
EEC1 (engine speed) and DM1 (active diagnostic trouble codes) — encodes each
one, then decodes it straight back. Because every block round-trips, the output
is self-validating: if the codec were wrong, the numbers coming out would not
match the numbers going in.
The imports
#![allow(unused)]
fn main() {
use machbus::j1939::{DiagnosticLamps, DmDtcList, Dtc, Eec1, Fmi};
use machbus::net::Message;
}
Eec1is the Electronic Engine Controller 1 message (PGN 61444): engine speed, driver demand, actual torque, and so on.DmDtcListis the diagnostic-message DTC list — the structure behind DM1 (active faults) and its siblings.Dtcis a single Diagnostic Trouble Code: an SPN plus a failure mode plus an occurrence count.Fmiis the Failure Mode Identifier enum — the kind of fault (too high, too low, intermittent, and so on).DiagnosticLampsis the lamp-status block that rides along with a DM1 (malfunction lamp, warning lamp, etc.).Message(frommachbus::net) is the assembled-message type: a PGN, a data buffer, and a source address. It is what you get after the transport layer has reassembled multi-frame traffic for you.
The codec contract
Every J1939 codec in machbus follows the same shape, and learning it once means you know all of them:
Struct::decode(&[u8]) -> Option<Self>— parse raw payload bytes. It returns anOptionbecause the bytes might be too short or malformed;Nonemeans “this is not a valid payload for this message”. Always handle theNonecase when you are reading off a real bus, where truncated and garbage frames happen.Struct::from_message(&Message) -> Option<Self>— the same, but starting from an assembledMessage. It reads thedataout of the message and runsdecodeon it. Use this when you already have aMessagein hand.struct.encode() -> [u8; 8](or-> Vec<u8>for variable-length messages) — serialize back to the wire format you would transmit.
Decoding EEC1
#![allow(unused)]
fn main() {
{{#include ../../../examples/j1939_decode.rs:eec1}}
}
Step through it:
- The
Eec1struct is built with physical, human-readable values:engine_speed_rpm: 1500.0is RPM,driver_demand_percent: 40.0is a percentage, and so on. You do not deal with the scaling factors and offsets the wire format uses — the codec applies them for you.source_addressis the address this frame claims to come from. eec1.encode()returns a[u8; 8]— exactly one CAN frame’s worth of data, with each field scaled and packed into its bit positions. This is the buffer you would hand to your CAN driver to transmit.Eec1::decode(&bytes)parses those eight bytes back into anEec1. Note the.expect("valid EEC1 payload"):decodereturnsOption<Eec1>, and the example unwraps it because it just produced the bytes itself and knows they are valid. In real code you wouldmatchorif leton theOptioninstead, because off-bus bytes are not guaranteed to be well-formed.- The printout reads the decoded fields back:
1500 rpm, driver demand 40%, actual 38%. These match the input exactly, which is the round-trip proving the codec is symmetric.
Decoding from a Message
Often you do not have a bare byte slice — you have a Message that the network
layer handed you. The second block decodes from one of those:
#![allow(unused)]
fn main() {
{{#include ../../../examples/j1939_decode.rs:from_message}}
}
Message::new(61444, bytes.to_vec(), 0x00)builds a message for PGN 61444 (EEC1), carrying the bytes we just encoded, from source address0x00. In a real reader these three pieces come off the bus, not from a localencode.Eec1::from_message(&msg)returnsOption<Eec1>. The example usesif let Some(e) = …— this is the idiomatic way to handle the fallible decode. If the message’s PGN or payload did not match, you would simply skip it.- The output,
from_message: 1500 rpm from source 0x00, shows you can pull the source address off theMessageitself (msg.source) alongside the decoded engine speed. This is the pattern you will use in a real dispatch loop: match on the PGN, call the matchingfrom_message, and use both the decoded fields and the message metadata.
Decoding DM1 diagnostics
Diagnostics are where J1939 gets interesting, because a single DM1 can carry a
list of faults and can spill across multiple CAN frames. machbus models this
with DmDtcList:
#![allow(unused)]
fn main() {
{{#include ../../../examples/j1939_decode.rs:dm1}}
}
Read it field by field:
lamps: DiagnosticLamps::default()sets all the diagnostic lamps to their default (off) state. On a real ECU these flags tell the operator whether the malfunction lamp, amber warning lamp, red stop lamp, or protect lamp is lit.dtcs: vec![…]is the list of active faults. EachDtchas:spn— the Suspect Parameter Number, identifying what is faulty.110is engine coolant temperature;190is engine speed.fmi: Fmi::AboveNormalModerate/Fmi::BelowNormal— the Failure Mode Identifier, an enum describing the nature of the fault.AboveNormalModeratemeans a value is moderately too high (coolant running hot);BelowNormalmeans a value is too low. BecauseFmiis an enum, the example can print it with{:?}and get a readable name instead of a raw number.occurrence_count— how many times this fault has been seen.
dm1.encode()returns aVec<u8>, not a[u8; 8]. DM1 is variable-length: a few faults fit in a single frame, but more will exceed eight bytes and require the transport protocol (multi-frame). The codec produces the full logical payload; the transport layer handles the framing.DmDtcList::decode(&payload)parses it back, again returning anOption. The example loops overdecoded.dtcsand prints each one. The two faults come back exactly as they went in.
Run it
$ cargo run --example j1939_decode
Output:
EEC1: 1500 rpm, driver demand 40%, actual 38%
from_message: 1500 rpm from source 0x00
DM1: 2 active fault(s):
SPN 110 FMI AboveNormalModerate (count 3)
SPN 190 FMI BelowNormal (count 1)
Every value printed is the value that was fed in, which is the whole point of a round-trip example: it proves the encode and decode paths agree.
What to change for real bus data
The example encodes its own bytes so the demo is reproducible. On a live bus
you skip the encode entirely. You read a frame, build a Message from the
PGN, payload, and source (or, for multi-frame messages, let the transport layer
reassemble the full payload first), then call the matching from_message. The
crucial difference: handle the Option. Real frames can be truncated,
corrupted, or simply a different PGN than you expected, so decode and
from_message will legitimately return None — treat that as “skip this
frame”, not as a panic. Replace the .expect(...) you see here with an
if let Some(...) or a match.
More J1939 codecs
EEC1 and DM1 are two members of much larger families. machbus provides codecs across the J1939 message groups, including:
- Engine and powertrain — engine controllers, fuel economy, hours and revolutions, temperatures and fluid levels.
- Diagnostics — the full DM family (active, previously active, and the
related diagnostic messages), built on the same
DmDtcList/Dtc/Fmitypes you used above. - Network management and heartbeat — address claim, keep-alive, and status messages.
- Language and units — operator language and unit-system preferences.
They all follow the identical decode / from_message / encode contract, so
the patterns in this tutorial carry over directly. See
J1939 messages and PGNs for the catalogue.
See also
- J1939 messages and PGNs — the message catalogue and PGN meanings.
- Tutorial: inspect CAN identifiers — how to get the PGN in the first place, before you decode the payload.
- SAE J1939: the heritage — the standard these codecs implement.
- Powertrain — engine and drivetrain messages in a full application.
- Diagnostics — the DM family at application scale.
Tutorial: decode NMEA 2000
NMEA 2000 shares the physical CAN bus and the PGN addressing model with J1939,
but its navigation messages have their own decoding style in machbus. Instead
of one struct per message with a static decode function, you use a
pump-style decoder: [machbus::nmea::NMEAInterface]. You subscribe to the
on_* events for the PGNs you care about, then feed it Messages. As each
message arrives, the interface decodes it and fires the matching event with the
decoded value. No protocol stack, no session, no IO.
This tutorial walks through examples/nmea2000_decode.rs. It sets up an
interface listening for the GNSS navigation profile, encodes a position fix and
a course/speed update with the matching build_* helpers, and feeds the bytes
back in so the decode path actually fires and the event handlers print.
The imports
#![allow(unused)]
fn main() {
use machbus::geo::Wgs;
use machbus::nmea::{GNSSPosition, NMEAConfig, NMEAInterface};
use machbus::net::Message;
use machbus::net::pgn_defs::{PGN_GNSS_COG_SOG_RAPID, PGN_GNSS_POSITION_RAPID};
}
NMEAInterfaceis the pump decoder itself — it holds the event channels and any reassembly state.NMEAConfigselects which PGN families the interface should decode. You enable the parts you need rather than paying for everything.GNSSPositionis the decoded position type: a WGS coordinate plus fix metadata (satellites, fix type, and more).Wgs(frommachbus::geo) is the geographic-coordinate type: latitude, longitude, altitude.Message(frommachbus::net) is the assembled message you feed in — the same type the J1939 codecs use.PGN_GNSS_POSITION_RAPID(129025) andPGN_GNSS_COG_SOG_RAPID(129026) are named PGN constants frommachbus::net::pgn_defs, so you do not sprinkle magic numbers through your code.
The pump / event decode model
The mental model is different from the J1939 one-shot codecs, so it is worth stating plainly:
- You construct an
NMEAInterfacewith a config that says which PGNs to decode. - You subscribe handlers to the
on_*events. Each event corresponds to a decoded quantity —on_position,on_cog,on_sog, and so on. - You feed the interface raw
Messages withhandle_message. For each message, the interface decodes it (reassembling Fast Packet PGNs across multiple frames first, if needed) and dispatches the result to every subscribed handler for that event.
This inverts control compared to calling decode yourself: you describe what
you want once, then push messages through. It maps naturally onto a real bus
loop where messages arrive continuously and you react to them.
Setting up the interface
#![allow(unused)]
fn main() {
{{#include ../../../examples/nmea2000_decode.rs:setup}}
}
Walk through it:
NMEAConfig::default().with_gnss_navigation(true)builds a config that enables the GNSS navigation profile — rapid position, COG/SOG, heading, attitude, and the related navigation PGNs. The builder style means you can chain.with_*calls to enable exactly the families you need.NMEAInterface::new(config)constructs the decoder. It is declaredmutbecause subscribing and feeding messages mutate its internal state.nmea.on_position.subscribe(|pos: &GNSSPosition| { … })registers a handler for decoded positions. The closure takes&GNSSPosition— handlers areFnMut(&T), so they receive the decoded value by reference and can hold mutable captured state across calls. Inside, the example readspos.wgs.latitude,pos.wgs.longitude,pos.satellites_used, andpos.fix_type.nmea.on_cog.subscribe(|cog_rad: &f64| { … })andnmea.on_sog.subscribe(|sog_mps: &f64| { … })register handlers for course over ground and speed over ground. Note the units carried over the wire: COG is in radians, SOG is in metres per second. The handlers convert for display —cog_rad.to_degrees()andsog_mps * 3.6(m/s → km/h). machbus hands you SI units; presentation is your choice.
Nothing has decoded yet at this point. You have only declared what to do when a position, course, or speed arrives.
Feeding messages
#![allow(unused)]
fn main() {
{{#include ../../../examples/nmea2000_decode.rs:feed}}
}
This is where the decode path fires:
- The
GNSSPositionis built withWgs::new(52.379_189, 4.899_431, 0.0)— latitude, longitude, altitude for a point in Amsterdam — andsatellites_used: 12. The..Default::default()fills the remaining fields (fix type and the rest) with their defaults. NMEAInterface::build_position(&fix)encodes that fix into a[u8; 8]payload for PGN 129025, the rapid-position message. This is the inverse of what the decoder does — it exists so the example can produce valid bytes to feed back in.nmea.handle_message(&Message::new(PGN_GNSS_POSITION_RAPID, pos_bytes.to_vec(), 0x80))feeds the encoded bytes back as aMessagefrom source0x80. The interface recognises PGN 129025, decodes it to aGNSSPosition, and fireson_position— which runs your subscribed handler and prints the line.- The second pair does the same for course and speed:
NMEAInterface::build_cog_sog(60.0_f64.to_radians(), 5.0)encodes a heading of 60° (passed in as radians) and a speed of 5 m/s into PGN 129026’s payload, andhandle_messagefeeds it in. The interface fires bothon_cogandon_sog, running their handlers.
The rapid-PGN satellite caveat
Look closely at the expected output below: it reports sats 0, even though the
fix was built with satellites_used: 12. This is correct, not a bug. PGN
129025 is the rapid position message — it is deliberately minimal, carrying
only latitude and longitude so it can be sent at high rate. It does not carry a
satellite count. When the interface decodes a rapid-position frame, there is no
satellite field to read, so satellites_used decodes to its default of 0.
The takeaway: a decoded struct reflects only what the wire format actually carries. If you need the satellite count, fix quality, and the rest, you subscribe to the richer (Fast Packet) GNSS PGN that carries them — but you pay for it in bus bandwidth and reassembly. The rapid PGN is for position, fast; nothing more.
Fast Packet reassembly
Many NMEA 2000 PGNs carry more than eight bytes and are transmitted as a Fast
Packet: a sequence of CAN frames the receiver must stitch back together before
decoding. handle_message handles this transparently. You feed it each frame
as it arrives, and it holds partial state internally; only when a Fast Packet
is complete does it decode and dispatch the event. The two PGNs in this example
are single-frame rapid messages, so no reassembly happens here, but the same
handle_message call is what drives reassembly for the larger PGNs.
Run it
$ cargo run --example nmea2000_decode
Output:
position: 52.379189, 4.899431 (sats 0, fix GNSSFix)
course over ground: 60.0°
speed over ground: 18.0 km/h
Read it back against the inputs:
- The latitude and longitude match the Amsterdam coordinate exactly.
sats 0is the rapid-PGN caveat from above — the rapid message does not carry a satellite count, so it decodes to the default.fix GNSSFixis the default fix type printed with{:?}.- Course is
60.0°— the 60° heading round-tripped through radians. - Speed is
18.0 km/h— 5 m/s converted (5 × 3.6).
What to change for real bus data
The example encodes its own bytes with build_position and build_cog_sog so
the demo is reproducible. On a live bus you drop those entirely. Your job
becomes: read frames off the wire, wrap each one in a Message (PGN, payload,
source), and call nmea.handle_message(&msg) for every frame. The interface
does the rest — decoding single-frame PGNs immediately and reassembling Fast
Packet PGNs across frames before dispatching. Your on_* handlers fire exactly
as they do here. You write the subscription logic once and let the message pump
drive it.
See also
- NMEA 2000 on the bus — the message families and how NMEA 2000 relates to J1939.
- Tutorial: inspect CAN identifiers — how to read the PGN off the identifier, including the high PGN range NMEA 2000 lives in.
- Positioning: NMEA and GNSS — the positioning standards behind these PGNs.
- NMEA 2000 — NMEA 2000 in a full application.
- Serial GNSS — the serial-input counterpart for GNSS data.
Getting started
This section gets you from a checkout to a running node.
Read:
- Install Rust
- Build and verify
no_stdon microcontrollers, if you are building firmware or an embedded task- First node
- Virtual bus
Use SocketCAN only after the in-process examples make sense.
Install Rust
machbus is a Rust crate with optional C and Python surfaces.
Recommended local setup:
rustc --version
cargo --version
make build
If this repository is opened through its Nix shell, use the repository-provided environment and still prefer Makefile targets for validation.
Feature flags are documented in Feature flags.
Build and verify
Use Makefile targets first.
make build
make test
make verify
make verify is the canonical local gate. It runs the normal build/test lane,
all-feature checks, clippy, rustdoc, C binding checks, C examples, Python smoke,
trace replay, fuzz smoke, claim-boundary checks, package checks, hardware
evidence contract checks, and whitespace checks.
Embedded/no-std checks are intentionally separate while that profile is still evolving. If you changed feature gates, protocol-core imports, the session loop, CAN transport seams, storage/file splits, or embedded examples, also run:
make no-std-check
make no-std-target-check
make no-std-surface-check
make embedded-examples-check
make no-std-target-check uses the configured NO_STD_TARGET embedded target
and will tell you to install it if it is missing.
If you only changed documentation, still run at least:
make book
make whitespace-check
before claiming the book is healthy. Run make verify when documentation changes
also depend on source, examples, bindings, or generated artifacts.
no_std on microcontrollers
machbus can be built for firmware-style applications where there is no
operating-system standard library. In that mode the crate is a caller-driven
protocol engine: your board code owns the clock, CAN peripheral, storage, task
scheduling, allocator, and panic behavior.
Use this page when you are targeting a microcontroller, an RTOS task, or a small embedded Linux component that wants the same explicit ownership model.
Why this exists
Desktop examples can lean on files, host clocks, virtual buses, SocketCAN, C bindings, Python bindings, and rich geo libraries. A microcontroller usually cannot. It has a CAN peripheral, a timer, maybe flash or an SD card, and a main loop or RTOS executor.
The embedded feature split keeps those worlds separate:
- hosted/default mode keeps the convenient OS integrations;
embeddedcompiles the protocol/session surface asno_std + alloc;embeddedadds fixed-capacity helpers for bounded buffers and selected transport paths.
The important boundary is simple: machbus owns protocol state, while the
application owns hardware and resources.
Mental model
board / RTOS / firmware application
┌────────────────────────────────────────────────────┐
│ monotonic timer │
│ CAN driver / interrupt queues │
│ flash, SD, EEPROM, LittleFS, or no persistent store │
│ allocator + panic handler │
│ main loop, RTOS task, or executor │
└───────────────┬──────────────────────┬─────────────┘
│ Instant │ Frame
▼ ▼
┌────────────────────────────────────┐
│ machbus no_std + alloc core │
│ │
│ Session / Driver::poll_at │
│ J1939 / ISOBUS codecs │
│ address claim, TP, ETP, Fast Packet │
│ VT / TC / FS pump state │
└────────────────────────────────────┘
│
▼
events and outbound CAN frames
There is no hidden host clock and no hidden CAN backend in the embedded path. Each poll step receives explicit time and drains or emits explicit frames.
Dependency setup
Disable default features and enable the embedded profile:
[dependencies]
machbus = { path = "../machbus", default-features = false, features = ["embedded"] }
For fixed-capacity helper APIs:
[dependencies]
machbus = { path = "../machbus", default-features = false, features = ["embedded"] }
The embedded profile intentionally does not compile:
| Hosted surface | Why it is not in embedded |
|---|---|
ffi / C ABI | Uses hosted ABI and std facilities. |
| Python bindings | Uses pyo3 and requires std. |
| SocketCAN | Linux-specific host interface. |
wirebit | Host virtual-bus and simulation adapter. |
concord | Rich hosted geo conversion stack. |
| file load/save helpers | Firmware decides how flash, SD, EEPROM, or LittleFS are used. |
host-clock Driver::poll() | Firmware must pass board time explicitly through poll_at. |
What no_std + alloc means
embedded is not a zero-heap profile. It means:
- the crate itself does not require Rust
std; - heap-backed structures can still be used through
alloc; - the final firmware binary must provide any global allocator it needs;
- the final firmware binary must provide its panic strategy or panic handler;
machbusis checked as a library, not as a complete firmware image.
That shape is intentional for the first embedded target. It gets the host APIs out of the protocol core without forcing every high-level ISOBUS service into fixed storage immediately.
If your MCU project forbids heap allocation entirely, start from
embedded and use its fixed-capacity helpers at the CAN/session boundary,
but treat the full no-alloc migration as still in progress.
The embedded loop
The loop shape is:
- get board monotonic time;
- drain received CAN frames from your driver or interrupt queue;
- feed frames into
Session; - tick protocol timers;
- transmit every queued frame through your CAN driver;
- handle session events.
Conceptually:
#![allow(unused)]
fn main() {
let mut now = board_monotonic_instant();
while let Some((port, frame)) = can_recv() {
session.feed(port, &frame, now);
}
session.tick(now);
while let Some((port, frame)) = session.poll_transmit() {
can_send(port, &frame)?;
}
while let Some(event) = session.poll_event() {
handle_event(event);
}
}
The compiled example is examples/embedded_session_loop.rs. It runs as a host
example for convenience, but it compiles machbus with --no-default-features --features embedded and uses the same board-owned clock/CAN/storage shape an
MCU application would use.
CAN adapter boundary
The embedded CAN boundary is machbus::net::CanTransport, re-exported by the
session module as machbus::session::Transport.
Your adapter receives concrete frames from the MCU HAL and converts them into
machbus::net::Frame. Outbound frames go the other way.
HAL frame ──► board adapter ──► machbus Frame ──► Session
HAL frame ◄── board adapter ◄── machbus Frame ◄── Session
Keep the adapter thin. It should know about CAN IDs, DLC, data bytes, and the
local CAN port number. It should not contain ISOBUS state machines. The
protocol state belongs in Session, IsoNet, or the lower-level protocol
helpers.
See examples/embedded_hal_adapter.rs for the compiled adapter shape without
adding a dependency on a specific HAL crate.
Time and scheduling
Use machbus::time::Instant as the protocol timestamp. Convert your board
timer ticks into that type at the application boundary.
Use Driver::poll_at(now) rather than hosted Driver::poll(). The _at
method is deterministic because it only sees the time value you pass in. That
makes it suitable for:
- bare-metal superloops;
- RTOS periodic tasks;
- cooperative executors;
- deterministic simulation tests.
Storage ownership
Embedded storage is buffer-oriented:
| Data | Embedded shape |
|---|---|
| NIU config | parse/format text buffers; application persists them. |
| IOP/object-pool bytes | parse bytes already supplied by the application. |
| VT stored pools | encode/decode storage blobs; application writes blobs to flash/SD/etc. |
| File Server data | in-memory protocol model; real media integration is application-owned. |
| candump traces | file save/load is hosted; line parse/format is the reusable part. |
This avoids baking one flash layout or filesystem into the protocol crate.
Protocol surface available today
The embedded feature currently covers the useful protocol core:
- CAN frame, identifier, priority, PGN, and address-claim primitives;
- J1939 request, acknowledgment, diagnostics, heartbeat, engine, powertrain, maintain-power, and related codecs;
- TP, ETP, and Fast Packet helpers;
- NMEA/GNSS encode/decode paths;
- NIU filtering/routing and safety/physical helper data;
- selected ISO 11783 application codecs;
- Sequence Control core state and recording helpers;
- Task Controller heap-backed pump state, DDOP/object codecs, TC-GEO, grids, task logging, rate limiting, outstanding requests, ISOXML parsing, and totals;
- File Client / File Server pump state, codecs, path validation, in-memory file storage, and volume status helpers;
- Virtual Terminal object pools, VT Client / VT Server pump state, update helpers, storage-agnostic stored-version blobs, and working-set state.
The VT renderer/GTUI layer remains hosted because it is a UI/filesystem integration surface, not an MCU protocol primitive.
Fixed-capacity helpers
embedded is the first checked bounded-memory layer. It adds types such
as:
FixedQueue<T, N>;FixedFrameQueue<N>;FixedSlots<T, N>;FixedBytes<N>;FixedMessage<N>;- fixed event polling with
Session::poll_fixed_event::<N>(); - bounded TP/ETP/Fast Packet helper paths.
Use this mode when you want fixed RX/TX queues around the session loop or when
you are hardening a specific transport path. Do not claim the whole crate is
no-alloc yet: the session core is still the no_std + alloc profile.
See examples/embedded_fixed_queue.rs.
Validate locally
Use Makefile targets, not ad-hoc Cargo commands:
make no-std-check
make no-std-target-check
make no-std-surface-check
make embedded-examples-check
make no-std-target-check uses the documented embedded target. If your local
Rust toolchain does not have it yet:
rustup target add thumbv7em-none-eabihf
For dependency audits:
cargo tree --no-default-features --features embedded -e normal
The embedded dependency graph should stay free of wirebit, concord, pyo3,
and SocketCAN dependencies.
What this proves / does not prove
A green embedded check proves that the selected Rust surface compiles without
std and without the hosted adapter dependencies. It also proves the examples
and public embedded imports still type-check.
It does not prove:
- your MCU has enough RAM for your chosen feature set;
- your allocator strategy is real-time safe;
- CAN interrupt buffering is correctly sized;
- physical bus timing and wiring are correct;
- the product is ISO 11783, SAE J1939, NMEA, or AEF certified.
For deployment, combine these checks with board-level tests, trace captures, interoperability tests, and the hardware evidence process.
See also
- Feature flags
- Validation gates
- The session facade
- Onto real hardware with SocketCAN
- Hardware evidence
First node
This page is the shortest path to a running node. For the full tour of plugins, the sans-IO core, and the driver/handle split, read The session facade.
A node owns one local role on a bus. The exact plugins you add depend on the role, but the pattern is always the same:
- choose a NAME
- choose a preferred source address
- plug only the subsystems you need
- spawn over a transport — this gives you
(Controls, Driver) - start, then claim an address before normal traffic
- drive
driver.poll()?and handle events
Conceptual Rust shape:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:build}}
}
The snippet above is copied from examples/session_minimal.rs. Use runnable
examples for the exact current API calls.
make run EXAMPLE=session_minimal
What to check
ctrl.is_claimed()becomes true once the handshake completes, andctrl.address()reports the address the node owns.- The node does not send application traffic before address claim.
- Optional fine control such as
ctrl.with_mut::<Diagnostics, _>(...)is only available when you plugged that subsystem (it returnsNoneotherwise). - Events are drained regularly enough for your application queue policy.
Common mistakes
| Symptom | Likely cause | Fix |
|---|---|---|
| address claim times out | no transport or peer traffic not being pumped | drive the virtual bus and call driver.poll()? |
with_mut::<Diagnostics> returns None | diagnostics not plugged | add .plug(Diagnostics::every(1000)) |
| no GNSS events | GNSS not plugged or no GNSS PGN/sentence was sent | add .plug(Gnss::listen()) and send input |
| normal traffic ignored by peers | node has not claimed an address | wait for ctrl.is_claimed() before sending |
Virtual bus
The virtual bus is the easiest way to test two roles without hardware. It is the recommended first integration environment because it exercises real stack routing without requiring SocketCAN permissions, wiring, or a running tractor.
Common setup:
- tractor node
- implement node
- both attached to the same in-process bus
- run ticks until both claim addresses
- send one workflow message
- drain events on the peer
This is how many stack tests prove behavior without relying on SocketCAN timing or machine wiring. After the virtual-bus workflow passes, move to SocketCAN traces.
make run EXAMPLE=virtual_can_demo
Two-node checklist
| Step | Tractor-like node | Implement-like node |
|---|---|---|
| NAME | function/identity for tractor role | function/identity for implement role |
| preferred address | normally tractor range | normally implement range |
| enabled surfaces | diagnostics, GNSS, tractor/persona helpers | diagnostics, VT/TC/FS/implement helpers |
| startup | start address claim | start address claim |
| loop | call tick() | call tick() |
| proof | drain tractor events | drain implement events |
Why this matters
The virtual bus catches address conflicts, destination-specific PGN mistakes, event fan-out bugs, and many high-level stack regressions before you touch real hardware. It does not prove physical timing or transceiver behavior.
SocketCAN
SocketCAN is the normal Linux interface for CAN devices and virtual CAN interfaces.
For local smoke testing, use vcan0 where possible. For real machinery, use
the correct interface, bitrate, termination, and safety procedure.
Typical setup outside this book:
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
Real machine warning: never attach experimental software to a working machine without a safe test plan, isolation, and permission from the equipment owner.
Logging and traces
Traces turn bus behavior into reviewable evidence.
Useful trace sources:
candumpfrom SocketCAN- compact fixture files
- bracketed candump-style fixture files
- malformed-line fixtures for parser hardening
machbus includes replay tooling and tests that reject invalid trace shapes such as standard IDs where extended IDs are required, overlong classic CAN payloads, bad hex, and CAN FD-looking tokens in classic-only paths.
Trace evidence should include the command, interface, bitrate, connected devices, and expected result.
The session facade
machbus::session is how you build an application. You compose a node from
plugins (one per subsystem), drive a pure Session core, and — for the
common case — let a Driver own the CAN interface while a cheap
Controls handle issues commands. It covers every protocol machbus speaks,
with a composable and testable shape. Hosted/default builds expose the full
plugin/controls facade. Embedded builds use the same sans-IO loop shape through
the no_std + alloc Session/Driver::poll_at surface, but not every hosted
convenience or plugin wrapper is part of that embedded profile.
Why this shape
The session facade is built around three ideas:
- Sans-IO core.
Sessionis a pure state machine: you feed it frames and a timestamp and drain its outputs. No socket, no system clock. That makes it deterministically testable and is the basis of the currentembeddedno_std + allocbuild. - Plugin composition. Each subsystem is a
Pluginyou.plug(...). The set is explicit, and a plugin instance is the fine-control object for that subsystem. - Handle / driver split.
spawn(transport)returns(Controls, Driver): the driver runs the loop you own; theControlsis a cheap handle for commands and status.
Mental model
┌── Driver + Controls ── owns the CAN transport + clock, runs the loop ┐
│ let (ctrl, mut driver) = Session::builder(name, addr) │
│ .plug(...).spawn(transport)?; │
│ ctrl.start()?; loop { driver.poll()? ... } │
├── Session ── pure sans-IO state machine (no IO, no clock) ───────────┤
│ s.feed(port, &frame, now); s.poll_transmit(); s.poll_event(); │
├── Plugins ── one per subsystem; reuse the pure codecs ───────────────┤
│ Diagnostics · Gnss · VtClient · TcClient · FsClient · Implement · …│
└── Codecs ── encode/decode primitives (net / j1939 / isobus) ─────────┘
A received frame flows up (transport → feed → routed to interested plugins →
events). Commands and cadenced broadcasts flow down (plugin → outbound buffer
→ poll_transmit → transport).
The pieces
| Type | Role |
|---|---|
Session | The sans-IO core. feed / tick / poll_transmit / poll_event / drain::<E>. |
SessionBuilder | Session::builder(name, address), .plug(p), .plug_group(g), .build() or .spawn(transport). |
Plugin | A composable subsystem (see session::plugins). One instance per type. |
PluginCtx | A plugin’s keyhole during a callback: send, emit, now, address, set_name. |
Transport | The CAN boundary (recv/send). EndpointTransport adapts a wirebit::CanEndpoint. |
Driver<T> | Owns the transport + clock; poll / poll_at / pump run the loop. |
Controls | Cheap cloneable handle: start, address, is_claimed, with/with_mut, send_raw, drain. |
Subscription | RAII handle from Driver::on(...); drop it to unsubscribe. |
Building and driving
Build a node, plug the subsystems you want, and split into controls + driver:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:build}}
}
Then drive the loop. Driver::poll_at(now) does one cycle — read the transport,
feed frames, advance timers, flush outbound — and returns the next event:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:claim}}
}
In a hosted real-time program you would call driver.poll() (which reads the
host monotonic clock) in a loop instead of advancing now by hand. In
microcontroller or deterministic embedded code, keep using driver.poll_at(now)
and pass time from the board timer.
Available plugins
Everything the old facade covered, as plugins in machbus::session::plugins:
| Area | Plugin |
|---|---|
| Diagnostics (DM1) | Diagnostics |
| GNSS / NMEA 2000 | Gnss |
| Virtual Terminal | VtClient, VtServer |
| Task Controller | TcClient, TcServer |
| File Server | FsClient, FsServer |
| Implement messages | Implement (hitch / PTO / aux / speed / lighting) |
| Sequence Control | ScMaster, ScClient |
| TIM | Tim |
| Powertrain (J1939) | Powertrain |
| Heartbeat | Heartbeat |
| Maintain Power | MaintainPower |
| Shortcut Button | ShortcutButton |
| Language Command | LanguageCommand |
| Auxiliary (AUX-O/N) | Auxiliary |
| DM14/15/16 + IDs | DmMemory |
| CF Functionalities | ControlFunctionalities |
| Group Function | GroupFunction |
| Request2 | Request2 |
| NAME Management | NameManagement |
Plug one per node:
#![allow(unused)]
fn main() {
// illustrative — the API mirrors the tested types in `src/session`
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(VtClient::new(vt_config, pool, working_set))
.plug(TcClient::new(tc_config, ddop))
.plug(Diagnostics::every(1000))
.spawn(transport)?;
}
Plugging two instances of the same plugin type is a build error (one per type).
Fine control
There are two first-class ways to drop below the facade — both work for every subsystem:
Own the subsystem component. Reach a plugged subsystem by type and call its own methods:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:finecontrol}}
}
ctrl.with::<P>(...) / ctrl.with_mut::<P>(...) (or session.get::<P>() /
get_mut::<P>()) return None if that plugin was not plugged.
Drive the pure core directly. Skip the driver entirely and run Session
yourself — feed frames with an injected timestamp, drain outbound and events.
This is what the tests and embedded loops use:
#![allow(unused)]
fn main() {
// illustrative shape
let mut s = Session::builder(name, 0x80).plug(Diagnostics::every(1000)).build()?;
s.start()?;
s.feed(0, &frame, now); // a received frame + the time
s.tick(now); // advance timers
while let Some((port, frame)) = s.poll_transmit() { bus.send(port, &frame); }
while let Some(event) = s.poll_event() { /* handle */ }
}
For arbitrary PGNs there is a raw escape hatch: ctrl.send_raw(pgn, &data, dst, priority) / session.send_raw(...).
Events, three ways
The core produces one unified Event enum. Consume it however suits you:
-
Unified enum + poll —
driver.poll()?/session.poll_event(). One match site for everything. -
Typed per-subsystem stream —
session.drain::<VtEvent>()/controls.drain::<VtEvent>()returns just that subsystem’s events and leaves the rest queued. No matching a 20-variant enum for one concern. -
Callbacks (RAII) — register typed callbacks and pump:
#![allow(unused)] fn main() { // illustrative shape let sub = driver.on::<VtEvent>(|e| handle_vt(e)); // returns a Subscription loop { driver.pump()?; } // dispatches to callbacks drop(sub); // unsubscribes }
Presets (personas)
session::presets bundles curated plugin groups — one call wires up a role’s
usual subsystems — for plug_group:
#![allow(unused)]
fn main() {
// illustrative shape
use machbus::session::presets;
let (ctrl, mut driver) = Session::builder(name, 0xF0)
.plug_group(presets::tractor()) // diagnostics + implement + powertrain
.plug(Heartbeat::every(100)) // add or drop pieces freely
.spawn(transport)?;
}
Available: presets::tractor(), presets::implement(pool, ws, ddop),
presets::diagnostic_node().
Hosted versus embedded session use
The same conceptual loop exists in both modes, but the available conveniences are different:
| Build mode | Session shape | What owns time/CAN/storage |
|---|---|---|
| Hosted/default | Session::builder(...).plug(...).spawn(transport)?, Driver::poll(), Controls, callbacks, presets, host adapters | Driver can read the host clock; adapters can use host transports and files. |
| Embedded | Session::builder(...).build()?, Driver::new(session, transport), Driver::poll_at(now), feed/tick/poll_transmit/poll_event | Your firmware owns the monotonic timer, CAN HAL, allocator, panic behavior, and persistence. |
| Embedded fixed helpers | Same embedded session plus fixed-capacity boundary helpers such as FixedFrameQueue, FixedMessage, and poll_fixed_event::<N>() | Your firmware chooses queue sizes and handles overflow explicitly. |
For the MCU-focused version of this loop, see
no_std on microcontrollers.
Validate locally
make run EXAMPLE=session_minimal
make standard-suite-check
make verify
If you change feature gates or embedded session behavior, also run:
make no-std-check
make no-std-target-check
make no-std-surface-check
make embedded-examples-check
What this proves / does not prove
Running session_minimal proves the facade builds, claims an address, drives a
plugin, and routes events across a virtual bus. It does not prove vendor
interoperability or physical-bus timing — see
Hardware evidence and the
Claim boundary.
See also
- First node — the shortest path to a running node.
no_stdon microcontrollers — the embedded loop and HAL boundary.- Crate map — where
sessionsits in the crate. - Receiving and routing — the feed/route/event model in plain words.
Where to start
Welcome to the machbus guided walkthrough. This is a hands-on, build-along track: you start a real Rust program, add a few lines, run it, watch ISOBUS traffic happen, and then keep extending the same program until it does something genuinely useful on a tractor bus. Every chapter ends with a program you can actually run.
If the rest of the book is the reference manual, this track is the lab course.
New here? Read The session facade first — it shows the whole shape of the API end to end. Most chapters below teach the protocols one at a time using that same session facade, building one program up step by step; chapters 7 and 8 drop down to the lower-level VT and Task-Controller client codec APIs to show how a subsystem works underneath the facade.
Who this is for
- You know a little Rust (enough to build a project with
cargo) and want to learn ISOBUS by doing. - Or you know ISOBUS and want to see how it maps onto the machbus API.
You do not need a CAN adapter or a tractor. The first ten chapters run entirely in software on a simulated bus. Real hardware shows up in chapter 10, and it is optional.
How the track is structured
The chapters build on each other. Each one keeps the program from the previous chapter and adds to it:
| # | Chapter | You will build | Level |
|---|---|---|---|
| 1 | The ISOBUS Hello World | A node that claims an address on a bus | Beginner |
| 2 | The Hello World, line by line | The same program, fully understood | Beginner |
| 3 | Sending and receiving messages | Broadcast and receive a PGN | Beginner |
| 4 | Requests and acknowledgements | Ask another node for data | Beginner |
| 5 | Moving big data with transport | Send a payload too large for one frame | Intermediate |
| 6 | Talking diagnostics | Publish and read fault codes | Intermediate |
| 7 | Your first Virtual Terminal client | Put a screen on the terminal | Intermediate |
| 8 | Your first Task Controller client | Report a working value to a TC | Intermediate |
| 9 | Tractor and implement personas | A tractor and an implement talking | Advanced |
| 10 | Onto real hardware with SocketCAN | The same code on a vcan interface | Advanced |
| 11 | Async event streams | An async, await-driven event loop | Advanced |
| 12 | Capstone: a complete implement ECU | Everything, combined | Advanced |
How to follow along
Every chapter is anchored to a real example that ships in the repository, so the
code you read is code that compiles and runs. Wherever you see a code block
pulled from examples/, you can run that exact example:
cargo run --example session_minimal
# or, equivalently, through the Makefile:
make run EXAMPLE=session_minimal
The recommended way to work through the track is to keep your own scratch example file open beside the book and paste each step into it as you go. If you get stuck, the finished example for that chapter is named at the top of the page — diff your version against it.
What you need installed
- A recent stable Rust toolchain (see Install Rust).
- The repository checked out, so
cargo run --example ...works. - Nothing else for chapters 1–9 and 11. Chapter 10 uses Linux SocketCAN and a
vcanvirtual interface.
Before you dive in
You will get more out of the track if you have skimmed two short concept pages first. They are quick reads and the walkthrough refers back to them:
- ISOBUS in plain words — the five-minute mental model.
- NAME and address claim — why a node needs an identity before it can talk.
Everything else is explained as it comes up.
A word on what this proves
The walkthrough teaches the machbus API and ISOBUS behavior in software. It is a
learning resource, not a certification. machbus is not certified; shipping a
real machine still needs the official standards, real hardware, and
interoperability testing with the actual terminals, task controllers, and ECUs
you intend to work with.
Ready? Start with The ISOBUS Hello World.
1. The ISOBUS Hello World
Anchor example:
examples/session_minimal.rs— run it any time withcargo run --example session_minimal.
Every networking tutorial has a “hello world”, and on ISOBUS that hello world is not “print some text” — it is claim an address on the bus. Until a node has claimed an address it is not allowed to say anything else, so this is genuinely the first thing every real ECU does. By the end of this chapter you will have a program that brings a node onto a (simulated) ISOBUS network and watches it take ownership of an address.
We will go fast here and just get it running. The next chapter, The Hello World, line by line, pulls the same program apart and explains every piece.
What we are building
A tiny program that:
- creates a simulated CAN bus with two seats on it,
- builds an machbus
Sessionfor our node, - drives the address-claim handshake, and
- prints the address our node ended up owning.
There are two nodes on the bus because a node is never really alone on a real
machine — there is always at least a terminal or a tractor ECU present. We will
call our node node_a; node_b stands in for “some other ECU that is already
there”.
Step 1 — a node needs a NAME
Before anything else, a node needs an identity: its NAME. The NAME is a 64-bit value that says what the node is and which specific unit it is. We will cover every field in the next chapter and in depth in the Address claim tutorial; for now, a tiny helper that stamps out a NAME from an identity number is enough:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:name}}
}
The important part is with_self_configurable(true): it tells the bus “if I
lose a fight over an address, I am allowed to move to another one.” That keeps
our hello world from getting stuck.
Step 2 — a bus to plug into
On real hardware the bus is a pair of wires and a CAN controller. In software,
machbus talks to a bus through a transport, and the companion wirebit crate
can build a simulated bus with named seats. We make a bus called bus0 with two
members and take an endpoint for each, then wrap each endpoint in an
EndpointTransport. Think of take_endpoint("a") as “plug a cable from seat
a into our node”.
Step 3 — build the sessions
Now we build a Session for each node. The session is the machbus facade that
hides all the protocol bookkeeping. Session::builder(name, addr) takes a NAME
and a preferred address; .plug(...) adds optional capabilities (here a
Diagnostics plugin); and .spawn(transport) returns a (Controls, Driver)
pair. We then call start() on the controls to begin the claim:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:build}}
}
node_a would like address 0x80; node_b would like 0x81. They do not
conflict, so both should get their wish — but the bus does not know that yet.
Right after spawn(), neither node has actually claimed anything. The Controls
half (ctrl_a) is how you command and inspect the node; the Driver half
(drv_a) is how you advance it and read events.
Step 4 — drive the claim
Claiming is not instant. A node announces its NAME at its preferred address and then waits a short window to see if anyone objects. We make that happen by polling each driver (advancing its sense of time and draining the events it produces) and pumping the bus (delivering frames between the two endpoints) in a loop:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:claim}}
}
drv.poll_at(now) hands you one event at a time until there is nothing left for
that instant; built.pump_all() carries the frames between endpoints. In a real
program you would call drv.poll() instead, which uses the host clock, and you
would not need to drive a simulated bus. That loop is the heartbeat of every
machbus program: poll the drivers, pump the bus, repeat.
Step 5 — run it
Run the finished example:
cargo run --example session_minimal
You should see output that includes something like:
[claim] node A → 0x80, node B → 0x81
[events] node A saw 1 address-claim event(s)
That is the whole game. After polling and pumping, node_a owns 0x80 and
node_b owns 0x81. You can confirm with ctrl_a.is_claimed() and
ctrl_a.address(). Our node is now a full citizen of the bus and may start
sending real traffic — which is exactly what the
next chapters do.
What just happened
node_a: "NAME 0x.... wants 0x80" ─┐
├─► bus carries both claims
node_b: "NAME 0x.... wants 0x81" ─┘
no conflict → both keep their preferred address → Claimed
Because the two preferred addresses were different, there was nothing to fight
over. If both had asked for 0x80, the node with the lower NAME would have kept
it and the other — being self-configurable — would have slid to the next free
address. You can see that contest play out in the
Address claim tutorial.
Things that trip people up
- Forgetting to pump. On a simulated bus, if you poll the drivers but never
call
pump_all(), the claim announcements never reach the other node and nobody ever becomes claimed. Poll and pump. - Forgetting to
start(). A session does not begin claiming until you callctrl.start(). Build, start, then poll. - Sending too early. A node must be claimed before it sends application traffic. We will rely on that in the next chapter.
- Reusing a NAME. Two nodes with identical NAMEs cannot be told apart. Give each node a distinct identity number.
Validate locally
cargo run --example session_minimal
make test
What this proves / does not prove
Proves: machbus can bring a node onto a simulated bus and complete address claim, and you can drive that from a few lines of Rust.
Does not prove: anything about real-hardware timing or interoperability with a specific ECU. The simulated bus is for learning and testing.
Next
→ 2. The Hello World, line by line — now that it runs, understand every line.
2. The Hello World, line by line
Anchor example:
examples/session_minimal.rs.
In chapter 1 we got a node onto the bus. It worked, but a few things were waved past. This chapter slows down and explains every part of that program, because almost everything you do later is a variation on it. Nothing new is built here — this is the “read the manual for the thing you just used” chapter.
The imports
#![allow(unused)]
fn main() {
use machbus::Instant;
use machbus::j1939::diagnostic::{Dtc, Fmi};
use machbus::net::{Name, Result};
use machbus::session::plugins::Diagnostics;
use machbus::session::{ClaimEvent, DiagEvent, Event};
use machbus::session::{EndpointTransport, Session};
use wirebit::topology::Topology;
}
Two layers show up here, and it is worth knowing which is which:
machbus::netis the low-level protocol layer — raw frames, NAMEs, addresses, priorities. You reach into it when you want fine control.machbus::sessionis the surface layer — theSessionfacade, itsControls/Driverpair, plugins, and the unifiedEventtype. This is what most application code uses.wirebitis the bus transport crate.Topologybuilds simulated buses and hands out endpoints. On real hardware you would build a transport over a SocketCAN link instead (see chapter 10).
A session is generic over its transport. That is how the exact same session code runs on a simulated bus today and a real one later: only the transport changes.
The NAME helper
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:name}}
}
A Name is built with consuming with_* setters. This helper sets only three
things and leaves the rest at default, which is fine for a demo. In a real
product every field carries meaning:
| Field | What it says | In the helper |
|---|---|---|
| Identity number | Which specific unit this is | the identity argument |
| Function code | What the node does | 0x80 |
| Self-configurable | May I move addresses if I lose? | true |
| Manufacturer, device class, instances, … | The rest of the identity | left at default |
The two nodes differ only by identity number (0x100 vs 0x999), which is
enough to make their NAMEs distinct. Distinct NAMEs are mandatory — two nodes
that present the same NAME break the bus’s ability to tell them apart. The full
field-by-field meaning is in
NAME and address claim.
Building the simulated bus
We describe a network with wirebit, build it, and take an endpoint per seat:
#![allow(unused)]
fn main() {
let mut topo = Topology::new();
let n1 = topo.add_node("a");
let n2 = topo.add_node("b");
topo.can_bus("bus0").members(&[n1, n2]);
let mut built = topo.build().unwrap();
let bus = built.can_bus_mut("bus0").unwrap();
let ep_a = bus.take_endpoint("a").unwrap();
let ep_b = bus.take_endpoint("b").unwrap();
}
Line by line:
Topology::new()starts an empty description of a network.add_node("a")/add_node("b")declare two devices.can_bus("bus0").members(&[n1, n2])wires both onto one CAN segment.build()turns the description into a live, runnable bus.take_endpoint("a")removes seata’s endpoint and hands it to us, so we can wrap it in a transport. Each endpoint can only be taken once.
The object built still owns the bus itself. We keep it around because we have
to pump it later to actually move frames between the seats.
Building the session
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:build}}
}
The builder reads like a sentence: this session has this NAME, prefers this
address, has this plugin, and is spawned on this transport. The
EndpointTransport::new(0, ep_a) channel index (0) matters once a node sits on
more than one bus (a router/NIU node does); for a single-bus node it is always
0.
spawn() returns a Result, so a misconfigured session fails loudly rather
than limping along. It also splits the session into two halves:
ctrl_a— theControls: command and inspect the node (start(),is_claimed(),address(),with_mut::<Plugin, _>(...)).drv_a— theDriver: advance the node and read events (poll()/poll_at()).
After spawn() the session exists but has not claimed anything, and it will
not begin until you call ctrl.start(). If you query it before the loop runs,
ctrl_a.is_claimed() is false and ctrl_a.address() is the null/no-address
placeholder.
The poll-and-pump loop
This is the single most important pattern in machbus, so let us read it carefully:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:claim}}
}
start()(called just above, in the build snippet) tells each session to begin announcing its claim. This only queues the work; nothing is on the wire yet.- Inside the loop,
now.add_millis(50)advances the simulated clock by 50 milliseconds, anddrv.poll_at(now)lets the driver do any work that became due — including sending its claim and, later, deciding the contention window has closed — handing you each resulting event in turn. built.pump_all()is the bus’s job: it carries frames that one endpoint sent over to the other endpoint’s inbox.- The loop exits early once both
ctrl_a.is_claimed()andctrl_b.is_claimed()report success.
The mental model: sessions produce and consume frames when you poll their
drivers; the bus moves frames when you pump it. Neither happens on its own. A
real application runs this loop forever using drv.poll() (host clock, no
simulated bus to pump), interleaving it with reading sensors and updating
displays.
After enough iterations the contention window has elapsed with no conflict, and each node is claimed at its preferred address. That is the line:
[claim] node A → 0x80, node B → 0x81
Where claim results show up as events
The driver does not only let the node change its state — poll_at (and poll)
also hand you an event per piece of progress:
#![allow(unused)]
fn main() {
let mut a_claims: Vec<ClaimEvent> = Vec::new();
while let Some(event) = drv_a.poll_at(now)? {
if let Event::AddressClaim(claim) = event {
a_claims.push(claim);
}
}
}
The unified Event enum has a variant per subsystem — AddressClaim, Diag,
Vt, Tc, and so on. A claim success arrives as
Event::AddressClaim(ClaimEvent::Claimed { address }).
You will spend a lot of time in later chapters matching on these events. The rule to remember: poll the driver regularly. Events are produced as you poll; if you stop polling, the node stops making progress and you stop seeing what it did.
Fine control through the plugin
The Diagnostics plugin we attached is reachable through the controls. To raise
a fault code on node A:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:finecontrol}}
}
ctrl.with_mut::<Diagnostics, _>(|diag| ...) borrows the named plugin so you can
call its methods. This is the general escape hatch for poking at a specific
capability without leaving the session facade. Diagnostics get a full chapter
later; here it is just a taste of how fine control works.
The shape of every machbus program
Strip away the demo specifics and every program in this track has the same skeleton:
build a NAME
build a Session (name + preferred address + plugins) → (controls, driver)
controls.start()
loop:
driver.poll() → react to each Event
do your application work (controls.send_*, controls.with_mut, ...)
The chapters from here on only change what happens inside “react” and “do your application work”. The plumbing stays identical.
Validate locally
cargo run --example session_minimal
make test
What this proves / does not prove
Proves: you understand the machbus lifecycle and event model well enough to read any later chapter.
Does not prove: anything about hardware or certification — the same caveats from chapter 1 apply.
Next
→ 3. Sending and receiving messages — now that the node is on the bus, make it talk.
3. Sending and receiving messages
Anchor example:
examples/session_minimal.rs— run it any time withcargo run --example session_minimal.
In chapter 1 we got a node onto the bus, and in chapter 2 we read the program line by line. Both nodes are now claimed, which is the moment a node earns the right to say anything beyond “this address is mine”. This chapter spends that right: we make one node broadcast a message and the other node receive it.
We keep the same (controls, driver) pair from the previous chapters and the
same poll-and-pump loop. Nothing new gets built; we just add traffic.
What we are adding
node_bbuilds and broadcasts a raw frame through its controls.- We poll and pump until the message arrives.
node_areads the inbound event out of its driver.
Sender and receiver are two different nodes on the same simulated bus, exactly like a tractor ECU talking to an implement.
Step 1 — send a frame
A node sends an arbitrary frame through its controls handle. The session exposes
send_raw for hand-crafted traffic:
#![allow(unused)]
fn main() {
ctrl_b.send_raw(0xFECA, &[0xDE, 0xAD, 0xBE, 0xEF], BROADCAST_ADDRESS, Priority::Default)?;
}
Reading send_raw argument by argument:
| Argument | Value here | Meaning |
|---|---|---|
| PGN | 0xFECA | which message this is |
| payload | &[0xDE, 0xAD, 0xBE, 0xEF] | the data bytes |
| destination | BROADCAST_ADDRESS | who it is for — everyone |
| priority | Priority::Default | how urgent the frame is on the bus |
The source address is filled in for you: the session uses the address node_b
actually claimed. That is why we had to wait until the node was claimed before
sending — a node with no address has nothing valid to put there.
Why 0xFECA specifically? It is a PDU2 PGN — its PDU Format byte is 0xFE,
which is >= 0xF0. PDU2 PGNs are broadcast-only: the message has no single
addressee, so the destination field is not part of the PGN and the address we
pass as “destination” will not overwrite it. That keeps this first example
simple — we can broadcast to everyone without worrying about who is listening.
PGNs below 0xF000 (PDU1) are addressed to one node and behave differently; the
difference is covered in
PGNs, priority, source and destination.
BROADCAST_ADDRESS (the value 0xFF) means “no specific recipient”. Because we
chose a PDU2 PGN, this is the natural and only sensible choice: the message goes
out and any node on the bus will pick it up.
Step 2 — poll and pump until it lands
Sending only queues the frame in node_b’s transport. As always in machbus,
nothing moves until you pump the bus (on a simulated bus), and nothing is
processed until you poll the drivers. So we run the same heartbeat from the
previous chapters, watching node_a’s inbound events:
#![allow(unused)]
fn main() {
for _ in 0..10 {
now = now.add_millis(50);
while drv_b.poll_at(now)?.is_some() {} // let node_b flush the send
built.pump_all().unwrap(); // bus carries the frame
while let Some(event) = drv_a.poll_at(now)? {
// node_a turns the inbound frame into an Event here
}
built.pump_all().unwrap(); // settle
}
}
This is the same loop from chapter 1, just with traffic flowing. We poll
node_b (flush the send), pump (carry the frame from node_b’s outbox to
node_a’s inbox), poll node_a (let it recognise the frame and produce an
event), and pump again to settle. A real application would call drv.poll() and
run this forever, with no simulated bus to pump.
Step 3 — read the inbound event
Polling node_a’s driver hands you each inbound message as an Event. You match
on it the same way you matched on claim events:
#![allow(unused)]
fn main() {
while let Some(event) = drv_a.poll_at(now)? {
match event {
Event::Custom { pgn, source, data } => {
println!("got PGN {pgn:#06X} from {source:#04X}: {data:02X?}");
}
other => println!("{other:?}"),
}
}
}
A raw inbound PGN that no subsystem claimed arrives as a Custom event carrying:
pgn— which PGN this is (0xFECA), so one handler can serve several message kinds.source— the address of the node that sent it (node_b’s claimed address), so you know who is talking.data— the payload bytes, exactly whatnode_bput in (DE AD BE EF).
Anything that is not the variant you care about falls through to the other
arm. That catch-all is a habit worth keeping: the event stream is shared across
every subsystem, so a handler should always have a default branch.
If you prefer to collect one subsystem’s events without matching the whole
stream, ctrl.drain::<E>() pulls the buffered events of a single typed kind. The
poll-and-match form above is the general path; drain is the convenience when
you only want one event family.
What just happened
node_b: ctrl_b.send_raw(0xFECA, ..) ── build + queue a frame on node_b's transport
│
pump_all() ── bus carries it to node_a's inbox
│
drv_a.poll(..) ── session recognises the frame,
makes an Event
│
match event { Event::Custom { .. } => . } ── you read { pgn, source, data }
Broadcasting (a PDU2 PGN to BROADCAST_ADDRESS) means anyone on the bus hears
it; an addressed message (a PDU1 PGN to a specific node’s address) is delivered
to just that one recipient. We use the broadcast form here because it is the
simplest thing that demonstrably works; addressed exchanges show up when we send
a request and wait for an answer.
Things that trip people up
- Forgetting to pump. On a simulated bus, the frame never reaches the other
node until you call
pump_all(). Poll and pump. - PDU1 vs PDU2 confusion. With a PDU2 PGN (
>= 0xF000) the destination you pass is harmless — it does not change the PGN. With a PDU1 PGN it is part of the addressing and absolutely does matter. Get this wrong and your message goes to the wrong place or nowhere. When in doubt, re-read PGNs, priority, source and destination. - Not polling the driver. Inbound frames become events only when you poll. If you stop polling, your “missing” message simply never surfaced. Poll on every loop iteration.
- Sending before claimed. The source address is only valid once the node has claimed. Sending earlier puts a bogus source on the bus.
Validate locally
cargo run --example session_minimal
make test
What this proves / does not prove
Proves: machbus can broadcast a frame from one node, move it across a simulated bus, and surface it on another node as an event with its PGN, source, and payload intact.
Does not prove: anything about real-hardware timing, electrical behaviour, or interoperability with a specific ECU. The simulated bus is for learning and testing; a real deployment still needs hardware and the official standards.
Next
→ 4. Requests and acknowledgements — broadcasting is one-way. Next we send a request to a specific node and wait for its reply.
See also
- Request a PGN — the addressed request/response pattern in depth.
- Receiving and routing — how the session decides which frames become events and where they go.
- PGNs, priority, source and destination — the anatomy of a message identifier and the PDU1/PDU2 split.
4. Requests and acknowledgements
No dedicated example for this chapter. This page builds directly on the chapter-3 program (two sessions on a simulated bus, one broadcasting a PGN the other receives). There is no separate
examples/binary for a bare PGN request, so the request/answer snippets below are illustrative shape grounded in the realmachbusAPI, not compiled includes. Everything still validates withmake test, and the codecs they use are unit-tested in the crate.
In chapter 3 a node pushed data: one node broadcast a PGN and the other picked it up whenever it happened to arrive. That is the right shape for data that streams on a schedule. But sometimes you need a value now — the current address of a peer, a version string, a one-off status — and you do not want to wait for the next broadcast that may be seconds away.
This chapter adds the pull side of the bus: you will ask another node for a specific PGN, and handle whatever comes back — including the polite “I cannot give you that” answer.
Pull versus wait-for-broadcast
So far every value reached you because someone decided to send it. A request flips that around: you name a PGN and ask a node (or everyone) to transmit it.
PULL (this chapter) PUSH (chapter 3)
requester sender
│ Request(PGN = X) ─► │ PGN X data ─► (on a schedule)
│ │
│ ◄─ PGN X data (the answer) ▼
│ or receiver picks it up
│ ◄─ Ack (cannot/denied/…) whenever it arrives
A request is a tiny message whose payload is the PGN you want. The answer is either that PGN’s normal data, or — when there is nothing to give — an Acknowledgement that explains why.
Step 1 — start from the chapter-3 program
Keep the exact two-node setup you already have: a simulated bus0 with seats
a and b, a session for each, the claim handshake driven by the poll-and-pump
loop, and both nodes claimed. We will call them node_a (the requester) and
node_b (the responder). The full skeleton, unchanged from chapter 3 — a
(controls, driver) pair per node, started, then driven by the poll-and-pump
loop:
#![allow(unused)]
fn main() {
// build NAMEs, build the two sessions, then:
ctrl_a.start()?;
ctrl_b.start()?;
let mut now = Instant::ZERO;
for _ in 0..30 {
now = now.add_millis(50);
while drv_a.poll_at(now)?.is_some() {}
while drv_b.poll_at(now)?.is_some() {}
built.pump_all().unwrap();
if ctrl_a.is_claimed() && ctrl_b.is_claimed() {
break;
}
}
// both are now Claimed — only now may we send a request
}
Do not send a request before you are Claimed. A request from a node with no
address is malformed, and a well-behaved responder ignores it. This is the same
rule you met with address claim.
Step 2 — build a request for a PGN
A request rides on PGN_REQUEST (0xEA00) and carries a three-byte payload: the
PGN you want, little-endian. The codec lives in j1939::pgn_request:
#![allow(unused)]
fn main() {
use machbus::j1939::encode_request;
use machbus::net::pgn_defs::PGN_ADDRESS_CLAIMED;
// Ask for "address-claimed" data — a safe PGN every node answers.
let payload = encode_request(PGN_ADDRESS_CLAIMED).unwrap();
}
encode_request returns a [u8; 3] and rejects any PGN outside the 18-bit
J1939/ISOBUS range with an error, so you cannot accidentally ask for a malformed
PGN.
Step 3 — send it like any frame
A request is just a frame. You send it exactly the way you broadcast in chapter
3, but you point it at a destination. Send to node_b’s address for a
destination-specific request, or to BROADCAST_ADDRESS (0xFF) to ask
everyone who owns the PGN:
#![allow(unused)]
fn main() {
use machbus::net::{BROADCAST_ADDRESS, Priority};
use machbus::net::pgn_defs::PGN_REQUEST;
ctrl_a.send_raw(
PGN_REQUEST,
&payload,
ctrl_b.address(), // destination: a specific peer (or BROADCAST_ADDRESS)
Priority::Default,
)?;
}
The source address is filled in for you from the address node_a claimed — the
same send_raw you used to broadcast in chapter 3, just pointed at a destination
and carrying a request payload.
Then run the same poll-and-pump loop so the frame actually crosses the bus and any answer comes back:
#![allow(unused)]
fn main() {
for _ in 0..20 {
now = now.add_millis(10);
while drv_a.poll_at(now)?.is_some() {}
while drv_b.poll_at(now)?.is_some() {}
built.pump_all().unwrap();
}
}
That is the whole send path. Nothing here is new machinery — it is the chapter-3 pump loop carrying one more frame.
Step 4 — receive the answer (data, or an Ack)
On the requester, drain events the way you already do. A request resolves in one of two broad ways:
You get the data. If node_b owns the PGN, it transmits it on that PGN. Any
inbound PGN that no subsystem plugin claimed surfaces as an Event::Custom when
you poll node_a’s driver — exactly like the broadcast you received in chapter 3.
No subscription step is needed:
#![allow(unused)]
fn main() {
// ... after the pump loop ...
while let Some(ev) = drv_a.poll_at(now)? {
if let Event::Custom { pgn, source, data } = ev {
println!("answer: pgn=0x{pgn:04X} from 0x{source:02X} = {data:02X?}");
}
}
}
You get an Acknowledgement instead. When a node cannot answer with data, it
may reply on PGN_ACKNOWLEDGMENT (0xE800). That is the next step.
Expected output
For a request that the peer can answer, you see the requested PGN come back as a
Custom event from node_b’s address — the same event shape as a chapter-3
broadcast, except this time you triggered it:
answer: pgn=0xEE00 from 0x81 = [..]
What just happened
You named a PGN, sent it to a destination, and the responder transmitted that PGN back. The request did not say “do something” — it said “send me PGN X.” The answer is ordinary bus traffic; the only special thing was that you asked for it.
Step 5 — handle the four acknowledgement outcomes
When the honest answer is not data, the responder sends an Acknowledgment
(j1939::Acknowledgment) on 0xE800. Its control byte (AckControl) tells you
which of four things happened. On the requester, decode it and branch:
#![allow(unused)]
fn main() {
use machbus::j1939::{AckControl, Acknowledgment};
use machbus::net::pgn_defs::PGN_ACKNOWLEDGMENT;
if msg.pgn == PGN_ACKNOWLEDGMENT {
if let Some(ack) = Acknowledgment::from_message(&msg) {
match ack.control {
AckControl::PositiveAck => { /* accepted — proceed */ }
AckControl::NegativeAck => { /* not supported — stop waiting */ }
AckControl::AccessDenied => { /* exists, but not for us right now */ }
AckControl::CannotRespond => { /* exists, not ready yet — retry later */ }
}
// ack.acknowledged_pgn and ack.address let you match it to your request
}
}
}
What a client should do for each:
AckControl | Meaning | What the requester does |
|---|---|---|
PositiveAck | The request was accepted (used where a request needs confirming, not answering with data). | Treat as success; carry on. |
NegativeAck | Understood, but the node will not / does not supply that PGN. | Give up on this PGN. Do not retry — the answer is “no.” |
AccessDenied | The node owns the PGN but you may not read it in the current state. | A refusal, not an absence. Do not retry blindly; the data exists but is gated. |
CannotRespond | The node owns the PGN but cannot produce it yet (busy, not initialised). | The only outcome where retrying later is reasonable. |
The two fields acknowledged_pgn and address let you confirm the Ack refers to
the request you actually sent, which matters when several requests are in flight.
Building an Ack (the responder side)
If you are writing the responder and you do not implement a requested PGN, answer a destination-specific request with a NACK rather than silence:
#![allow(unused)]
fn main() {
let nack = Acknowledgment::nack(requested_pgn, ctrl_b.address());
// send nack.encode().unwrap() on PGN_ACKNOWLEDGMENT, back to the requester
}
Acknowledgment::ack(pgn, addr) and Acknowledgment::nack(pgn, addr) cover the
two common cases; encode produces the eight-byte wire format.
Global versus specific requests
This is the one policy decision that matters most:
- Specific (destination = a peer’s address): that peer should give a definite answer — the data, or a NACK so you are not left waiting forever.
- Global (destination =
BROADCAST_ADDRESS): only nodes that actually own the PGN answer; everyone else stays silent. An unanswered global request is normal, not an error.
So a request to one node that never answers is suspicious; a global request that some nodes ignore is expected.
Gotchas
- A request is not a command. It says “send me PGN X,” never “do X.” If you need a node to act, that is a different message; a request only pulls data.
- An unanswered global request is fine. Treat silence on a global request as a valid outcome. Wait a bounded time, then move on.
- Do not block waiting. Never spin tightly re-sending the same request while
you wait. Send once, keep ticking-and-pumping, and only re-ask if the response
window genuinely closed empty — and even then, only for
CannotRespond. - Malformed requests get no answer.
decode_requestreturnsNonefor short, wrongly-padded, or out-of-range payloads. An invalid request is not a request — a good responder answers nothing.
Validate locally
make test
The Request and Acknowledgement codecs (encode_request / decode_request /
requested_pgn, and Acknowledgment encode/decode with every AckControl
value) are covered by unit tests beside their modules and by property tests, so
make test exercises the exact calls used above.
What this proves / does not prove
Proves: you can build a PGN request, send it to a specific node or to everyone,
and handle both a data answer and each of the four acknowledgement outcomes,
using the real machbus codecs on the same simulated-bus loop from chapter 3.
Does not prove: how a specific third-party ECU times or answers a request on real
hardware, or any conformance or certification claim. machbus is not certified;
a real deployment still needs official standards, real hardware, and
interoperability evidence.
Next
→ 5. Moving big data with transport — when the answer to a request is larger than eight bytes, it cannot ride in one frame. The next chapter shows how the response is segmented and reassembled.
See also
- PGN request — the reference page: full anatomy of Request, Request2 / Transfer, the responder registry, and the session facade.
- PGN requests and acknowledgements — the basics, why the pull mechanism exists at all.
- Talking diagnostics — diagnostic parameter groups are a common target of destination-specific requests.
5. Moving big data with transport
Anchor example:
examples/transport_demo.rs— run it any time withcargo run --example transport_demo.
In chapter 3 and chapter 4 every message fit in a single CAN frame. That is fine for a speed reading or a button press, but it does not get you far: a CAN frame holds eight bytes of payload, and the messages you actually care about — a Virtual Terminal object pool, a Task Controller device description, a diagnostic fault list, a GNSS record — are hundreds or thousands of bytes. Something has to chop the big payload into 8-byte frames, carry them across the bus, and glue them back together on the far side.
That something is the Transport Protocol. By the end of this chapter you will have sent payloads far too big for one frame and watched machbus split them, ship them, and reassemble them byte for byte — first with plain TP, then with the extended ETP for a very large payload, then with NMEA Fast Packet as the NMEA-2000 equivalent.
This is the build-along version. The Transport Protocol tutorial is the reference companion: it has the full state machine, every timeout, and every abort reason. We will point you there for the deep parts instead of repeating them.
What we are building
transport_demo is unusual for this track: instead of a session, it drives the
bare transport engines directly so every frame is visible. You create a
sender engine and a receiver engine, then pass frames between them by hand. That
makes the splitting and reassembly something you can watch happen, step by step.
big payload reassembled payload
│ ▲
▼ │
[ sender engine ] ──► 8-byte frames over the bus ──► [ receiver engine ]
We will do this three times — TP, ETP, Fast Packet — each as its own self-
contained block in main.
Step 1 — a connection-mode TP round trip (CMDT)
Start with a 40-byte payload. That is five times bigger than one frame can hold, so it cannot go out as a single message. We use connection mode (CMDT), the flavour with a handshake: the sender asks permission, the receiver paces the flow, and the receiver confirms when every byte has arrived.
Add this block — it is the whole TP section of the example:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:17:56}}
}
Run it:
cargo run --example transport_demo
Expected output (the [TP CMDT] section):
[TP CMDT]
RTS for 40 bytes (≈6 packets)
CTS num=6 next_seq=1
TX queued 6 DT frames
delivered 40 bytes (equal? true)
What just happened
Read the block as a four-message conversation between tx and rx:
tx (0x10) rx (0x20)
│ RTS "40 bytes, 6 packets" ───────►│ open a receive session,
│ │ allocate the buffer
│◄────── CTS "send me 6, start at 1" │ receiver sets the pace
│ DT seq 1..6 ─────────────────────►│ 7 payload bytes per frame
│◄────── EndOfMsgAck "got all 40" │ confirm + close
▼ ▼
Complete Complete
Line by line in the code:
tx.send(0xEF00, &payload, 0x10, 0x20, 0, Priority::Lowest)validates the payload, opens a transmit session, and hands back the RTS frame (“Request To Send”). The0x10/0x20are the sender and receiver addresses.rx.process_frame(&rts, 0)opens the matching receive session and returns a CTS (“Clear To Send”) granting a window of packets. Thenum=6is how many DT frames it is ready for;next_seq=1is where to start.tx.process_frame(&cts, 0)accepts the window;tx.get_pending_data_frames()drains it into the DT (data-transfer) frames — one sequence byte plus seven payload bytes each. Six frames carry the 40 bytes (ceil(40 / 7) = 6).- Feeding each DT frame back into
rx.process_framereassembles the bytes, and when the last one lands the receiver returns an EndOfMsgAck. tx.process_frame(&eoma)confirms delivery, which fires theon_completeevent we subscribed to. The completedTransportSessioncarries the reassembleddata, andgot.data == payloadproves it came back intact.
The key idea: the receiver is in charge of pace. It never gets more frames than the CTS it just issued asked for, so a slow receiver cannot be flooded. That handshake-and-flow-control is exactly what makes CMDT the right choice for the big, must-not-drop uploads (object pools, device descriptions) you will meet in later chapters.
Step 2 — the same idea, much bigger (ETP)
TP has a ceiling. Its on-wire counters cap a single transfer at 1785 bytes. When your payload is bigger than that, you reach for the Extended Transport Protocol (ETP), which widens the counters to carry payloads into the megabytes. ETP is connection-mode only — there is no broadcast form.
Here we push 2.5 KiB (2500 bytes), well past TP’s limit, so ETP is required. The handshake is the same shape, with one extra step folded in (a packet-offset message that lets ETP’s small sequence numbers address a huge payload — the tutorial covers it). Because that means several CTS windows, the example pumps in a loop until reassembly completes:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:60:96}}
}
Expected output (the [ETP] section):
[ETP]
converged in 23 turns, delivered 2500 bytes (equal? true)
The exact turn count is not important — what matters is that the loop keeps
pumping frames back and forth until received is filled, then checks that 2500
bytes came back byte-for-byte equal. The pump loop here is the same heartbeat you
have used since chapter 1, just with the bus stood in by hand: keep feeding
frames between the two engines until the transfer converges. Stop pumping early
and the transfer never finishes.
The size ladder
The three mechanisms form a ladder, and the choice is decided purely by how many bytes you are sending:
| Payload size | Mechanism |
|---|---|
| up to 8 bytes | one ordinary CAN frame — no transport at all |
| 9 to 1785 bytes | TP (this chapter’s step 1) |
| 1786 bytes and up | ETP (step 2) |
machbus enforces these bounds for you: TransportProtocol::send rejects a
payload that is 8 bytes or smaller (“use a single frame”) and one that is too big
for TP (“use ETP”), and ExtendedTransportProtocol::send rejects anything small
enough for plain TP. You generally do not pick the protocol yourself at the
session layer — it looks at the size and routes to the right engine.
Step 3 — Fast Packet, the NMEA side
ISOBUS rides on J1939, where TP/ETP is the transport. NMEA 2000 — the marine and GNSS world — shares the same CAN physical layer but uses its own lighter multi-frame scheme called Fast Packet for payloads up to a couple hundred bytes. machbus speaks it too, and the API mirrors TP closely.
Here we send a 30-byte GNSS-style payload as Fast Packet:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:100:119}}
}
Expected output (the [Fast Packet] section):
[Fast Packet]
5 frames for 30 bytes
reassembled 30 bytes (equal? true)
What happened
Fast Packet has no handshake at all. tx.send(PGN_GNSS_POSITION, &payload, 0x10)
returns the complete set of frames in one go — the first frame carries a
length and the leading bytes, each following frame carries a sequence counter and
more payload. The receiver glues them back together as they arrive and returns
the finished message once the last frame lands. Thirty bytes split into five
frames; msg.data == payload confirms the round trip.
Fast Packet is to NMEA what BAM (the broadcast form of TP) is to ISOBUS: a fire-and-forget, no-flow-control way to push a moderately sized payload to listeners. It trades the safety of a handshake for simplicity and speed.
Broadcast vs connection-mode
You have now seen both postures, and the difference is the one thing to carry forward:
- Connection-mode (CMDT, ETP) — there is exactly one receiver, it paces the flow with CTS windows, it can refuse the transfer, and it confirms completion. Reliable. Use it for uploads that must not drop a byte.
- Broadcast (BAM, Fast Packet) — the sender pushes to everyone with no acknowledgement and no flow control. A listener that misses a frame just fails to reassemble and drops the message; nobody retransmits. Use it when many nodes want the same data and occasional loss is acceptable.
machbus handles the framing, sequencing, and reassembly in both cases. Your job is to hand it a payload and pump.
Things that trip people up
- Stopping the pump too early. Reassembly only completes when you keep
feeding frames between the engines. In the ETP step that is the whole point of
the loop — break out before
receivedis filled and you get nothing. With a real session this is the usual poll-and-pump loop; keep it running. - Pumping only one side. A transfer needs frames flowing both ways (RTS→CTS→DT→ack). On a virtual bus, if you only pump the sender, the receiver never gets a chance to issue its CTS and the transfer stalls until it times out.
- Expecting flow control on broadcast. BAM and Fast Packet have no CTS and no ack. There is no back-pressure and no retry — by design.
- Aborts and timeouts exist. Out-of-order frames, duplicate sequences, exhausted buffers, and silent peers all end a session with an abort or a timeout. The full list of abort reasons and the timeout windows lives in the Transport Protocol tutorial — reach for it when a transfer misbehaves.
Validate locally
cargo run --example transport_demo
make test
The example runs all three round trips and prints equal? true for each,
proving every payload was split and reassembled byte for byte.
What this proves / does not prove
Proves: machbus can split a payload too large for one CAN frame, carry it as a stream of 8-byte frames, and reassemble it intact — over plain TP, over ETP for very large payloads, and over NMEA Fast Packet — and that it picks the right mechanism by payload size.
Does not prove: anything about real-hardware timing, interoperability with a specific third-party ECU, or any conformance or certification claim. machbus is not certified; real deployment still needs official standards, hardware, and interoperability evidence.
Next
→ 6. Talking diagnostics — fault codes and the kind of fault lists that ride on top of the transport you just built.
See also
- Transport Protocol tutorial — the full reference: state machine, timeouts, every abort reason.
- Fast Packet tutorial — the NMEA multi-frame mechanism in depth.
6. Talking diagnostics
Anchor example:
examples/diagnostic_demo.rs— run it any time withcargo run --example diagnostic_demo.
In chapter 5 we moved a payload too big for one frame. Now we make the node say something a human cares about: what is wrong with it.
Picture a service technician plugging a laptop into a machine that quit in the field. The first two questions are always “what failed?” and “which box do I replace?” Diagnostics answers both. A faulting ECU broadcasts the codes that are active right now, keeps a history of ones that were active so a tool that arrives late still sees them, and exposes an identity so the tool knows exactly which unit and software it is talking to.
By the end of this chapter you will have built a fault code by hand, packed it into the active-fault message, read it back out, and you will know how the previously-active list, clearing, and identity fit around it.
What we are building
We work at the codec layer here — pure build-the-struct, encode, decode.
No bus, no claim loop yet; just the bytes a diagnostic message is made of, so the
anatomy is unmistakable. The example:
- builds a DTC (a single fault) from its three parts,
- packs two of them into a DM1 (the “active faults” message) with a lamp lit,
- encodes that to bytes and decodes it straight back, and
- round-trips a lone DTC field byte-for-byte.
Then we look at how the previously-active list, clearing, and identity ride the exact same types once you wrap a session around them.
Step 1 — the imports
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:4:4}}
}
Five names, one module. Dtc is a single fault. Fmi says how it failed.
DmDtcList is the message that carries a list of faults plus a lamp panel
(DiagnosticLamps), and LampStatus is the state of one lamp. Everything lives
under machbus::j1939 because ISOBUS diagnostics is the J1939 diagnostic layer
with a few extra fields — see
Diagnostics basics.
Step 2 — anatomy of a DTC
A Diagnostic Trouble Code is the unit of fault information, and it has exactly three meaningful parts:
| Part | Field | Means |
|---|---|---|
| SPN — Suspect Parameter Number | spn: u32 | What is faulty (a numeric handle for the parameter or component). 19 bits on the wire. |
| FMI — Failure Mode Indicator | fmi: Fmi | How it is faulty (VoltageLow, MechanicalFail, AbnormalRateChange, …). 5 bits. |
| Occurrence count | occurrence_count: u8 | How often it has happened. 7 bits. |
Read together: “parameter X is failing in manner Y, and it has happened N times.” Build one and watch it survive a round-trip through its 4-byte wire form:
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:48:61}}
}
The SPN here (0x1_2345) is wide on purpose: encoding clamps it to 19 bits and
masks the count to 7 bits, so a Dtc always produces a valid wire field rather
than corrupting the bytes around it. The assert_eq! proves the value you put in
is the value you get back.
Step 3 — pack faults into a DM1
DM1 is the active diagnostics message: the faults a node has right now. In
machbus it is DmDtcList — a lamp panel plus a Vec<Dtc>. We build one with
the amber warning lamp on and two faults in the list:
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:9:32}}
}
The lamp panel and the DTC list are independent fields. A DTC says what is
wrong; the lamp says how loudly to alert the operator. We lit amber_warning and
left the malfunction, red-stop, and engine-protect lamps at their default. The
same two-byte lamp block rides at the front of every active/previously-active
message, so a reader learns the codes and the alert level from one frame.
Step 4 — encode, send, decode
On a real bus you would put dm1.encode() on the wire and the receiver would
call DmDtcList::decode(..). Here we do both ends in one breath so you can see
the bytes survive the trip:
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:34:46}}
}
encode always produces at least 8 bytes (lamps, then the DTCs), and decode
returns the faults back as a Vec<Dtc>.
Step 5 — run it
cargo run --example diagnostic_demo
Expected output:
=== Diagnostic Demo ===
[DM1] 2 active DTCs, amber=On
[encode/decode] 10 bytes round-trip → 2 DTCs
SPN=0x00208 FMI=VoltageLow OC=0
SPN=0x000BE FMI=AbnormalRateChange OC=7
[DTC] 4-byte field: SPN=0x12345 → wire [45, 23, 27, 0C] → SPN=0x12345
✓ DTC round-trips byte-exact
What just happened
Dtc{spn:520, fmi:VoltageLow, oc:0} ─┐
Dtc{spn:190, fmi:AbnormalRateChange, oc:7} ─┤
lamps{ amber: On } ├─► DmDtcList.encode() ─► 10 bytes
─┘ │
▼
DmDtcList.decode(bytes) ◄──────────────────────── back to 2 DTCs + lamps
Two faults plus a two-byte lamp block encoded to 10 bytes, and decoding gave
back the same two faults. SPN 520 prints as 0x00208, SPN 190 as 0x000BE
— that is just hex, not a different value. The standalone Dtc proved its 4-byte
field round-trips byte-exact (45 23 27 0C), which is the guarantee everything
above is built on.
Active vs previously-active
DM1 is only half of the read story. When a fault clears, it does not vanish — it
moves to the previously-active list, which is published as DM2. DM2 uses the
same DmDtcList type. The split is what lets a late-arriving service tool
reconstruct what happened:
fault raised ──► ACTIVE list ──► DM1 (broadcast, ~once a second)
│
fault clears / repaired
▼
PREVIOUSLY-ACTIVE list ──► DM2 (sent on request)
A healthy node still broadcasts DM1 — with an empty list. “Nothing wrong” is a real, useful state, not the absence of a message.
Clearing and identity (the session surface)
The codec layer you just used has no list management on purpose. Once you wrap a
session around it — plug in the Diagnostics plugin — the active/previous
bookkeeping, the periodic DM1 broadcast, and request handling come for free. The
shapes below are illustrative — for the verified behavior and the full plugin
API, follow the Diagnostics tutorial.
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled call — see the tutorial.
ctrl.with_mut::<Diagnostics, _>(|diag| {
diag.raise(Dtc { spn: 520, fmi: Fmi::VoltageLow, occurrence_count: 1 });
}); // adds an active fault → DM1
// ... after the repair ...
ctrl.with_mut::<Diagnostics, _>(|diag| {
diag.clear();
}); // clears the active list
}
Clearing on the Diagnostics plugin is clear-all: clear() takes no
arguments and empties the active DTC list in one call. (The DM protocol itself
also defines a service-tool single clear that names one (spn, fmi) and gets an
Ack or Nack back; that targeted form is not exposed on this plugin.)
Identity answers the second technician question — which box? A node can be asked for its identification strings (part number, serial, manufacturer, software version, and so on). The tool reads those to confirm exactly which ECU and firmware it is about to clear codes on or reflash. The codecs for those strings live alongside the DM messages; the tutorial covers them in full.
A note on size
Two faults fit in one CAN frame. A long fault list, or a multi-field identity string, will not — those payloads ride the transport protocol you met in chapter 5. You do not change how you build the message; the lower layer fragments and reassembles it for you.
Things that trip people up
- No-fault is a valid state. An empty
DmDtcListstill encodes to a proper DM1. Do not treat “zero DTCs” as an error or a reason to stay silent. - Clearing is a move, not a delete. A cleared active code lands in the previously-active list. A technician an hour later still sees it on DM2. That is the whole point — do not expect it to disappear.
- Occurrence counting is yours to manage at this layer. The count does not
auto-increment. When the same
(spn, fmi)recurs, bump the count instead of pushing a duplicate (useDtc::matches, which compares by(spn, fmi)and ignores the count). TheDiagnosticsplugin’sraiseis idempotent by(spn, fmi), so the counting policy stays explicitly yours. - Lamps and codes are independent. A lamp on with no code, or codes with all lamps off, are both legal. Decide your lamp policy deliberately; a reader will not infer one from the other.
- Decoders are strict. A wrong length or a bad placeholder returns nothing rather than a half-parsed value. Check the result; do not unwrap blindly on bytes from the wire.
Validate locally
cargo run --example diagnostic_demo
make test
The example builds a DM1 with two DTCs, round-trips it through encode/decode, and asserts a single DTC field round-trips byte-exact. The test suite exercises every DM codec — round-trips, SPN clamping, lamp packing, and the strict-decode rejections.
What this proves / does not prove
Proves: you can build a DTC from its three parts, pack faults into the active
message, and round-trip them through encode/decode in software — and you know
where the previously-active list, clearing, and identity fit.
Does not prove: real-hardware timing, interoperability with a specific third-party ECU or service tool, or any conformance/certification claim. The same caveats from the earlier chapters apply.
Next
→ 7. Your first Virtual Terminal client — put a screen on the operator’s terminal.
See also
- Diagnostics tutorial — the full DM family, the session diagnostics surface, clearing, memory access, and identity strings.
- Diagnostics basics — the conceptual primer for DTCs and the DM messages.
7. Your first Virtual Terminal client
Anchor example:
examples/vt_client_demo.rs— run it any time withcargo run --example vt_client_demo.
So far our node has claimed an address, swapped messages, moved big payloads, and answered diagnostics. It is a good bus citizen, but it has no way to talk to the person in the cab. That is the job of a Virtual Terminal client.
Here is the key idea: an implement usually has no screen of its own. Instead it borrows the terminal’s. The implement ships the terminal a description of its user interface — a tree of objects called an object pool — and the terminal draws it. From then on the implement sends small commands (“set this number to 42”) and receives input events (“soft key 7 was pressed”) back over the bus.
By the end of this chapter you will have driven a VT client from
Disconnected all the way to Connected, watched its connect state machine
advance step by step, and pushed one runtime update to the screen. We will keep
it focused: building a real pool is a big topic with its own tutorial, so our
pool here is deliberately tiny.
The deep reference for behavior is the Virtual Terminal client tutorial and Virtual Terminal concepts. This chapter is hands-on: add a bit, run it, see what happens.
What we are building
The example stands in for the whole bus in software. It plays the implement
(the VT client) and fakes the terminal at address 0x80 by feeding the client
the frames a real terminal would send. That lets us watch every step of the
connect handshake without any hardware. The program will:
- build a minimal object pool and a client,
connect()and pump the connect state machine toConnected,- react to the terminal’s replies along the way, and
- send one UI command once the pool is live.
Step 1 — build a pool and a client
A VT client always starts from an object pool: the UI it wants drawn. Ours is the smallest pool that means anything — a working set object (the implement’s “name badge” on the terminal) that points at a single data mask (one full-screen layout):
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:16:28}}
}
VTClient::new takes a VTClientConfig (the default is fine — a 6-second
per-step timeout, VT version 4). set_object_pool hands it the UI tree.
connect() does not block and does not put anything on the wire yet; it
arms the state machine and moves the client to “listening for a terminal”. Right
after it returns, client.state() reports WaitForVTStatus.
Why a working set plus a mask? The working set is your implement’s whole identity on the terminal; a mask is one screen within it. The terminal needs both: something to identify the client, and something to draw. The full object model is covered in Virtual Terminal concepts.
Step 2 — the terminal announces itself
A real VT periodically broadcasts a status frame. The client is waiting for
exactly that: the first valid status binds the session to that terminal’s
address and advances the FSM. We fake the broadcast (terminal at 0x80,
reporting VT version 4) and feed it in with handle_vt_message, then call
update to get the first outbound frame:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:30:48}}
}
This is the same tick-and-pump rhythm from
chapter 2, specialized for the VT: inbound frames
drive transitions (handle_vt_message); update performs the send-side steps
and hands you back a Vec of frames to ship. Each returned frame is a
ClientOutbound carrying a pgn, data, and dest — you route it through
your own send path exactly as given.
Two update calls happen here. The first emits the Working Set Master frame
(announcing our working set to the network). The second emits Get Memory,
which asks the terminal to reserve room for the pool — the example asserts that
frame’s first byte is cmd::GET_MEMORY.
Step 3 — memory OK, then upload the pool
The terminal answers Get Memory with a verdict: “I have room” or “I do not”. We feed back an OK reply, then pump the pool transfer and the End Of Object Pool marker, and finally feed the terminal’s activation reply:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:51:75}}
}
Notice the ordering. update ships the pool transfer first (on a real bus this
is the part the transport-protocol layer fragments across many CAN frames), and
only after that does End Of Object Pool go out. The client deliberately lets the
transfer drain before it sends the end marker — sending “that’s all” while the
multi-frame transfer is still in flight confuses a real terminal. When the
terminal’s End Of Object Pool reply comes back with no error, the client reaches
VTState::Connected. The example asserts exactly that.
Step 4 — the connect state machine, in one picture
What you just pumped through is a fixed sequence of states. It is worth seeing the whole path at once:
Disconnected
│ connect()
▼
WaitForVTStatus ◄── VT status frame binds the terminal
│ update()
▼
SendWorkingSetMaster ── emits Working Set Master
│ update()
▼
SendGetMemory ── emits Get Memory (pool size)
│ update()
▼
WaitForMemory ◄── "memory OK" reply
│ update()
▼
UploadPool ── emits the pool transfer
│ update() (after a settle delay)
▼
WaitForEndOfPool ◄── End Of Object Pool reply, no error
│
▼
Connected ── UI commands now allowed
Each “waiting” state is bounded by the config timeout: if the terminal goes
silent, the client falls back to Disconnected instead of hanging, and a later
status frame can start a fresh attempt. The full table — including the language
ReloadPool path and the memory-not-OK branch — is in the
client tutorial.
Step 5 — send a runtime update
Now that we are Connected, the pool is live on the terminal and we may send UI
commands. The simplest is changing a number — the value behind an output-number
object:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:78:84}}
}
change_numeric_value(object_id, value) returns a ClientOutbound addressed to
the bound terminal. This is the everyday loop of a running implement: read your
sensors, then push the changed values down with commands like this. You change
the value an object references, not the layout — the layout was uploaded once,
back in step 3.
The command surface is broad (hide_show, enable_disable,
change_string_value, change_active_mask, and many more); they all share two
rules: they return a ClientOutbound addressed to the terminal, and they all
require the Connected state, returning an error if you call them too early.
The full set is in VT updates.
Step 6 — run it
cargo run --example vt_client_demo
You should see the state machine march through its steps, each line printed by the example as it advances:
=== VT Client Demo ===
[1] connect() → WaitForVTStatus
[2] VT_STATUS → SendWorkingSetMaster
[3] update() → 1 frame (SendGetMemory)
[4] update() → GET_MEMORY (WaitForMemory)
[5] mem OK → UploadPool
[6] update() → ... frames (pool transfer + EOP)
[7] EOP ack → Connected ✓
[ui] change_numeric_value(0xCAFE, 42) → pgn=0x..., dest=0x80, len=8
Read it top to bottom and you can see the whole lifecycle: connect arms the
machine, the status frame binds the terminal, each update advances one step and
emits the right frame, the activation reply flips us to Connected, and only
then does the UI command go out — addressed to the terminal at 0x80.
Reacting to operator input
In the demo we drive everything by hand, but a real client is event-driven. The terminal sends input events down when the operator acts, and the client fans them out to handlers you register before connecting. The ones you will reach for first:
| Event | Fires when | You typically |
|---|---|---|
on_state_change | The FSM transitions. | Log progress; gate UI commands on Connected. |
on_soft_key | A soft key is activated. | Map (ObjectID, ActivationCode) to an action. |
on_button | A button object is activated. | Same, for on-screen buttons. |
on_numeric_value_change | The operator edits a number. | Update your app model. |
on_active_ws_status | Your working set becomes (in)active. | Show or hide your interface. |
The pattern is always the same: operator presses a soft key → the terminal sends
an input event up → your on_soft_key handler reads the object ID and the
ActivationCode (Pressed, Released, Held, Aborted) → you run your logic
→ you send commands back down to update the screen. The terminal never runs your
logic; every visible change is a command you issued. The full event list is in
the client tutorial.
Things that trip people up
- Sending updates before
Connected. Every command method enforces theConnectedstate and returns an error otherwise. Watchstate()(oron_state_change) and gate your own logic on it — do not generate UI churn into the void. - Ending the pool too early. Do not try to shortcut the settle delay between the pool transfer and End Of Object Pool. The client waits on purpose so the multi-frame transfer can finish; sending the end marker over a still-draining transfer confuses a real terminal.
- A pool the terminal rejects. An unknown object type, a duplicate object ID,
or a missing child reference surfaces as a pool error in the End Of Object Pool
reply — the client fires
on_pool_errorand drops toDisconnected. Fix the offending object and reconnect. - No active-WS status. The client only reports whether your working set is
the active one after you tell it your own address with
set_self_address; until thenon_active_ws_statusstays quiet.
Validate locally
cargo run --example vt_client_demo
make test
make test exercises the client’s transition, timeout, and pool-validation
paths beyond the happy path the example walks.
What this proves / does not prove
Proves: machbus can drive a VT client through its full connect-and-upload state machine — discover, announce, reserve memory, transfer the pool, end it, and go active — and then issue UI commands, all from a few lines of Rust against a simulated terminal.
Does not prove: rendering on a real terminal, interoperability with a specific third-party VT, or any conformance/certification claim. machbus is not certified; real deployment still needs official standards, real hardware, and interoperability evidence.
Next
→ 8. Your first Task Controller client — give the implement a screen’s quieter cousin: a controller that logs and commands its work without an operator watching.
See also
- Virtual Terminal client — the connect lifecycle, version/memory negotiation, and event fan-out in depth.
- VT object pools — how to build a real interface tree (more than our one-mask demo).
- VT updates — the full command surface for keeping the UI in sync with your application state.
8. Your first Task Controller client
Anchor example:
examples/tc_client_demo.rs— run it any time withcargo run --example tc_client_demo.
In chapter 7 we gave the implement a screen to talk to the operator. Now we give it the quieter partner that needs no screen at all: a Task Controller. Where the Virtual Terminal is about showing things to a person, the Task Controller is about logging and controlling the actual field work — how much product to apply, which sections are on, and what was really done. A documentation TC mostly listens (it records measured values for the field log); a control TC also talks back (it sends setpoints down).
Here is the key idea, and it mirrors the VT one: before the implement can trade any work data, it must describe itself first. With the VT it uploaded an object pool of screens. With the TC it uploads a device description — a small tree called the DDOP (Device Descriptor Object Pool) that says “this is what I am and these are the values I can talk about.” Only after the TC has that description, and has activated it, do the two sides start exchanging process data.
By the end of this chapter you will have driven a TC client from Disconnected
all the way to Connected, watching its connect state machine advance one step
at a time as you feed it the frames a real Task Controller would send.
The deep reference for behavior is the Task Controller client tutorial and Task Controller concepts. This chapter is hands-on: add a bit, run it, see what happens.
What we are building
The example stands in for the whole bus in software. It plays the implement (the
TC client) and fakes the Task Controller at address 0x33 by feeding the
client the frames a real TC would send. That lets us watch every step of the
connect handshake without any hardware. The program will:
- build a tiny DDOP and a client,
connect(), then hear the TC announce itself,- announce a working set and negotiate the TC’s version,
- upload the DDOP and activate it, landing on
Connected.
Step 1 — build a DDOP and a client
A TC client always starts from a DDOP: the description of the machine the TC
will log and control. Ours is the smallest pool that means anything — one
device object (the implement as a whole, with a designator and software
version) that owns one device element (the Root of the device tree):
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:22:34}}
}
TaskControllerClient::new takes a TCClientConfig (the default is fine — a
six-second per-step timeout, a version-4 client). set_ddop hands it the device
description. connect() does not block and does not put anything on the
wire yet; it validates the DDOP, arms the state machine, and moves the client to
“listening for a TC”. Right after it returns, client.state() reports it is
waiting for the server’s status.
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:36:39}}
}
Why so small? A real DDOP describes every boom, section, bin, and reportable quantity, each with a DDI and units. That is a topic of its own — see the DDOP tutorial. Here we keep it to one device and one element so the handshake is the only thing on screen.
Step 2 — the TC announces itself
A real Task Controller periodically broadcasts a process-data status frame.
The client is waiting for exactly that: the first valid status binds the session
to that TC’s address and advances the FSM. We fake the broadcast (TC at 0x33)
and feed it in with handle_tc_message:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:41:51}}
}
This is the same tick-and-pump rhythm from
chapter 2, specialized for the TC: inbound frames
drive transitions (handle_tc_message); update performs the send-side steps
and hands you back the frames to ship. After this status, tc_address() is bound
to 0x33 — every reply from here on must come from that source, and frames from
any other address are refused.
Step 3 — announce the working set, then ask the TC’s version
With a TC in sight, the client announces which working set owns the upcoming pool
and then asks the TC what protocol version it speaks. Each update performs one
send-side step:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:53:60}}
}
The first update ships the Working Set Master announcement (member count
one). The second ships the version request. Version negotiation comes before
anything substantial on purpose: a connection only works at the level both ends
support, so “what version is this TC” is the first fact the client establishes.
Step 4 — the version reply
The TC answers with its protocol version and its technical capabilities (how many booms and sections it can handle). We feed that reply back and read the recorded version:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:63:72}}
}
tc_version() now reports what the TC told us. A malformed version reply is
ignored and the client keeps waiting until the timeout, rather than proceeding on
bad data.
Step 5 — upload the DDOP, then activate it
Now the description goes up. update serializes the DDOP and emits it as an
object-pool transfer (on a real bus, large pools are fragmented across many CAN
frames by the transport-protocol layer):
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:74:80}}
}
The TC replies with a verdict on the pool — accepted or rejected — and the client then sends the activate command and waits for its acknowledgement. A zero status means success:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:82:95}}
}
When the activation reply comes back clean, the client reaches
TCState::Connected. The example asserts exactly that. Only now — pool uploaded
and activated — is it meaningful to emit process data.
Step 6 — the connect handshake, in one picture
What you just pumped through is a fixed sequence of states. It is worth seeing the whole path at once:
Disconnected
│ connect() (validates the DDOP)
▼
WaitForServerStatus ◄── TC status frame binds the server address
│ update()
▼
SendWorkingSetMaster ── emits Working Set Master
│ update()
▼
RequestVersion ── emits the version request
│
▼
WaitForVersion ◄── version + capabilities reply
│ update()
▼
TransferDDOP ── emits the device-description upload
│
▼
WaitForPoolResponse ◄── pool accepted
│ update()
▼
ActivatePool ── emits the activate command
│
▼
WaitForActivation ◄── activation reply, no error
│
▼
Connected ── process data now allowed
Each “waiting” state is bounded by the config timeout: if the TC goes silent, the
client falls back to Disconnected instead of hanging, and a later status frame
can start a fresh attempt. There is also a branch the demo skips for brevity — the
label check — described next.
Label-based caching: the upload you can skip
A real client does not always re-upload its DDOP. Between announcing its version and transferring the pool, it can ask the TC two questions: do you already hold my structure? and do you already hold my localization (language/units)? The device object carries a structure label and a localization label for exactly this. If the TC answers that both already match, the client skips the whole transfer and jumps straight to activation; if they do not match, it deletes the stale pool first and then uploads. Set stable labels on your device object so a TC that has met your implement before does not re-download the pool every session. The full label-driven decision is in the client tutorial.
Process data: the live conversation
Once Connected, the handshake is over and the running exchange begins. Every
process-data message names an element (which part of your device, from the
DDOP) and a DDI (which quantity, from the shared data dictionary), plus a
32-bit value where one applies. It flows both ways:
- Up — measured values. The implement reports what it is actually doing: real rates, flows, counts, section states. A documentation TC logs these. You build an ECU→TC value frame and ship it; the client also calls your value-request handler when the TC asks for a current reading.
- Down — setpoints and commands. A control TC sends what it wants done: target rates, section on/off. The client calls your value-command handler with the element, DDI, and value. A downward command is a request — your application validates it against the real machine state and decides whether acting on it is safe. The implement always owns its own behaviour.
A DDI is only meaningful with its owning element: the same “actual rate” DDI can appear once per section, so “DDI X changed” is ambiguous until you know which element reported it. The DDOP is the map that removes that ambiguity. The message-level detail is in DDOP and process data.
Step 7 — run it
cargo run --example tc_client_demo
You should see the handshake march through its steps, each line printed by the example as it advances:
=== TC Client Demo ===
[1] connect → WaitForServerStatus
[2] TC_STATUS → SendWorkingSetMaster, tc_addr=0x33
[3] sent WS Master + VERSION_REQUEST → WaitForVersion
[4] TC version=4, → TransferDDOP
[5] DDOP frame size=8 bytes → WaitForPoolResponse
[6] activated → Connected ✓
Read it top to bottom and you can see the whole lifecycle: connect arms the
machine, the TC status binds the server at 0x33, the working-set announcement
and version request go out, the version reply advances us, the DDOP uploads, and
the activation reply flips us to Connected. Only after that last line would real
process data start to flow.
Things that trip people up
- Process data before
Connected. Reporting values against a pool the TC has not activated is meaningless, and the client’s state guards exist to stop it. Watchstate()(or the state-change event) and gate your own logic onConnected— do not emit work data into the void. - Skipping the version handshake. Both ends cap themselves to the version and capabilities the other actually supports. Establish “what version is this TC” and “how many sections does it support” first, not as an afterthought.
- A DDOP the TC rejects. A non-zero pool response means the upload was
refused; the client returns to
Disconnectedrather than pretending the pool is live. Re-check the DDOP for duplicate object IDs or dangling references before retrying. - A value out of range. Element numbers are carried in a 12-bit wire field, so a number that does not fit is rejected when you build the payload — a coding error surfaces as an error, not a corrupted frame on the bus.
- A reply from the wrong TC. Once a TC is bound, frames from any other source address are refused. Make sure the TC you target is the one whose status you first heard.
Validate locally
cargo run --example tc_client_demo
make test
make test exercises the client’s label-driven upload/skip/delete decision, the
timeout-to-Disconnected path, the re-upload sequence, and the value-request and
setpoint callbacks — well beyond the happy path the example walks.
What this proves / does not prove
Proves: machbus can drive a TC client through its full connect handshake —
discover the TC, announce a working set, negotiate the version, upload the device
description, and activate it — landing on Connected, all from a few lines of
Rust against a simulated Task Controller.
Does not prove: interoperability with a specific third-party Task Controller, real-hardware timing or bandwidth behaviour, or any conformance/certification claim. machbus is not certified; real deployment still needs official standards, real hardware, and interoperability evidence.
Next
→ 9. Tractor and implement personas — step back from a single service and wire the whole machine together: a tractor ECU and an implement ECU talking across the bus.
See also
- Task Controller client — the connect FSM, the label-based upload decision, and the process-data callbacks in depth.
- DDOP — how to build and validate the device description you upload (more than our one-element demo).
- DDOP and process data — the message-level view of the device description and the live values.
9. Tractor and implement personas
Anchor example:
examples/session_minimal.rs— run it any time withcargo run --example session_minimal. It shows the session build-and-claim loop the personas below ride on top of.The curated roles live in
presets:presets::tractor()andpresets::implement(pool, ws, ddop)are plugin groups you plug into a session in one call.
Every chapter up to now built a single node and taught it one trick: claim, send, request, move big data, talk diagnostics, drive a VT, drive a TC. A real machine is not one node doing one trick. It is at least two cooperating ECUs on the same bus — a tractor that knows how fast the ground is moving and where its hitch sits, and an implement that needs those numbers to do its job and occasionally wants the tractor to lift the hitch or spin the PTO.
This chapter stands both of those up. We use two pre-wired presets — plugin groups the session assembles for you — so you can think in machine terms (“this is the tractor”, “this is the implement”) instead of wiring every subsystem by hand. The deep behaviour lives in the Tractor ECU and Implement ECU tutorials; here we build and run.
What a preset is
A preset is a bundle of plugins with the right subsystems already switched on for
a job. presets::tractor() returns the plugin group a tractor ECU needs — the
tractor message groups and its classification. presets::implement(pool, ws, ddop)
returns the implement-side group. You feed either to the session builder with
.plug_group(...):
#![allow(unused)]
fn main() {
use machbus::session::{Session, presets};
let (ctrl, mut driver) = Session::builder(name, addr)
.plug_group(presets::tractor())
.spawn(transport)?;
ctrl.start()?;
}
Underneath, both presets build the same Session you have used since chapter 2 —
the poll-and-pump loop, the event stream, and address claim all work exactly as
before. The preset just turns on a coherent set of subsystems in one call.
There are two directions of traffic, and keeping them straight is the whole point of this chapter:
TRACTOR preset IMPLEMENT preset
┌────────────────┐ ┌────────────────┐
│ PUBLISH state │ ── speed/distance ──► │ read latest │
│ speed, hitch, │ ── hitch / PTO ─────► │ values, react │
│ PTO, GNSS │ │ │
│ │ ◄── hitch command ─── │ COMMAND tractor│
│ accept command?│ ◄── PTO command ───── │ (if allowed) │
└────────────────┘ ◄── aux-valve cmd ─── └────────────────┘
Publishing is a one-way broadcast: the tractor states a fact, anyone who cares subscribes. Commanding is a request the other side may refuse. The tractor publishes; the implement commands. (A capable tractor may also accept commands — that is the right-to-left arrow.)
Step 1 — build a tractor preset
A tractor describes itself with a classification: its base class, whether it
offers navigation messages, whether it is front-mounted, and so on. The
presets::tractor() group carries sensible tractor defaults and the tractor
message groups; you plug it into the session alongside the usual NAME, preferred
address, and transport:
#![allow(unused)]
fn main() {
let (tractor, mut tractor_drv) = Session::builder(tractor_name, 0xF0)
.plug_group(presets::tractor())
.plug(Gnss::listen()) // the tractor preset has no GNSS — add it to publish position
.spawn(tractor_transport)?;
tractor.start()?;
}
The builder reads like a description of the machine: this identity, prefers
address 0xF0 (the conventional TECU address), with the tractor role plugged in.
The presets::tractor() group bundles diagnostics, implement messages, and
powertrain — it does not include GNSS, so we add Gnss::listen() here because
Step 3 publishes a position. spawn() returns a Result so a contradictory
configuration fails loudly.
Step 2 — claim
Then drive the same claim handshake from chapter 1 — poll the driver, pump the
bus, repeat until claimed. This is exactly the claim anchor in the session
example:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:claim}}
}
ctrl.is_claimed() and ctrl.address() tell you where the node landed.
Everything you learned about the loop still applies; the preset just rides on top
of it.
Step 3 — publish state
A claimed tractor broadcasts what it knows. Use fine control on the relevant plugin to publish a GNSS position (latitude, longitude, ground speed) or raise a diagnostic, then keep polling and pumping so the frames actually move:
#![allow(unused)]
fn main() {
tractor.with_mut::<Gnss, _>(|gnss| {
gnss.broadcast_position(&pos);
});
}
This is publishing in its purest form — the tractor states a fact and walks away. It does not wait for an acknowledgement and does not care who reads it; the poll-and-pump loop puts the position on the wire.
Step 4 — the events queue
Whatever a node receives surfaces on the one unified event stream you drain
with driver.poll_at(now)? (or driver.poll()? on a real clock). Personas do not
get their own parallel queue — every subsystem feeds the same Event enum you
have been draining since chapter 2:
#![allow(unused)]
fn main() {
while let Some(event) = tractor_drv.poll_at(now)? {
match event {
Event::Gnss(g) => { /* position fix */ }
Event::Diag(d) => { /* fault code */ }
_ => {}
}
}
}
Step 5 — two nodes on one bus
The real-machine shape is a tractor and an implement on the same bus0, the
tractor built with presets::tractor() and the implement with
presets::implement(pool, ws, ddop):
#![allow(unused)]
fn main() {
let (implement, mut impl_drv) = Session::builder(impl_name, 0x80)
.plug_group(presets::implement(pool, ws, ddop))
.spawn(impl_transport)?;
implement.start()?;
}
Both must claim before either can say anything else, so the claim loop polls
both drivers and pumps the shared bus between them — the same shape as the
single-node claim anchor above, with a second poll_at for the implement.
This is the gotcha that bites everyone: if only one session claims, the other
stays at the 0xFE null address and silently drops any application traffic you try
to send through it. Poll both, pump once, every iteration.
Step 6 — the tractor commands the implement
With both nodes claimed, the tractor issues commands through its implement-message
plugin: a rear-hitch raise, a rear-PTO speed, an auxiliary-valve extend. Use
with_mut to reach the plugin and queue each command; the following pump loop
delivers them. Note the raw values — 4320 raw for ~540 rpm, 0x4000 for 50%
flow. The session passes the encoded field through; it does not invent units for
you.
Step 7 — the implement reads and reacts
On the other side, the implement drains its stream and matches on
Event::Imp(ImplementEvent::…). Then it reads the cached latest value
for each through fine control on its implement plugin.
There are two ways to consume implement traffic, and you will use both:
| Style | Source | When |
|---|---|---|
| Event-driven | ImplementEvent::HitchCommand, PtoCommand, AuxValveCommand | react the moment a command arrives |
| Latest-value | the implement plugin’s cached getters (last rear hitch, last rear PTO) | read the current state on your own schedule |
The event tells you something changed; the cached getter tells you what the value is right now. Control loops usually poll the cached value each tick and only watch events for edges (a command just landed, a value went stale).
ImplementEvent at a glance
ImplementEvent is the implement subsystem’s slice of the unified Event enum.
The variants this chapter exercises are HitchCommand { hitch, msg } (someone
asked to move a hitch), PtoCommand { pto, msg } (drive a PTO at a target speed
and ramp), and AuxValveCommand(m) (drive an aux valve at a flow rate). The
Hitch and Pto enums (Hitch::Rear, Pto::Rear) pick which actuator; the
message body carries the command. Match the variant you care about and ignore
the rest, exactly as with any other Event. Both Hitch and Pto are
re-exported from machbus::session.
Gotchas
- Both sessions must claim first. On a two-node bus, poll and pump both every
iteration until each reports claimed. A node still at
0xFEcannot send anything. - Check facility availability. A preset only offers what it turned on. Gate on
the relevant cached getters returning
Some(...)before you trust a value. ANonefrom a last-hitch getter means “no command seen yet”, not “hitch is down”. - Plan for stale data. Published state can stop arriving (cable knocked loose, sender reset). The cached getter keeps handing you the last value it saw, which can be dangerously old. Real control logic times out a value and falls back to a safe default — stop metering, hold position — rather than acting on a stale number.
- Commands are requests. The tractor publishes; the implement commands. A command is never a guarantee — the receiving side decides whether to honour it, and on a real machine its interlocks have the final say.
Validate locally
cargo run --example session_minimal
make test
What this proves / does not prove
Proves: machbus can stand up tractor and implement presets on one simulated bus, have the tractor publish state and command the implement, and have the implement consume both event-driven and via cached latest values — all on the same poll-and-pump loop and unified event stream from chapter 2.
Does not prove: anything about real-hardware timing, signal cadence, or interoperability with a specific commercial tractor or implement. The simulated bus is for learning and testing; a real pairing still needs official standards, hardware, and interoperability evidence.
Next
→ 10. Onto real hardware with SocketCAN — take the same session code off the simulated bus and onto a real CAN interface.
See also
- Tractor ECU — the publish-vs-command split, classes and facilities, message groups in depth.
- Implement ECU — the consumer side: reading state, safe-state thinking, requesting actions.
- TIM and automation — when an implement is allowed to drive the tractor automatically, and the safety machinery around it.
10. Onto real hardware with SocketCAN
Anchor example:
examples/socketcan_capture.rs— a real SocketCAN program. With thesocketcanfeature on and a Linux CAN interface available, run it withcargo run --features wirebit --example socketcan_capture. It is a frame capture tool: it transmits a few ISOBUS-shaped frames on the interface and records them back into acandumpfile, proving the wire path end-to-end. It does not build aSession— the session-on-SocketCAN snippets below are illustrative shape showing how you would swap the transport, grounded in the realspawn(...)API.
Up to chapter 9 every node lived on a simulated
bus: a wirebit topology whose frames you moved by hand with pump_all(). That
is perfect for learning and testing, but it is not a wire. This chapter is where
you leave the simulator. You will run the same kind of session code from
chapter 1, unchanged in spirit, against a real Linux CAN
interface — using a virtual vcan0 so you need no adapter and no machine.
The headline you should carry out of this chapter: the session code does not
change. Only the transport changes. You build the session exactly as you have
all along; you just hand spawn(...) a SocketCAN transport instead of an
endpoint transport. Everything you already know still applies.
If you only want the setup reference, the short version lives in Getting started → SocketCAN. This chapter is the build-along.
What we are building
The anchor example puts real ISOBUS-shaped frames on a real vcan0 interface and
records them with a CaptureRecorder, while candump lets you watch the same raw
bytes cross the wire — in the same format you read about in
Reading candump traces. Alongside it,
this chapter shows illustratively how a full Session would run over the same
SocketCAN transport: the only thing that changes versus the simulated chapters is
the argument you hand to spawn(...).
Step 1 — turn on the socketcan feature
SocketCAN support is behind a Cargo feature. The hosted default build includes
the virtual-bus adapter, but not the Linux-only SocketCAN backend, and the
embedded profile excludes both. Everything in this chapter needs the hosted
socketcan feature. The exact feature name is socketcan, and you pass it on
the command line:
cargo run --features wirebit --example socketcan_capture
If you just want to confirm the examples type-check on your machine without a live interface, there is a make target for exactly that:
make socketcan-examples-check
That runs cargo check --features wirebit --examples. It does not open any
interface, so it works anywhere — it only proves the code compiles.
Step 2 — bring up a virtual CAN interface
You do not need a physical adapter to exercise the wire format. Linux ships a
virtual CAN driver, vcan, that behaves like a real interface but loops frames
back in the kernel. Create one called vcan0:
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set vcan0 up
These three commands load the vcan kernel module, create the interface, and
bring it up. They touch kernel networking state, so they need privileges — hence
sudo. Once vcan0 is up it persists until you reboot or remove it
(sudo ip link delete vcan0).
Sanity-check that it exists and is up:
ip link show vcan0
You should see vcan0 listed with state UP (vcan often reports UNKNOWN/UP
flags — that is normal for a virtual link).
The same
vcan0shows up in the replay tutorial too; see SocketCAN replay for turning captures into repeatable fixtures.
Step 3 — build a session on a SocketCAN transport
Here is the only real difference from chapter 1. Instead of taking an endpoint
from a simulated topology, you open a SocketCAN transport on the named interface
and hand it to spawn(...). A SocketCanConfig names the interface;
create_if_missing: false means “the interface must already exist” (you made it
in step 2). The interface name commonly comes from an environment variable like
MACHBUS_SOCKETCAN_IFACE, defaulting to vcan0.
From there, the session builder is identical to the simulated case — same NAME, same preferred address, same plugins. (The anchor example is a capture tool and does not build a session; this snippet is illustrative shape for the transport swap.)
#![allow(unused)]
fn main() {
let (ctrl, mut driver) = Session::builder(name, addr)
.plug(Plugin::...)
.spawn(socketcan_transport)?;
ctrl.start()?;
}
This is the payoff of the transport being a pluggable parameter. Compare it to the
hello-world builder in
chapter 2: the only thing that
moved is the argument to spawn(...) — an endpoint transport became a SocketCAN
transport. Everything above the transport is unchanged.
Step 4 — claim, then drive
The driving loop is the same rhythm from chapter 1, with one important change:
there is no pump_all().
On the simulated bus, you carried frames between endpoints by pumping. On
SocketCAN there is no software bus to pump — the kernel moves the frames. You
still drive the session with driver.poll()? to let it produce and consume frames
and advance, but delivery is the operating system’s job now. Because you are on a
real host clock, use driver.poll() rather than the simulated driver.poll_at(now);
let real wall-clock time pass between polls so the claim’s contention window can
elapse and the OS has a moment to ferry frames.
So the loop slogan from chapter 1 — poll the driver, pump the bus — becomes
poll the driver, and let the OS move frames. Everything above the transport is
unchanged: ctrl.start(), driver.poll(), ctrl.is_claimed(), ctrl.address(),
and the plugin event handling all behave exactly as they did on the simulator.
Step 5 — run it, watching the wire
Open two terminals.
Terminal 1 — watch the raw wire:
candump -td -L vcan0
-td prints the delta time between frames; -L prints in a log format that
includes the interface. Leave it running.
Terminal 2 — run the example:
cargo run --features wirebit --example socketcan_capture
candump (terminal 1) then shows the actual frames the capture example puts on
vcan0 — a handful of ISOBUS-shaped frames (an Address Claimed-style frame and a
couple of broadcasts). Those are real CAN frames in the standard 29-bit
extended-ID encoding, identical in layout to what a physical ECU would put on the
wire. A session running over the same transport would emit its own Address Claimed
frame and broadcasts in exactly this format.
What just happened
example process vcan0 (kernel) candump
─────────────── ───────────── ───────
tx → ISOBUS frame ──► frame on the wire ──► raw bytes
tx → ISOBUS frame ──► frame on the wire ──► raw bytes
No pump_all() appears anywhere. The kernel is the bus. Independent processes —
the example and candump — both attach to the same interface and see the same
traffic, exactly as separate ECUs and a logging tool would on a shared CAN
segment. A session running over a SocketCAN transport works the same way: you
poll the driver and the kernel moves the frames.
Bit-rate and timing realities
On vcan the bitrate field is cosmetic: a virtual interface has no physical
layer, so frames appear effectively instantly and a bitrate: 250_000 in the
config is just carried metadata. On a real adapter that number matters a great
deal. Every node on a CAN segment must agree on the bitrate (ISOBUS commonly runs
at 250 kbit/s), and the sample point and termination have to be right or you get
errors and bus-off conditions instead of frames. Real adapters also impose real
arbitration and queueing delays that vcan does not model. The concepts behind
all of this — arbitration, priority, the J1939 ID structure — are covered in
CAN and J1939. Treat vcan as a faithful model of the
wire format, not of wire timing.
Things that trip people up
- Feature not enabled. Without
--features wirebitthe SocketCAN code is not compiled in. If you see a usage hint instead of “opening SocketCAN interface”, rerun the example withcargo run --features wirebit --example socketcan_capture. - Interface down or missing. With
create_if_missing: false, ifvcan0does not exist or is notUP, the example errors at startup. Re-run the step 2 commands and checkip link show vcan0. - Permissions. Creating and bringing up an interface needs
sudo. Opening an existingvcan0from the examples usually does not — but on a locked-down host, opening raw CAN sockets may still require elevated capabilities. - Nothing observed. If
candumpshows no frames, the session probably ran on a different interface. Use the sameMACHBUS_SOCKETCAN_IFACEeverywhere. - PDU2 vs PDU1 on the wire. What you see in
candumpis the assembled 29-bit CAN identifier, not the friendly PGN names the API uses. A broadcast PDU2 message carries its PGN directly in the ID; a destination-specific PDU1 message packs the destination address into the same bits. If a frame’s ID does not match the PGN you expected, that mapping is usually why — see Reading candump traces for decoding identifiers by hand.
Validate locally
The live example needs a Linux CAN interface, so it is not part of make verify.
What you can always run is the type-check:
make socketcan-examples-check
With vcan0 up (step 2), the full run is:
cargo run --features wirebit --example socketcan_capture
What this proves / does not prove
Proves: machbus produces correct, standard-format CAN frames on a real Linux
interface, and those frames round-trip to a separate listener (the capture
recorder and candump) — confirming the on-the-wire encoding end to end. The same
transport swap carries a full Session unchanged.
Does not prove: anything about a physical adapter’s timing, bitrate negotiation,
electrical termination, or interoperability with a specific real-world ECU. vcan
is a kernel loopback; it models the wire format, not the physics. Real deployment
still needs proper hardware, configuration, and interoperability evidence, and
machbus ships no certification.
Next
→ 11. Async event streams — instead of a hand-rolled poll-and-react loop, bridge the session into async event streams.
11. Async event streams
By chapter 2 you knew the machbus heartbeat: poll the
driver, pump the bus, then react to each event it hands back. Every chapter
since has matched on those events by polling — driver.poll_at(now)? or
driver.poll()? in your own loop. That works, but in an async program it is
awkward: you end up threading the event loop through your own scheduling instead
of just await-ing the next thing that happens.
This chapter shows how to bridge the session’s event loop into async code you can
await. You still drive poll and pump the bus yourself — the session does not
sprout a background thread — but a local task can sleep on an async channel until
the next event lands instead of busy-polling. This is an advanced, opt-in
pattern; it builds directly on the event model from chapter 2, so make sure that
one feels solid first.
Why bridge to async
The polling API is fine for a tight, synchronous loop. An async application is
different: it already has an executor, and it wants to express “wait here until
something happens” as an .await. The pattern below gives you exactly that. The
events are the same events the sync API drains; the async channel is a thin
view over them. Nothing about the protocol changes — only how your code waits.
The one rule that shapes everything below: the session is single-threaded and
!Send by design. Its internal state lives in Rc<RefCell<_>>, so the session
itself cannot move to another thread. You run it on a local executor. There is
no tokio dependency baked in and no hidden runtime; CAN progress happens only when
you poll and pump.
Step 1 — turn on the async feature
Async helpers live behind the async Cargo feature. Enable it on your dependency
line:
[dependencies]
machbus = { version = "0.1", features = ["async"] }
This pulls in futures-core (for the Stream trait). It does not add a
runtime — see Feature flags for exactly what each
flag pulls in.
Step 2 — build the session and a local executor
The setup is the familiar one from chapter 1: a one-node topology, an endpoint,
and a session split into (ctrl, driver):
#![allow(unused)]
fn main() {
let (ctrl, mut driver) = Session::builder(name, addr)
.plug(Plugin::...)
.spawn(transport)?;
ctrl.start()?;
}
Because we will not move the session across threads, the executor you pick is a
local one — for example futures::executor::LocalPool, which runs tasks on the
current thread, or a tokio LocalSet with spawn_local. A multi-threaded
executor cannot hold a !Send future, so do not reach for a plain tokio::spawn
or a worker pool.
Step 3 — feed events into an async channel
The bridge is one idea: in your synchronous poll loop, push each event the driver
returns into an async channel, and let a local task await the receiving end.
The driver stays on the main thread; the channel carries owned Event values, so
the consumer side can live in any local task.
┌─────────────────────┐
poll() ─► │ Session (!Send) │ produces events
pump ─► │ └─ each Event ◄────┼── push into an async channel (Sender)
└─────────────────────┘
▲
await ───┘ local task awaits the channel's Receiver
When the channel is empty, awaiting the receiver parks the task and stores its waker; the next pushed event wakes it. You drive the producer side; the consumer side reads like ordinary async code.
Step 4 — spawn a local task that awaits events
Spawn a task onto the local pool that loops over the receiver. This is the
ergonomic payoff: while let Some(event) = rx.next().await reads like ordinary
async code, and each iteration parks until an event actually arrives.
#![allow(unused)]
fn main() {
// On the local pool:
spawn_local(async move {
while let Some(event) = rx.next().await {
// react to `event` here, fully async
}
});
}
spawn_local is the local-executor counterpart of a thread-spawning spawn; it
keeps the task on this thread, which is exactly what a !Send consumer requires.
Step 5 — keep driving poll and pump yourself
Here is the part people miss: spawning the task did not start any CAN traffic. The session still only makes progress when you poll it and pump the bus. So the main thread runs the same poll-and-pump loop from chapter 1, pushing each event into the channel and giving the executor a chance to run:
#![allow(unused)]
fn main() {
ctrl.start()?;
loop {
while let Some(event) = driver.poll_at(now)? {
tx.unbounded_send(event).ok();
}
pump_bus();
pool.run_until_stalled(); // let the listening task drain the channel
if ctrl.is_claimed() { break; }
now = now.add_millis(50);
}
}
ctrl.start() queues the claim; each poll_at advances and lets the session send
and settle; pumping moves frames; run_until_stalled() gives the listening task a
chance to pull events off the channel and react. On a real host clock you would
call driver.poll() instead of driver.poll_at(now).
What just happened
main thread local task
----------- ----------
ctrl.start()
loop:
driver.poll_at(now) ──pushes──► channel
pump / run_until_stalled ──wake─► rx.next().await returns event
reacts to it, fully async
(claimed?) break
The session produced an event the moment the claim landed; pushing it into the channel woke the parked task; the task reacted to it. No polling on the consumer side, and no extra thread anywhere.
Things that trip people up
- Forgetting to still poll and pump. The async channel is not a runtime. If
you spawn the listener but never call
driver.poll()and pump the bus, no frames move, no events are produced, and your task sleeps forever. The application still owns the heartbeat. - Expecting
Send. The session is!Sendon purpose. Trying to move it onto a multi-threaded worker (a plaintokio::spawn, a thread pool) will not compile — and you should not work around it. Keep the driver and its consumer on one thread. - Picking the wrong executor. Use a local executor that polls on the current
thread:
LocalPool, or a tokioLocalSetwithspawn_local. A multi-threaded executor cannot hold a!Sendfuture. - Bounded channels back-pressure. If you use a bounded channel and the consumer falls behind, the producer side fills up. Drain regularly, or size the channel for your worst-case burst.
What this proves / does not prove
Proves: machbus can bridge its event loop into a runtime-agnostic async channel that integrates with a local async executor, while you keep full control of the poll-and-pump heartbeat.
Does not prove: any multi-threaded safety. The session stays single-threaded and
!Send even with async enabled — this surface is a consumption convenience, not
a concurrency model. The usual caveats hold: nothing here is certified, and a real
deployment still needs official standards, hardware, and interoperability
evidence.
See also
- Feature flags — what
asyncpulls in and why the session stays single-threaded. - Receiving and routing messages — the event model the channel is a view over.
Next
→ 12. Capstone: a complete implement ECU — put the whole track together into one working node.
12. Capstone: a complete implement ECU
Anchor example:
examples/session_minimal.rs— run it any time withcargo run --example session_minimal. It is the smallest end-to-end session; the capstone below scales the same shape to three nodes.
This is the last chapter of the track, and it is the one where everything you built in isolation comes together into a single program that looks like a real machine. So far each chapter taught one thing on its own bus. Here we put a tractor, a virtual terminal, and an implement on one bus, let them claim, connect, publish, and react, and drive all three from one poll-and-pump loop. No new concept is introduced — this is the assembly chapter.
What we have learned
Every step below is a capability from an earlier chapter. The capstone uses all of them at once:
| From chapter | Capability | Where it shows up here |
|---|---|---|
| 1 / 2 | Build a NAME and a session; claim an address | All three nodes claim before any traffic |
| 3 | Broadcast and receive a PGN | GNSS position out, DM1 in |
| 4 | Ask a node for data | The presets request/respond under the hood |
| 5 | Move payloads bigger than one frame | The VT object pool and DM1 lists ride transport |
| 6 | Publish and read fault codes | The low-fuel alarm becomes a DM1 every peer sees |
| 7 | Stand up a VT | A VT server emitting status |
| 8 | Report working values | The implement connects its TC client, the same data a TC consumes |
| 9 | Tractor and implement presets | Tractor and implement talking on one wire |
| 10 | The same code on real CAN | Swap the endpoint transport for a SocketCAN one, nothing else changes |
| 11 | Drive the event loop | The combined drain at the end of the run |
The session facade — the presets, the plugins, and the unified Event enum — is
what lets one short main hold all of this. Doing the same at the raw
net/isobus layer would mean wiring each protocol’s state machine by hand on
each node.
What we are building
bus0
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Tractor │ │ VT │ │ Implement │
│ 0xF0 │ │ 0x26 │ │ 0x80 │
│ GNSS │──────│ 800x480 │──────│ 6 sections │
│ alarm │ │ status │ │ reads DM1 │
└────┬─────┘ └──────────┘ └──────┬───────┘
│ position, DM1 (low fuel) │
└────────────► every peer ◄───────────┘ sections on
Three presets, one bus, one loop. The tractor publishes its position and raises a low-fuel alarm; the VT server comes online and emits status; the implement turns on three sections and watches the tractor’s diagnostics roll in.
Step 1 — the NAME helper and the topology
Same NAME helper as chapter 1, reused for all three nodes. They differ only by
identity number, which is enough to keep their NAMEs distinct — exactly the name
anchor from the session example:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:name}}
}
The topology now has three seats on bus0 instead of two. You build it and
take one endpoint per node, then wrap each in a transport for spawn(...).
Step 2 — build the three sessions from presets
This is the heart of the session facade. Instead of one generic session configured by hand, each node plugs in the preset that turns the right subsystems on for its role:
#![allow(unused)]
fn main() {
let (tractor, mut tractor_drv) = Session::builder(make_name(1), 0xF0)
.plug_group(presets::tractor())
.plug(Gnss::listen()) // the tractor preset does not include GNSS — add it
.spawn(tractor_transport)?;
let (vt, mut vt_drv) = Session::builder(make_name(2), 0x26)
.plug(VtServer::new(VTServerConfig::default())?) // no VT preset — plug the server
.spawn(vt_transport)?;
let (implement, mut impl_drv) = Session::builder(make_name(3), 0x80)
.plug_group(presets::implement(pool, ws, ddop))
.spawn(impl_transport)?;
}
Read what each builder asks for:
- the tractor prefers address
0xF0(the tractor’s conventional range) and plugs in the tractor preset (diagnostics, implement messages, powertrain). The preset does not include GNSS, so we addGnss::listen()to publish position. - the VT prefers
0x26(the first VT address). There is no VT preset, so we plug aVtServerdirectly;VtServer::newvalidates the config and returns aResult. - the implement prefers
0x80and plugs the implement preset, which takes the object pool, working set, and DDOP it advertises.
Each spawn() returns a Result. Every session is the same Session underneath;
the preset just bundles the plugins, and fine control with
ctrl.with_mut::<Plugin, _>(...) reaches any one of them when you need
role-specific behaviour.
Step 3 — claim, all three at once
Nothing may talk before it has an address, so the first loop is pure address claim.
You call start() on every session, then run the familiar poll-pump-settle loop —
only now it polls three drivers per iteration. This is the same claim anchor
from the session example, scaled up:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:claim}}
}
After about a simulated second all three are claimed and the program prints their addresses:
[claim] tractor=0xF0, vt=0x26, implement=0x80
The ordering matters: claim comes first, application traffic second. If you publish before a node is claimed, the send is rejected — there is no address to send from.
Step 4 — bring the subsystems online
Now that everyone owns an address, each session does its job through fine control on its plugins. The VT server starts emitting status, the tractor publishes a position fix and raises a low-fuel alarm, and the implement connects to the task controller so it can report its working values:
#![allow(unused)]
fn main() {
// VtServer::start returns a Result — handle it.
vt.with_mut::<VtServer, _>(|srv| srv.start())
.transpose()?;
tractor.with_mut::<Gnss, _>(|g| g.broadcast_position(&pos));
tractor.with_mut::<Diagnostics, _>(|diag| {
// SPN 96 is the fuel-level parameter; FMI BelowNormal is the low-fuel alarm
diag.raise(Dtc { spn: 96, fmi: Fmi::BelowNormal, occurrence_count: 1 });
});
implement.with_mut::<TcClient, _>(|tc| tc.connect())
.transpose()?;
}
These are independent actions on independent sessions. Because they all share one bus, what one node sends, the others can receive. The DM1 the tractor raises is the diagnostic message every node on the bus can read; connecting the TC client is how the implement begins reporting the working-set values a task controller logs.
with_mut returns Option<R> (None if the plugin is not plugged); when the
inner call returns a Result, .transpose()? turns Option<Result<…>> into
Result<Option<…>> so you can propagate any error.
Step 5 — the single combined run loop
You drain the claim events first (you already saw the claim print), then run the same loop again for two simulated seconds. This time it is not driving a handshake — it is letting the periodic broadcasts (VT status, the tractor’s DM1, GNSS) fire repeatedly and flow to every peer:
#![allow(unused)]
fn main() {
for _ in 0..40 {
now = now.add_millis(50);
while tractor_drv.poll_at(now)?.is_some() {}
while vt_drv.poll_at(now)?.is_some() {}
while impl_drv.poll_at(now)?.is_some() {}
built.pump_all().unwrap();
}
}
This is the whole point of the chapter. One loop body — poll every driver, pump the bus — drives an entire machine. A real application would interleave reading sensors and updating displays inside this loop, but the plumbing is exactly what you wrote in chapter 1.
Step 6 — drain the unified event stream
Each node’s driver hands back events it received from its peers. The unified
Event enum carries a variant per subsystem, so one match reaches across
diagnostics, GNSS, VT, and the rest. The implement counts the DM1 broadcasts it
saw, and the tractor counts the echoes it received:
#![allow(unused)]
fn main() {
while let Some(event) = impl_drv.poll_at(now)? {
if let Event::Diag(DiagEvent::Dm1Received { source, active, .. }) = event {
// the implement seeing the tractor's low-fuel alarm
}
}
}
Event::Diag(DiagEvent::Dm1Received { source, active, .. }) is the implement
seeing the tractor’s low-fuel alarm — the same SPN/FMI the tractor raised in step
4, now decoded on the other side of the bus. The tractor’s drain matches
DiagEvent::Dm1Received and GnssEvent::Position from the same unified enum.
Drain or drop: the event stream is bounded. Poll it to empty every iteration;
if you never read it, the oldest events are dropped to make room. In a
long-running machine you drain every loop iteration, not once at the end. Typed
draining is also available — ctrl.drain::<DiagEvent>() pulls just the
diagnostics events.
Putting it together — expected output
Run the minimal session example to see the build-and-claim shape end to end:
cargo run --example session_minimal
=== Session facade — minimal demo ===
[claim] node A → 0x80, node B → 0x81
[events] node A saw 1 address-claim event(s)
[rx] node B received DM1 from 0x80 with 1 active DTC(s)
Done.
The capstone scales that same shape to three nodes: three nodes boot, all three claim, the VT comes online, the tractor publishes and alarms, the implement acts, and over two seconds the periodic diagnostics flow across the bus and land as events. The exact DM1 count depends on broadcast cadence and the run window; the shape is what matters.
How the pieces compose
The three sessions never call each other directly. They compose through the bus:
one node broadcasts, the bus carries the frame, every other node decodes it into an
event. That is why the implement sees the tractor’s low-fuel alarm without any
wiring between them — they only share bus0 and the same poll loop.
Where the session facade saved you work versus the low-level layer:
- Presets and plugins instead of hand-wired subsystems.
presets::tractor()andpresets::implement(...)bundle a role’s plugins, and a single plug such asVtServer::new(...)turns one subsystem on. At thenet/isobuslayer you would assemble each protocol’s state machine yourself on each node. - One
Eventenum instead of N inboxes. Diagnostics, GNSS, VT, and TC events all arrive on one stream you drain with onematch. You do not poll each subsystem separately. - Fine control for the rest.
ctrl.with_mut::<Plugin, _>(...)reaches any plugin when you need a role-specific call — raise a fault, send a position, flip a section — without leaving the facade.
When you need to own every byte — tests, tight control loops, an unusual PGN — the low-level layer is still there underneath. The presets are built on it.
Gotchas
- Claim before app traffic. Every node must be claimed before it publishes. The example runs a full claim loop first for exactly this reason.
- Drain events or they drop. The event stream is bounded. Poll it regularly.
- Poll every driver, every iteration. A session that stops being polled stops sending its periodic broadcasts and stops decoding inbound frames. In a multi-node loop it is easy to forget one — poll all three here.
- Pump after polling. Frames a session produced sit in its outbox until you pump the bus. Poll and pump.
Validate locally
cargo run --example session_minimal
make test
What this proves / does not prove
Proves: machbus can host a realistic multi-node machine — tractor, VT, and implement presets — on one bus, drive address claim, subsystem startup, publishing, and cross-node event reaction from a single loop, entirely in software. It is a working software integration of everything in this track.
Does not prove: that this is a certified or hardware-proven machine. machbus is
not certified. The bus here is simulated; the timing, the terminals, and the ECUs
are not the real ones you would ship against. A real deployment still needs the
official standards, real hardware, and interoperability testing with the actual
devices you intend to work with.
Where to go next
You have finished the build-along track. To go deeper, leave the lab course and open the reference manual:
- Tutorials — one page per subsystem, in depth: the Implement ECU and Tractor ECU tutorials expand the presets you just used, and there are dedicated pages for the VT client, TC client, diagnostics, and the rest.
- Reference — the crate map, protocol coverage, feature flags, and error handling, for when you are building a real product and need exact behavior.
- Language bindings — driving machbus from C or Python when your application is not pure Rust.
- Conformity — exactly what is and is not tested, and where the evidence boundary sits. Read this before you make any claim about a machine built on machbus.
That last page is the honest edge of everything you have built: the track teaches the API and the behavior, and conformity tells you what still stands between a passing demo and a certified machine.
Tutorial overview
Tutorial chapters are organized by job. Each one explains what the workflow is for, what protocol ideas matter, how to test locally, and what can go wrong.
Use Rust first. C and Python notes are included when the surface is available.
Recommended order:
- Address claim
- PGN request and NAME management
- Transport Protocol
- The service you actually need: VT, TC, FS, diagnostics, NMEA, or SC
Address claim
Address claim is the first thing almost every control function does on an
ISOBUS or J1939 network. Before a node may send normal application traffic it
has to own a source address — a single byte, 0x00–0xFD, that uniquely
identifies it as a message source on the bus. This tutorial explains why that
matters, how the negotiation works end to end, and how to drive it with
machbus at both the low level and through the session facade.
If you only read one network-management page, read this one: nearly every other workflow (VT, TC, FS, diagnostics) assumes the node has already claimed an address.
Why this exists
A CAN bus has no master and no central address registry. Any node may transmit, and arbitration on the wire decides which frame wins when two start at once. That is fine for moving bytes, but an application needs to know who sent a message and where to send a reply. ISOBUS solves this with two linked ideas:
- a permanent 64-bit identity called the NAME, baked into the product, and
- a temporary 8-bit source address that the node claims at power-up.
Address claim is the handshake that binds one NAME to one address on this particular bus, at this particular time, and resolves the case where two nodes want the same address. Because the address is only a label and the NAME is the real identity, a node can lose an address fight and simply move to another address without changing who it is.
Mental model
power on
│
▼
pick a preferred address ──► announce "NAME X wants address A"
│ │
│ someone else also wants A?
│ ┌────────┴─────────┐
│ no│ │yes → compare NAMEs
▼ ▼ ▼
nobody objects keep A lower NAME wins A,
within the window (Claimed) higher NAME must move
│
self-configurable? ─ yes ─► try next free address
│
no ─► give up (Cannot claim, address 0xFE)
The whole negotiation is event-driven and bounded by a short waiting window: a node announces its claim, listens for objections for roughly a quarter second, and is considered the owner of the address if nobody with a better NAME contests it.
Anatomy: the NAME
The NAME is a 64-bit value built from a fixed set of fields. machbus
represents it as net::Name, constructed with consuming with_* setters. Every
field has a meaning that other nodes can read, and the numeric value of the
whole 64-bit word is what decides arbitration.
| Field | Width | What it expresses |
|---|---|---|
| Identity number | 21 bits | A per-unit serial-like value; keeps otherwise-identical products distinct. |
| Manufacturer code | 11 bits | Who built the ECU. |
| ECU instance | 3 bits | Which of several identical ECUs for the same function this is. |
| Function instance | 5 bits | Which occurrence of the function on this device. |
| Function | 8 bits | What the node does (for example, a terminal, a task controller, a tractor ECU). |
| Device class | 7 bits | The broad equipment category the function belongs to. |
| Device class instance | 4 bits | Which occurrence of that device class on the network. |
| Industry group | 3 bits | The industry the node belongs to (agriculture, for ISOBUS). |
| Self-configurable address | 1 bit | Whether the node may move to a different address if it loses a fight. |
In machbus you build a NAME like this (taken from the address-claim example):
#![allow(unused)]
fn main() {
{{#include ../../../examples/address_claim.rs:14:23}}
}
Two rules matter most when you choose these fields:
- Uniqueness. Two control functions on the same bus must not share a NAME. The identity number and instance fields exist precisely so that two copies of the same product can coexist. If two nodes really do present identical NAMEs, the bus cannot tell them apart and behavior becomes undefined — design your identity numbers so this never happens.
- The self-configurable bit is a policy choice. Set it when the node is allowed to pick a different address after losing arbitration. Clear it when the node must own one specific address or nothing.
How arbitration reads the NAME
When two nodes claim the same address, the one with the numerically lower NAME wins. Because the assigned-by-industry fields sit in the most significant bytes while the per-unit identity number sits in the least significant bytes, the comparison is dominated by what the node is before it is decided by which specific unit it is. The practical consequence: arbitration is deterministic and repeatable. The same two NAMEs always produce the same winner, which is what makes the network predictable across power cycles.
You can inspect the raw value with name.raw to reason about who would win a
contest, exactly as the example prints it:
#![allow(unused)]
fn main() {
{{#include ../../../examples/address_claim.rs:25:29}}
}
Lifecycle and state machine
A control function moves through a small set of claim states. machbus exposes
them as net::ClaimState:
| State | Meaning | What the node may send |
|---|---|---|
| Not started / idle | No claim attempted yet. | Nothing application-level. |
| Waiting | A claim was announced; the contention window is open. | Only claim-related traffic. |
| Claimed | The node owns the address. | Full application traffic. |
| Cannot claim | No address could be secured. | Only the special “cannot claim” announcement from the null address 0xFE. |
The transitions:
- Start. The node announces its NAME at its preferred address and opens a short waiting window (the contention timeout is roughly a quarter second).
- No contest. If the window closes with no better claim seen, the node becomes Claimed and may begin normal traffic.
- Contest, you win. If another node claims the same address with a higher NAME, you keep the address. The other node must yield.
- Contest, you lose. If another node claims the same address with a lower NAME, you must stop using it. If your NAME is self-configurable, you retry at the next candidate address; otherwise you go to Cannot claim and announce that from the null address.
- Request to claim. Any node may ask everyone to re-announce. A claimed node responds by re-sending its current claim; this is how a newcomer learns who owns what.
The two addresses you never claim as a source are the global/broadcast
address 0xFF (a destination, not a source) and the null address 0xFE
(used as the source only for the “cannot claim” message). machbus exposes
these as BROADCAST_ADDRESS and NULL_ADDRESS.
Doing it with machbus
There are two ways to drive address claim, and they suit different needs.
The session facade (recommended for applications)
The Session facade hides the sequencing.
You give it a NAME, a preferred address, and a transport; it runs the claim for
you and reports the result. The shape is:
#![allow(unused)]
fn main() {
let (ctrl, mut driver) = Session::builder(my_name, 0x80)
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
while !ctrl.is_claimed() {
driver.poll()?;
}
println!("owned address: 0x{:02X}", ctrl.address());
}
ctrl.start()? kicks off the claim; pumping driver.poll()? advances the bus
and the claim state machine until the node is Claimed. Once ctrl.is_claimed()
returns true, ctrl.address() reflects the owned address and you may start
sending — application sends made before the claim confirms are rejected, so the
ordering is enforced for you. See Getting started
for the full builder.
The low-level claimer (for tests and embedded control)
Underneath, net::AddressClaimer drives a single net::InternalCf. You own the
timing: you call start, feed in observed claims with handle_claim, and
advance time with update. The address-claim example shows a full contention
between two nodes that both prefer 0x80:
#![allow(unused)]
fn main() {
{{#include ../../../examples/address_claim.rs:31:56}}
}
The loser, being self-configurable, shifts from 0x80 to 0x81; the winner
keeps 0x80. The claimer raises on_address_claimed and on_address_lost
events so your application can react to both outcomes.
Events and responsibilities
Whichever API you use, your application is responsible for reacting to the outcome:
| Event | Meaning | Typical action |
|---|---|---|
| Claimed | The node owns a usable address. | Enable normal application traffic. |
| Lost | A lower NAME took the address. | Stop normal traffic; move or stop per policy. |
| Cannot claim | No address was available. | Stay silent except the allowed claim traffic. |
| Request for claim | A node asked everyone to re-announce. | Re-send the current claim if Claimed. |
The one rule you must never break: do not transmit application traffic before you are Claimed. A node that talks during the waiting window, or from an address it does not own, corrupts other nodes’ view of the bus.
Edge cases and failure modes
- Preferred address already taken by a lower NAME. A self-configurable node walks to the next free address; a fixed-address node fails to claim. Decide which behavior your product needs before shipping.
- Two nodes with identical NAMEs. This is a configuration bug, not a scenario the protocol can resolve. Arbitration cannot pick a winner between equal NAMEs. Ensure identity numbers differ.
- Claiming too fast after power-up. Other nodes need time to answer. The waiting window exists for a reason; do not shorten it below what the timeout constants allow.
- Address violation. A node that keeps using an address it lost will be seen contesting against the rightful owner. The correct response to losing is to stop, not to fight again with the same NAME.
- No transport / no bus traffic. If nothing is pumping the bus, the claim
never completes and
ctrl.is_claimed()never turns true. On a virtual bus you must drive both sides; see Virtual bus.
Advanced
- Several control functions in one ECU. A physical ECU may host more than one control function — each one has its own NAME and claims its own address independently. Model each as its own CF; do not try to share an address.
- Self-configurable address ranges. A self-configurable node should retry within the address range appropriate for its function rather than walking the entire space. Choose a preferred address inside that range so the first attempt usually succeeds.
- Commanded address. A configuration tool can tell a node to move to a specific address. This is covered together with NAME management in NAME management.
- Fine control vs the bare codecs. The session facade is right for
applications: it sequences the claim, retries, and event fan-out for you. The
AddressClaimer/InternalCfpair is right for unit tests and tightly controlled embedded loops where you own every millisecond of timing.
Validate locally
make run EXAMPLE=address_claim
make test
The example runs a two-node contention entirely in software and asserts that the
lower NAME keeps 0x80 while the higher NAME self-configures to 0x81. To run
the same claim against a real or virtual vcan interface, build a Session over
a SocketCAN transport and pump driver.poll()? until ctrl.is_claimed() is
true.
What this proves / does not prove
Proves: the NAME comparison, the contention window, and the self-configure path behave deterministically in software, and the machbus API drives them correctly.
Does not prove: real-hardware timing, interoperability with a specific third-party ECU, or any conformance/certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- NAME and address claim — the conceptual primer.
- NAME management — commanded address and NAME-level negotiation.
- PGN request — the next building block after claiming.
- Address conflicts — when claims go wrong.
PGN request
Most traffic on an ISOBUS or J1939 network is sent on a schedule, but sometimes a node needs a value now — the current address of a peer, a software version, a time-and-date stamp. The PGN Request is the pull mechanism for exactly that: one control function asks another (or asks everyone) to transmit a named parameter group. This tutorial covers the plain Request, its richer Request2 / Transfer sibling, and the Acknowledgement message that a node sends when it answers a request with a yes, a no, or a refusal instead of data.
If you have read address claim, you have already met the Request indirectly: “request for address-claimed” is how a newcomer asks every node to re-announce who it is. The same envelope carries every other pull on the bus.
Why this exists
A scheduled broadcast is fine for data that changes often, but it wastes bus bandwidth for data that rarely changes or that only one node ever cares about. The Request gives the network a request/response shape on top of an otherwise broadcast medium:
- A node that just powered on can ask for state it missed instead of waiting for the next periodic broadcast.
- A diagnostic tool can poll a specific ECU for a specific parameter group.
- A configuration step can confirm that a peer is present and answering.
Because a request names a PGN rather than a node’s internal API, any node that owns that PGN can answer in a uniform way, and the requester does not need to know how the responder is built.
Mental model
requester responder(s)
│ │
│ Request(PGN = X) ──► destination │
│ (specific DA, or global 0xFF) │
│ │
│ do I own PGN X?
│ ┌─────────────┴─────────────┐
│ yes│ │no
│ ▼ ▼
│ send PGN X data (specific request)
│ ◄──────────────── on PGN X NACK on PGN 0xE800
│ or │
│ Ack(positive/ │
│ ◄─────────────── denied/cannot) ◄──────────────────┘
A request is just a tiny message whose payload is the PGN being asked for. The response is whatever that PGN normally looks like — or, when there is no data to give, an Acknowledgement that explains why.
Anatomy: the Request message
The plain Request rides on PGN_REQUEST (0xEA00) and carries a three-byte
payload: the 18-bit PGN being requested, little-endian. machbus keeps this as
a thin codec in j1939::pgn_request:
| Symbol | Role |
|---|---|
encode_request(pgn) | Build the 3-byte payload, rejecting any PGN outside the 18-bit range. |
decode_request(data) | Parse a payload back to a Pgn, or None if it is malformed. |
requested_pgn(msg) | Pull the requested PGN straight out of a received Message. |
The decoder is deliberately strict but tolerant of one real-world variation. The
canonical payload is exactly three bytes, but some stacks pad the same content
into a full eight-byte classic-CAN frame with 0xFF filler. decode_request
accepts that padded shape and rejects anything else — a four-byte payload, or
eight bytes whose tail is not all 0xFF, returns None rather than guessing.
The destination of the request decides its scope. A request sent to a specific
source address is destination-specific; a request sent to the global address
0xFF (BROADCAST_ADDRESS) asks every node that owns the PGN to answer.
Anatomy: Request2 and Transfer
Request2 (PGN_REQUEST2, 0xC900) is an extended form defined in ISO 11783-3.
It does everything the plain Request does and adds two things: up to three bytes
of extended identifier that let the requester narrow what it wants, and a
use-transfer flag that asks the responder to reply through the Transfer PGN
rather than on the requested PGN directly. machbus models the message as
j1939::Request2Msg:
| Field | Meaning |
|---|---|
requested_pgn | The PGN being asked for, same as the plain Request. |
extended_id | Up to three optional bytes that qualify the request. |
use_transfer | When set, the answer comes back wrapped in a Transfer message. |
Request2Msg::encode produces a fixed eight-byte payload; Request2Msg::decode
reverses it and rejects reserved control bits, stray padding, or an out-of-range
PGN. When the transfer flag is set, the reply is carried by a TransferMsg on
PGN_TRANSFER (0xCA00): the original PGN as a three-byte prefix, followed by
the actual response bytes. This lets a requester correlate a Transfer reply with
the request that triggered it even when several are in flight.
Anatomy: the Acknowledgement
Sometimes the honest answer to a request is not data. The Acknowledgement
message (PGN_ACKNOWLEDGMENT, 0xE800) is how a node says something other than
here-is-your-data. machbus models it as j1939::Acknowledgment, with the
outcome carried in the AckControl control byte:
AckControl | Wire value | What it tells the requester |
|---|---|---|
PositiveAck | 0 | The request was accepted (used where a request needs confirming rather than answering with data). |
NegativeAck | 1 | The request was understood but the node will not or cannot supply that PGN. |
AccessDenied | 2 | The node owns the PGN but the requester is not permitted to have it right now. |
CannotRespond | 3 | The node owns the PGN but is temporarily unable to produce it (busy, not yet initialised). |
An Acknowledgment also records the acknowledged_pgn it refers to and the
address it was acknowledged for, so the requester can match the response to the
request it sent. The constructors Acknowledgment::ack(pgn, addr) and
Acknowledgment::nack(pgn, addr) cover the two common cases; encode and
decode (or from_message) handle the eight-byte wire format, validating the
control byte and the reserved padding on the way in.
The request to response lifecycle
A single request resolves in one of a few ways. None of these are timed states
inside machbus; they are behaviours the responder chooses and the requester
must be ready to observe.
- Data answer. The responder owns the PGN and has a current value. It transmits that PGN — directly, or through Transfer if the requester asked for it. This is the normal, happy path and there is no separate acknowledgement.
- Positive acknowledgement. For requests that are commands rather than
data pulls, the responder confirms acceptance with
PositiveAck. - Negative acknowledgement. The responder understood the request but will
not supply that PGN — it does not implement it, or policy forbids it. It
sends a
NegativeAckon0xE800. A NACK is the correct answer to a destination-specific request for an unsupported PGN; it tells the requester to stop waiting. - Access denied. The responder has the PGN but the requester may not read it in the current state. This is a refusal, not an absence — the data exists.
- Cannot respond. The responder has the PGN but cannot produce it yet. The requester may reasonably try again later, unlike a NACK or Access-Denied, which are answers in their own right.
A key asymmetry: a node that cannot satisfy a global request usually stays silent rather than flooding the bus with NACKs, while a node that cannot satisfy a destination-specific request is expected to answer so the single requester is not left waiting. Build your responder policy around that distinction.
Doing it with machbus
There is no dedicated examples/ binary for the bare Request codec, so the
snippets below show the real API shape rather than a compiled include. Each call
is grounded in the types above.
Sending and parsing a plain Request
#![allow(unused)]
fn main() {
use machbus::j1939::{encode_request, requested_pgn};
use machbus::net::pgn_defs::{PGN_REQUEST, PGN_ADDRESS_CLAIMED};
// Ask for address-claimed data. Send to a specific DA, or 0xFF for everyone.
let payload = encode_request(PGN_ADDRESS_CLAIMED)?;
// ... transmit `payload` on PGN_REQUEST after you have claimed an address ...
// On the receiving side, recover the requested PGN from the inbound message:
if msg.pgn == PGN_REQUEST {
if let Some(pgn) = requested_pgn(&msg) {
// decide whether you own `pgn` and how to answer
}
}
}
Answering Request2 with the responder registry
j1939::Request2Responder is a small registry that turns an incoming Request2
into reply metadata without owning a bus. You register a deterministic payload
per PGN, then hand it each inbound message:
#![allow(unused)]
fn main() {
use machbus::j1939::Request2Responder;
let responder = Request2Responder::new()
.with_response(PGN_TIME_DATE, time_date_bytes)?;
if let Some(reply) = responder.handle_message(&msg) {
// reply.pgn is PGN_TIME_DATE for a direct answer,
// or PGN_TRANSFER when the request set use_transfer.
// reply.destination is the original requester's address.
net.send(reply.pgn, &reply.data, self_cf, reply.destination, Priority::Default)?;
}
}
The registry refuses to answer a request whose source is the null or broadcast
address, and returns None for an unregistered PGN — leaving you free to
synthesise a NACK instead.
Building an Acknowledgement
#![allow(unused)]
fn main() {
use machbus::j1939::Acknowledgment;
use machbus::net::pgn_defs::PGN_ACKNOWLEDGMENT;
// We do not implement the requested PGN: answer a destination-specific
// request with a NACK that names the PGN and our address.
let nack = Acknowledgment::nack(requested, my_address);
net.send(PGN_ACKNOWLEDGMENT, &nack.encode()?, self_cf, requester, Priority::Default)?;
}
The session facade
For applications, the Session facade wires
a Request2 responder into its pump so you never touch the inbound queue by hand.
Plug the Request2 plugin at build time, and the session will listen on
0xC900, filter out frames not addressed to it, pick direct-vs-Transfer
automatically, and send the reply for you:
#![allow(unused)]
fn main() {
let (ctrl, mut driver) = Session::builder(my_name, 0x80)
.plug(Request2::new(Request2Responder::new().with_response(pgn, data)?))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
}
You can inspect or adjust the live registry through
ctrl.with_mut::<Request2, _>(|r| ...), which returns None when the plugin is
not installed.
Events and responsibilities
| Event | Meaning | Your responsibility |
|---|---|---|
| Inbound Request for a PGN you own | A peer wants that data. | Send the PGN, or an Ack explaining why not. |
| Inbound Request for a PGN you do not own | Not your concern, usually. | NACK only a destination-specific request; stay silent on a global one. |
Inbound Request2 with use_transfer | Peer wants the answer wrapped. | Reply on PGN_TRANSFER with the original PGN prefixed (the responder does this for you). |
| Inbound Acknowledgement | A peer answered your request with a status. | Match it to the request and stop waiting; retry only on CannotRespond. |
The one rule that mirrors address claim: do not send requests before you are Claimed. A request from an unclaimed or null source address is malformed from the responder’s point of view, and a well-behaved responder ignores it.
Edge cases and failure modes
- No responder. A global request for a PGN nobody owns produces no answer at all. Treat silence as a valid outcome — wait a bounded time, then move on. Do not retry in a tight loop and create a request storm.
- Global versus specific destination. This is the single most important policy decision. A specific request deserves a definite answer (data or a NACK); a global request should only be answered by nodes that actually own the PGN, and unanswered otherwise.
- Repeated requests. Re-issuing the same request before the first answer arrives multiplies bus load for no benefit. Send once, wait, and only re-ask if the response window genuinely closed empty.
- Request for a PGN you do not support. Answer a destination-specific request
with
Acknowledgment::nack; never fabricate empty data on the requested PGN, which the requester would read as a real (wrong) value. - Malformed payloads.
decode_requestandRequest2Msg::decodereturnNonefor short, padded-wrong, or out-of-range payloads. A responder that decodes toNoneshould answer nothing — an invalid request is not a request. - Mistaking a response for a request. The responder must never treat an
inbound Acknowledgement or a data PGN as a new request to answer. The session
responder only reacts to
PGN_REQUEST2, which avoids this entirely.
Advanced
- Request scheduling and response windows. ISO 11783-3 frames requests as
something to issue sparingly, with the requester giving peers reasonable time
to answer before concluding nothing is coming.
machbusdoes not impose a timer on the bare codec; you own the wait window. Pick one long enough to cover a multi-packet response (see below) and short enough that your application does not stall. - Multi-packet responses. When the requested PGN carries more than eight bytes, the answer cannot fit in a single classic frame and travels over the transport protocol. The requester’s response window must allow for the extra round-trips of a segmented transfer. See transport protocol.
- PDU1 versus PDU2 scope. Whether a response may go to a specific or global destination depends on the format of the requested PGN and its data length. In practice the session handles addressing for you; the rule to remember is that a destination-specific request should yield a destination-specific answer.
- Fine control versus the bare codecs. The codecs in
j1939::pgn_request,j1939::request2, andj1939::acknowledgmentare pure encode/decode with no bus coupling — ideal for tests and embedded loops where you own every send. TheRequest2plugin is the right choice for applications: it installs the inbound callback, filters by destination, and sends replies on your behalf.
Validate locally
make test
make check
The codec round-trips, the responder registry, and the Acknowledgement
encode/decode paths are covered by unit tests beside each module
(src/j1939/pgn_request.rs, src/j1939/request2.rs,
src/j1939/acknowledgment.rs) and by the property tests in src/j1939/mod.rs.
To exercise a request and response across a real virtual bus, drive two sessions
over a shared link and watch the responder answer; the transport demo shows the
two-node pump shape:
make run EXAMPLE=transport_demo
What this proves / does not prove
Proves: the Request, Request2, Transfer, and Acknowledgement codecs round-trip correctly, reject malformed and out-of-range input, and that the responder registry answers registered PGNs while ignoring unknown ones, invalid sources, and non-request traffic.
Does not prove: real-hardware timing, the response behaviour of a specific
third-party ECU, or any conformance or certification claim. machbus is not
certified; a real deployment still needs official standards, real hardware, and
interoperability evidence.
See also
- Address claim — claim an address before you ever send a request; “request for address-claimed” uses this same envelope.
- Transport protocol — how a response larger than eight bytes is segmented and reassembled.
- Diagnostics — diagnostic parameter groups are a common target of destination-specific requests.
NAME management and commanded address
Once a control function owns an address, the network is not done with it. A configuration tool may need to move that node to a specific address, or change some of its NAME fields so two otherwise-identical units can be told apart. These are the network-management operations that sit on top of the basic claim. This tutorial explains the commanded-address message, the NAME-management negotiation, and how a correct node behaves when its address is commanded, contested, or violated.
This page is the sibling of Address claim. That page owns the basic claim — power-up, the contention window, NAME arbitration, and the self-configure walk. Read it first. This page assumes the node has already claimed an address and covers what happens after.
Why this exists
The basic claim answers one question: “which address does each NAME get when everyone powers up at once?” It does not answer the operational questions a real machine raises later:
- A service tool wants every ECU at a known, documented address so a wiring diagram or a diagnostic script can find it. The claim alone gives addresses that can drift between power cycles.
- Two physically identical implements hang off the same bus. Their NAMEs collide on every field except the per-unit identity number. The integrator needs to set an instance field on one of them so the rest of the network can address each independently.
- A generic ECU ships with a placeholder function and is configured into its real role at install time.
Commanded address and NAME management are the standardized, in-band ways to do
all of this without reflashing firmware. Both are optional capabilities in
ISO 11783-5: a node may support neither, one, or both, and may guard them behind
a source check or a proprietary security step. machbus gives you the protocol
machinery to respond correctly when you choose to support them.
Mental model
Think of three actors and two messages.
commanding CF (tool/bridge) target CF (your node)
───────────────────────── ─────────────────────
│ commanded-address (NAME + new SA) │
│ ──────────────────────────────────►│ is the NAME mine?
│ │ yes ─► re-claim at new SA
│ ◄───────── address-claimed ────────│
│ │
│ name-mgmt: set pending (fields) │
│ ──────────────────────────────────►│ identity unchanged? store it
│ ◄────────── ACK / NACK ────────────│
│ name-mgmt: adopt pending │
│ ──────────────────────────────────►│ swap NAME ─► re-claim
│ ◄───────── address-claimed ────────│
Two ideas carry the whole topic. First, address and identity are separable. Commanded address changes only the label; the NAME is untouched. NAME management changes the identity; the address is then re-negotiated from scratch. Second, every accepted change ends in a fresh address claim. Whenever a node moves address or adopts a new NAME, it must re-run the claim and win an address before it resumes normal traffic — exactly the handshake from Address claim.
Anatomy: the two messages
machbus carries these on two distinct PGNs, exposed as
net::pgn_defs::PGN_COMMANDED_ADDRESS and net::pgn_defs::PGN_NAME_MANAGEMENT.
Commanded address
The commanded-address payload is nine bytes: the eight-byte NAME of the node being commanded, followed by one byte holding the address it should move to. The NAME acts as the addressee — a commanding CF must already know which NAME it wants to relocate, because the source address it sends to could have drifted between the decision and the command.
NameManager::handle_commanded_address(msg, our_name) decodes this for you and
applies four guards before it accepts anything:
| Guard | Rejected when | Why |
|---|---|---|
| PGN match | msg.pgn is not the commanded-address PGN | Wrong message entirely. |
| Source sanity | source is the null or broadcast address | A command must come from a real claimed node. |
| Length | payload is not exactly nine bytes | Malformed; not a valid command. |
| Target match | the carried NAME is not our_name | The command is for some other node. |
| Address range | the new address is above MAX_ADDRESS (0xFD) | 0xFE/0xFF are reserved and never claimable. |
If all guards pass, the method returns Some(new_address) and fires
on_commanded_address. It does not move you on its own — applying the address
and re-claiming is the caller’s job, because only the caller owns the CF state.
NAME management
The NAME-management message is a fixed wire shape: a one-byte mode, eight
NAME bytes, a one-byte NACK reason, and padding, encoded to a canonical 17-byte
payload. machbus models it as net::NameManagementMsg:
#![allow(unused)]
fn main() {
pub struct NameManagementMsg {
pub mode: NameMgmtMode,
pub name_data: [u8; 8],
pub nack_reason: NameNackReason,
}
}
NameManagementMsg::encode and NameManagementMsg::decode move between this
struct and the wire bytes. decode is strict: it rejects non-canonical lengths,
unknown modes, invalid NACK reason codes, and any padding byte that is not 0xFF.
NameManagementMsg::for_name(mode, name) is the convenient constructor, and
msg.name() extracts the carried NAME.
The NAME-management modes
The mode byte selects which of nine operations a frame represents. machbus
enumerates them as net::NameMgmtMode:
| Mode | Direction | Meaning |
|---|---|---|
RequestCurrent | tool → node | “Tell me the NAME you are using right now.” |
RequestCurrentResponse | node → tool | The current NAME, answering the above. |
SetPending | tool → node | “Stage this NAME; do not adopt it yet.” |
RequestPending | tool → node | “Tell me the NAME you have staged.” |
RequestPendingResponse | node → tool | The staged NAME, answering the above. |
AdoptPending | tool → node | “Make the staged NAME your current NAME and re-claim.” |
Acknowledge | node → tool | A set or adopt succeeded. |
NegativeAcknowledge | node → tool | A request was refused; carries a reason. |
RequestAddressClaim | tool → bus | “If your NAME matches, send your address claim.” |
The shape of a configuration session is: query the current NAME, stage one or
more field changes with SetPending, optionally read back what was staged with
RequestPending, then trigger the swap with AdoptPending. The node keeps
running under its current NAME the whole time it has a pending one staged; nothing
changes on the bus until adoption.
How machbus answers each mode
NameManager::handle_name_management(msg, current_name) is the responder. It
ignores frames from the null or broadcast source, decodes the payload, and
dispatches by mode. It always emits the on_name_management event for
observability, and returns the reply to send (if any):
| Incoming mode | machbus reply |
|---|---|
RequestCurrent | RequestCurrentResponse carrying current_name. |
SetPending | Acknowledge if accepted, else NegativeAcknowledge. |
RequestPending | RequestPendingResponse if one is staged, else NACK PendingNotSet. |
AdoptPending | Acknowledge if a pending NAME existed, else NACK PendingNotSet. |
all response/ack modes, RequestAddressClaim | no reply — observed only. |
The last row is deliberate. Acknowledgements and responses are observed but
never answered, so two machbus nodes can never fall into a response-to-response
loop where each one replies to the other’s reply forever. Those frames also never
mutate the pending NAME.
The one rule that constrains a SetPending
NameManager::set_pending(current_identity, new_name) enforces the single
invariant the standard places on NAME changes: the identity number must not
change. Every other field — instances, function, device class, industry group,
even the self-configurable bit — can be re-commanded, but the per-unit identity
number is fixed at manufacture and stays put. If a SetPending carries a NAME
whose identity number differs from the node’s current one, machbus rejects it
with NegativeAcknowledge and reason InvalidItems, and stages nothing.
NACK reasons as behavior
When machbus refuses an operation it answers with NegativeAcknowledge and a
net::NameNackReason. Read each one as “what the node is telling the tool to do
differently”:
| Reason | What it means in practice |
|---|---|
Security | The node will not accept this change from this source. The tool must authenticate or come from an allowed CF (a bridge or service tool). |
InvalidItems | One or more commanded fields are not allowed to change — in machbus, attempting to change the identity number. |
Conflict | The node cannot take on what the change implies (it cannot perform the requested function, or cannot be self-configurable as asked). |
Checksum | The integrity check that guards against an addressee mismatch did not match. |
PendingNotSet | A RequestPending or AdoptPending arrived but nothing is staged. machbus returns this for both. |
Other | A catch-all when none of the above fit. |
Self-configurable address ranges
When a self-configurable node loses arbitration, it must pick another address to try. ISO 11783-5 narrows the choice: a self-configurable node draws its retry candidates from the dynamic range (the upper region of the address space), and should prefer the lower part of that range before reaching into addresses that double as preferred slots for other functions. A node with an assigned preferred address may also fall back to that preferred address.
machbus walks candidates in AddressClaimer. After a loss it calls an internal
find_next_address that steps linearly from the contested address, skips the
node’s own preferred address (it was just contested), and skips any address it has
already seen claimed by someone else. The claimer remembers occupied addresses
from every peer claim it observes, so a saturated network does not make it cycle
onto slots it already knows are taken. If the walk exhausts every claimable
address, the node transitions to the failed state and announces cannot-claim from
the null address — the same dead end the claim page describes, reached through a
fuller search.
Two practical consequences:
- Pick a preferred address inside the range appropriate for your function so the very first attempt usually succeeds and the walk never runs.
- Do not configure the null or broadcast address as a local preferred source address. machbus treats that as unclaimable at startup and takes the cannot-claim path instead of briefly advertising an unusable address.
- After a node settles on a new address, that address should become its initial address for the next power-up. Persisting it (see Advanced) is what keeps the network stable across restarts instead of re-shuffling every boot.
Contention and address-violation handling
There are two distinct “someone wants my address” situations, and they are not the same.
Contention during a claim. Another node sends an address-claimed for an
address you are claiming. This is the normal arbitration path: lower NAME wins,
the loser self-configures or fails. AddressClaimer::handle_claim owns this and
it is covered in Address claim.
Address violation after a claim. You are already the owner, online and sending, and you observe some other message using your source address — not an address claim, just ordinary traffic from a node that thinks the address is its. A correct node does not stay quiet. It re-announces its claim to the whole bus so the network re-converges on the true owner, and it raises the corresponding diagnostic condition so a tool can see that two nodes are fighting over one address. The wrong response is to keep transmitting as if nothing happened, which leaves the bus with an ambiguous owner.
The closely related failure is a duplicate NAME: two distinct devices present
the same NAME on different source addresses. Arbitration has no tie-breaker for
equal NAMEs, so it cannot resolve this. AddressClaimer::handle_duplicate_name
treats it as fatal for the local node: it goes offline, transitions to the failed
state, drops to the null address, and emits a single cannot-claim announcement
rather than continuing under an identity the bus cannot distinguish. The cure is
upstream — make the identity numbers differ — not on the wire.
Doing it with machbus
There are two layers, mirroring the claim page.
The session facade (recommended)
The session facade owns the NAME-management responder when you plug the
NameManagement plugin. It registers a callback on the NAME-management PGN,
drains incoming frames each pump, and runs NameManager::handle_name_management
for you. When a reply is produced it sends it; when an AdoptPending is accepted
it applies the new NAME to your control function and restarts address claiming
automatically — you do not have to re-sequence the claim by hand. The shape is:
#![allow(unused)]
fn main() {
// Illustrative shape — reacting to a tool adopting a new NAME for this node.
ctrl.with_mut::<NameManagement, _>(|nm| {
nm.manager_mut().on_name_changed.subscribe(|new_name| {
// The session will re-claim under `new_name`; persist it if you want
// it to survive the next power cycle.
});
});
}
The plugin is a responder: it answers a commanding CF. Driving the commanding
side (sending SetPending/AdoptPending to other nodes) is an application
concern you build on top of the raw send API.
The low-level manager (for tests and embedded control)
net::NameManager is the pure, stateless-by-design helper. You feed it decoded
Messages and route its replies yourself:
#![allow(unused)]
fn main() {
// Illustrative shape — not a compiled example.
let mut mgr = NameManager::new();
// A tool commands us to move to 0x42.
if let Some(new_addr) = mgr.handle_commanded_address(&msg, our_name) {
cf.set_address(new_addr);
// re-run the claim at new_addr (see the address-claim tutorial)
}
// A tool runs a NAME-management exchange.
if let Some(reply) = mgr.handle_name_management(&msg, current_name) {
net.send(PGN_NAME_MANAGEMENT, &reply.msg.encode(),
self_cf, reply.destination, Priority::Default)?;
}
}
The manager tracks only the pending NAME. set_pending, adopt_pending,
has_pending, and pending_name give you direct control for unit tests, and
adopt_pending returns the adopted NAME and fires on_name_changed so the
caller knows it now owes a re-claim.
Events and responsibilities
| Event | Fired by | Your responsibility |
|---|---|---|
on_commanded_address | handle_commanded_address | Apply the new address and re-claim. The session does this for you. |
on_name_changed | adopt_pending | The current NAME is now the adopted one; re-claim under it and persist it. |
on_name_management | every received NM frame | Observe/log. Do not reply from here — the manager already decides replies. |
The rule that never bends, same as the claim page: after any commanded move or NAME adoption, do not resume normal traffic until you have successfully claimed an address again. The address you were commanded to, or the identity you adopted, is not yours until the claim confirms it.
Edge cases and failures
- Commanded to an occupied address. The command is accepted and you re-claim at the new address — but the claim can still lose if a lower NAME already owns it. The correct end state is whatever the claim yields, not blind occupation. A node that cannot take the commanded address re-announces its current address instead.
- Commanded to a reserved address.
handle_commanded_addressreturnsNonefor any address aboveMAX_ADDRESS, so0xFE/0xFFare silently ignored. - NACKed name change. A
SetPendingthat touches the identity number, or that the node will not accept from this source, comes back as aNegativeAcknowledge. The pending state is left untouched; nothing was staged. - Adopt with nothing staged.
AdoptPendingwhen no pending NAME exists NACKs withPendingNotSetand changes nothing.adopt_pendingconsumes the pending NAME, so a second adopt also fails — adoption is one-shot. - Response/violation storms. Because
machbusnever replies to ack or response modes, a bus full of NM responses cannot drag your node into an ever-escalating reply loop. Likewise a real violation triggers exactly one re-announce per detection, not a continuous stream. - Stale source address. A node can claim a new address between a tool deciding
to command it and the command arriving. Commanded address sidesteps this by
addressing the NAME, not the source; NAME management guards the same race with
an integrity check that surfaces as the
ChecksumNACK reason.
Advanced
- Multi-CF ECUs. One physical ECU may host several control functions, each
with its own NAME and its own address. Commanded address and NAME management are
per-CF: model each control function separately and run a
NameManagerper CF. Never try to share one address between two of them. - Configuration tools and security. Supporting these messages is optional, and
the standard explicitly lets a manufacturer accept them only from a bridge or
service tool, or behind a proprietary security check. If your product is a
responder, decide who you accept commands from before shipping, and answer
unauthorized requests with the
SecurityNACK reason rather than silently obeying. - Persistence across power cycles. When a node moves address — by losing
arbitration, by self-configuring, or by command — that new address is meant to
become the initial address it tries next boot.
machbusdoes not own your non-volatile storage; persist the address (and an adopted NAME) yourself from theon_address_claimedandon_name_changedevents so the network comes back up in the same shape it settled into. - Fine control vs the bare codecs. The
NameManagementplugin is right for applications: it pumps, replies, applies adopted NAMEs, and re-claims for you. The bareNameManageris right for tests and tightly controlled embedded loops where you route every frame and own every state transition.
Validate locally
make test
The library tests exercise the responder end to end: request-current, set-pending with matching and mismatched identity, request-pending when set and unset, adoption and double-adoption, malformed and overlong payloads, response modes that must not loop, and commanded-address targeting us, targeting someone else, with a reserved address, and with short or overlong payloads. For the basic claim and self-configure walk that these operations re-trigger, run the address-claim example:
make run EXAMPLE=address_claim
What this proves / does not prove
Proves: in software, machbus decodes and answers NAME-management and
commanded-address traffic by the rules above, refuses identity changes and
unstaged adoptions, never loops on responses, and re-claims after any accepted
move or NAME change.
Does not prove: real-hardware timing, interoperability with a specific
third-party tool or ECU, or any conformance or certification claim. Supporting
these optional messages on a shipping product still needs official standards, real
hardware, and interoperability evidence. machbus ships no certification.
See also
- Address claim — the basic claim, contention window, and self-configure walk this page builds on.
- Control functions and partners — what a CF is and how multi-CF ECUs are modeled.
- Network routing — how addressed traffic moves once every node owns an address.
- Address conflicts — diagnosing claims and violations when they go wrong.
Transport Protocol (TP and ETP)
A classic CAN frame carries at most eight payload bytes. Plenty of ISOBUS and
J1939 messages — a VT object pool, a TC device description, a diagnostic list, a
GNSS record — are far larger than that. The Transport Protocol (TP) and its
larger sibling the Extended Transport Protocol (ETP) are how a control function
breaks one logical message into a stream of 8-byte frames, ships them across the
bus, and reassembles them on the far side. This tutorial explains both forms of
TP (broadcast and connection-managed), shows where ETP takes over, and grounds
all of it in the machbus net::TransportProtocol and
net::ExtendedTransportProtocol engines.
Why this exists
The data link can only move eight bytes at a time, but applications think in whole messages. Something has to chop a large payload into frames, number them, pace them so a slow receiver is not flooded, detect a dropped or duplicated frame, and signal the end. Doing this ad hoc per message type would be a mess, so ISO 11783-3 (Data link layer) and J1939 define one reusable multi-frame transport that every larger PGN rides on top of.
Two needs pull the design in different directions:
- Broadcast. Sometimes a producer wants to push data to everyone — no single receiver to pace against, no acknowledgement to wait for. Fast, simple, fire-and-forget.
- Point-to-point. Sometimes there is exactly one receiver that must be able to pace the flow, reject the transfer if it is out of buffer space, and confirm that every byte arrived. Slower, but reliable and flow-controlled.
TP answers both: a broadcast form (BAM) and a connection-managed form (CMDT, built on RTS/CTS). ETP answers a third need — payloads too big for TP’s 16-bit byte counter.
Mental model
Every multi-frame transfer is split into two channels that share the same PGN target but use different connection-management byte codes:
- a connection-management channel that sets the transfer up, paces it, and closes it out (RTS, CTS, EndOfMsgAck, BAM, Abort), and
- a data-transfer channel that carries the actual bytes, one sequence number plus seven payload bytes per frame.
Connection-managed (CMDT) exchange
sender (0x10) receiver (0x20)
│ │
│ RTS (here come N bytes, P packets) ───────►│ open the session,
│ │ allocate a buffer
│◄─────── CTS (send me K packets from seq S) │ receiver paces
│ │
│ DT seq 1 ──────────────────────────────────►│
│ DT seq 2 ──────────────────────────────────►│ ingest 7 bytes/frame
│ ... (K frames) ──────────────────────────►│
│ │
│◄─────── CTS (next window) ───────────────────│ repeat until all
│ DT ... ───────────────────────────────────►│ packets delivered
│ │
│◄─────── EndOfMsgAck (got all N bytes) ───────│ confirm + close
▼ ▼
Complete Complete
The receiver is in charge of pace: it never gets more than the per-CTS packet count it just asked for, and it can pause the sender by issuing a CTS for zero packets. Any problem on either side ends the session with an Abort.
Broadcast (BAM) exchange
sender (0x10) everyone (0xFF)
│ │
│ BAM (here come N bytes, P packets) ──►│ each listener opens a
│ │ receive session
│ DT seq 1 ───────────────────────────►│
│ (≥50 ms gap) │
│ DT seq 2 ───────────────────────────►│ no CTS, no ack —
│ ... │ purely time-paced
│ DT seq P ───────────────────────────►│
▼ ▼
done (no ack) Complete per listener
BAM has no handshake. The sender announces, then drips out data frames spaced by
a minimum interval (TP_BAM_INTER_PACKET_MS, 50 ms in machbus) so listeners
can keep up. There is no acknowledgement and no retransmission: a listener that
misses a frame simply fails to reassemble and drops the session.
Size boundaries: single frame → TP → ETP
The choice of transport is decided purely by payload length, and machbus
pins the boundaries with compile-time constants in net::constants:
| Payload length | Mechanism | Constant |
|---|---|---|
0..=8 bytes | One ordinary CAN frame, no transport | CAN_DATA_LENGTH = 8 |
9..=1785 bytes | TP (BAM or CMDT) | TP_MAX_DATA_LENGTH = 1785 |
1786..=117_440_505 bytes | ETP (connection-mode only) | ETP_MAX_DATA_LENGTH = 117_440_505 |
These bounds are enforced, not advisory. TransportProtocol::send rejects a
payload of 8 bytes or fewer with an “use single frame” error and rejects
anything above TP_MAX_DATA_LENGTH with a buffer-overflow error.
ExtendedTransportProtocol::send mirrors that from the other side: it rejects
anything at or below 1785 bytes (“use TP”) and anything above the ETP ceiling.
TP’s 1785-byte limit is a direct consequence of its wire format: the byte count
is a 16-bit field and the packet count is a single byte, so at seven bytes per
packet the largest transfer is 255 packets — exactly 1785 bytes.
Every data frame, in both protocols, carries one sequence byte plus seven
payload bytes (TP_BYTES_PER_FRAME = 7), so the packet count for any payload is
always ceil(total_bytes / 7) — exposed as TransportSession::total_packets.
Anatomy: the two message channels
machbus routes a frame to a transport engine by its target PGN. TP uses one
PGN for connection management and one for data transfer; ETP uses its own pair.
You will rarely touch these bytes directly, but understanding them makes the
state machine legible.
TP connection-management frames all start with a control byte
(net::tp::tp_cm):
| Control | Name | Who sends it | Carries |
|---|---|---|---|
RTS (0x10) | Request To Send | sender | total bytes, total packets, advertised packets-per-CTS, target PGN |
CTS (0x11) | Clear To Send | receiver | how many packets to send now, which sequence to start at |
EOMA (0x13) | EndOfMsgAck | receiver | echoed total bytes/packets, confirming completion |
BAM (0x20) | Broadcast Announce | sender | total bytes, total packets, target PGN |
ABORT (0xFF) | Connection Abort | either side | the abort reason byte |
TP data-transfer frames are dead simple: byte 0 is the 1-based sequence
number, bytes 1–7 are payload. The receiver computes the buffer offset from the
sequence number ((seq - 1) * 7) and copies the seven bytes into place.
The connection-management vs data-transfer split is the heart of the design: control traffic and bulk traffic are separated so flow control can interleave with the byte stream without ambiguity.
Lifecycle and state machine
machbus models each transfer as a net::TransportSession whose state field
is a net::SessionState. The same enum serves both directions; which states a
session passes through depends on whether it is a Transmit or Receive
session (net::TransportDirection) and whether it is broadcast or
connection-managed.
| State | Side | Meaning |
|---|---|---|
None | — | Freshly constructed, not yet started. |
WaitingForCTS | sender (CMDT) | RTS sent; waiting for the receiver to clear a window. |
SendingData | sender (CMDT) | A CTS granted a window; drain that many DT frames. |
WaitingForEndOfMsg | sender (CMDT) | Last packet sent; waiting for EndOfMsgAck. |
ReceivingData | receiver (BAM) | Mid-stream broadcast reassembly. |
WaitingForData | receiver (CMDT) | CTS sent; waiting for the granted DT window. |
Complete | either | All bytes transferred (and acknowledged, for CMDT). |
Aborted | either | Torn down before completion. |
The CMDT sender walks WaitingForCTS → SendingData → WaitingForCTS → … → WaitingForEndOfMsg → Complete, looping back to WaitingForCTS after each window
until the last packet, then waiting for the ack. The CMDT receiver walks
WaitingForData → WaitingForData → … → Complete, issuing a fresh CTS each time
the granted window is exhausted and emitting the EndOfMsgAck once the final byte
lands. A BAM receiver starts in ReceivingData and goes straight to Complete
when the last sequence arrives — no CTS, no ack.
Per-CTS packet count and back-pressure
The per-CTS packet count is the receiver’s flow-control knob. The RTS advertises
how many packets the sender is willing to send per round (byte 4), but the
receiver’s CTS is authoritative: the sender clamps each window to the
receiver’s requested count, capped by TP_MAX_PACKETS_PER_CTS (16). A receiver
can therefore slow a fast sender simply by granting small windows, and a CTS
that requests zero packets is a hold — the receiver is paused but the session
stays alive. While paused, machbus re-emits a keep-alive CTS every
TP_T_HOLD_MS (500 ms) so the sender’s timer does not expire.
Timeout windows
Timers live on the session and advance through TransportProtocol::update.
Different waiting states use different windows, taken from net::constants:
| Waiting state | Timeout constant | Value |
|---|---|---|
WaitingForCTS / WaitingForEndOfMsg (sender) | TP_TIMEOUT_T3_MS | 1250 ms |
WaitingForData / ReceivingData (receiver) | TP_TIMEOUT_T1_MS | 750 ms |
The auxiliary TpTimerSession tracker (a coarse TpSessionState view used by
the network layer) also applies TP_TIMEOUT_T4_MS (1050 ms) while actively
sending. When any window elapses, the session moves to Aborted, fires the
abort event with reason Timeout, and — for a connection-managed session — puts
an Abort frame on the wire. A BAM session that stalls just expires silently;
there is nobody to notify.
Every abort reason
Aborts are wire-compatible bytes, modelled as net::TransportAbortReason. Every
variant maps to a real failure path in the engines:
| Reason | Numeric | Raised when |
|---|---|---|
None | 0 | Placeholder / unknown byte decoded back to no-reason. |
AlreadyInSession | 1 | An RTS arrives for a transfer that already has a live receive session. |
ResourcesUnavailable | 2 | No free session slot, or the advertised size exceeds the receive-allocation cap. |
Timeout | 3 | A waiting window elapsed. |
ConnectionModeError | 4 | A CTS arrived while the sender was already mid-window (a protocol-ordering error). |
MaxRetransmitsExceeded | 5 | The receiver asked the sender to back up too many times. |
UnexpectedPgn | 6 | Reserved; a frame targeted a PGN that does not fit the session. |
BadSequence | 7 | A data frame arrived with the wrong sequence (zero, ahead, or — in ETP — before its DPO). |
DuplicateSequence | 8 | A data frame repeated a sequence already received. |
UnexpectedDataSize | 9 | The advertised byte count is malformed or out of range for the protocol. |
The numeric column is the byte sent on the wire, so an abort received from a
real ECU decodes to the same variant via TransportAbortReason::from_u8.
Doing it with machbus
The transport_demo example drives a full CMDT round trip in software. Both
sides are plain TransportProtocol engines; you pump frames between them by
hand, which makes every step visible:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:17:56}}
}
Read it as the state machine in motion:
tx.send(...)validates the payload, opens aTransmitsession inWaitingForCTS, and returns the RTS frame.rx.process_frame(rts)opens aReceivesession, allocates the reassembly buffer, and returns a CTS granting the first window.tx.process_frame(cts)moves the sender toSendingData;tx.get_pending_data_frames()drains that window as DT frames.- Feeding the DT frames into
rx.process_framereassembles the bytes and, once the last packet lands, returns the EndOfMsgAck. tx.process_frame(eoma)confirms delivery and fireson_complete.
For broadcast, you call send with the destination set to BROADCAST_ADDRESS;
the engine emits a BAM instead of an RTS, then TransportProtocol::update
releases one DT frame per TP_BAM_INTER_PACKET_MS interval until the payload is
drained — no CTS pump required.
You tune a receiver’s caps when constructing the engine:
TransportProtocol::with_max_receive_bytes clamps the largest payload it will
accept (rejecting bigger RTS/BAM before allocating), with_max_sessions caps
concurrent transfers, and with_advertised_packets_per_cts sets the RTS byte-4
advertisement.
Raw TP/ETP engines reject a second active transfer that would reuse the same
data-frame source/destination path. That keeps DT frames unambiguous because the
PGN is named by the control traffic, not repeated in every data frame. The
higher-level IsoNet surface preserves this rule but queues same-path
application sends, so a session can answer several large requests to the same
peer without overlapping the underlying DT stream.
Events and responsibilities
Both engines expose events your application subscribes to:
| Event | Fires when | Your job |
|---|---|---|
on_complete | A session reaches Complete | Take the reassembled TransportSession::data and hand it to the application layer. |
on_abort | A session tears down (either side) | Log the TransportAbortEvent (PGN, peer, reason) and decide whether to retry. |
on_session_timeout | A TpTimerSession window elapses | React to the higher-level timeout (TP only). |
You are responsible for the pump: call process_frame for every inbound TP/ETP
frame, call update(elapsed_ms) regularly so timers advance and BAM data flows,
and drain get_pending_data_frames after each CTS. The session facade wires
this pump for you; the raw engines are for tests and tightly controlled embedded
loops where you own the timing.
For observability, TransportProtocol::stats returns a TransportStats snapshot
counting dropped frames, dropped sessions, aborts sent and received, timeouts,
and resource rejections — the defensive paths that drop bad input instead of
panicking.
Edge cases and failures
- Timeout. A receiver that goes quiet, or a sender that never gets its CTS, trips the window above and aborts. On a virtual bus you must pump both sides or every transfer times out.
- Unexpected data frame. A DT frame that does not match any open receive session is dropped and counted; it never creates a session on its own.
- Out-of-order or zero sequence. A DT frame whose sequence is ahead of the
expected one (or zero) aborts the session with
BadSequence. - Duplicate sequence. A repeated sequence aborts with
DuplicateSequence(connection-mode; a BAM receiver has no back-channel to abort with). - CTS while sending. A CTS that arrives mid-window is a protocol-ordering
error and aborts with
ConnectionModeError— except an exact duplicate of the current window, whichmachbustreats as an idempotent retry so pump ordering cannot kill a healthy transfer. - Receiver back-pressure. A CTS for zero packets pauses the sender; the engine keeps the session warm with periodic keep-alive CTS frames.
- Out of resources. An RTS/BAM whose advertised size exceeds the receive cap,
or that arrives with no free session slot, is rejected with
ResourcesUnavailablebefore any buffer is allocated — large transfers can be audited without reserving memory. - Malformed control frame. Short frames, non-canonical reserved bytes, a destination-specific CM frame sent to broadcast, or an out-of-range byte count are all dropped (and counted) rather than acted on.
ETP: extending TP for very large payloads
ETP exists because TP’s counters run out. TP’s byte count is 16 bits and its
packet count is a single byte, capping a transfer at 1785 bytes. ETP widens the
byte count to 32 bits and reaches ETP_MAX_DATA_LENGTH (about 117 MB). It is
connection-mode only — there is no broadcast ETP — so send rejects a
broadcast destination outright.
ETP reuses the RTS/CTS/EndOfMsgAck shape but adds one new control message: the
Data Packet Offset (DPO). The problem it solves: a single DT frame’s
sequence byte only counts to 255, but an ETP transfer can have millions of
packets. So ETP does not use one global sequence space. Instead, the receiver’s
CTS names an absolute next packet (a 24-bit number), the sender replies with a
DPO announcing the packet offset for the upcoming group, and then the DT
frames within that group restart their sequence numbers at 1. The receiver
reconstructs the absolute byte position as (dpo_packet_offset + seq - 1) * 7.
A DT frame that arrives before its DPO, or a DPO whose offset does not match the
receiver’s expected position, aborts with BadSequence.
The ETP control codes live in net::etp::etp_cm: RTS (0x14), CTS (0x15),
DPO (0x16), EOMA (0x17), ABORT (0xFF). The exchange per window is therefore
CTS → DPO → DT×K → CTS → …, each window still capped at TP_MAX_PACKETS_PER_CTS
packets. Its sole timeout is ETP_TIMEOUT_T1_MS (750 ms), shared by every
waiting state.
The example pumps an ETP transfer the same way as TP, just with the DPO step
folded into get_pending_data_frames:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:60:96}}
}
ExtendedTransportProtocol::receive_profile_for_advertised_size lets you
validate an advertised size against the protocol and your local receive cap
without allocating — useful for auditing a protocol-maximum transfer profile
before committing real memory to it.
Advanced
- Broadcast vs peer-to-peer. Reach for BAM when many nodes need the same data and loss is tolerable (or the data repeats). Reach for CMDT when one receiver must pace, reject, or confirm — VT object-pool uploads and TC device descriptions are the canonical CMDT cases.
- Performance. CMDT throughput is governed by the per-CTS window and how promptly each side pumps; BAM throughput is governed by the 50 ms inter-packet floor. Bigger CTS windows mean fewer round trips but less back-pressure headroom.
- Fine control vs the bare codecs. The session facade owns the pump, the
timers, and the event fan-out; use it in applications. The bare
TransportProtocol/ExtendedTransportProtocolengines give you frame-level control for unit tests and embedded loops, at the cost of driving everyupdateandget_pending_data_framesyourself. - Concurrent sessions. Control frames identify the target PGN, but data
frames do not. For that reason
machbusrejects a second TP/ETP transfer that would reuse the same active data-frame source/destination path, even when the requested PGN is different. That avoids reassembling bytes from one transfer into another.
Validate locally
make run EXAMPLE=transport_demo
make test
The example runs a CMDT round trip, an ETP round trip, and a Fast Packet round
trip entirely in software and prints that each payload was reassembled byte for
byte. The test suites in src/net/tp.rs and src/net/etp.rs cover malformed
control frames, out-of-order and duplicate DT packets, backward/duplicate CTS
behavior, sender and receiver timeouts, invalid endpoints, session caps,
allocation caps, and the receiver-side abort direction.
What this proves / does not prove
Proves: the packetization, reassembly, CTS windowing, DPO offset handling, timeout aborts, and every abort reason behave deterministically in software, and that the size boundaries between single-frame, TP, and ETP are enforced.
Does not prove: real-hardware timing, interoperability with a specific
third-party ECU, or any conformance or certification claim. machbus is not
certified; real deployment still needs official standards, hardware, and
interoperability evidence.
See also
- PGN request — the request/response building block whose large responses ride on TP.
- Fast packet — NMEA 2000’s smaller multi-frame mechanism, an alternative for payloads up to 223 bytes.
- Address claim — every transport endpoint must own a source address first.
Fast Packet
NMEA 2000 Fast Packet is the multi-frame transport that NMEA 2000 uses to send a
payload that is too big for one CAN frame but still modest in size — things like
a GNSS position fix, a wind report, or an engine parameter group. It is not
the ISOBUS/J1939 Transport Protocol (TP). It rides the same 29-bit CAN bus and
the same PGN addressing, but it has its own framing, no handshake, and no flow
control. This tutorial explains why a second transport exists, how the frames are
laid out, how reassembly works, where it can silently fail, and how to drive it
with machbus’s FastPacketProtocol.
If you are coming from ISOBUS, the one-line summary is: Fast Packet is what TP would look like if you stripped out the connection setup and the acknowledgements and just streamed the data. Read Transport Protocol first if you want the contrast in full.
Why this exists
A classic CAN frame carries at most eight bytes. Many NMEA 2000 parameter groups need a few dozen. ISOBUS solves the same problem with TP and ETP, but those protocols pay for reliability with round trips: a request to send, a clear to send, sequenced data, and an end-of-message acknowledgement. On a marine or mixed network where the same parameter group is broadcast many times a second to every listener at once, that handshake is the wrong trade. There is no single receiver to acknowledge, and a dropped frame is cheaper to ignore than to re-negotiate, because a fresh copy of the data is already on its way.
Fast Packet is the answer to “I have 9 to 223 bytes, I am broadcasting to everyone, and the data refreshes faster than I could retransmit it.” It chops the payload into a short burst of back-to-back frames carrying a tiny counter, and the receiver stitches them back together. No setup, no acknowledgement, no retry — if a frame is lost, that copy of the message is lost and the next periodic broadcast takes its place.
machbus is not certified for any standard; treat this as a faithful software
model of the framing, not a conformance statement.
Mental model
A Fast Packet transfer is one first frame followed by zero or more continuation frames, all sharing the same source address, PGN, and a small 3-bit sequence counter that tags the whole burst. Inside each frame the low five bits are a frame counter that orders the pieces.
sender broadcasts a 30-byte payload (sequence = 3)
───────────────────────────────────────────────────────────────
byte0 byte1 bytes 2..8
┌──────────────┬───────────┬──────────────────────────────┐
│ seq=3 │ fc=0 │ total=30 │ payload[0..6] │ first frame
└──────────────┴───────────┴──────────────────────────────┘
byte0 bytes 1..8
┌──────────────┬───────────────────────────────────────────┐
│ seq=3 │ fc=1 │ payload[6..13] │ continuation
├──────────────┼───────────────────────────────────────────┤
│ seq=3 │ fc=2 │ payload[13..20] │ continuation
├──────────────┼───────────────────────────────────────────┤
│ seq=3 │ fc=3 │ payload[20..27] │ continuation
├──────────────┼───────────────────────────────────────────┤
│ seq=3 │ fc=4 │ payload[27..30] + 0xFF padding │ continuation
└──────────────┴───────────────────────────────────────────┘
───────────────────────────────────────────────────────────────
receiver keys the session on (source, PGN, seq), counts frames
0,1,2,3,4 in order, and emits the reassembled 30-byte message.
The receiver never speaks. It just listens, accumulates, and either completes a message or drops the partial one. Because the sequence counter distinguishes bursts, the same sender can have more than one Fast Packet transfer for the same PGN in flight at once without the pieces getting mixed up.
Anatomy: first frame vs continuation frame
Every Fast Packet frame spends its first byte on two packed counters:
- The high three bits are the sequence counter (
0..=7). All frames of one transfer carry the same value; the sender bumps it for the next transfer so a late frame from an old burst cannot poison a new one. - The low five bits are the frame counter (
0..=31). The first frame of a transfer is always frame counter0; continuation frames count up1, 2, 3, …in transmission order.
That split is why a transfer is capped: five bits of frame counter and a few bytes per frame is all you get.
| Frame | Byte 0 | Byte 1 | Bytes 2..8 | Payload bytes carried |
|---|---|---|---|---|
| First (frame counter 0) | seq + counter | total payload length | first slice of data | FIRST_FRAME_DATA = 6 |
| Continuation (1, 2, …) | seq + counter | data | data | SUBSEQUENT_FRAME_DATA = 7 |
Only the first frame spends a byte on the total length, so the receiver knows
up front how big the final message is and how many continuation frames to expect.
Every continuation frame gives up one extra payload byte (seven instead of six)
because it does not repeat that length. Unused trailing bytes in the last frame
are padded with 0xFF. machbus exposes these slice widths as
net::fast_packet::FIRST_FRAME_DATA and SUBSEQUENT_FRAME_DATA.
The maximum payload
With six bytes in the first frame, seven in each of up to 31 continuation frames,
and an eight-bit length field, the ceiling is 223 bytes. machbus pins that
to a single constant, net::constants::FAST_PACKET_MAX_DATA = 223, mirrored as
FastPacketProtocol::MAX_DATA_LENGTH. Anything larger is not a Fast Packet at
all — it belongs to TP or ETP. Anything eight bytes or smaller is not a Fast
Packet either: it fits in one ordinary CAN frame, so send refuses it.
Reassembly and what happens when a frame is lost
The reassembler is deliberately simple, and the simplicity is the point. For each
incoming frame machbus:
- Reads byte 0 and splits it into sequence and frame counters.
- If the frame counter is
0, starts a new session: it reads the total length, allocates a buffer, copies the first six bytes, and records that it now expects frame counter1. - Otherwise it looks for an in-flight session matching
(source, PGN, sequence). If none exists, the frame is an orphan and is dropped. - It checks that the frame counter equals the expected next value. If it
does, the seven bytes land at the right offset and the expected counter
advances. When the accumulated byte count reaches the declared total, the
session completes and a reassembled
Messageis returned.
There is no retransmission, and this is the sharp difference from TP. If a
continuation frame is dropped or arrives out of order, the next frame’s counter
will not match what the receiver expects, so machbus discards the whole
in-flight session and waits for a fresh first frame. The partially built
message is thrown away, not patched. TP would request the missing packet; Fast
Packet cannot, because there is no back channel and the sender already moved on.
A few more behaviors fall out of this design:
- Orphan continuation frame. A continuation frame with no matching first frame (you joined mid-burst, or the first frame was lost) is dropped and counted as a dropped frame.
- Malformed first frame. A first frame whose declared length is
≤ 8or> 223is rejected outright; nothing is allocated. - Short frame. A frame shorter than eight bytes is dropped before it can touch any session.
- Timeout. A session that stops making progress is dropped after the
transport idle window (
TP_TIMEOUT_T1_MS) once you callupdate. This is the cleanup that stops a half-finished broadcast from lingering forever.
All of these increment counters on a TransportStats snapshot you can read back,
so a stalled reassembly is observable rather than mysterious.
Single-frame vs TP vs ETP vs Fast Packet
| Transport | Used for | Handshake | Flow control | Max payload |
|---|---|---|---|---|
| Single CAN frame | Up to 8 bytes; the common case | No | No | 8 bytes |
| Fast Packet (NMEA 2000) | 9–223 byte broadcast parameter groups | No | No | FAST_PACKET_MAX_DATA = 223 |
| TP (ISOBUS / J1939) | 9–1785 bytes, point-to-point or BAM | Yes (RTS/CTS), BAM is open-loop | Yes (CTS windows) | TP_MAX_DATA_LENGTH = 1785 |
| ETP | Very large transfers | Yes | Yes | ETP_MAX_DATA_LENGTH ≈ 117 MB |
The shape of the decision: pick the smallest transport that fits the payload, and pick Fast Packet over TP when the data is a repeating broadcast where a lost copy is cheaper than a retransmit. TP earns its handshake when you need a reliable delivery to a specific node; Fast Packet earns its silence when you are firehosing the same value to the whole bus.
Doing it with machbus
The whole protocol is one type, net::FastPacketProtocol. One side calls send
to chop a payload into frames; the other side feeds each frame to process_frame
and watches for the completed Message. The transport_demo example wires both
ends together for a GNSS-position payload:
#![allow(unused)]
fn main() {
{{#include ../../../examples/transport_demo.rs:100:119}}
}
What the calls mean:
FastPacketProtocol::new()builds a sender/receiver with the default cap on simultaneous receive sessions (FAST_PACKET_DEFAULT_MAX_RX_SESSIONS = 32). Usewith_max_rx_sessions(n)to bound memory more tightly; a cap of0refuses all new multi-frame sessions.send(pgn, data, source)returns theVec<Frame>for the transfer and advances the internal transmit sequence counter so the next call uses a fresh sequence value. It errors with a buffer-overflow code for payloads over 223 bytes and an invalid-state code for payloads of eight bytes or fewer (those belong in a single frame). It also rejects an invalid PGN or a null/broadcast source address before allocating anything.process_frame(&frame)returnsSome(Message)only when the frame that completes a session arrives, andNonefor every frame before it (and for every frame it drops). The reassembledMessagecarries the PGN, the source, a broadcast destination, and the timestamp of the last frame that completed it.update(elapsed_ms)ages in-flight sessions and drops the ones that have gone quiet pastTP_TIMEOUT_T1_MS. Call it from your bus loop so stalled reassemblies are reclaimed.
For diagnostics, rx_session_count() reports how many reassemblies are in flight
and stats() returns a TransportStats snapshot (dropped frames, dropped
sessions, timeouts, resource rejections); clear_stats() resets them without
disturbing live sessions.
Events and responsibilities
Fast Packet has no events to subscribe to and no acknowledgements to send. Your responsibilities are narrow but real:
- Pump
process_framefor every received frame whose PGN you handle, and act on theMessagewhen one completes. - Call
updateon a regular tick so timed-out sessions are reclaimed; if you never call it, a partial broadcast holds a buffer until the sender happens to restart that exact sequence. - Do not expect delivery guarantees. Treat a missing or late message as normal, because the protocol gives you no way to ask for a retransmission.
- Respect the size envelope. Build payloads of 9–223 bytes for Fast Packet; route anything larger through TP or ETP.
Edge cases and failure modes
- Dropped continuation frame. The next frame’s counter mismatches, the whole
session is discarded, and
dropped_sessionsincrements. There is no recovery short of the next first frame. - Out-of-order frames. Same outcome as a drop — the reassembler expects strictly increasing frame counters and tears down on the first surprise.
- Two bursts, same source and PGN. Allowed, as long as the sender used different sequence counters; they reassemble in parallel. If a new first frame reuses a sequence value already in flight, it replaces the stale session rather than duplicating it.
- Sequence counter wrap. The transmit sequence is three bits, so it wraps
after eight transfers. A wrapped first frame for a
(source, PGN, seq)that still has a stale reassembly pending replaces that stale state — it does not grow an unbounded pile of sessions. - Session cap reached. New first frames are refused once
max_rx_sessionsin-flight sessions exist; the frame is dropped and a resource rejection is recorded. This bounds worst-case memory under a flood of first frames. - Null or broadcast source. A frame claiming
NULL_ADDRESSorBROADCAST_ADDRESSas its source is invalid and dropped on both send and receive paths.
Advanced
- Mixing with NMEA PGNs. Fast Packet is a transport, not a message format.
The reassembled
Messagestill has to be interpreted as the specific NMEA 2000 parameter group its PGN names. See NMEA 2000 for howmachbusroutes a completed Fast Packet payload into the NMEA layer, and Serial GNSS for the position-data path that most commonly rides Fast Packet. - Performance. A transfer is a short burst of back-to-back frames with no
inter-frame negotiation, so it is fast and cheap on the wire — the cost is the
absence of recovery. On a busy bus, sizing
max_rx_sessionsto the number of distinct broadcasters you expect keeps memory predictable without dropping legitimate traffic. - Surface vs low-level.
FastPacketProtocolis the low-level building block. In a full stack the NMEA layer drives it for you; reach for the protocol object directly in tests and tightly controlled loops where you own each frame.
Validate locally
make run EXAMPLE=transport_demo
make test
The transport_demo example sends a multi-frame Fast Packet payload and
reassembles it end to end in software, alongside the TP and ETP paths so you can
see the three transports side by side. The unit and property tests in the Fast
Packet module exercise the maximum 223-byte payload, the nine-byte two-frame
minimum, out-of-order and orphan frames, sequence-counter wrap, parallel
sessions, timeouts, and the session cap.
What this proves / does not prove
Proves: the first-frame/continuation framing, the sequence and frame counters,
the 223-byte ceiling, in-order reassembly, parallel sessions keyed on
(source, PGN, sequence), and the drop-on-mismatch behavior all behave
deterministically in software, and the machbus API encodes and decodes them
correctly.
Does not prove: real-hardware timing, interoperability with a specific NMEA 2000 device, or any conformance or certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- Transport Protocol — the ISOBUS/J1939 multi-frame transport with the handshake and flow control Fast Packet omits.
- NMEA 2000 — how reassembled Fast Packet payloads become NMEA parameter groups.
- Serial GNSS — the position-data path that most often rides Fast Packet.
Network routing
Most ISOBUS machines are not one bus. A tractor carries its own segment, the
implement hanging off the back carries another, and something has to sit between
them deciding which traffic crosses and which stays local. That something is a
Network Interconnect Unit (NIU) — a router or bridge that joins two CAN
segments and selectively forwards frames between them. This tutorial explains
why those multi-segment networks exist, what an NIU actually does to each frame,
and how to drive one with machbus through Niu, Router, and NiuConfig
(ISO 11783-4).
If you have read Address claim, you already know how a single node finds its place on one segment. This page is about what happens when there is more than one segment and the two have to be stitched together without flooding either side.
Why this exists
A single CAN segment has hard limits. It can only be so long, carry so many nodes, and sustain so much traffic before arbitration latency and bus load make it unreliable. A working machine routinely exceeds what one segment can carry:
- The tractor segment hosts the tractor ECU, the operator’s terminal, engine and transmission controllers, and the network-management traffic that keeps them coordinated.
- The implement segment hosts whatever is mounted or towed — a sprayer, a seeder, a baler — with its own controllers, sensors, and high-rate sensor chatter that the tractor side never needs to see.
Connectors get plugged and unplugged in the field, implements are swapped between tractors, and each side wants to keep its own dense local traffic private while still exchanging the handful of messages that matter across the join: ground speed, guidance, lighting, address claims. If you wired the two segments into one electrically, every frame on one side would burden the other, and the combined node count and cable length would blow past the physical limits. So instead you put a small forwarding device in the middle that listens to both sides and copies across only what should cross.
That device is the NIU. Depending on how much it rewrites, the standard calls it
a repeater (copies everything), a bridge (copies a filtered subset), a router
(also rewrites addresses so a whole segment looks like a single node), or a
gateway (repackages message content). machbus ships the two that matter most:
the filtering bridge as Niu, and the address-translating router as Router.
Mental model
Picture two buses joined by one box. Frames flow in from either side; a filter in the middle decides each one’s fate; survivors are emitted on the opposite side.
TRACTOR SEGMENT IMPLEMENT SEGMENT
┌───────────────────┐ ┌───────────────────┐
│ tractor ECU │ │ sprayer controller│
│ terminal (VT) │ ┌───────┐ │ section valves │
│ engine / trans │ ─────► │ NIU │ ────►│ rate sensors │
│ ... │ ◄───── │ filter│ ◄────│ ... │
└───────────────────┘ Side │ + rate│ Side └───────────────────┘
Tractor │ limit │ Implement
│ (+ xlat
│ table)│
└───────┘
for each frame:
allow / block / monitor / rate-limit
(router also rewrites src & dst address)
The NIU is a pump, not a daemon. You hand it one frame at a time together
with the side it arrived on; it returns either the frame to put on the other
side, or nothing if the frame was dropped. The caller — typically the loop
around your two IsoNet segments — is responsible for actually placing the
returned frame on the destination bus. Nothing forwards itself.
Anatomy: the pieces of an NIU
An NIU in machbus is built from a few well-separated parts.
| Piece | Type | What it holds |
|---|---|---|
| The two sides | Side::Tractor, Side::Implement | A two-valued label for “which segment a frame came from”. Side::other() flips it. |
| Configuration | NiuConfig | Name, default-forwarding policy, filter mode, and loop-guard tuning. |
| Filter rules | Vec<FilterRule> | The per-PGN / per-NAME table of what to do. |
| Forward decision | ForwardPolicy | Allow, Block, or Monitor for a matched frame. |
| The forwarder | Niu | Applies the rules, rate-limits, and counts forwarded vs. blocked. |
| Translation table | AddressTranslationDb | Maps a NAME’s address on one side to its address on the other. |
| The router | Router | A Niu plus an AddressTranslationDb — forwards and rewrites addresses. |
A FilterRule is the unit of policy. It carries the PGN it matches (0 meaning
“any PGN”, used by NAME-based rules), the ForwardPolicy to apply, a
bidirectional flag (when false the rule only applies to tractor-originated
frames), optional source_name / destination_name constraints, and an
optional max_frequency_ms that throttles how often a matching frame may cross.
Rules can also be marked persistent; local runtime reloads through
Niu::clear_filters keep those rules as the baseline while removing temporary
rules.
NiuConfig sets the defaults around those rules. The most important field is
filter_mode:
NiuFilterMode | Default for unmatched frames |
|---|---|
PassAll | Forward unless a rule blocks it — open by default, block the noisy exceptions. |
BlockAll | Drop unless a rule allows it — closed by default, allow only the messages that must cross. |
PassAll suits a bridge that should be mostly transparent; BlockAll suits a
tight join where you enumerate exactly what is permitted to cross. NiuConfig
also carries forward_global_by_default and forward_specific_by_default,
which let broadcast and destination-specific traffic default differently under
PassAll.
How a forwarding decision is made
When a frame arrives, Niu::process_frame(frame, origin, now_ms) walks a fixed
sequence before it ever consults a rule:
- Inactive NIU. If the NIU has not been
started, everything is dropped. - Bad source address. A frame whose source is the broadcast address, or the null address on anything other than an address-claim, is dropped as malformed. It never reaches the filter.
- Loop echo. If this exact frame was just forwarded toward this side inside the loop-guard window, it is dropped (see Loop prevention).
- Learn NAMEs. Address-claim frames are recorded so the NIU knows which NAME owns which address on each side — this is what makes NAME-based rules work.
- Resolve policy. Now the rule table is consulted.
Rule matching runs top to bottom. A rule matches when its PGN matches (or is
0), its direction matches the origin side, and any source_name /
destination_name constraints are satisfied by the NAMEs the NIU has learned.
The first matching rule wins, and its ForwardPolicy decides the outcome:
Allow— forward the frame to the other side; bump the forwarded counter.Block— drop it; bump the blocked counter.Monitor— forward it and additionally raiseon_monitored, so you can observe a class of traffic without blocking it.
If a matching rule carries a max_frequency_ms, the NIU also rate-limits: the
first crossing always passes, and subsequent crossings are dropped until that
many milliseconds have elapsed. Rate limiting is how you let a high-rate
diagnostic or status PGN cross “occasionally” without relaying every frame. If
no rule matches, the filter_mode default applies.
Address translation across segments
A plain Niu forwards a frame’s bytes unchanged. That is fine when both
segments share one address space, but a Router does more: it makes an entire
segment appear, from the other side, as a single well-known node.
The reason is addressing. A node’s source address is only meaningful on the
segment where it claimed it. Address 0x80 on the implement side and address
0x80 on the tractor side can be two completely different control functions. If
the router copied addresses across verbatim, replies would go to the wrong node.
So the Router keeps an AddressTranslationDb: for each NAME it cares about, it
stores that NAME’s tractor_address and its implement_address. On forward, it
rewrites the source and destination so each segment sees the address that is
valid for that segment.
implement side router tractor side
src=0x80 (sprayer) ──► translate 0x80→0x21 ──► src=0x21 (sprayer-as-seen)
dst=0x21 (tractor) ──► translate 0x21→0x80 ──► dst=0x80 (tractor-local addr)
Router::add_translation(name, tractor_addr, implement_addr) populates the
table; AddressTranslationDb enforces that a given side-local address belongs to
only one active NAME, returning an AddressConflict error if you try to map two
NAMEs onto the same address on the same side. Translation behaves differently for
the two traffic shapes:
- Broadcast frames — only the source is translated; the destination stays the broadcast address.
- Destination-specific frames — both ends are translated, and if the destination has no translation, the frame is blocked. There is no point forwarding a directed frame to a node the other side cannot address.
Address-claim frames get special care: the router checks that the NAME inside the claim matches the NAME the translation table expects for that address, and blocks the claim if it does not — preventing a node from impersonating another’s mapped identity across the bridge.
Loop prevention
The standard requires that the physical network have exactly one path between any
two control functions. If two NIUs accidentally create a second path, a single
broadcast can circle forever, each NIU dutifully forwarding what the other just
emitted. machbus adds a defensive loop guard so a misconfiguration
degrades instead of melting the bus.
Every forwarded frame is remembered — its identifier, length, and payload, tagged
with the side it was sent toward and a timestamp. When a frame arrives that
exactly matches something recently forwarded toward the side it just came from,
the NIU treats it as an echo and drops it. Two NiuConfig knobs tune this:
loop_guard_window_ms (how long a forwarded frame stays remembered) and
loop_guard_max_recent_forwards (how many to remember). Setting either to 0
disables the guard. The guard is a safety net, not a substitute for a correct
single-path topology — design the network so loops cannot form, and keep the
guard as backstop.
The NIU control protocol
An NIU is not only a silent forwarder; it is a node that other tools can
configure over the bus. It participates in a dedicated control message,
NiuNetworkMsg, carried on its own PGN. Each message names a NiuFunction and a
port, and Niu::handle_niu_message reacts to it:
NiuFunction | What the NIU does |
|---|---|
AddFilterEntry | Install a new allow rule for the given PGN. |
DeleteFilterEntry | Remove the rule matching that PGN. |
DeleteAllEntries | Clear the whole filter table, including persistent rules. |
SetFilterMode | Switch between PassAll and BlockAll. |
RequestPortStats | Reply with forwarded / blocked counts. |
RequestFilterDb / FilterDbResponse | Read back the installed rules. |
RequestPortConfig / PortConfigResponse | Report port configuration. |
OpenConnection / CloseConnection | Manage a forwarded destination-specific connection. |
Every handled message also raises on_niu_message, so your application can log
or audit reconfiguration as it happens. This is how a diagnostic tool on the bus
can tighten or loosen the filter, or read traffic statistics, without
recompiling the NIU.
Doing it with machbus
The examples/niu_demo.rs example builds a bridge in BlockAll mode, allows a
couple of PGNs, blocks one, and then feeds frames through it.
You configure the NIU declaratively, then start it:
#![allow(unused)]
fn main() {
{{#include ../../../examples/niu_demo.rs:11:19}}
}
NiuConfig::default().name(...).mode(...) sets the policy; allow_pgn,
allow_pgn_rate_limited, and block_pgn install rules; start flips the state
to Active so frames begin flowing. The true argument on each rule makes it
bidirectional.
Then each frame is pumped through process_frame, which returns Some(frame) to
forward or None to drop:
#![allow(unused)]
fn main() {
{{#include ../../../examples/niu_demo.rs:27:60}}
}
The heartbeat is allowed and forwards; the request PGN is explicitly blocked; the
DM1 is rate-limited to one crossing per 100 ms, so the frame at t=50 passes,
the one at t=100 is dropped as too soon, and the one at t=200 passes again
once the window has elapsed. forwarded() and blocked() report the running
totals.
To add address translation, wrap the same configuration in a Router instead and
register the NAMEs that cross. The shape (illustrative, not from the example) is:
#![allow(unused)]
fn main() {
// illustrative shape — see the niu.rs API for exact signatures
let mut router = Router::new(NiuConfig::default().mode(NiuFilterMode::BlockAll));
router.niu_mut().allow_pgn(PGN_HEARTBEAT, true);
router.add_translation(sprayer_name, 0x21, 0x80)?; // tractor 0x21, implement 0x80
let forwarded = router.process_frame(frame, Side::Implement, now_ms);
}
Router::niu_mut() gives you the underlying Niu for filter and event
management, while Router::process_frame runs the filter first and then rewrites
addresses on whatever survives.
Events and responsibilities
The NIU raises events so your application can observe forwarding without sitting in the decision path:
| Event | Fires when | Typical use |
|---|---|---|
on_forwarded | A frame crossed to the other side. | Metrics, tracing. |
on_blocked | A frame was dropped (rule, rate limit, loop, bad source). | Diagnose over-tight filters. |
on_monitored | A Monitor-policy frame crossed. | Tap a traffic class for inspection. |
on_niu_message | A control message reconfigured the NIU. | Audit remote reconfiguration. |
Your responsibilities around the pump:
- Drive both directions. Call
process_framefor frames from each side; the NIU does not poll a bus itself. - Deliver what it returns. A returned frame must actually be placed on the
destination segment’s
IsoNet— the NIU only decides, it does not transmit. - Pass honest time.
now_msdrives rate limiting and the loop guard; feed a monotonic clock so windows mean what they say. - Claim before translating. A router needs to learn NAMEs from address claims before its NAME-based rules and translations work; let claims flow.
Edge cases and failures
- Filter misconfiguration. A
BlockAllNIU with no allow rules silently drops everything; aPassAllNIU with no block rules is a transparent repeater. If “nothing crosses”, checkblocked()and the mode before suspecting the bus. - Translation collisions. Mapping two NAMEs onto the same side-local address
fails with an
AddressConflict. The database refuses it rather than making routing non-deterministic — fix the address plan. - Directed frame with no destination translation. A
Routerblocks a destination-specific frame whose destination has no mapping, because the other side has no valid address to deliver it to. Broadcasts are unaffected. - Forwarding loops. Two paths between the same nodes will loop. The loop guard suppresses the immediate echo, but the real fix is a single-path topology. If loop-guard blocked counts climb, you have a redundant path.
- Segment overload. An NIU that forwards too liberally can push one segment
past its bus-load budget. Use
BlockAllplus narrow allow rules, and rate-limit high-frequency PGNs, to keep each side within its capacity. - Stale clock. If
now_msnever advances, rate-limited rules behave as if no time passes and may block every crossing after the first.
Advanced
- Multi-router topologies. Larger machines chain several NIUs — for example a
tractor segment, an implement segment, and a sub-implement behind that. Keep
the single-path rule across the whole tree; every additional join is another
place a loop or a translation gap can hide. Give each NIU a distinct
nameso control messages and traces are unambiguous. - Performance and bus load. Forwarding is not free: every crossed frame is
traffic the destination segment must absorb. Think in terms of each segment’s
budget and forward only what the other side needs. Rate limiting and a
BlockAlldefault are your main levers; broadcast-heavy PGNs are the usual culprits. - Bridge vs. router. Use
Niuwhen both segments share an address space and you only need to filter. UseRouterwhen a segment must appear as a single node to the other side, or when the two sides allocate addresses independently and a directed reply must reach the right node. - Snapshots for audit.
Niu::policy_snapshotandRouter::policy_snapshotreturn deterministic, runtime-state-free dumps of the configured policy and translations — useful for regression fixtures and operator review without leaking mutable counters or learned state.
Validate locally
make run EXAMPLE=niu_demo
make test
The example runs entirely in software: it builds a BlockAll bridge, forwards a
heartbeat, blocks a request PGN, and demonstrates the 100 ms rate limit on a DM1,
printing the forwarded and blocked totals at the end. No bus or hardware is
required.
What this proves / does not prove
Proves: the filter rules, forward policies, rate limiting, loop guard, and
address translation behave deterministically in software, and the Niu /
Router API drives them as described.
Does not prove: real-hardware forwarding latency, priority-ordered queueing under
load, interoperability with a specific third-party NIU, or any
conformance/certification claim. Those still require official standards, real
hardware, and interoperability evidence. machbus is not certified.
See also
- Address claim — how each node on a segment gets the address the NIU learns and translates.
- Transport protocol — what happens to multi-frame messages that cross a segment.
- PGNs, priority, source, destination — the addressing fields every filter rule matches on.
- Receiving and routing — how a single node dispatches the traffic an NIU delivers to it.
Virtual Terminal client
A Virtual Terminal (VT) is the display-and-input device in the cab — the screen,
soft keys, and dial that the operator uses to drive an implement. The implement
itself has no screen. Instead it ships a description of its user interface to
the VT and then talks to that interface over the bus. The piece of software on
the implement side that does this is the VT client, and this tutorial shows
how to drive one with machbus through isobus::vt::VTClient.
If you are new to the VT model, read Virtual Terminal concepts first; this page assumes you know what a working set, an object pool, and a data mask are, and focuses on the client lifecycle: how to find a VT, hand it the interface, and then keep that interface in sync with your application state.
Why this exists
The cab terminal is a shared, general-purpose display. Many different implements — a seeder today, a sprayer tomorrow — must each present their own controls on the same screen without the terminal knowing anything about them in advance. ISO 11783-6 (Virtual Terminal) solves this by making the implement describe its interface as a tree of objects (the object pool) and upload that tree to the terminal at connection time. The terminal renders the pool; the implement then sends small commands (“set this number to 42”, “hide this object”) and receives input events (“soft key 3 was pressed”) back.
The client’s job is three things, in order:
- Find a VT partner on the bus and bind to it.
- Upload the object pool so the terminal has something to draw.
- Drive the UI — push value updates out, take operator input in — for as long as the connection lasts.
Get the first two wrong and there is nothing on screen; this tutorial spends most of its length on getting them right.
Mental model
your app state the bus the terminal
───────────── ─────── ────────────
build ObjectPool
│
VTClient::connect() ──── listen for VT status ────────► VT broadcasts
│ its status
▼ ◄─────────────────────────────── (found it)
announce working set
ask "got memory?" ──────── GetMemory(size) ───────────► reserve room
│ ◄──── memory OK / not OK ────────
▼
stream the pool ──────── pool transfer (TP/ETP) ─────► store objects
say "that's all" ──────── EndOfObjectPool ────────────► parse + validate
│ ◄──── parsed OK / error ──────────
▼
CONNECTED
│
change_numeric_value() ──── ECU→VT command ─────────────► redraw
on_soft_key / on_button ◄── VT→ECU activation ────────── operator presses
The whole thing is a pump: you feed inbound frames into the client, you call
update, and the client hands you the frames it wants put on the wire. The
client never holds a network handle and never sends on its own — you route every
byte. That makes it testable in pure software and easy to slot under whatever
transport you use.
Anatomy: the pieces tied to the API
| Piece | machbus type | Role |
|---|---|---|
| Connection state | vt::VTState | Where the client is in the connect/upload lifecycle. |
| Configuration | vt::VTClientConfig | Per-session timeout and preferred VT version. |
| The interface | vt::ObjectPool + vt::WorkingSet | The tree of objects you upload. |
| An outbound frame | vt::ClientOutbound | A { pgn, data, dest } triple the caller ships. |
| Version preference | vt::VTVersion | Which VT generation (3–6) you target. |
| Language | vt::LanguageCode | The two-letter language the pool was built for. |
VTClientConfig defaults to a 6-second per-step timeout and VT version 4. You
can adjust either with the consuming setters with_timeout and with_version,
and you can change the version preference later with set_vt_version_preference.
Every command method returns a ClientOutbound (or an error). ClientOutbound
carries the PGN, the payload bytes, and an optional destination: None means
broadcast, Some(addr) means addressed to the bound VT. You take that value
and dispatch it through your own send path.
Lifecycle and state machine
VTState enumerates the connect-and-upload path. The client advances through it
as a pump: inbound frames drive transitions in handle_vt_message, and
update performs the time-based, send-side steps.
| State | What it means | What update does here |
|---|---|---|
Disconnected | No session. | Nothing. |
WaitForVTStatus | Listening for a VT to announce itself. | Times out to Disconnected. |
SendWorkingSetMaster | A VT was found; announce our working set. | Emits the Working Set Master frame, advances. |
SendGetMemory | Ask the VT to reserve room for the pool. | Emits Get Memory with the serialized size, advances. |
WaitForMemory | Waiting for the VT’s memory verdict. | Times out to Disconnected. |
UploadPool | Stream the serialized pool. | Emits the object-pool transfer, advances. |
WaitForPoolStore | Let the transfer drain before ending. | After a settle delay, emits End Of Object Pool. |
WaitForEndOfPool | Waiting for parse/activate result. | Times out to Disconnected. |
ReloadPool | Language changed; re-upload. | Loops back to SendGetMemory. |
Connected | Pool is live; UI commands allowed. | Nothing time-based. |
The transitions, end to end:
- Discover.
connectserializes the pool once as a sanity check, clears any stale VT binding, and moves toWaitForVTStatus. The client now waits for a VT to broadcast its status. The first valid status frame binds the session to that VT’s address and advances toSendWorkingSetMaster. - Announce. The next
updatebroadcasts the Working Set Master frame so the network knows this working set exists, then moves toSendGetMemory. - Reserve memory. The following
updateserializes the pool, sends Get Memory carrying the byte size, and waits inWaitForMemory. The VT replies either “I have room” or “I do not”. The hosted server accepts only the canonical fixed request shape: command byte, four-byte requested size, then0xFFreserved tail bytes. On OK the client moves toUploadPool; otherwise it drops toDisconnected. - Transfer.
updateships the pool transfer command with the serialized bytes (this is what the transport-protocol layer fragments across many CAN frames) and moves toWaitForPoolStore. - Drain, then end. The client does not send End Of Object Pool
immediately. It computes a settle delay sized to the transfer, waits that
long in
WaitForPoolStoreso the multi-frame transfer can finish on the wire, then emits End Of Object Pool and waits inWaitForEndOfPool. - Activate. The VT parses the pool and responds. A clean response (no error
code, no pool-error bitmask) moves the client to
Connected. Any error fireson_pool_errorand drops the client toDisconnected.
Every waiting state is bounded by config.timeout_ms; if the partner goes
silent, the client falls back to Disconnected rather than hanging. A drop to
Disconnected always clears the VT session binding, so a later VT status frame
can start a fresh attempt.
Version and pool management
Two distinct things get negotiated here, and it helps to keep them apart.
VT version. A terminal reports the VT generation it implements in its status
frame. The client records that value (vt_version_value), and you state your
own preference with set_vt_version_preference or VTClientConfig::with_version.
Targeting a lower version keeps your pool compatible with older terminals at the
cost of newer object types.
Stored pools. Re-uploading a full pool on every power cycle is slow. The VT can keep a pool in non-volatile memory under a short label, so the client has a choice: upload fresh, or ask the VT to reload what it already has.
| Operation | Method | Effect |
|---|---|---|
| List stored labels | get_versions | VT replies with its stored labels (on_versions_received). |
| Save current pool | store_version(label) | Ask the VT to persist the active pool under label. |
| Reload a stored pool | load_version(label) | Skip the upload; the VT restores label. |
| Forget a stored pool | delete_version(label) | Remove a stored label. |
load_version is the fast path: instead of streaming the whole pool again, the
client sends the label and jumps to WaitForEndOfPool, expecting the VT to
restore the stored objects and respond. On success it reaches Connected
directly. Newer terminals also support extended version labels (longer,
file-style names); the client exposes the parallel
request_extended_version_label, send_extended_store_version,
send_extended_load_version, and send_extended_delete_version, and reports
whether the VT supports them via vt_supports_extended_versions. A typical
boot sequence is therefore: request versions, and if your label is present call
load_version; otherwise upload fresh and store_version it for next time.
Doing it with machbus
For applications, use the session facade; drop to the codec when you need to own the pump.
The session facade (recommended)
Plug the VtClient plugin with your object pool and
working set. The plugin drives the whole connect/upload/activate FSM on each tick,
ships the frames for you, and surfaces VT activity as Event::Vt(VtEvent::…). You
point it at a server, then push UI updates through fine control:
#![allow(unused)]
fn main() {
// illustrative shape — the API mirrors the tested `session::plugins::VtClient`
use machbus::session::{Session, EndpointTransport, plugins::VtClient};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(VtClient::new(VTClientConfig::default(), pool, working_set))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
// once an address is claimed, target a VT and let the FSM upload + activate:
ctrl.with_mut::<VtClient, _>(|vt| vt.connect_to(0x26));
loop {
match driver.poll()? {
Some(Event::Vt(VtEvent::SoftKey { id, .. })) => { /* react */ }
Some(_) | None => {}
}
// push a UI update when your app state changes:
ctrl.with_mut::<VtClient, _>(|vt| vt.set_value(numeric_object, 42))?;
}
}
with_mut::<VtClient> exposes the same command surface as the codec
(show/hide, enable/disable, set_value, set_string,
change_active_mask, …); each is buffered and shipped on the next tick. Soft-key,
button, and value-change activations arrive as VtEvent — match them on
driver.poll() or filter with controls.drain::<VtEvent>().
Driving the codec directly
The vt_client_demo example walks the whole connect FSM in software, standing in
for a VT server at address 0x80. Start by building a minimal pool — a working
set object that points at one data mask — and calling connect:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:16:28}}
}
connect only arms the state machine; it does not block. From here you pump.
When the (simulated) VT broadcasts its status, you feed it in, then call update
to get the next outbound frame:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:30:48}}
}
Each update returns a Vec<ClientOutbound> in emission order — usually one
frame, but the upload step can produce the pool transfer and then, a tick later,
the End Of Object Pool. You ship each ClientOutbound exactly as given: its
pgn, data, and dest. The example continues by feeding a memory-OK reply,
pumping the transfer and end-of-pool, feeding the activation reply, and then —
once state() is VTState::Connected — calling change_numeric_value to push a
UI update.
The command surface (all of which require the Connected state) covers the
common UI operations: hide_show, enable_disable, change_numeric_value,
change_string_value, change_active_mask, change_soft_key_mask,
change_alarm_soft_key_mask, change_attribute, change_size,
change_background_colour, change_child_location, change_child_position,
change_list_item, select_colour_map, select_colour_palette,
select_input_object, lock_unlock_mask, control_audio_signal,
set_audio_volume, and execute_macro. Each returns a ClientOutbound
addressed to the bound VT, or Error::not_connected if you call it too early.
Technical-data helpers follow the same rule. Most are fixed [code][FF×7]
requests, but WideChar discovery is a real query: use
get_supported_widechars() for code plane 0 over the full range, or
get_supported_widechars_range(code_plane, first, last) when you need the
standard clipped range response. The hosted server validates the same reserved
bytes for parameterless technical-data requests, so requests such as Get
Hardware, Get Number of Soft Keys, Get Text Font Data, and Get Window Mask Data
must keep bytes 1 through 7 as 0xFF.
Events and responsibilities
The client is event-driven. Subscribe to the Event fields before you connect,
and the client will emit into them as inbound frames arrive:
| Event | Fires when | You typically |
|---|---|---|
on_state_change | The FSM transitions. | Log progress; gate UI commands on Connected. |
on_soft_key | A soft key is activated. | Map (ObjectID, ActivationCode) to an action. |
on_button | A button object is activated. | Same, for button objects. |
on_numeric_value_change | The operator edits a number. | Update your app model from (ObjectID, u32). |
on_string_value_change | The operator edits a string. | Update your app model from (ObjectID, String). |
on_pool_error | The VT rejects the pool. | Inspect the error byte; fix the pool. |
on_active_ws_status | This working set becomes (in)active. | Show/hide your interface accordingly. |
on_language_change | The VT’s language differs from yours. | Reload a localized pool (see below). |
on_unsupported_function | The VT can’t do a function you used. | Degrade gracefully; check unsupported_functions. |
on_versions_received | A version list arrives. | Decide upload-fresh vs load_version. |
on_store_version_response / on_load_version_response | A store/load completes. | Read (success, error_code). |
Two responsibilities are non-negotiable. First, only send UI commands while
Connected — every command method enforces this and returns an error
otherwise, but you should also gate your own logic so you are not generating
churn into the void. Second, to detect whether your working set is the active
one on the terminal, call set_self_address with your control function’s
address; until you do, the client never claims active-WS status and
on_active_ws_status stays quiet.
Edge cases and failures
- Pool won’t serialize.
connectserializes the pool up front and fails immediately if it cannot — for example a working set with too many children to encode. You get an error before any frame leaves, and the state staysDisconnected. The send-side steps also re-check this and bail toDisconnectedrather than emit a malformed transfer. - Empty pool.
connectrejects an empty pool with an invalid-state error. There is nothing to draw, so there is nothing to connect with. - VT reports no memory. If the Get Memory reply says “not enough room”, the
client drops to
Disconnected. The pool is too large for that terminal; shrink it or target a different VT. - Pool parse error. A non-zero error code or pool-error bitmask in the End Of
Object Pool response fires
on_pool_errorwith the reported byte and drops the session. Unknown object type, duplicate object ID, and a missing child reference all surface here — fix the offending object and re-upload. - End sent before the transfer drains. Sending End Of Object Pool while the multi-frame transfer is still on the wire confuses the terminal. The client avoids this by waiting a settle delay sized to the transfer length before it emits End Of Object Pool; do not shortcut that step.
- VT busy or silent. Every waiting state is timeout-bounded by
config.timeout_ms. A partner that stops responding lands you back inDisconnected, where a fresh VT status frame can begin a new attempt. - Frames from the wrong VT. Once bound, the client ignores VT frames whose source is not the bound VT, so a second terminal on the bus cannot hijack the session mid-flight.
Advanced
- Multiple VTs on the bus. Several terminals may broadcast status. The client
binds to the first valid status it sees and ignores the rest for the rest of
the session (
vt_addressreports which one). If you need to target a specific terminal, driveconnect/disconnectso you bind during the window the intended VT is announcing. - Language and units changes. The operator can switch the cab’s language at
any time, broadcast over the language command PGN. Feed those frames to
handle_language_command. Ifauto_reload_on_language_changeis on (the default) and the VT’s language differs from yours, the client fireson_language_changeand, whileConnected, moves toReloadPoolto re-upload a pool built for the new language. Toggle this withset_auto_reload_on_language_changeif your application manages localization itself. - Swapping pools at runtime. While
Connected,swap_poolre-uploads a new pool (optionally storing the old one first), andquick_swap_to_versionreloads a previously stored pool by label without a full transfer. - Reconnect. Because a drop to
Disconnectedclears the session binding, the reconnect story is simply: keep feeding inbound frames. The next VT status frame restarts the lifecycle fromWaitForVTStatuswith no special handling on your part. - Macros. Register reusable command sequences with
register_macroand fire them by ID withexecute_macro; the client emitson_macro_executedwhen it ships one.
Validate locally
make run EXAMPLE=vt_client_demo
make test
The example runs the full connect FSM against a simulated VT entirely in
software: it builds a pool, connects, walks Disconnected → WaitForVTStatus →
SendWorkingSetMaster → SendGetMemory → WaitForMemory → UploadPool →
WaitForEndOfPool → Connected, asserts the state reaches Connected, and then
sends one change_numeric_value command. make test exercises the client’s
transition, timeout, and validation paths.
What this proves / does not prove
Proves: the connect-and-upload state machine, the memory/version negotiation,
the transfer-then-end ordering with its settle delay, and the inbound-event
fan-out behave correctly in software, and the machbus API drives them as
described.
Does not prove: rendering on a real terminal, interoperability with a specific
third-party VT, or any conformance/certification claim. machbus is not
certified; real deployment still needs official standards, real hardware, and
interoperability evidence.
See also
- Virtual Terminal concepts — the model behind working sets, masks, and object pools.
- VT object pools — how the interface tree is built and serialized.
- VT updates — the command surface for keeping the UI in sync.
- Virtual Terminal server — the other side of this handshake.
Virtual Terminal server
A Virtual Terminal (VT) is the display that an operator looks at in the cab. The
VT server is the terminal side of the relationship: it advertises itself on
the bus as a screen that implements can talk to, accepts the object pools they
upload, keeps each pool as semantic state, and hands operator input — soft keys,
buttons, edited values — back to whichever implement is in control. This page
explains the server’s job, the upload and connection lifecycle, how machbus
models several connected implements at once, and how to drive the whole thing
with VTServer and the session facade.
If you are writing the implement side — the ECU that uploads a pool and reacts to operator input — read Virtual Terminal client instead. For the shared vocabulary (object pools, masks, working sets), read Virtual Terminal concepts first.
Why this exists
An ISOBUS tractor has one terminal but may tow or carry several implements, each made by a different manufacturer. None of them ship their own screen. Instead each implement carries a description of the screen it wants — an object pool of masks, buttons, numbers, strings, and pictures — and uploads that description to the terminal. The terminal renders it and reports back what the operator does.
The VT server is the half of that contract that lives in the terminal. Its job,
in machbus terms, is to:
- advertise itself as a VT, periodically, so clients know a terminal exists;
- accept a client’s working set and let it begin an upload;
- store and validate the uploaded object pool before activating it;
- track render state — which mask is active, what values changed, what is hidden — as a semantic cache;
- deliver operator input (key and button presses, edited values, selections) back to the client as events.
VTServer itself does not own a GUI window or pixel framebuffer. It keeps the
meaning of an activated pool — enough for protocol tests, auditing, and render
effect replay. Hosted Rust code can feed that state into VtRenderRuntime,
GtuiRenderer, or FramebufferRenderer to produce backend-neutral commands or
deterministic RGB snapshots. The C and Python bindings expose the session/client
and server protocol surfaces; hosted object-pool layout/rendering remains
Rust-only for now.
Mental model
implement (client) terminal (VTServer)
────────────────── ───────────────────
VT_STATUS (every ~1 s)
◄───────────────────────────── "a VT is here"
Get Memory ─────────────────────────────────────►
◄───────────── Get Memory Response (upload OK)
Object Pool Transfer ───────────────────────────► store + validate
(sent over the transport protocol, reassembled)
End of Object Pool ─────────────────────────────►
◄────────── End of Pool Response (accept/reject)
activate pool,
become active WS
change-numeric / hide / change-active-mask ─────► mutate state cache
◄────────── soft-key / button / value events
The exchange is request/response and event-driven. The server announces itself on a fixed cadence, walks one client at a time through the upload handshake, and once a pool is activated it both records the change commands the client sends and emits the input the operator generates.
Anatomy: the server pieces
machbus splits the server into a small set of types under isobus::vt.
| Type | Role |
|---|---|
VTServerConfig | The advertised screen: screen_width, screen_height, vt_version. Built with with_screen, with_width, with_height, with_version; validate() rejects a zero dimension or an out-of-range version. |
VTServer | The server engine. Holds the FSM, the list of connected working sets, the status cadence, and the input events. |
VTServerState | Where the server is in its lifecycle: Disconnected, WaitForClientStatus, SendWorkingSetMaster, WaitForPoolUpload, Connected. |
ServerWorkingSet | Per-client tracking: the client address, the uploaded pool, the upload flags, stored versions, and the object_state cache. |
ServerObjectState | The semantic cache for one activated pool — active mask, visibility, numeric/string values, attributes, and more. |
OutboundFrame | One frame the server wants to put on the wire, with dest: Some(addr) for a reply or None for the broadcast status. |
The version the server advertises is bounded: VT_SERVER_MIN_VERSION (3) through
VT_SERVER_MAX_VERSION (6). The status broadcast cadence is
VT_STATUS_INTERVAL_MS (one second). These are the only “what kind of terminal”
knobs you set; everything else is driven by what clients upload. The server
follows ISO 11783-6 (Virtual Terminal) for the message shapes.
VTServer is pump-style: it does not own a network handle. You feed it
inbound PGN_ECU_TO_VT messages and it returns the OutboundFrames it wants to
send; you advance its clock with update. The session facade wires that pump to
a real bus for you (covered below).
Lifecycle and state machine
A client connects by walking the server through one upload handshake. The server
tracks each client independently with ServerWorkingSet and moves its own
top-level VTServerState as the first client progresses.
Disconnected
│ start() (validates the advertised config)
▼
WaitForClientStatus ──────► update() begins broadcasting VT_STATUS
│ Get Memory from a client
│ → reply Get Memory Response, allow upload
▼
WaitForPoolUpload
│ Object Pool Transfer (reassembled by the transport)
│ → deserialize, validate graph, stash as pending
│ End of Object Pool
├── pool valid & pending ──► activate, become active WS
│ emit on_client_connected
▼
Connected ◄──── more clients upload, change commands flow
│ stop()
▼
Disconnected (saves stored versions, clears clients)
Step by step:
- Advertise. After
start(), every call toupdate(elapsed_ms)advances a timer; once a second’s worth of time has accumulated it returns the eight-byteVT_STATUSpayload to broadcast. That frame names the active working set and the advertised VT version, and it is how a client first learns a terminal exists. - Get Memory. A client asks whether the terminal has room for its pool. The
server registers the client, marks
pool_upload_allowed, and replies with a Get Memory Response. FromWaitForClientStatusthis also moves the server toWaitForPoolUpload. - Transfer. The client sends its serialized pool. For anything but a tiny
pool this arrives over the transport protocol (BAM or RTS/CTS) and is
reassembled into one message before the server sees it. The server
deserializes it, runs
pool.validate(), and — if it parses and is non-empty — stores it as pending activation. - End of Object Pool. The client signals it is done. The server re-checks
that a pending, uploaded, non-empty, valid pool exists. If so it marks the
pool activated, replies with a success End of Pool response, transitions to
Connected, makes this client the active working set if none was set, and emitson_client_connected. If not, it replies with an error response and clears the pending flags. There is no separate “activate” command — a successful End of Object Pool response is activation. - Run. With the pool activated, the client streams change commands and the server records them; the operator generates input and the server emits it.
The optional version storage steps (Store / Load / Get / Delete Version) let a client save an uploaded pool under a short label so a later session can reload it without re-uploading. A successful Load Version activates the restored pool just like an End of Object Pool would.
Managing multiple working sets
Several implements can be connected to one terminal at the same time. The server
keeps a ServerWorkingSet per client in clients(), each with its own pool,
upload state, and object-state cache, keyed by the client’s source address. They
do not interfere: a change command from 0x42 only ever mutates 0x42’s cache.
Exactly one of them is the active working set at a time — the one whose
screen the operator currently sees. active_working_set() returns its address
(or the null address when none is active). The active selection moves in three
ways:
- the first client to finish a successful upload becomes active if no working set was active yet;
- a client can ask to become active with the Select Active Working Set command, which the server honours only if that client has an activated pool;
- the application can force it with
set_active_working_set(addr).
Whenever the active working set changes, the server emits
on_active_ws_changed carrying (old, new), and the next VT_STATUS broadcast
advertises the new active address. If the active client deletes its pool (Delete
Object Pool), the server clears the active selection back to the null address.
Doing it with machbus
There are two ways to drive the server: the VtServer plugin on a Session for
applications, and the bare VTServer pump for tests and tight control loops.
The session facade (recommended for applications)
Plug the VtServer plugin into a Session. It claims an address, runs the FSM,
routes inbound PGN_ECU_TO_VT traffic into the server, and ships the periodic
VT_STATUS for you on every poll. You give it a screen size and a version:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::VtServer};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(VtServer::new(VTServerConfig::default())?)
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
}
After the address is claimed you start the server FSM through fine control on the plugin:
#![allow(unused)]
fn main() {
ctrl.with_mut::<VtServer, _>(|vt| vt.start())?;
loop {
driver.poll()?;
for ev in ctrl.drain::<VtEvent>() {
// state changes, client connect/disconnect, active-working-set
// changes, soft keys, buttons, numeric/string value changes,
// input-object selections
}
}
}
ctrl.drain::<VtEvent>() yields the server events as they happen, and
ctrl.with_mut::<VtServer, _>(|vt| ...) reaches the underlying VTServer for
state queries, version management, and callbacks not surfaced as events. Driving
driver.poll()? claims, starts the server, and ships the VT_STATUS frames a
watching node receives.
The low-level pump (for tests and embedded control)
Underneath, VTServer is a self-contained engine you drive by hand. Construct it
from a VTServerConfig, then start():
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_server_demo.rs:14:25}}
}
Feed each inbound message to handle_ecu_message(&msg), which returns the
OutboundFrames to send and applies side effects (state transitions, events).
The demo walks a single client at 0x42 through Get Memory, pool transfer, and
End of Object Pool. The upload step:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_server_demo.rs:39:49}}
}
And the activation step, where a success response makes the pool active:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_server_demo.rs:58:67}}
}
To advertise, call update(elapsed_ms) each loop; when it returns Some(bytes)
you broadcast those bytes. clients() and active_working_set() let you inspect
the connection table at any time.
Events and responsibilities
Whichever API you use, the server raises events your application reacts to. On
the raw VTServer these are Event fields; the VtServer plugin bridges them
to VtEvents you drain from the session.
| Event | Meaning | Typical action |
|---|---|---|
on_state_change | The server FSM moved. | Track connection progress / UI state. |
on_client_connected | A client’s pool activated. | Add it to the rendered set; pick it if it should be foreground. |
on_client_disconnected | A client dropped. | Remove its surface; reassign the active working set. |
on_active_ws_changed | The foreground client changed. | Repaint with the new client’s pool. |
on_soft_key_activation / on_button_activation | The operator pressed a key/button. | Route the key number to the active client. |
on_numeric_value_change / on_string_value_change | The operator edited a value. | Reflect and forward the new value. |
on_input_object_selected | An input object was selected / opened for edit. | Move focus on the rendered screen. |
The server’s own responsibilities are: never accept change commands for a pool
that is not activated; only mutate the cache of the client that sent the command;
and keep advertising VT_STATUS for as long as it is running so clients do not
time the terminal out.
Edge cases and failures
- Bad or truncated pool. If the transferred bytes fail to deserialize, are empty, or fail graph validation (missing root, bad child reference, unknown object), the server silently drops the transfer — the pending pool never becomes activated and End of Object Pool then returns an error response.
- End of Object Pool with nothing pending. If a client signals end-of-pool without a valid pending upload, the server clears the upload flags and replies with an error response rather than activating stale state.
- Unsupported function. An ECU-to-VT function the server does not implement
is answered with an Unsupported VT Function reply naming the function byte,
built by
build_unsupported_function, rather than being silently ignored. - Commands before activation. Change commands (hide/show, change active mask,
numeric value, and the rest) only mutate state once the client’s pool is
pool_activated; before that they are no-ops. Many also re-check that the referenced object exists and is of the expected type, so a command naming a non-existent or wrong-typed object is dropped. - Malformed change command. Commands with the wrong length, a non-canonical
boolean, or a non-
0xFFreserved tail are rejected without mutating state, so a single bad frame cannot corrupt the cache. - Client disappears. A client that stops talking leaves its
ServerWorkingSetin place; deciding when to evict it and reassign the active working set is the application’s call. A Delete Object Pool from the active client clears the active selection. - Multiple clients racing for foreground. Only one working set is active.
Honour Select Active Working Set, or arbitrate in the application with
set_active_working_set; do not assume the last uploader wins.
Advanced
- Soft-key and aux routing. Soft-key and button activations come back as
events carrying the object ID, the parent ID, and the physical key number, so
the application can map a press to the right client and object. For auxiliary
inputs (joysticks, encoders), the server advertises channel capabilities set
with
set_aux_capabilities, binds an uploaded AUX input object to an AUX function withassign_aux_input, and folds incoming AUX status frames into the cache withhandle_aux_input_status— rejecting cross-family (AUX-O vs AUX-N) assignments. See VT auxiliary capabilities. - Language and units. Operator language and unit preferences are broadcast separately from the VT exchange; a client adapts its pool to them. That broadcast is covered in the language-command material rather than here.
- Version storage.
set_storage_path,load_all_versions,save_all_versions, andcleanup_expired_versionspersist uploaded pools per client under short labels, so a returning implement can reload a stored pool instead of re-uploading it. Stored versions are capped per client and validated on read. - Session facade vs the bare codec. The
VtServerplugin is right for applications: it sequences the claim, the FSM, the inbound routing, and the status broadcast. The bareVTServerpump is right for unit tests and tightly controlled loops where you own every message and every millisecond.
Validate locally
make run EXAMPLE=vt_server_demo
make test
vt_server_demo drives one client through the full handshake in software and
asserts that End of Object Pool returns success and the server reaches
Connected. The session tests build the VtServer plugin, claim an address,
start the server, and check the VT_STATUS broadcasts a second node receives.
What this proves / does not prove
Proves: the upload handshake, pool validation, activation, the active-working-set
rules, and the change-command state cache behave as described in software, and
the machbus API drives them correctly.
Does not prove: pixel-accurate rendering on a specific commercial VT,
real-hardware timing, interoperability with a specific third-party terminal or
implement, or any conformance/certification claim. The hosted Rust renderer and
software framebuffer are regression/evidence tools, not certification. machbus
is not certified; real deployment still needs official standards, hardware, and
interoperability evidence.
See also
- Virtual Terminal client — the implement side that uploads a pool and consumes the input events.
- Virtual Terminal concepts — object pools, masks, and working sets.
- VT auxiliary capabilities — advertising and routing auxiliary inputs.
- Address claim — the claim every VT server completes before it advertises.
VT object pools
An ISOBUS implement does not paint pixels on the tractor’s display. Instead it
ships the terminal a complete description of its user interface — every mask,
button, number field, bar graph, and icon — as a tree of typed objects called an
object pool. The Virtual Terminal owns the screen and the rendering; the
implement owns the description. This tutorial explains why that split exists,
walks the object families that make up a pool, and shows how to build, validate,
and serialize one with machbus using the real isobus::vt types.
If you have not yet read Working sets and object pools or Virtual Terminal concepts, start there: this page assumes you know that a terminal hosts many implements at once and that each implement uploads its pool before it can show anything.
Why a pool exists
A tractor cab has one display but many implements may want to use it — a sprayer, a baler, a seeder — sometimes in the same season, sometimes swapped within minutes. None of those implement vendors can ship code that runs on the terminal; terminals come from a different set of vendors entirely. So ISOBUS inverts the usual arrangement. The implement does not run UI code on the screen. It describes its whole UI declaratively and uploads that description once. The terminal then renders it, handles touch and soft-key input locally, and only tells the implement when something the implement cares about happens (a button press, an edited value).
That description is the object pool. Because it is data, not code, the same pool renders on a small monochrome terminal and a large colour one; the terminal adapts. And because the implement keeps the canonical copy, it can change values at runtime by sending small commands rather than re-uploading the screen. The pool is the contract: get it right and the implement’s UI appears; get it wrong and the terminal rejects it before a single frame is drawn.
Mental model
A pool is a flat list of objects, but the objects form a tree by reference. Each object has a unique 16-bit ID; parents name their children by ID. The root is always the Working Set — the implement’s identity on the terminal — and the masks below it are the full-screen “pages” the operator sees.
Working Set (id, the implement's root)
├── Data Mask ── a normal full-screen page
│ ├── Output Number ── a live readout (references a Number Variable)
│ ├── Output String ── a label (references a String Variable + Font)
│ ├── Button ── a soft target the operator can press
│ │ └── Output String / Picture (the button's face)
│ ├── Container ── groups objects, can be shown/hidden as a unit
│ │ ├── Line / Rectangle / Ellipse / Polygon (drawn shapes)
│ │ └── Meter / Linear Bar Graph / Arched Bar Graph
│ └── Input Number ── an editable field (references a Number Variable)
├── Alarm Mask ── a page the terminal forces to the front on alarm
│ └── ... (its own child objects + a referenced Soft Key Mask)
└── Soft Key Mask ── the row of physical/soft keys for a mask
└── Key
└── Output String / Picture (the key's face)
The leaf objects that show data (numbers, strings, bars) do not store their value directly. They point at a variable object (Number Variable or String Variable). To change what the screen shows, the implement updates the variable; every object that references it redraws. That indirection is the whole reason a running pool needs so few bytes on the wire — covered in VT updates.
Anatomy of the object families
machbus models every declared VT object type in isobus::vt. The discriminant
is ObjectType, a #[repr(u8)] enum whose byte values match ISO 11783-6. Each
type has a typed body struct (DataMaskBody, ButtonBody, …) with encode /
decode, plus a VTObject container that carries the ID, the type, the encoded
body bytes, and a list of child IDs. Group the types by what they do.
Structural objects (the skeleton)
These define the page hierarchy and the regions on screen.
| Type | machbus body | Role |
|---|---|---|
WorkingSet | WorkingSetBody | The implement’s root. Exactly one per pool. Children are masks. |
DataMask | DataMaskBody | A normal full-screen page; names a Soft Key Mask. |
AlarmMask | AlarmMaskBody | A page the terminal raises on an alarm; carries priority and an acoustic-signal hint. |
SoftKeyMask | SoftKeyMaskBody | The set of soft keys shown alongside a mask. |
Container | ContainerBody | Groups child objects so they can be moved or hidden together. |
WindowMask | WindowMaskBody | A reusable framed region (the version-6 “auxiliary” window family). |
KeyGroup | KeyGroupBody | Groups keys for the version-6 key arrangement. |
Key | KeyBody | One soft key; its key_code is what the terminal reports on press. |
The Working Set sits at the top because the terminal needs one well-known entry point. Its body carries no fixed fields in this representation — its meaning is entirely its child list, which must reference at least one Data Mask or Alarm Mask.
Input objects (the operator talks back)
These accept operator input. The terminal manages the editing UI; the implement just learns the final value.
| Type | machbus body | Edits |
|---|---|---|
InputBoolean | InputBooleanBody | A toggle, backed by a Number Variable. |
InputNumber | InputNumberBody | A bounded number with scale/offset/decimals and a min/max range. |
InputString | InputStringBody | Free text with an optional validation character set. |
InputList | InputListBody | A pick-one list whose items are other object IDs. |
An Input Number, for example, carries min_value/max_value (encode rejects a
min above the max), an integer scale and offset, a decimal count, and a
display format. It references a Font Attributes object, an optional Input
Attributes object, and the Number Variable it reads and writes.
An Input List has the same value-source split as the list-style output objects:
if variable_reference points at a Number Variable, that variable supplies the
selected index; if it is NULL, the value byte in InputListBody is the
inline selected index, with 255 meaning no chosen item.
Output objects (the implement shows data)
These are read-only on the terminal. Numbers and strings render a value; the shape and graph objects draw geometry.
| Type | machbus body | Draws |
|---|---|---|
OutputNumber | OutputNumberBody | A formatted number from a Number Variable. |
OutputString | OutputStringBody | Text from a String Variable (or an inline value). |
Line | OutputLineBody | A straight line with a referenced Line Attributes. |
Rectangle | OutputRectangleBody | A box with line + fill attributes and per-side line suppression. |
Ellipse | OutputEllipseBody | A circle/arc; ellipse_type selects closed/segment/section. |
Polygon | OutputPolygonBody | A closed or open shape; needs at least three points. |
Meter | MeterBody | A round gauge with a needle, driven by a Number Variable. |
LinearBarGraph | LinearBarGraphBody | A straight fill bar with an optional target line. |
ArchedBarGraph | ArchedBarGraphBody | A curved fill bar. |
The graph and gauge bodies all carry a min_value/max_value window and a
variable_reference ID; encode enforces the min-below-max rule and that
angle fields stay in the half-degree range the wire allows.
Graphics and attribute objects (look and feel)
These do not appear on their own; other objects reference them.
| Type | machbus body | Provides |
|---|---|---|
PictureGraphic | PictureGraphicBody | A bitmap (1/4/8-bit indexed, optionally RLE). |
ObjectPointer | ObjectPointerBody | An indirection — renders whatever object it points at. |
FontAttributes | FontAttributesBody | Colour, size, type, and style for text. |
LineAttributes | LineAttributesBody | Colour, width, and dash pattern for strokes. |
FillAttributes | FillAttributesBody | Fill colour or pattern for closed shapes. |
InputAttributes | InputAttributesBody | A valid/invalid character set for input strings. |
NumberVariable | NumberVariableBody | A shared 32-bit value that output/input numbers read. |
StringVariable | StringVariableBody | A shared text value that output/input strings read. |
ColourMap / ColourPalette | ColourMapBody / ColourPaletteBody | Colour remapping (the palette form is version 6). |
The two variable types are the hinge of the whole runtime model: many objects can point at one variable, so one update redraws all of them.
Colour Maps are tied to VT graphics depth: their record carries a two-byte entry count and the standard sizes are 2, 16, or 256 entries.
Behaviour and auxiliary objects
| Type | machbus body | Role |
|---|---|---|
Macro | MacroBody | A recorded list of VT commands the terminal runs on an event. |
AuxFunction / AuxInput | AuxFunctionBody / AuxInputBody | The classic auxiliary-control objects. |
AuxFunction2 / AuxInput2 | AuxFunction2Body / AuxInput2Body | The version-2 auxiliary family. |
AuxControlDesig | AuxControlDesignatorBody | A designator that names an aux function or input. |
Animation | AnimationBody | A timed sequence of picture/object frames. |
GraphicContext / GraphicsContext | GraphicContextBody / GraphicsContextBody | A drawable canvas with a viewport. |
Auxiliary objects connect an implement function to a physical control (a joystick button, a switch) that the operator assigns on the terminal. See VT auxiliary capabilities for how that pairing works.
A handful of version-6 types round out the enum — ExternalObjectDefinition,
ExternalReferenceName, ExternalObjectPointer, ScaledGraphic,
ScaledBitmap, GraphicData, and ObjectLabelRef. They let one pool reference
objects defined by another working set and scale graphics; machbus carries
body structs for all of them so a real-world pool round-trips without loss.
GraphicData is the standard PNG-payload object; the hosted renderer can draw a
small deterministic subset as RGBA image commands and reports unsupported PNG
compression/format variants as explicit placeholders. ScaledGraphic uses the
standard Width, Height, ScaleType, Options, and Value fields; ScaleType selects
the scaling mode plus horizontal/vertical justification, while Value names a
GraphicData, PictureGraphic, ObjectPointer, or NULL graphic source. When
Value names an ObjectPointer, the pointer chain must still resolve to NULL,
GraphicData, or PictureGraphic; non-graphic targets and pointer cycles are
rejected by pool validation and by runtime retarget commands.
ObjectID: uniqueness and references
Every object is identified by vt::ObjectID, a #[repr(transparent)] newtype
over u16. It is deliberately distinct from the Task Controller’s object ID type
so the two cannot be mixed up. The reserved value ObjectID::NULL (0xFFFF)
means “no object” — a field set to NULL is simply absent, which is how an alarm
mask says it has no soft-key mask, or an output number says it has no variable.
Two rules govern IDs:
- Uniqueness. No two objects in a pool may share an ID.
ObjectPool::addenforces this and refuses a duplicate, so you cannot accidentally build an ambiguous pool. - References are by ID, not by pointer. A Data Mask names its Soft Key Mask
by ID; a Meter names its Number Variable by ID; a Button lists its face
objects in its child list. Every such reference must resolve to an object of
the right type that actually exists in the pool. A reference left at
ObjectID::NULLis treated as intentionally empty and is allowed.
Validate before you upload
A terminal will reject a malformed pool, but it is far cheaper to catch the
problem locally first. machbus validates at two layers.
Raw IOP parsing. A pool exported from a design tool arrives as an .iop
byte buffer. net::parse_iop_data walks it object header by object header
([id:2][type:1][width:2][height:2] then the type body) and returns a list of
RawIopObjects, or fails. It is strict: an empty or short buffer, a trailing
partial header, or a body that runs past the end of the input all return an
InvalidData error rather than silently decoding a prefix. net::validate is
the boolean form — it returns true only if the whole buffer walks to a clean
end.
Structured pool validation. Once you have a vt::ObjectPool (built directly
or via ObjectPool::deserialize), ObjectPool::validate checks the object
graph:
- exactly one Working Set exists (zero or several is an error);
- the Working Set references at least one Data Mask or Alarm Mask;
- every object’s body decodes for its declared type (
validate_body); - typed references resolve to the right type — a Data Mask’s soft-key mask must actually be a Soft Key Mask, an Output Number’s variable reference must be a Number Variable, and so on;
- no child reference points at a non-existent object.
deserialize itself rejects an unknown object-type byte and a malformed
child-list tail with a PoolValidation error, so importing a corrupt pool fails
at parse time rather than at render time. The failure classes you will actually
hit are: unknown type, duplicate ID, a missing or wrong-typed child or variable
reference, a body too short for its type, and a pool whose serialized object
exceeds the body-length field.
Building a pool with machbus
Build objects with VTObject plus a typed body helper, or with one of the
create_* convenience functions, then add them to an ObjectPool. The shape,
using the real API:
#![allow(unused)]
fn main() {
use machbus::isobus::vt::{
ObjectPool, DataMaskBody, NumberVariableBody, OutputNumberBody,
create_data_mask, create_number_variable,
};
let mut pool = ObjectPool::default();
// Root + one page.
pool.add(working_set)?; // references the data mask by child id
pool.add(create_data_mask(1000, &DataMaskBody::default()))?;
// A shared value and a readout that points at it.
pool.add(create_number_variable(2000, &NumberVariableBody { value: 0 }))?;
let readout = machbus::isobus::vt::VTObject::default()
.with_id(3000u16)
.with_type(machbus::isobus::vt::ObjectType::OutputNumber)
.with_output_number_body(&OutputNumberBody {
variable_reference: 2000u16.into(),
..Default::default()
})?;
pool.add(readout)?;
pool.validate()?; // catch graph errors before upload
let bytes = pool.serialize()?; // ready for the upload sequence
}
The body helpers come in two flavours. Helpers for types that cannot encode an
invalid body (with_data_mask_body, with_number_variable_body) return Self
directly. Helpers whose body has validity rules (with_output_number_body,
with_input_number_body, with_meter_body, …) return Result<Self>, because
encode rejects out-of-range options, bad angle values, or a min above the max
at construction time — you never get a half-built object onto the wire.
Authoring advice that keeps pools manageable:
- Start with the smallest useful pool: a Working Set, one Data Mask, one visible output object.
- Add a Soft Key Mask only when you need keys.
- Only then add inputs, macros, auxiliary objects, and graphics.
- Keep a byte fixture for every pool that has ever caused a failure — a fixture replays in a test, where a screenshot cannot.
Serializing a pool to bytes
VTObject::serialize writes the length-driven wire form: the 16-bit ID, the
type byte, a 16-bit body length, then the body bytes. For object types that have
children (Working Set, masks, containers, keys, buttons, …) the child list — a
16-bit count followed by that many IDs — is appended and counted inside the
body-length field, so the whole object is self-delimiting. ObjectPool::serialize
simply concatenates every object’s serialization in order; the result is the byte
stream the upload sequence pushes to the terminal.
ObjectPool::deserialize is the inverse: it reads each header, validates the
type byte, splits the body from the child-list tail at the known offset for that
type, and rebuilds the VTObject. Because the body length is explicit, a
truncated or oversized object is caught immediately.
Edge cases and failure modes
- No Working Set, or more than one.
validaterejects both. A pool needs exactly one root. - Working Set with no mask child. A root that points at nothing the operator can see is rejected — the Working Set must reference at least one Data Mask or Alarm Mask.
- Dangling or mistyped reference. Pointing an Output Number at an object that
is not a Number Variable, or at an ID that is not in the pool, fails
validation. A
NULLreference is fine; a wrong one is not. - Body too short. Each
decodechecks the minimum length for its type and returnsInvalidData. A 3-byte buffer where a Data Mask expected 3 bytes is fine; one byte short is rejected. - Reserved bits set. Bodies with option bitfields reject reserved bits in
encodeanddecode, so a pool that sets undefined options never reaches the terminal. - Pool too large. A single object whose body plus child tail exceeds the
16-bit length field is rejected at
serialize. Terminals also bound total pool size; an oversized pool is a deployment concern, not just an encoding one. - Unknown type byte on import.
deserializerefuses an object type it does not recognise rather than guessing, so a pool built for a newer feature set fails loudly.
Advanced
- Versioning. A pool carries a
version_label, andnet::hash_to_versionderives a stable short string from the raw bytes. Terminals can cache a pool by version and skip re-upload when the label matches — change the pool, change the label. - Object pointers and indirection. An
ObjectPointerrenders whatever object itsvaluenames, so you can swap a whole sub-tree at runtime by repointing one object instead of editing many. The version-6 external-object types extend this across working sets. - Language and localisation. String content lives in String Variable objects and inline output-string values, separate from layout, so a pool can present different text per language without changing its structure. Font Attributes and the colour-map/palette objects keep styling out of the layout objects too.
- Containers for show/hide. Grouping objects under a Container lets the implement hide or move a whole region with one command, which is cheaper than touching each child.
Validate locally
make run EXAMPLE=iop_parser_demo
make test
The iop_parser_demo example synthesizes a tiny IOP buffer, confirms it
validates, parses it object by object, and prints the derived version string:
#![allow(unused)]
fn main() {
{{#include ../../../examples/iop_parser_demo.rs:12:36}}
}
make test exercises the full vt::objects suite — body encode/decode round
trips, the duplicate-ID guard, graph validation, and serialize/deserialize — plus
the IOP parser’s strict-rejection property tests.
What this proves / does not prove
Proves: machbus models every declared VT object type, enforces ID uniqueness
and typed references, encodes and decodes bodies losslessly, and rejects
malformed pools — both as raw IOP bytes and as a structured object graph —
before they would ever reach a terminal.
Does not prove: that a given terminal renders your pool the way you expect, that
your pool fits a particular terminal’s size and colour limits, or any
conformance or certification claim. machbus is not certified; real deployment
still needs official standards, real terminal hardware, and interoperability
evidence.
See also
- Virtual Terminal concepts — what a terminal is and how it hosts many implements.
- Virtual Terminal client — uploading a pool and handling terminal events.
- VT updates — changing values at runtime through variables and commands.
- VT auxiliary capabilities — pairing aux objects to physical controls.
- VT upload problems — when a pool is rejected.
VT updates
Once your object pool is live on the terminal, you do not re-upload it to change
what the operator sees. You send small runtime commands that mutate
individual objects in place: bump a numeric value, replace a label, hide a
widget, switch to a different screen. This tutorial explains that command
surface, how each command targets an object, how the terminal answers, and how
machbus lets you batch updates so you do not drown the bus in traffic.
It assumes the pool is already uploaded and the client has reached the connected state. If you are not there yet, read Virtual terminal client first — uploading is a one-time event; everything on this page happens afterward, repeatedly, for the life of the connection.
Why this exists
A pool upload is expensive: it streams every object across a transport-protocol session and the terminal validates and lays it out. You do that once. But a working machine changes constantly — a tank level falls, a speed readout ticks, an alarm fires, the operator pages to a settings screen. Re-sending the whole pool for each of those would saturate a shared CAN bus and stall every other control function on it.
So ISO 11783-6 (Virtual Terminal) splits the job. The pool defines the structure and identity of every object. A separate, compact family of ECU-to-VT commands carries runtime changes, each one naming a single object by its Object ID and saying what to do to it. The terminal already holds the object; the command just nudges it.
Mental model
┌─────────────── your implement ECU (Working Set) ───────────────┐
│ application logic: "tank is now 73%, show the run mask" │
│ │ │
│ ▼ │
│ build a command targeting an Object ID │
│ change_numeric_value(level_id, 73) │
│ change_active_mask(ws_id, run_mask_id) │
└────────────┬───────────────────────────────────────────────────┘
│ one short ECU→VT frame per command
▼
┌───────────────┐ applies change, redraws the object
│ VT terminal │ ───────────────────────────────────► screen
└───────┬───────┘
│ response frame: success, or an error code
▼
your error handler / state tracker
The pool is the noun store; commands are the verbs. Each verb references one noun by ID, the terminal performs it and (for most commands) answers with a response that is either “done” or a coded rejection.
Anatomy: the command families
machbus names the wire function codes in cmd (see
src/isobus/vt/commands.rs) and exposes one builder method per command on
VTClient. Each builder returns a ClientOutbound — a ready-to-send frame on
the ECU-to-VT PGN, addressed to the terminal — that you hand to your transport.
The runtime command set groups into a few intentions:
| Intention | cmd code | VTClient method | What it changes |
|---|---|---|---|
| Change numeric value | CHANGE_NUMERIC_VALUE | change_numeric_value(id, value) | The numeric value held by an output number, meter, bar graph, or similar. |
| Change string value | CHANGE_STRING_VALUE | change_string_value(id, &str) | The text of an output/input string object. |
| Hide / show | HIDE_SHOW | hide_show(id, visible) | Whether a container (and its children) is drawn. |
| Enable / disable | ENABLE_DISABLE | enable_disable(id, enabled) | Whether an input object accepts operator interaction. |
| Change active mask | CHANGE_ACTIVE_MASK | change_active_mask(ws_id, mask_id) | Which Data or Alarm Mask is the active screen for a working set. |
| Change soft-key mask | CHANGE_SOFT_KEY_MASK | change_soft_key_mask(data_mask_id, sk_mask_id), change_alarm_soft_key_mask(alarm_mask_id, sk_mask_id) | Which soft-key bank is shown alongside a Data Mask or Alarm Mask. |
| Select colour map / palette | SELECT_COLOUR_MAP | select_colour_map(id), select_colour_palette(id) | Which Colour Map or Colour Palette remaps VT colour indexes. |
| Change attribute | CHANGE_ATTRIBUTE | change_attribute(id, attribute_id, value) | A single addressable attribute of any object that exposes one. |
| Change list item | CHANGE_LIST_ITEM | change_list_item(list_id, index, new_item_id) | Which child object occupies a slot in a list. |
| Change child location | CHANGE_CHILD_LOCATION | change_child_location(parent, child, dx, dy) | A child’s position by a relative offset within its parent. |
| Change child position | CHANGE_CHILD_POSITION | change_child_position(parent, child, x, y) | A child’s absolute position within its parent. |
| Change size | CHANGE_SIZE | change_size(id, width, height) | An object’s width and height. |
| Change background colour | CHANGE_BACKGROUND_COLOUR | change_background_colour(id, colour) | An object’s background colour index. |
| Select input object | SELECT_INPUT_OBJECT_COMMAND | select_input_object(id, option) | 0xFF focuses an input field/Button/Key or clears focus for NULL; 0 opens an input field for data input. |
| Lock / unlock mask | LOCK_UNLOCK_MASK | lock_unlock_mask(mask_id, lock, timeout_ms) | Freeze a mask’s display so a burst of updates lands atomically. |
The “change attribute” command is the general escape hatch: any object attribute that carries an attribute ID and is not read-only can be set with it, which covers font, line, fill, visibility flags, and colours beyond the dedicated commands above. The dedicated commands exist because the standard groups commonly-changed attributes into one compact message for efficiency — for example, font properties travel together rather than as several separate attribute writes.
How a command references its target
Every command carries the Object ID of the thing it acts on, encoded
little-endian. In machbus that ID is ObjectID, and the builder methods accept
anything that converts into one (impl Into<ObjectID>), so a bare integer
literal works:
#![allow(unused)]
fn main() {
// illustrative shape, not a compiled call
let frame = client.change_numeric_value(0xCAFE, 73)?; // 0xCAFE → ObjectID
let frame = client.hide_show(detail_panel_id, false)?; // hide the panel
}
A handful of commands name two objects. change_active_mask names the
working set and the mask to make active. The standard
CHANGE_SOFT_KEY_MASK frame names a Data Mask or Alarm Mask, selected by
its Mask Type byte, plus the soft-key mask to pair with it; the
VTClient::change_soft_key_mask convenience method emits the Data Mask variant
and VTClient::change_alarm_soft_key_mask emits the Alarm Mask variant.
change_list_item,
change_child_location, and change_child_position name a parent plus the
child inside it. The terminal already knows the object owner from the source
address of your frame, so commands never repeat that.
The response and error model
Most commands have a matching VT response. The pattern is uniform: you send the command, the terminal applies it (or refuses), and answers on the VT-to-ECU PGN with the same function code plus an error indication. A zero error means the change took effect; a non-zero code is a rejection that tells you why. Typical rejections are an unknown Object ID, a value outside the object’s allowed range, a type mismatch, or a command sent against an object whose mask is not in a state to accept it.
Two consequences shape how you write the client:
- A sent command is not a confirmed change. Until the response arrives the terminal’s view and your internal value may differ. This is why the update helper only updates its cached state once you confirm a send succeeded, rather than optimistically the moment you build the frame.
- Operator input races your commands. The operator may be editing the very object you are updating. The standard makes the working set responsible for validating what comes back and for avoiding changes to an object that is open for input where doing so would disrupt the interaction. Newer terminals accept attribute changes even on an in-use object and may cache them until the edit finishes; older deprecated behavior returned an “object in use” rejection. Do not assume either — handle the response code you actually get.
Doing it with machbus
Sending a single command
After the client reports connected, every command builder is callable. They all
guard on connection state and return Err if you call them too early. The VT
client demo connects a client and then sends one update:
#![allow(unused)]
fn main() {
{{#include ../../../examples/vt_client_demo.rs:77:85}}
}
change_numeric_value returns a ClientOutbound { pgn, dest, data } — the
terminal’s address in dest, the encoded command in data. You transmit that
through whatever link the rest of your stack uses. The same shape applies to
every builder in the table above.
Switching screens
Changing the active mask is the command behind “go to the run screen” or “open settings”. It names the working set whose screen changes and the mask to show:
#![allow(unused)]
fn main() {
// illustrative shape
let frame = client.change_active_mask(working_set_id, run_mask_id)?;
}
The terminal answers with a change-active-mask response. Note that the active mask the terminal reports back is the authoritative one — the helper below does not pre-cache an active-mask change, precisely because the terminal echoes the real active mask in its own status, and that is what your state tracker should believe.
The VtBatch helper: coalescing and deduplicating
If your control loop runs at, say, 50 Hz and naively sends every value it touches
each tick, you will flood the bus with redundant frames — many of them setting a
value to what it already is. machbus gives you VTClientUpdateHelper (in
src/isobus/vt/update_helper.rs) to stop that at the source. It does two things:
- Deduplicates against cached state. The helper borrows a
VTClientStateTracker. When you callset_numeric_value(id, v)and the tracker already hasvfor that object, it returnsNone— no frame is produced. The same short-circuit applies to strings, visibility, enable state, and the active mask. - Coalesces a batch to last-write-wins. Between
begin_batch()andend_batch(), setters queue ops keyed by slot — the (kind, Object ID) pair. Writing the same slot twice keeps only the last value. Writing a value and then reverting it to what the tracker already holds removes the pending op entirely.end_batch()drains the deduplicatedVec<UpdateOp>for you to send.
The setters return an UpdateOp (or queue it, in batch mode). To turn one into a
frame, call op.to_client_outbound(&client), which maps it to the canonical
command for that kind. After a send succeeds, call helper.confirm(&op) so the
tracker’s cache reflects the new value and future identical writes short-circuit.
#![allow(unused)]
fn main() {
// illustrative shape of a batch
helper.begin_batch();
helper.set_numeric_value(speed_id, 1200); // queued
helper.set_numeric_value(speed_id, 1250); // coalesces: last value wins
helper.set_string_value(label_id, "READY"); // queued
helper.hide(spinner_id); // queued
for op in helper.end_batch() { // drains 3 ops, not 4
let frame = op.to_client_outbound(&client)?;
// ...transmit frame...
helper.confirm(&op);
}
}
Convenience wrappers sit on top of the bare setters: show/hide,
enable/disable, set_numeric_clamped to pin a value into a range, and
set_numeric_scaled / try_set_numeric_scaled to apply a (value + offset) * scale conversion. The fallible try_* forms reject non-finite input or a
scaled result outside the u32 wire domain instead of silently saturating; the
plain forms drop such input as None for compatibility. try_set_string_value
likewise rejects a string longer than the two-byte length field before it can
enter a batch.
If you attach a pool with with_pool(&pool), change_active_mask validates that
the target ID exists and is a Data or Alarm Mask, returning Err otherwise — a
cheap guard against switching to a non-mask object.
Rate and throttling considerations
A CAN bus and the terminal both have finite bandwidth, shared across every working set using the terminal. ISO 11783-6 recommends sending a command only when the visible data actually changed, and reducing or stopping updates for a working set whose mask is not currently shown. Practical rules:
- Update only what is on screen. There is no point streaming a gauge that lives on a mask the operator is not looking at — but you may keep an off-screen mask’s values current so it is ready when activated.
- Let the helper’s deduplication do the throttling. If a value has not changed, no frame is sent, which naturally collapses a fast loop to the rate of real change.
- The numeric-value command in particular is rate-limited by the standard; do not exceed the permitted update frequency for a single object.
- Batch related updates and, when several must land together visually, consider
lock_unlock_maskso the operator does not see a half-updated screen.
Events and responsibilities
Your application owns both directions of this exchange:
| Event | Source | Your responsibility |
|---|---|---|
| Command response (success) | VT | Treat the change as applied; confirm the op so the tracker caches it. |
| Command response (error code) | VT | Decode the rejection; correct the value, ID, or timing and retry as appropriate. |
| Operator input (key, button, numeric/string change) | VT | Update your internal model; you may have raced a command you sent. |
| Active-mask change notification | VT | Trust the terminal’s reported active mask over any local guess. |
The one discipline that prevents most surprises: do not assume a command
succeeded. Wait for the response, and key your cached state off confirmed sends,
which is exactly the contract confirm encodes.
Edge cases and failures
- Updating before connected. Every command builder returns
Errif the client is not connected. Reaching the connected state is a precondition, not a best effort. - Unknown Object ID. The terminal rejects a command naming an object that is not in its copy of the pool. With a pool attached, the helper catches the active-mask case locally; other commands surface it as a response error.
- Value out of range or wrong type. A numeric value the object cannot hold,
or a string longer than the length field, is rejected. The
try_*helpers catch the encodable-range problems before they ever reach the bus. - Flooding the bus. Naive per-tick sends starve other nodes. Deduplicate, batch, and gate on real change.
- Racing the operator. A command and an operator edit can cross in flight. Validate what comes back; avoid mutating an object that is open for input unless you are sure the change is harmless.
Advanced
- Batching strategy. Group a frame’s worth of related changes per control cycle, drain once, send, confirm. The slot-keyed coalescing guarantees one frame per object per batch regardless of how many times your code touched it.
- Partial updates. Keep an off-screen mask’s values current with cheap numeric/string updates so activating it is instant, while suppressing redraw churn for objects nobody is viewing.
- Animation and metering. Drive a bar graph or meter by repeatedly changing its numeric value; the helper’s dedup means a held value costs nothing, so you only pay for actual motion. Respect the per-object numeric update-rate limit.
- Atomic screens. When several objects must change together without an intermediate flicker, lock the mask, push the batch, then unlock — the timeout argument bounds how long the freeze can last if you never unlock.
- Surface vs low-level. The helper centralizes the
UpdateOp→ wire-command mapping and the dedup logic; the rawVTClientbuilders give you every command directly when you need one the helper does not wrap.
Validate locally
make run EXAMPLE=vt_client_demo
make test
The demo walks a client to connected and then sends a single
change_numeric_value, printing the resulting frame’s PGN, destination, and
length. The update-helper module’s own tests assert the dedup-and-coalesce
behavior end to end, including last-write-wins, revert-to-cached removal, and the
canonical command bytes each UpdateOp produces.
What this proves / does not prove
Proves: the command builders encode the documented ECU-to-VT layouts, the helper deduplicates and coalesces updates as specified, and the connection guard rejects commands sent too early — all in software.
Does not prove: how a specific real terminal renders or rate-limits these
commands, interoperability with any particular VT, or any conformance or
certification claim. machbus is not certified; real deployment still needs the
official standards, real hardware, and interoperability evidence.
See also
- Virtual terminal client — getting to the connected state these commands require.
- VT object pools — the objects and IDs your commands target.
- Virtual terminal concepts — the conceptual primer on masks, working sets, and the terminal model.
VT auxiliary control (AUX-N)
Auxiliary control lets a physical input device — a joystick, a multi-function
lever, a bank of switches — drive implement functions through the virtual
terminal. The classic example is a lever on the armrest mapped to “raise/lower
the hitch”: the operator moves the lever, the input device reports the new
state, the VT routes it to the implement, and the implement actuates. This is
the AUX-N workflow defined in ISO 11783-6 (Virtual Terminal), and this page
explains the model, the capability exchange, the assignment that binds an input
to a function, and how to drive the pieces with machbus.
Read VT concepts first: auxiliary control sits on top of an active VT session, an object pool, and a working set. Everything here assumes the implement has already connected to a VT.
Why this exists
An implement has functions an operator wants under their fingers — lift, fold, section on/off, flow rate — but the implement has no buttons of its own. It borrows them. A separate auxiliary input device (joystick or control panel) offers a set of physical inputs, and the operator decides, through the VT’s configuration screen, which input drives which implement function. The VT is the broker that learns both sides, lets the operator pair them, remembers the pairing, and forwards live input changes to the implement.
This decouples controls from implements. One joystick can drive a planter today and a sprayer tomorrow; one implement can be operated from whatever input device happens to be in the cab. The price of that flexibility is a careful negotiation so that an input never gets wired to a function it cannot safely drive.
Mental model
auxiliary input device virtual terminal implement
(joystick / panel) (broker + memory) (working set)
────────────────── ──────────────── ──────────────
declares INPUTs ─pool─► holds both pools ◄─pool─ declares FUNCTIONs
(lever, switch...) + operator screen (lift, fold...)
operator pairs input ↔ function on the VT screen
│
▼
VT stores the assignment
│
lever moves ─input status─► VT routes to function ─function status─► implement acts
Two object pools meet at the VT. The input device’s pool contains auxiliary input objects; the implement’s pool contains auxiliary function objects. The operator (or a stored preferred assignment) pairs them. After that, every physical change on a bound input flows through the VT to the matching function.
Functions versus inputs
The single most important distinction in AUX-N is who provides what.
| Concept | Provided by | What it represents | machbus object body |
|---|---|---|---|
| Auxiliary function | the implement | a thing the operator can command (lift, fold, rate) | AuxFunction2Body (new style) / AuxFunctionBody (classic) |
| Auxiliary input | the input device | a physical control the operator can move | AuxInput2Body (new style) / AuxInputBody (classic) |
| Auxiliary control designator | either side | optional operator-facing label/icon for an aux object | AuxControlDesignatorBody |
An implement never owns inputs and an input device never owns functions. They
advertise their halves in their object pools, and the VT joins them. The
machbus object types live in isobus::vt::objects, and the matching object
type tags are ObjectType::AuxFunction2 (31) and ObjectType::AuxInput2 (32)
for the new style, with AuxFunction (29) and AuxInput (30) for the classic
style and AuxControlDesig (33) for designators.
The two styles
ISO 11783-6 defines an older auxiliary scheme and a newer one. machbus carries
both:
- New style (AUX-N / type 2). Function and input objects are
AuxFunction2Body/AuxInput2Body; live status rides onPGN_AUX_INPUT_TYPE2and the setpoint range is the full0..=65535. - Classic style. Function and input objects are
AuxFunctionBody/AuxInputBody; live status rides onPGN_AUX_INPUT_STATUSwith a setpoint range of0..=10000(0.0–100.0%).
New designs should use the new style. The classic types remain so machbus can
talk to older devices still on the bus.
Anatomy: function and input types
Both a function and an input carry a type that classifies its behavior. The
type is what makes matching possible. machbus models it as AuxFunctionType
in isobus::auxiliary:
| Type | AuxFunctionType | Behavior |
|---|---|---|
| 0 | Type0 | Boolean on/off (a latched or momentary switch). |
| 1 | Type1 | Variable speed (analog, e.g. a proportional lever). |
| 2 | Type2 | Variable position (analog, absolute position). |
A function’s live value is reported with an AuxFunctionState —
Off, On, or Variable — alongside a 16-bit setpoint. machbus derives the
state from the type and the setpoint with auxiliary::derive_state: a boolean
(Type0) is On for any non-zero setpoint and Off otherwise, while the analog
types are always Variable. That keeps the reported state consistent with the
value the device actually measured.
Capability exchange: who advertises what
Before any pairing, each side has to know what the other offers, and the
application has to know what the VT itself supports. machbus gives you a small
pump-style helper, AuxCapabilityDiscovery in isobus::vt::auxiliary_caps, that
asks a VT which auxiliary objects it can handle.
The flow is request/response over the VT command channel:
- You call
request_capabilities(). It returns the 8-byte Get Supported Objects request payload to send onPGN_ECU_TO_VT, naming the new-style auxiliary object types in the request, and marks a request as pending. - The VT replies on
PGN_VT_TO_ECU. You hand the inboundMessagetohandle_response(). - On a well-formed reply, the helper returns the populated
AuxCapabilitiesand clears the pending flag. Each entry is anAuxChannelCapabilitycarryingchannel_id,aux_type(0 boolean / 1 analog / 2 bidirectional),resolution(step count for analog channels), andfunction_type.
A second request_capabilities() while one is already in flight returns an
error, so you cannot accidentally overlap two discoveries.
Assignment workflow
Discovery tells you what is possible; assignment is what is chosen. The binding of one input to one function is an assignment, and a remembered default pairing is a preferred assignment.
implement pool loaded ──► functions visible to VT
input device pool loaded ─► inputs visible to VT
│
operator opens the VT aux config screen
│
picks: input ◄───► function (must be type-compatible)
│
VT records the assignment ──► confirms to both sides
│
input moves ──► VT forwards value ──► function acts on implement
The lifecycle of a single binding:
- Advertise. Both pools are uploaded; the VT now holds the function and input objects with their types.
- Propose. A preferred assignment may be offered at connect time so a known joystick comes up already wired the way the operator left it. If none applies, the operator pairs manually.
- Validate. The VT checks the input type against the function type. If they are not compatible, the assignment is refused.
- Confirm. A valid assignment is stored and acknowledged to both the implement and the input device, so each knows the binding is live.
- Operate. Live input changes are forwarded as function status. For the new
style this is
PGN_AUX_INPUT_TYPE2; classic usesPGN_AUX_INPUT_STATUS. - Release. The operator can clear an assignment, or it falls away when a pool is unloaded or the session ends.
How assignments are stored, recalled, and confirmed
An assignment is keyed by identity, not by position. A function is identified by the implement’s NAME plus the function’s object, and an input by the input device’s NAME plus the input’s object. That is why a preferred assignment survives a power cycle: when the same NAMEs reappear, the VT recognises the pair and restores the binding. Recall is the VT re-applying a stored preferred assignment; confirmation is the VT telling both sides the binding now holds.
Matching rules
A function can only be driven by a compatible input. The type field is the gate:
- A boolean function (
Type0) needs a boolean input. A latched/momentary switch can raise or lower a hitch; an analog lever cannot pretend to be a clean on/off. - An analog function (
Type1variable speed orType2variable position) needs an analog input that can deliver a value across its range. - A bidirectional input (
aux_type == 2in the capability descriptor) can serve controls that need both directions from one physical axis.
machbus enforces the value side of these rules in its object bodies: encoding
or decoding an AuxFunction2Body, AuxInputBody, AuxInput2Body, or the classic
AuxFunctionBody rejects any type outside 0..=2, and AuxInput2Body rejects an
input_status outside 0..=3. A reserved or out-of-range type never makes it
onto the wire, so an obviously incompatible object is caught before it can be
assigned. The higher-level “does this input suit this function” decision is the
VT operator’s to make on the configuration screen.
Doing it with machbus
The auxiliary types are deliberately small, composable pieces. You assemble the objects in your pool, run capability discovery against the live VT, and then encode/decode live status frames.
Declare an implement function (new style) as an object body:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled example.
use machbus::isobus::vt::objects::AuxFunction2Body;
let lift = AuxFunction2Body {
function_type: 0, // boolean on/off
function_attributes: 0,
name: name_object_id, // a String/label object in the pool
icon: icon_object_id, // a Picture/icon object in the pool
};
let bytes = lift.encode()?; // rejects function_type > 2
}
Discover what the VT supports before you rely on a binding:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled example.
use machbus::isobus::vt::auxiliary_caps::AuxCapabilityDiscovery;
let mut discovery = AuxCapabilityDiscovery::new();
let request = discovery.request_capabilities()?; // 8-byte payload for PGN_ECU_TO_VT
// ... send `request`, then feed the VT's reply back in ...
if let Some(caps) = discovery.handle_response(&incoming_msg) {
for ch in &caps.channels {
// ch.channel_id, ch.aux_type, ch.resolution, ch.function_type
}
}
}
Build and read a live function status frame with the auxiliary helpers:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled example.
use machbus::isobus::auxiliary::{AuxNFunction, AuxFunctionType};
// Lever at half travel on a variable-speed function:
let frame = AuxNFunction::with_setpoint(7, AuxFunctionType::Type1, 0x8000);
let bytes = frame.encode(); // 8 bytes for PGN_AUX_INPUT_TYPE2
// Decoding an inbound status frame:
if let Some(status) = AuxNFunction::decode(&incoming_msg) {
// status.function_number, status.r#type, status.state, status.setpoint
}
}
with_setpoint derives the state for you via derive_state, so a boolean
function reports On/Off and an analog one reports Variable without you
having to keep the two fields in sync. The classic style is the same shape with
AuxOFunction over PGN_AUX_INPUT_STATUS.
Events and responsibilities
| Event | Who acts | Responsibility |
|---|---|---|
| Capability response arrives | implement / input app | Decode with handle_response; cache only for this session. |
| Assignment confirmed | both sides | Treat the binding as live; begin forwarding/acting. |
| Input value change | input device | Send a status frame for the bound input. |
| Function status received | implement | Actuate to the new state/setpoint, or hold safe-state. |
| Assignment cleared / pool unloaded | both sides | Drop the binding; stop acting on the stale input. |
The application owns the safety decision. The stack moves the bytes; deciding whether a received setpoint is safe to apply right now is yours.
Edge cases and failures
- Incompatible types. A boolean input cannot drive an analog function and
vice versa.
machbusrefuses reserved/out-of-range type and status values at encode/decode time; the VT refuses the pairing at the screen. - Lost assignment. If a pool is unloaded, the session drops, or the operator clears the binding, the function is no longer driven. The implement must fall back to its safe-state, not freeze on the last value it saw.
- Multiple input devices. More than one joystick may be present. Each input is identified by its device’s NAME, so assignments stay unambiguous, but the operator must not bind two inputs to the same function in a way that fights.
- Latched versus momentary inputs. A momentary input returns to off when released; a latched input holds its state. For a hitch this matters: a momentary “raise” stops when the operator lets go, while a latched switch keeps commanding raise until toggled. Choose the input type that matches how the function should behave when the operator stops touching it.
- Stale or truncated capability responses.
handle_responsereturnsNonefor a wrong command byte, wrong sub-function, a truncated channel list, or trailing bytes, and it leaves the request pending so a later valid reply still lands. Never treat aNoneas “no capabilities”.
Advanced
- Preferred assignment persistence. Because assignments are keyed by NAME and object identity, a VT can store a preferred set and restore it when the same devices reappear. Design your object identifiers to be stable across power cycles so the operator’s choices survive.
- Safe-state on aux loss. Build the implement so that losing the binding forces functions to a defined, safe value. Tie this into the same safe-state logic used for VT loss; see Shortcut button and safe state.
- Bidirectional inputs. A capability channel with
aux_type == 2advertises a control that delivers both directions on one axis. Match it only to functions that genuinely need both directions. - Classic interoperability. Keep the classic
AuxFunctionBody/AuxInputBody/AuxOFunctionpath available if you must talk to older devices; the new-style and classic frames travel on different PGNs and do not interfere.
Validate locally
make test
The stack tests cover capability discovery against a virtual bus, including the
malformed-response filtering described above (wrong command byte, wrong
sub-function, truncated channel list, trailing bytes), and the auxiliary object
bodies and status frames round-trip in the unit tests under
src/isobus/auxiliary.rs and src/isobus/vt/objects.rs.
What this proves / does not prove
Proves: machbus can build and parse the auxiliary function/input objects, build
a Get Supported Objects request, decode well-formed capability responses while
rejecting malformed ones, and round-trip live AUX-N and classic status frames in
software.
Does not prove: that a particular joystick and a particular implement will pair and operate correctly on real hardware, or any conformance/certification claim. Real deployment still needs official standards, real hardware, and interoperability evidence.
See also
- Virtual terminal concepts — the session, pool, and working-set model auxiliary control builds on.
- Virtual terminal server — the VT side that brokers and stores assignments.
- Virtual terminal client — the implement side that uploads function objects and acts on forwarded status.
- Shortcut button and safe state — what to do when control is lost.
Task Controller client
A Task Controller (TC) client is the implement side of documented work. The
implement — a sprayer, a seeder, a spreader — joins the bus, finds the Task
Controller, tells it what it is and what it can measure or control by uploading
a device description, and then trades process data with the TC for as
long as the task runs: it reports measured values up, and it accepts setpoints
coming down. This page explains that conversation end to end and shows how to
drive it with machbus through isobus::tc::TaskControllerClient and the
TcClient session plugin.
If you have read Task Controller concepts and DDOP and process data, this is where those ideas become code. The TC server side is covered in Task Controller server.
Why this exists
A TC running a prescription needs to know the shape of every implement it controls: how many booms and sections it has, which quantities it can report, which it can be commanded to change, and the units and resolution of each. There is no point sending a “set application rate” command to a device that cannot report or act on a rate. So before any real work happens, the implement publishes a self-describing model of itself — the Device Descriptor Object Pool, or DDOP — and the TC keeps it. From then on, the two sides speak in compact process-data messages keyed by an element number and a data dictionary identifier (DDI), instead of re-sending the structure each time.
This is the same separation you see throughout ISOBUS: a one-time description upload, then a long stream of small data messages against that description.
Mental model
implement (TC client) Task Controller (server)
│ │
│ listen for the TC's periodic status ◄────────┤ TC status
│ announce "I am a working set" ──────────────►
│ ask "what version do you speak?" ─────────────►
│ ◄──────────── version + capabilities
│ "do you already have my DDOP?" ─────────────► (compare by label)
│ ◄──────────── structure / loc. label
│ ┌── label matches ──► skip upload, just activate
│ └── no match / none ──► upload DDOP, then activate
│ upload DDOP (if needed) ──────────────────────►
│ ◄──────────── pool accepted / rejected
│ activate pool ────────────────────────────────►
│ ◄──────────── activated → CONNECTED
│ │
│ report measured value ────────────────────────► (task running)
│ ◄──────────── request value / setpoint
│ acknowledge / respond ────────────────────────►
The whole thing is event-driven and pump-style. The client never blocks: you
feed it inbound TC frames, you call update, and it hands you the outbound
frames to ship. The session does both halves for you on each driver.poll()?.
Anatomy: the client and its pieces
TaskControllerClient::new takes a TCClientConfig (its one knob is
timeout_ms, the wait budget for each handshake reply; the default is six
seconds). You hand the client a built DDOP with set_ddop, and you read it
back with ddop().
The client advertises what it can do through TCClientCapabilities: a protocol
version, a max_boot_time, an options bitmask, and counts of booms,
sections, and channels. These are answered when the TC asks the client for
its capabilities; the defaults describe a version-4 client with no extra options.
Outbound work leaves the client as TCClientOutbound records — a PGN, a data
payload, and an optional destination address (None means broadcast). You never
build CAN frames yourself: the client either broadcasts (the working-set
announcement) or addresses the TC it discovered.
Two callbacks connect the client to your application logic:
| Callback | Registered with | Fires when | You return |
|---|---|---|---|
| Value request | on_value_request | the TC asks for a current measured value | the i32 value, or an Err to stay silent |
| Value command | on_value_command | the TC sends a setpoint to your device | Ok(()) if applied, Err to signal it could not be |
Both callbacks receive an ElementNumber and a DDI so you know which part of
your device and which quantity is being addressed.
Lifecycle and state machine
The client walks a single FSM, exposed as TCState and read with state().
Every transition raises on_state_change. The states below are the ones you will
observe in order:
| State | What is happening |
|---|---|
Disconnected | Idle. Nothing attempted, or a fault returned here. |
WaitForServerStatus | connect() succeeded; waiting to hear a TC announce itself. |
SendWorkingSetMaster | A TC was heard; about to announce this working set. |
RequestVersion / WaitForVersion | Asking the TC its version and capabilities. |
RequestStructureLabel / WaitForStructureLabel | Asking whether the TC already stores this DDOP’s structure. |
RequestLocalizationLabel / WaitForLocalizationLabel | Same check for the localization (language/units) label. |
TransferDDOP / WaitForPoolResponse | Uploading the DDOP and waiting for accept/reject. |
ActivatePool / WaitForActivation | Activating the stored pool. |
Connected | The pool is active; process data flows. |
DeactivatePool / DeletePool and their waits | Tearing down the old pool during a re-upload. |
The driving rules:
- Discover.
connect()first validates the DDOP, then moves toWaitForServerStatus. The TC broadcasts a periodic status; the first one received bindstc_address()and advances toSendWorkingSetMaster. - Announce.
updateships the Working Set Master announcement (member count one) so the TC knows which working set owns the upcoming pool, then asks for the TC’s version. - Version handshake. The version reply carries the TC’s protocol version and
its boom/section counts. The client records
tc_version()and proceeds. - Label check (the “do I need to upload?” decision). The client asks the TC
for the structure label of any DDOP it already stores for this client.
An all-
0xFFlabel means the TC has nothing → go straight to upload. A label that matches this DDOP’s structure → check the localization label next, and if that also matches, jump straight to activation. A label that does not match → delete the stale pool first, then upload. - Upload, then activate.
TransferDDOPserializes the DDOP and sends it as an object-pool transfer. A success response advances to activation; a failure returns toDisconnected. The activate command then lands the client inConnected, and only now should process data be emitted.
Every WaitFor* state is bounded by timeout_ms. If a reply does not arrive in
time, update drops the client back to Disconnected rather than hanging.
Process-data exchange in practice
Once Connected, the conversation is entirely process data. Each message names
an element (a part of your device) and a DDI (a quantity from the data
dictionary), plus a 32-bit value where one applies.
Reporting measured values. Build an ECU→TC value frame with
TaskControllerClient::build_value_command(element, ddi, value) and ship it. The
element number is carried in a 12-bit field, so values above
MAX_TC_PROCESS_DATA_ELEMENT_NUMBER are rejected at build time rather than
silently truncated.
Answering value requests. When the TC asks for a current reading, the client
calls your on_value_request callback with the element and DDI and packages
whatever i32 you return into the reply automatically. Return an Err and the
client stays silent for that request.
Receiving setpoints. When the TC sends a value to your device, the client
calls on_value_command with the element, DDI, and value. If the TC used the
“set and acknowledge” form, the client emits a process-data acknowledge for you,
reporting success or, on a callback Err, an error code such as
“no processing resources available.” If your device does not register a command
callback at all, the acknowledge reports that the element is not supported.
Triggers. A TC does not poll blindly; it tells the client when to report a variable. ISO 11783-10 defines trigger kinds — on a fixed time interval, on a travelled-distance interval, on crossing a value threshold, on every change, and on explicit request. The guidance for fast control data (such as section work state) is to send on change as the primary trigger with a slow time interval as a fall-back, and to respect the per-variable message-rate ceiling so you do not flood the bus. Your job as the client is to honour the triggers the TC set and to keep each request/response pair ordered.
Peer control (TC-controlled section/rate)
Beyond plain measure-and-command, a TC can wire one control function’s output
directly to another’s input — for example, letting a guidance or rate source
drive an implement’s sections. machbus models these routes with
PeerControlAssignment and a PeerControlInterface registry. An assignment
records a source (from(element, ddi) at a source address) and a destination
(to(element, ddi) at a destination address), and you add_assignment,
activate_assignment, and remove_assignment against the registry. Each route
encodes to an 8-byte process-data payload with try_encode and decodes inbound
ones with PeerControlAssignment::decode. Think of it as the TC delegating a
slice of control without routing every value through itself.
Doing it with machbus
For applications, use the session facade; drop to the codec when you need to own the pump.
The session facade (recommended)
Plug the TcClient plugin with your DDOP. It runs
the discover/announce/upload/activate FSM on each tick, ships the frames, and
emits Event::Tc(TcEvent::StateChanged(..)) as the connection advances:
#![allow(unused)]
fn main() {
// illustrative shape — the API mirrors the tested `session::plugins::TcClient`
use machbus::session::{Session, EndpointTransport, plugins::TcClient};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(TcClient::new(TCClientConfig::default(), ddop))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
ctrl.with_mut::<TcClient, _>(|tc| tc.connect())?; // arm the handshake
loop {
if let Some(Event::Tc(TcEvent::StateChanged(state))) = driver.poll()? {
// when `state` reaches TCState::Connected the DDOP is active
}
}
}
Read status with ctrl.with::<TcClient, _>(|tc| tc.is_connected()), and reach the
underlying TaskControllerClient for value/command callbacks via
with_mut::<TcClient>(|tc| tc.client_mut()).
Driving the codec directly
The whole connect handshake runs in examples/tc_client_demo.rs. Start by
building a small DDOP that names the device and one element:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:22:34}}
}
Then create the client, hand it the pool, and begin the connection. The first TC status the client hears binds the server address:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:36:51}}
}
From there the example pumps update to ship the working-set announcement and
version request, feeds the TC’s replies back with handle_tc_message, lets the
DDOP upload, and finishes on an activate response with the client in
TCState::Connected.
More on the session facade
The TcClient plugin wraps the same client. You arm the handshake with
ctrl.with_mut::<TcClient, _>(|tc| tc.connect()), read state() or
is_connected(), and read tc_address() after the handshake (the negotiated
tc_version() lives on the underlying client via client_mut()).
Inbound TC frames are routed and the FSM’s outbound frames are shipped
automatically on every driver.poll()?, so you never hand-pump update. To
register the value and command callbacks, reach the underlying client with
ctrl.with_mut::<TcClient, _>(|tc| tc.client_mut()). Drain TC state changes from
the session with ctrl.drain::<TcEvent>(), which yields TcEvent::StateChanged
as the FSM advances toward Connected.
Events and responsibilities
| Situation | What the client does | What you must do |
|---|---|---|
| TC asks for a value | Calls on_value_request | Return the current reading, or Err to decline |
| TC sends a setpoint | Calls on_value_command | Apply it; return Err if you cannot |
| TC requests “set and acknowledge” | Builds the acknowledge for you | Make the callback’s result truthful |
| FSM transitions | Raises on_state_change / TcEvent::StateChanged | Gate application logic on reaching Connected |
| TC stops announcing | Times out a WaitFor* state → Disconnected | Stop emitting process data; reconnect when it returns |
The one hard rule: do not emit process data until Connected. Reporting
values against a pool the TC has not activated is meaningless and the client’s
state guards exist precisely to stop it.
Edge cases and failure modes
- TC rejects the DDOP. A non-zero pool response means the upload was refused.
The client returns to
Disconnectedinstead of pretending the pool is live. Re-check the DDOP for duplicate object IDs or dangling references before retrying. - Version or capability mismatch. A malformed version reply (wrong fixed layout) is ignored and the client keeps waiting until the timeout. Make sure the TC you target speaks a version your client supports.
- Value out of range. Element numbers wider than the 12-bit wire field are
rejected when you build the payload, so a coding error surfaces as an
Errrather than a corrupted frame on the bus. - Frame from the wrong TC. Once a TC is bound, frames from any other source
address are refused. Use
try_handle_tc_messageif you want the explicit envelope error (InvalidPgn,InvalidAddress,InvalidState,InvalidData) rather than the silent-ignore wrapper. - Connection loss. If the TC goes quiet, the active
WaitFor*state times out toDisconnected. Treat that as “stop work” and re-runconnect()when the TC reappears.
Advanced
- Caching the DDOP by label. The structure- and localization-label check is the bandwidth optimisation that matters most. When the TC already stores a pool whose labels match the one you are about to send, the client skips the entire transfer and jumps to activation. Set stable labels on your device object so a TC that has seen your implement before does not re-download megabytes of pool.
- Re-uploading while connected.
reupload_ddop(pool)fromConnectedvalidates the new pool, then deactivates, deletes, re-uploads, and reactivates without re-discovering the TC. Use it when the implement’s description changes mid-session. - Multiple TCs and large pools. A client connects to a single TC at a time; the binding follows the first/preferred TC it locks onto, so do not fan one client across servers. A wide implement reports many element/DDI pairs — lean on the triggers the TC assigns rather than polling everything yourself, and keep request/response pairs ordered so you stay under the per-variable rate ceiling.
- Session facade vs the bare codec. The
TcClientplugin is right for applications: it routes inbound frames and ships outbound ones on each poll. The rawTaskControllerClientis right for tests and embedded loops where you own theupdate/handle_tc_messagecadence.
Validate locally
make run EXAMPLE=tc_client_demo
make test
The example drives the full connect handshake — status discovery, working-set
announcement, version exchange, DDOP upload, and activation — entirely in
software, and asserts the client lands in TCState::Connected. The test suite
covers the label-driven upload/skip/delete decision, the timeout-to-Disconnected
path, the re-upload sequence, and the value-request and setpoint callbacks.
What this proves / does not prove
Proves: the client’s connect FSM, the label-based upload decision, the
process-data callbacks, and the re-upload teardown behave correctly in software,
and the machbus API drives them as documented.
Does not prove: interoperability with a specific third-party Task Controller, real-hardware timing and bandwidth behaviour, or any conformance or certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- Task Controller concepts — the conceptual primer for tasks, DDOP, and process data.
- DDOP — building and validating the device description you upload.
- Task Controller server — the other side of this conversation.
- TC geo prescription — how a TC turns a map into the setpoints this client receives.
Task Controller server
A task controller server is the node on an ISOBUS that an implement reports
to. It advertises itself as a task controller, accepts implement clients, stores
the device descriptions those clients send, and then exchanges process data
with them — logging the values an implement measures and, when the work calls
for it, pushing setpoints back the other way. This tutorial covers why the role
exists, the server’s lifecycle, and how to build one with machbus at both the
low level (tc::server) and through the session facade.
The mirror image of this page is Task Controller client: that side is the implement; this side is the controller it talks to. Read both to see a full exchange. ISO 11783-10 (Task Controller) is the part of the standard this role comes from.
Why this exists
An implement — a sprayer, a planter, a spreader — knows a great deal about itself: how many sections it has, what it can measure, what it can be told to do. But it has no display, no task plan, and nowhere to write a log. The task controller fills those gaps. It is the node that holds the job to be done, records what actually happened, and issues the commands that make the implement act on a prescription.
For that to work the controller cannot hard-code knowledge of every implement ever built. Instead each implement describes itself in a structured device description (a DDOP — see Device descriptions), uploads it once, and from then on the two sides speak in terms of elements and DDIs (data dictionary identifiers) rather than vendor-specific messages. The server’s job is to receive that description, validate it, keep it, and use it as the shared vocabulary for everything that follows.
Mental model
implement (client) task controller (server)
│ │
│ <───── TC status / version ────── │ advertises capabilities
│ │
│ ──── working set / version req ──> │ registers the client
│ │
│ ───────── DDOP upload ──────────> │ validate, store (inactive)
│ <──────── pool response ───────── │
│ │
│ ───────── activate pool ────────> │ activate, build lookups
│ <──────── activate response ───── │
│ │
│ ═══════ process data both ways ══ │ request / value / setpoint
│ │
│ ──────── deactivate / delete ───> │ drop active state on command
The server is a passive responder for almost everything: the client drives the handshake, and the server answers. The two things the server originates on its own are the periodic status broadcast (so clients know a task controller is present) and any value requests or setpoints the application chooses to send once a client is active.
Anatomy: what the server tracks
In machbus the low-level type is tc::TaskControllerServer. It is configured
with a TCServerConfig and keeps a small amount of state per client.
| Piece | machbus type / field | What it holds |
|---|---|---|
| Identity | TCServerConfig::tc_number, tc_version | The TC number that distinguishes this controller, and the version it speaks. |
| Capacity | num_booms, num_sections, num_channels | The boom/section/channel dimensions advertised to clients. |
| Feature flags | server_options | A bitfield of supported features (documentation, section control, peer control, geo). |
| Per-client record | TCClientInfo | Address, stored DDOP, whether the pool is activated, last transfer, tracked client version. |
| Stored labels | structure_label, localization_label | Seven-byte labels that let a returning implement skip re-uploading. |
| Trigger runtime | MeasurementTriggerRuntime | Per-value logging triggers (time, distance, threshold, on-change). |
You build the configuration with consuming with_* setters and validate it
before the server goes live:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_server_demo.rs:13:24}}
}
TCServerConfig::validate rejects topologies the wire format cannot express — a
zero boom count, or a section count outside the representable range. Reject
invalid topology before you start advertising it, never after.
Capabilities and options
The server_options byte tells clients which features this controller actually
supports. machbus names the bits in tc::ServerOptions:
| Flag | Meaning |
|---|---|
SupportsDocumentation | The controller can log values for record-keeping (the documentation TC). |
SupportsTCGEOWithoutPositionBasedControl | Geo features without position-based section/rate control. |
SupportsTCGEOWithPositionBasedControl | Geo features with position-based control (prescription maps). |
SupportsPeerControlAssignment | One client may be wired to control another’s value. |
SupportsImplementSectionControl | The controller can drive implement section on/off. |
These OR together into the single byte you pass to with_options. Advertise
only what your application truly implements: a client that sees a flag set will
expect the behaviour behind it.
Lifecycle and state machine
The server moves through three states, exposed as tc::TCServerState:
| State | Meaning | What the server does |
|---|---|---|
Disconnected | Not running. | Nothing. update returns no status. |
WaitForClients | Started, advertising, no client yet. | Emits periodic TC status; waits for a first client. |
Active | At least one client registered. | Full process-data exchange; still broadcasts status. |
The transitions:
- Start.
start()moves the server fromDisconnectedtoWaitForClients. From hereupdate(dt)returns a status payload roughly every two seconds (TC_STATUS_INTERVAL_MS), which the caller broadcasts so clients discover a task controller is present. - A client appears. When the first client sends a working-set master frame
or a technical-capabilities request, the server registers it and moves to
Active. Registration creates aTCClientInfoand raiseson_client_connected. - Capability exchange. The client asks what the controller supports; the
server answers with version, options, and boom/section/channel counts. The
server may also ask the client for its version with
request_client_version, and records the reply (raisingon_client_version_received). - DDOP upload. The client transfers its device description. The server
deserializes and validates it, stores it on the client’s record as an
inactive pool, and replies with an object-pool response carrying an
error code (
tc::ObjectPoolErrorCodes). - Activation. The client requests activation;
activate_poolflips the stored pool to active if it holds at least one device, otherwise it answers with an activation error (tc::ObjectPoolActivationError) and raiseson_pool_activation_error. - Process data. With an active pool the two sides exchange values: the
server answers
RequestValue, receivesValue/SetValueAndAcknowledge, and may originate its own requests and setpoints. - Teardown. The client may deactivate or delete its pool; the server drops
the active state only on a well-formed command.
stop()returns the server toDisconnectedand clears all client records.
Doing it with machbus
There are two ways to run a TC server, and they suit different needs.
The session facade (recommended for applications)
Plug the TcServer plugin into a Session: it claims an address, ships the
periodic status for you, and routes inbound implement traffic into the server on
every driver.poll()?. You build it with the topology you want to advertise:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::TcServer};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(TcServer::new(
TCServerConfig::default()
.with_booms(2)
.with_sections(16)
.with_channels(4),
)?)
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
}
After the address claim settles, you start the server subsystem and let the session pump it:
#![allow(unused)]
fn main() {
ctrl.with_mut::<TcServer, _>(|tc| tc.server_mut().start())?;
// ... each loop iteration:
driver.poll()?;
}
The session with the TcServer plugin runs the claim, starts the server, and
ships the TC_STATUS frames a passive peer observes on PGN_TC_TO_ECU. You reach
the underlying server through ctrl.with_mut::<TcServer, _>(|tc| ...). The
plugin itself exposes server_mut(), which hands you the full
TaskControllerServer for start, stop, state, callbacks, and measurement
triggers.
The low-level server (for tests and embedded control)
tc::TaskControllerServer is a pump: you feed it inbound frames and ship what it
returns. The standalone tc_server_demo example uses it directly. The two entry
points are:
handle_client_message(&msg)— feed an inboundPGN_ECU_TO_TCframe and get back aVec<TCOutbound>to send. Malformed or unrelated frames are ignored.try_handle_client_message(&msg)— the same, but returns explicit errors for the wrong PGN, an invalid source address, an empty message, or a malformed fixed-size request. Use this when your dispatch needs to know why a frame was rejected.handle_working_set_master(&msg)/try_handle_working_set_master(&msg)— register a client from its working-set announcement and request its version.update(dt)— returns the periodic status payload when the cadence elapses, otherwiseNone.
Each TCOutbound carries the payload plus an optional destination: dest: None
means broadcast, Some(addr) means a directed reply. You install behaviour with
three callbacks:
| Callback | Fires on | Your job |
|---|---|---|
on_value_request(cb) | Client RequestValue | Return the current i32 value for (element, DDI). |
on_value_received(cb) | Client Value / SetValueAndAcknowledge | Accept the value; return a ProcessDataAcknowledgeErrorCodes. |
on_peer_control_assignment(cb) | Peer-control assignment | Accept or reject one value driving another. |
To originate traffic, the server exposes builders that return an 8-byte payload:
build_request_value, build_set_value, build_set_value_and_acknowledge, and
the measurement-command builders (build_time_interval_measurement_command and
its distance/threshold/change siblings). These all validate the element number
against the 12-bit wire field and return a Result.
Events and responsibilities
Whichever API you use, the server enforces protocol shape but leaves the meaning to you. Your responsibilities:
- Validate the DDOP. The server deserializes and validates on upload; you decide whether the described device fits the task at hand.
- Answer value requests truthfully.
on_value_requestmust return the real current value, or the controller logs nonsense. - Issue setpoints deliberately. A setpoint makes the implement act. Send one only when the task and the implement’s active pool support it.
- Log measured values. Wire measurement triggers to your application’s time base and odometry so the right values get sampled at the right moments.
The native events you can subscribe to are on_state_change,
on_client_connected, on_client_disconnected, on_client_version_received,
on_pool_activation_error, and on_peer_control_assignment_received. Through
the session the same information arrives as TcEvent values from
ctrl.drain::<TcEvent>().
Managing multiple clients and stored descriptions
A real bus can carry several implements. The server keeps one TCClientInfo per
source address in clients(), each with its own stored DDOP and activation
state, so two implements never share a description. A directed reply always goes
back to the address that asked.
The structure and localization labels are how a returning implement avoids
re-uploading. An implement first asks for the controller’s stored labels; if they
match the DDOP it already has, it can skip the upload entirely. The server hands
back whatever you configured with set_structure_label / set_localization_label,
and the all-0xFF label means “nothing stored”, which prompts the client to
upload. The server also treats a byte-for-byte repeat of an already-accepted
transfer as idempotent — an identical re-upload does not deactivate a pool that
is already live.
Measurement triggers
The documentation side of a task controller logs values over time. Rather than
polling, the server keeps a local trigger runtime per value — built with
MeasurementTriggerRuntime::new(dest, element, ddi) and the with_* setters for
time, distance, threshold, and on-change conditions. Register one with
configure_measurement_trigger. Then:
update_measurements(dt)advances time-based triggers and returns any dueRequestValueframes — drive it from the same tick that pumps the stack.record_measurement_distance(dest, mm)adds travelled distance and returns due distance-triggered requests — call it when your application has odometry or a GNSS-derived distance.
Threshold and on-change triggers fire as values arrive through on_value_received.
A malformed inbound value must never overwrite the last accepted value, so the
log stays trustworthy.
Edge cases and failures
- Unknown DDI. A request or value for a DDI the application does not handle
should be answered with the appropriate acknowledge error
(
ElementNotSupportedByThisDevice), not silently. The default when no value callback is installed is exactly that error. - Malformed DDOP. A pool that fails to deserialize or validate is rejected with an object-pool error and is not stored; the previous state is left untouched.
- Activation with no device. Activating an empty or never-uploaded pool
yields
ThereAreErrorsInTheDDOPand raiseson_pool_activation_error. - Unsupported version or feature. A client that asks for a feature whose flag
you did not advertise must not get it. Keep
server_optionshonest. - Client disconnects mid-task. If a client falls off the bus, its
TCClientInfostill holds its last DDOP and activation state. Decide whether to retain it (so the implement can resume) or drop it; the protocol does not force the choice. - Wrong PGN or bad source address.
try_handle_client_messagereturns an error for traffic that is notPGN_ECU_TO_TC, or that arrives from the null or broadcast address. The lenient wrapper drops these instead.
Advanced
- Documentation vs control TC. A documentation controller only logs; a
control controller also issues setpoints and section commands. The
server_optionsflags you set decide which one your node presents as. Many controllers do both. - Peer control. With
SupportsPeerControlAssignmentadvertised, one client’s value can be wired to drive another’s. The server validates the assignment and hands it to youron_peer_control_assignmentcallback, which accepts or rejects it. See Task Controller concepts for the model. - Geo and prescriptions. Position-based control — applying a prescription map as the machine moves — sits on top of the geo option flags and is covered in TC-GEO prescription.
- Persistence. The in-memory store lives only as long as the server. If you want a returning implement to skip re-upload across power cycles, persist the labels and DDOPs yourself and restore them before clients connect.
- Session facade vs the bare codec. The
TcServerplugin is right for applications: it claims, routes, and broadcasts for you. The bareTaskControllerServeris right for unit tests and tight embedded loops where you own every frame and millisecond.
Validate locally
make run EXAMPLE=tc_server_demo
make test
tc_server_demo registers a client, answers a value request, and prints a status
broadcast entirely in software. The session tests build the TcServer plugin on
a virtual bus, run the address claim, start the server, and count the status
frames a passive watcher observes.
What this proves / does not prove
Proves: the server’s handshake, DDOP storage and validation, activation logic, process-data callbacks, and measurement triggers behave as described in software, and the machbus API drives them correctly.
Does not prove: real-hardware timing, interoperability with a specific
third-party implement or FMIS, or any conformance or certification claim. Those
still require official standards, real hardware, and interoperability evidence.
machbus ships no certification.
See also
- Task Controller client — the implement side of this exchange.
- Device descriptions — the DDOP the client uploads and the server stores.
- Task Controller concepts — elements, DDIs, process data, and peer control.
- TC-GEO prescription — position-based control on top of the geo options.
DDOP — building a Device Description Object Pool
Before a Task Controller can log a single number from your implement or send it
a single rate command, the implement has to describe itself. That self
description is the Device Description Object Pool (DDOP): a typed, structured
inventory of what the machine is — its booms, sections, tanks and sensors —
and what measurable quantities each part produces or accepts. This tutorial
builds a DDOP from the ground up with machbus, explains every object kind, and
walks the serialize / validate / upload path the TC client uses.
If the VT object pool is how an implement draws itself on a terminal, the DDOP is how it explains itself to a controller. No task data flows until the TC has a DDOP it understands and has accepted.
Why this exists
A Task Controller is generic. It does not ship with a built-in model of your sprayer, spreader or seeder. It knows how to log values over time and position, how to total them, and how to push setpoints — but only against a description the implement provides. The implement is the authority on its own structure, so it hands the TC a machine-readable map: “I am a sprayer with twelve boom sections; section 3 sits 4.5 m to the left of my hitch; it reports an actual application rate in litres per hectare and accepts a setpoint rate.”
With that map, the TC can address process data by element and meaning instead of by raw application bytes. It can decide which sections are inside a treatment zone, scale a raw count into engineering units for display, and write a complete task log that a farm management system can read back later. The DDOP is the contract that makes all of that possible, and ISO 11783-10 (Task Controller) defines its object kinds and how they relate.
Mental model
A DDOP is a flat list of objects joined by identifier references into a tree. The tree always has one Device at the root, fans out into Device Elements for the physical or logical parts of the machine, and hangs Process Data and Property objects off those elements. Value Presentation objects sit to the side and are pointed at by process data or properties when a raw integer needs scaling into a human unit.
Device "Sprayer 600" (DVC — the machine)
│
├─ DeviceElement Device/root (DET — whole-machine node)
│ └─ DeviceElement Function "Boom" (DET — a logical assembly)
│ ├─ DeviceElement Section 1 (DET — addressable part)
│ │ ├─ DeviceProperty Y offset = -4500 mm (DPT, fixed)
│ │ ├─ DeviceProperty working width = 1000 (DPT, fixed)
│ │ └─ DeviceProcessData actual rate, DDI → (DPD, live)
│ │ └── Value Presentation (DVP)
│ ├─ DeviceElement Section 2 …
│ └─ DeviceElement Section N …
│
└─ (Value Presentations referenced by DDI-bearing objects)
Two things make the tree real: every object owns a unique object ID, and the links are nothing more than IDs stored in other objects. An element names its parent by ID and lists its children by ID; a process-data object names its value presentation by ID. Get the IDs right and the tree is well formed; get one wrong and the pool fails validation before it ever reaches the bus.
Anatomy: the five object kinds
machbus exposes exactly five DDOP object types from
isobus::tc, each a plain struct with consuming with_* setters. The first
byte of every serialized object is its kind, modelled by TCObjectType.
| Object | machbus type | Wire tag | What it carries |
|---|---|---|---|
| Device | DeviceObject | DVC | The machine itself: designator, software version, serial, structure + localization labels. |
| Device Element | DeviceElement | DET | A node in the tree: a type, an element number, a parent, and a child list. |
| Process Data | DeviceProcessData | DPD | A live measurable: a DDI, trigger methods, an optional presentation. |
| Property | DeviceProperty | DPT | A fixed value baked into the description: a DDI and a constant i32. |
| Value Presentation | DeviceValuePresentation | DVP | Scale, offset, decimals and a unit string for turning raw values into engineering units. |
Device (DeviceObject)
There is exactly one device at the root. Its designator is required — machbus
rejects an empty one — and it carries two seven-byte labels that drive caching
(covered below). You build it with with_designator, with_software_version,
with_serial_number, with_structure_label and with_localization_label.
Device Element (DeviceElement)
Elements are the skeleton. Each has a DeviceElementType that tells the TC what
role it plays. The variants are Device, Function, Bin, Section, Unit,
Connector and NavigationReference. The whole-machine root is usually a
Device element; a boom or sub-boom is a Function; an individually
controllable boom segment is a Section; a hitch reference point is a
Connector. An element names its parent_id and lists the IDs of its
child_objects — those children may be other elements (deeper structure) or the
Process Data and Property objects attached to it.
Process Data (DeviceProcessData)
This is the live wire. A process-data object says “this element can report or
accept this quantity,” named by its DDI. It also declares the
trigger_methods that say when the value should be logged, and may point at a
Value Presentation. Triggers are a bitmask you OR together via
with_trigger using TriggerMethod: TimeInterval, DistanceInterval,
ThresholdLimits, OnChange and Total.
Property (DeviceProperty)
A property is a fixed value that is part of the description rather than
something measured at runtime. Section geometry — X/Y/Z offsets and working
width — is expressed as properties, because those numbers do not change during a
task. The struct carries a DDI and a constant i32 value. The helpers read
these to reconstruct implement geometry.
Value Presentation (DeviceValuePresentation)
Raw DDI values are integers in defined base units. A presentation describes how
to display them: scale, offset, decimal_digits and a unit_designator
string. A process-data or property object references one by ID through
with_presentation; if it has none, the reference is the null ID 0xFFFF and no
scaling is implied. The scale must be finite — NaN or infinity is rejected at
serialize and validate time.
ElementNumber, DDI, and how a value becomes meaningful
Two fields turn an otherwise anonymous struct into a quantity the TC can act on.
The DDI (Data Dictionary Identifier) names what the value means and in what
unit. It is a 16-bit code from the ISO 11783-11 data dictionary, modelled as
DDI. One DDI may identify a volume-per-area setpoint rate; a different DDI may
identify an actual rate, a total volume, or a working width. The implement does
not invent meaning — it picks the DDI that matches the real quantity, and both
sides then agree on units and resolution. machbus ships named constants under
the ddi module (for example
ddi::SETPOINT_VOLUME_PER_AREA_APPLICATION_RATE, ddi::ACTUAL_WORKING_WIDTH,
ddi::DEVICE_ELEMENT_OFFSET_Y) so you reference quantities by name instead of
by magic number. Use the constant whose meaning matches your value; this page
deliberately does not reprint the dictionary.
The ElementNumber (ElementNumber) distinguishes which physical instance
a value belongs to. When twelve sections all report the same DDI, the element
number is what the TC uses to tell section 1’s rate from section 7’s. Keep
element numbers stable and matched to the real-world part — they appear in the
task log and in section-control addressing.
Together: the DDI says “application rate, litres per hectare,” the element number says “section 7,” and the value presentation says “raw count 7000 displays as 70.00 L/ha.” That triple is the whole point of the DDOP.
The builder workflow
A practical DDOP comes together in a predictable order:
- Create the root
DeviceObjectwith a designator and version. - Add a root
DeviceElement(typeDevice) and, beneath it,Functionelements for booms andSectionelements for each controllable segment. - For each section, add
DevicePropertyobjects for fixed geometry (offsets, width) using the offset/width DDIs, andDeviceProcessDataobjects for the live rates and counts it reports or accepts. - Add
DeviceValuePresentationobjects for any value that needs scaling, and point the relevant process-data/property objects at them. - Wire the tree: set each element’s
parent_idandchild_objectsso the IDs form one connected hierarchy. - Validate, serialize, upload, then activate through the TC client.
machbus gives you two ways to add objects. The fluent with_* methods on
DDOP (with_device, with_element, with_process_data, with_property,
with_value_presentation) build a pool by chaining and are ideal for static
descriptions. The fallible add_* methods (add_device, add_element, …)
return the assigned ObjectID so you can capture an ID and reference it from a
later object — useful when you are generating sections in a loop. If you leave an
object’s id at 0, the pool allocates the next free identifier for you via
next_id; supply an explicit with_id when you want stable IDs across runs.
Reading geometry and rates back
Once a DDOP exists, the ddop_helpers module turns its tree back into
application-friendly views. extract_geometry walks connector, boom and section
elements and reads the offset/width properties into an ImplementGeometry (with
SectionInfo and SubBoomInfo entries and a computed total_width_mm).
extract_rates and extract_totals filter process-data and property objects by
their DDI class into RateInfo records, marking which are editable process data
and which are fixed property constants. section_count and
find_parent_element round out the surface. These are the functions a TC-side
consumer uses to understand your implement; building your DDOP so they return
sane values is a good self-check.
Building a DDOP with machbus
The TC client demo builds a minimal pool — one device and one root element — and runs it through the full upload handshake. Here is the pool it builds:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:22:34}}
}
A real sprayer extends that skeleton. The shape below is illustrative (not a compiled snippet) and shows how sections, geometry properties, a live rate and a value presentation hang together by ID:
#![allow(unused)]
fn main() {
// illustrative shape — see the helper-module tests for compiled equivalents
let ddop = DDOP::default()
.with_device(DeviceObject::default().with_id(1).with_designator("Sprayer 600"))
// a value presentation: raw count → L/ha at 0.01 resolution
.with_value_presentation(
DeviceValuePresentation::default()
.with_id(30).with_scale(0.01).with_decimals(2).with_unit("L/ha"),
)
// section 1 geometry, expressed as fixed properties
.with_property(
DeviceProperty::default()
.with_id(60).with_ddi(ddi::DEVICE_ELEMENT_OFFSET_Y).with_value(-4500),
)
.with_property(
DeviceProperty::default()
.with_id(61).with_ddi(ddi::ACTUAL_WORKING_WIDTH).with_value(1000),
)
// section 1 live rate, scaled by the presentation above
.with_process_data(
DeviceProcessData::default()
.with_id(62)
.with_ddi(ddi::SETPOINT_VOLUME_PER_AREA_APPLICATION_RATE)
.with_trigger(TriggerMethod::OnChange)
.with_presentation(30)
.with_designator("Rate"),
)
.with_element(
DeviceElement::default()
.with_id(3).with_type(DeviceElementType::Section)
.with_number(1).with_parent(2)
.with_designator("S1")
.with_children(vec![60, 61, 62]),
);
}
The element’s child_objects list (vec![60, 61, 62]) is the wiring: it ties
the two geometry properties and the live rate to section 1. The rate points at
presentation 30 by ID, so a raw value of 7000 displays as 70.00 L/ha.
Validation before upload
Never upload a pool you have not validated. DDOP::validate checks the structure
the TC will rely on and fails fast with a descriptive error:
- At least one device and one element. An empty pool or a device with no elements is rejected outright.
- Unique object IDs. Two objects sharing an ID is a hard error; the TC cannot resolve an ambiguous reference.
- Parent references resolve to a compatible kind. An element’s
parent_idmust point at an existing Device or Device Element, never at a process-data or presentation object. - Child references resolve to a compatible kind. Listed children must be Device Elements, Process Data, or Properties — and must exist.
- Presentation references resolve. If a process-data or property names a
presentation, that Value Presentation must exist; the null ID
0xFFFFmeans “none” and is allowed.
serialize runs the serialization-level checks (validate_serializable) before
emitting bytes: designators and unit strings must be ASCII and fit the one-byte
length field, and every presentation scale must be finite. The current Rust
surface stores text as UTF-8 and refuses to emit non-ASCII or overlong strings
rather than produce bytes that cannot decode back, so keep designators short and
plain.
Serializing, uploading, and caching by label
DDOP::serialize produces the byte pool the TC client transfers. On the wire the
client splits it across a transport session, the TC stores it, replies that the
pool was received, and the client then asks the TC to activate it. The demo
drives exactly that sequence: build pool → set_ddop → connect → version
exchange → DDOP transfer → object-pool response → activate. See
Task Controller client for the full handshake.
Uploading a pool every power-up is wasteful, so the Device object carries two seven-byte labels for caching:
- Structure label identifies the shape of the pool — the objects and their relationships. Change the structure and you must change this label so the TC knows its cached copy is stale.
- Localization label identifies the language and unit presentation. Change units, decimals or designator language and bump this label.
A TC that already holds a pool with matching labels can skip the transfer and reuse its cached copy. The discipline that makes this work: labels are a version stamp. If anything in the pool changes, change the corresponding label; if nothing changes, keep the labels byte-for-byte identical across runs so the cache hits. Random or timestamped labels defeat caching and make field traces hard to compare.
Edge cases and failure modes
- Dangling references. A child or parent ID with no matching object fails validation. This is the most common authoring bug — usually a typo in an ID or a forgotten object.
- Duplicate IDs. Easy to introduce when hand-assigning IDs across many sections. Validation catches it, but prefer the auto-allocator or a disciplined numbering scheme.
- Wrong parent kind. Pointing an element’s parent at a process-data object is rejected; parents are only Devices or Device Elements.
- Non-finite presentation scale. A
NaN/infinite scale is refused at both serialize and deserialize, so a corrupted pool cannot round-trip into a usable one. - Non-ASCII or overlong text. Designators and unit strings outside ASCII, or longer than the one-byte length field allows, are rejected before any bytes go out.
- Exhausted identifier space. A pool that fills the 16-bit ID space cannot
allocate another object;
next_idreturns the null ID andadd_*errors. - Empty designator on the device. Rejected immediately — the root must be named.
Advanced
- Large DDOPs. A real machine can carry hundreds of objects. The pool grows linearly and serializes in one pass; the cost is the transport upload, which is exactly why label-based caching matters. Validate once at build time, not on every connect.
- Multi-boom geometry. A wide implement can split into sub-booms. Model the
main boom as a
Functionelement and each sub-boom as aFunctionelement whose parent is the main boom, withSectionelements beneath each sub-boom.extract_geometryunderstands this nesting and fills thesub_boomslist with per-sub-boom sections and rates. - Connector / navigation reference. Use a
Connectorelement with an X-offset property to anchor the implement to the hitch, and aNavigationReferenceelement where guidance needs a defined reference point. The geometry helper reads the connector offset intoconnector_x_mm. - Stable IDs vs auto-allocation. Auto-allocation is convenient for generated
pools; explicit
with_idis better for products you will field-debug, because stable IDs make TC traces and cached-pool comparisons readable.
Validate locally
make run EXAMPLE=tc_client_demo
make run EXAMPLE=tc_geo_demo
make test
tc_client_demo builds a DDOP, sets it on the client, and runs the connect →
version → transfer → activate handshake entirely in software, ending in the
Connected state. tc_geo_demo exercises the geometry/prescription side. The
DDOP, objects and helper modules carry unit tests (round-trip serialize/
deserialize, validation of bad parent/child references, geometry and rate
extraction) that make test runs.
What this proves / does not prove
Proves: a DDOP built with the machbus types serializes to a byte pool,
round-trips through deserialize unchanged, rejects malformed structure and text,
and uploads through the TC client handshake to an accepted, activated state in
software. The geometry and rate helpers read a well-formed pool back into
application views.
Does not prove: that a specific third-party Task Controller accepts the pool, that the chosen DDIs and units interoperate with a particular FMIS, or any conformance or certification claim. Real deployment still needs official standards, real hardware, and interoperability evidence.
See also
- DDOP and process data — the conceptual primer on object kinds and DDIs.
- Task Controller client — the connect and upload handshake that ships the pool.
- TC-GEO prescription — using a DDOP’s geometry and DDIs against a prescription map.
- TC/DDOP problems — when a pool is rejected or a cache goes stale.
TC-GEO prescription
A prescription map ties a target application rate to a position in the field.
The agronomy decides “apply 200 here, 100 there, nothing in the wet hollow”,
and the machine carries that decision out as it drives: it reads its own GNSS
position, looks up the rate for where it is right now, and feeds that rate to
the implement as a setpoint. This is variable-rate application, and on ISOBUS it
is the position-based (“TC-GEO”) side of task control. This tutorial explains
why position-based control exists, how a prescription map is shaped in
machbus, how a position becomes a rate, and how that rate becomes a
process-data value the implement can act on.
It assumes you already understand process data and DDIs from DDOP and process data, and that a Task Controller client connection is what carries the setpoints. TC-GEO sits on top of both.
Why this exists
A field is not uniform. Soil type, yield history, weed pressure, and moisture all vary across a single block, and the agronomically correct dose of seed, fertiliser, or spray varies with them. Applying one flat rate everywhere either over-applies where less is needed (cost, runoff, lodging) or under-applies where more is needed (lost yield). The fix is to pre-compute a prescription — a map that says what rate belongs at each part of the field — and let the machine follow it automatically instead of asking the operator to ride a knob.
Position-based control exists so the task controller can do this lookup continuously. As the machine moves, the controller resolves the current position to a target rate and pushes it to the implement, which adjusts its metering in real time. The operator drives; the map does the dosing.
Mental model
Picture a field split into zones, each tagged with a target rate, plus a default for anywhere not covered. The machine knows its own position from GNSS and asks one question over and over: which zone am I in, and what rate does it want?
lon →
+-------------------------------+
| zone B (rate 200) |
| +-----------------------+ |
| | | |
| | ● machine here | | position ──► point-in-zone test
| | (GNSS fix) | | │
| +-----------------------+ | ▼
| zone A (rate 100) | in zone B → rate 200
| +-------+ | │
| | hole | no-apply | ▼
| +-------+ | rate → process-data setpoint
+-------------------------------+ │
outside → default rate ▼
implement meters 200
The loop is: fix → zone → rate → setpoint. Everything else (freshness checks, defaults, smoothing) hangs off that loop.
Anatomy: the pieces in machbus
machbus models the map with three plain types in the TC-GEO module, plus the
interface that ties them to a live position.
| Type | What it holds |
|---|---|
Wgs | A WGS84 latitude/longitude/altitude triple (re-exported from concord). Positions and zone vertices are both Wgs. |
GeoPoint | A Wgs position plus a timestamp_us. This is a timestamped fix, so you can reason about how fresh it is. |
PrescriptionZone | A boundary (a Vec<Wgs> polygon), optional holes (a Vec<Vec<Wgs>> of exclusion polygons), and an application_rate (an i32 in DDI-dependent units). |
PrescriptionMap | A structure_label (a String name) and its zones (a Vec<PrescriptionZone>). |
TCGEOInterface | Holds the loaded maps and the current position, performs lookups, and raises events. |
A zone is a polygon, not a rectangle: irregular field shapes are expressed as a
ring of vertices. A hole is a polygon inside the boundary that must not be
treated — a pond, a power-line apron, a buffer strip. The rate is a bare
integer; its meaning (litres per hectare, kilograms per hectare, and so on) is
fixed by the rate DDI you pair it with when you encode the setpoint, not by the
zone itself.
There is no separate “default rate” field on the map. A position that matches no
zone returns no rate (None), and your application decides what “no zone”
means — typically a safe default of “apply nothing” or a configured fallback.
Keeping the default in application code, rather than baking it into the map,
makes the safe behaviour explicit at the call site.
Position → zone → rate
The lookup is a point-in-polygon test. point_in_prescription_zone(point, zone)
returns whether a Wgs point lies inside a zone’s boundary and outside every
one of its holes. The outer boundary is inclusive (a point exactly on an edge
counts as inside); hole boundaries are conservative exclusions (a point on a hole
edge counts as not applied), which is the safe choice for a no-apply region.
TCGEOInterface::get_rate_at_position(pos) walks every loaded map and every zone
in order and returns the application_rate of the first zone that contains
the point, or None if none do. “First match wins” is what makes overlapping
zones resolve deterministically — there is no averaging or precedence beyond
declaration order, so order your zones intentionally.
Degenerate input is rejected rather than guessed at: a polygon with fewer than
three vertices, a zero-area polygon, or any non-finite coordinate makes the
point-in-polygon test return false. That keeps a malformed map from silently
matching everywhere.
Rate → process-data setpoint
A looked-up rate is just a number until it is wrapped as a process-data value the implement understands. TC-GEO speaks the same language as the rest of task control: an ECU-to-TC process-data payload keyed by a DDI.
Two paths take you from position to wire:
get_rate_at_position_engineering(pos, ddi)looks up the raw rate and converts it to the engineering unit defined by a rate DDI (applying that DDI’s resolution/scale), returningOk(Some(value)),Ok(None)for no match, or an error if the DDI is unknown, is not a rate DDI, or the value is out of range.rate_process_data_payload_at_position(pos, ddi)does the same lookup and hands back an 8-bytePGN_ECU_TO_TCProcess Data Value payload ready to ship, againSome/None/error.
The free functions prescription_rate_from_engineering,
prescription_rate_to_engineering, and prescription_rate_process_data_payload
expose the conversion on its own, so you can validate a rate against a DDI’s
range before it ever touches a zone. All of them refuse a DDI that is not an
application-rate DDI, which stops you from, say, shipping a fertiliser quantity
under a latitude DDI.
The position itself is also reportable. position_process_data_payloads()
returns the two 8-byte payloads (actual latitude, then actual longitude) for the
machine to send back to the TC, so the controller knows where the reported rate
was applied. It errors if no position has been recorded yet.
GNSS freshness and stale positions
Position-based control is only as trustworthy as the fix behind it. A
GeoPoint carries a timestamp_us precisely so your loop can ask “how old is
this fix?” before acting on it. If the fix is stale — the receiver dropped to a
degraded mode, lost lock, or the feed stalled — the rate you would compute is for
where the machine was, not where it is.
The decode path is already defensive. try_handle_gnss_position (and its
ignore-errors wrapper handle_gnss_position) rejects the not-available sentinel,
out-of-range coordinates, wrong PGNs, invalid source addresses, and
non-canonical lengths, and on rejection it leaves the previously cached position
unchanged rather than overwriting it with garbage. That guards against a
single bad frame, but it does not by itself notice a fix that is simply old.
That freshness check is yours: compare current_position().timestamp_us against
your clock, and when the gap exceeds your tolerance, fall back to a safe default
rate (usually “apply nothing”) rather than dosing against a stale position.
Doing it with machbus
The example builds a two-zone map, drives a short trajectory across it, and prints the rate at each step. First, the map — two square zones, one at rate 100 to the south and one at rate 200 to the north:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_geo_demo.rs:17:41}}
}
Then it subscribes to rate changes and walks four positions — into the south zone, onto the shared boundary, into the north zone, and off the map entirely — looking up both the raw rate and the engineering value at each:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_geo_demo.rs:53:77}}
}
The “outside” point returns None from get_rate_at_position, which is the
no-zone case your default policy handles. Finally it records a position and emits
the two position process-data payloads for the controller:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_geo_demo.rs:79:90}}
}
The whole flow runs in software with no bus: you load a map, set a position,
call update, and read the rate.
Events and responsibilities
TCGEOInterface raises three events you can subscribe to:
| Event | Fires when | Typical action |
|---|---|---|
on_position_update | A new position is recorded (via set_position or a decoded GNSS frame). | Note freshness; trigger a re-evaluation. |
on_application_rate_changed | update runs and the looked-up rate differs from the last one. | Send the new setpoint to the implement. |
on_prescription_map_received | A map is added with add_prescription_map. | Log/validate the new map; reset cached rate state. |
update(elapsed_ms) is the heartbeat: it re-evaluates the current position
against the maps and fires on_application_rate_changed only when the rate
actually changes, so identical re-evaluations do not spam setpoints. Your
responsibilities are the parts the interface deliberately leaves to you: decide
the default for no-zone, decide the staleness tolerance, and gate setpoints on a
valid, fresh fix.
Edge cases and failures
- Point outside all zones.
get_rate_at_positionreturnsNone. Treat this as “apply the safe default”, not “keep the last rate” — driving off the map should not silently freeze the dose. - Overlapping zones. Resolution is deterministic by declaration order: the first zone whose polygon contains the point wins. If two zones overlap, the one you listed first decides the rate. Order zones with this in mind.
- Holes and buffer strips. A point inside a zone but inside one of its holes
is not applied (
Nonefrom that zone). Points on a hole edge are excluded too, which is the conservative choice for no-spray regions. - Stale or lost position. The decoder keeps the last good fix on a bad frame,
but a fix can still go old. Check
timestamp_usand fall back to a safe rate when it ages past tolerance. - Coordinate precision. Positions are decoded at roughly 1e-7 degree
resolution and zone vertices are
f64. Near a boundary, two nearby fixes can land on opposite sides — see boundary jitter below. - Zone boundary jitter. Around a shared edge, small position noise can flip the matched zone back and forth, causing rapid rate toggling. Because the boundary is inclusive and overlaps resolve by order, you can damp this in application code (hysteresis, or a small dwell before acting on a change) rather than expecting the lookup to smooth it for you.
Advanced
- Large maps and performance. The lookup is linear: every map, every zone, every edge, until a match. For a field with many fine zones, pre-filter by a bounding box per zone before the full polygon test, or index zones spatially, so each fix touches only nearby candidates rather than the whole map.
- Smoothing and look-ahead. Real implements have application latency — the metering reacts a moment after the command. Production systems look the rate up slightly ahead of the current position along the travel direction so the change lands at the right ground point, and smooth transitions across a boundary instead of stepping instantly.
- Integration with section control. TC-GEO answers “what rate here”; section control answers “which sections are on here”. The two compose: a section that is switched off in an exclusion area should not apply even where the prescription rate is non-zero, and a hole in a prescription zone is one way to express that no-apply region. Keep the rate decision and the on/off decision as separate inputs to the implement.
- Surface vs low-level.
TCGEOInterfaceis the pump-style core: you feed it positions and maps and pull payloads and events out. It does not open a bus or run a TC connection on its own — pair it with a Task Controller client to actually carry the setpoints.
Validate locally
make run EXAMPLE=tc_geo_demo
make test
The example loads a two-zone map and prints the raw and engineering rates at
four positions, including the no-zone case off the map, and the rate-change
events observed along the way. make test exercises the point-in-polygon test,
zone walking, hole exclusion, overlap ordering, the GNSS decode guards, and the
rate/DDI conversions.
What this proves / does not prove
Proves: in software, a position resolves to the rate of the first containing zone (holes excluded), the decoder rejects malformed or out-of-range fixes without corrupting the cached position, and rates convert to and from process-data payloads under their rate DDIs deterministically.
Does not prove: real-hardware GNSS accuracy or latency, interoperability with a specific third-party task controller or implement ECU, correct application look-ahead on a moving machine, or any conformance/certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- Task Controller client — the connection that carries the setpoints TC-GEO produces.
- DDOP and process data — DDIs, value presentation, and the process-data form the rate is encoded into.
- NMEA 2000 and Serial GNSS — where the position fixes come from.
Tractor ECU
The Tractor ECU — the TECU — is the control function that speaks for the tractor on the implement bus. It is the gateway between the machine’s internal network and the implements hanging off the back (and front), and it is the node that publishes the tractor’s live state: how fast the wheels are turning, how far the machine has travelled, where the hitch sits, whether the PTO is spinning and how fast, the key-switch position, and the lighting state. A capable TECU also accepts a bounded set of commands from an implement — move the hitch, engage the PTO, drive an auxiliary valve — and decides, on its own terms, whether to act on them.
This page explains what a TECU is, the “classes and facilities” idea that says
which messages a given tractor offers, the message groups machbus models, the
difference between publishing state and accepting commands, and how to assemble
the pieces from the real types. It assumes the node has already claimed an
address — see Address claim first.
Why this exists
An implement cannot do useful work in isolation. A seeder needs ground speed to hold its seeding rate; a sprayer needs distance and direction to log coverage; a baler needs to know the PTO is turning before it commits. None of those signals originate on the implement bus — they live inside the tractor, on its own network or on directly wired sensors. Something has to translate the tractor’s private telemetry into the standard messages an implement understands, present the tractor to shared services (the virtual terminal, the task controller) as just another node, and route the occasional command back the other way.
That something is the TECU. ISO 11783-9 (Tractor ECU) defines it as the gateway between the tractor bus and the implement bus, and as the node that represents the tractor to everyone else on the implement bus. ISO 11783-7 (Implement messages) defines the actual messages it sends and receives.
Mental model
tractor side │ implement bus
(internal net + sensors) │
│
wheel/ground speed ───┐ │
hitch position ────┤ │ ┌──────────────┐
PTO speed/engage ────┼──► TECU ───┼──►│ implement │
key switch ────┤ (gateway)│ │ CF │
lighting ────┘ │ └──────┬───────┘
│ │
hitch / PTO / valve ◄──────────────┼──────────┘
actuators (if it accepts commands) │ commands (hitch move,
│ PTO engage, valve…)
Read it in two directions. Most of the traffic flows left to right: the TECU publishes state on a fixed cadence so any implement that cares can subscribe. A smaller stream flows right to left: an implement commands the tractor, and the tractor chooses whether to honour it. The TECU is never obliged to obey — a command is a request, and the tractor’s own logic and interlocks have the final say.
The TECU’s role and the facilities idea
A tractor advertises what it can do with two related concepts.
Class is a coarse capability tier. machbus models the base tier as
TecuClass with three values:
| Class | What it implies |
|---|---|
Class1 | Basic state: speed, hitch position, PTO, power management. |
Class2 | Full measurements: distance, direction, draft, lighting, aux-valve flow. |
Class3 | Accepts commands: hitch, PTO, and auxiliary-valve control. |
Each class is a superset of the one below it. A class is also a promise: a tractor may keep a classification even when a physical feature is missing — a tractor with no rear PTO still answers the PTO messages, but fills the values with the “not available” sentinel rather than dropping the message.
On top of the base class sit addenda, single-letter flags for optional
message families. machbus carries them as boolean fields on
TecuClassification: navigation (N, GNSS
position), front_mounted (F, front hitch/PTO), guidance (G, steering),
powertrain (P, speed control), and motion_init (M, motion initiation). The
Display impl renders the combination the way the standard writes it — a
class 2 tractor with navigation and a front hitch prints as Class 2NF, and
the addendum order is fixed at N, F, G, P, M.
The fine-grained answer to “which exact messages do you offer?” is the
tractor facilities response. machbus models it as
TractorFacilities: a flat struct of booleans, one
per feature, that packs into an eight-byte payload via encode and parses back
with decode. Two PGNs share that payload, distinguished by
TractorFacilitiesRole:
Response— the TECU broadcasting what it actually supports.Required— an implement telling the TECU which facilities it needs, so the TECU can suppress the messages nobody is listening for and save bandwidth.
Convenience builders fill whole tiers at once: with_class1_all,
with_class2_all, with_class3_all, plus with_class3_v2_all and
with_front_v2_all for the later limit-status and exit-code bits.
Anatomy of the message groups
The TECU’s published state breaks into a handful of families, each with its own
machbus codec. Every codec is a plain struct with encode → [u8; 8] and a
fallible decode. The decoders reject the wrong length, bad padding, and
reserved-bit violations rather than guessing — malformed input returns None.
Ground- and wheel-based speed and distance. A tractor has more than one
notion of speed. Wheel-based speed comes from the drivetrain and can slip;
ground-based speed (often radar) is closer to true travel. machbus keeps
them as separate codecs, WheelBasedSpeedDist and
GroundBasedSpeedDist. Both carry speed_mps,
accumulated distance_m, and a direction ([MachineDirection]). The
wheel-based message additionally carries max_power_time_min, key_switch_state,
and the implement start/stop and operator-direction-reversed states — it is the
message a maintain-power requester keys off at power-down. There is also
MachineSelectedSpeedFull: the speed the tractor is
actually steering by, tagged with its [SpeedSource] (wheel, ground, navigation,
or blended) and a limit status.
Rear and front hitch position. HitchStatus
reports position_percent, an in_work_indication, a limit_status
([LimitStatus]) and exit_code ([ExitReasonCode]), and draft_force_n for
class 2 tractors. The same struct serves both ends: the is_rear flag picks the
rear or front PGN, and pgn() returns the right one.
PTO state and speed. PtoStatus reports
shaft_speed_rpm, an engagement state, an economy_mode, plus the limit and
exit codes. As with the hitch, is_rear selects rear or front and pgn()
resolves it.
Operator, key, and lighting. The key-switch state and maximum power-on time ride along in the wheel-based speed message. Lighting is carried by its own state type in the implement modules; the function-instance-0 TECU is the node responsible for lighting control.
Key and maintain-power. Power management is its own concern, covered in the lifecycle section below.
Publishing state vs accepting commands
This distinction is the heart of the TECU and worth stating plainly.
Publishing is unconditional and periodic. The TECU sends speed, distance, hitch, and PTO messages on a fixed cadence whether or not anyone is listening (subject to the bandwidth-saving suppression a required facilities message can request). These are status and measured values — they report what is, and carry no expectation of a reply.
Accepting commands is conditional and discretionary. A class 3 (or
addendum-P) tractor receives command messages from an implement and may act on
them. machbus models the inbound commands as separate codecs:
HitchCommandMsg (with a
[HitchCommand] of NoAction/Lower/Raise/Position),
PtoCommandMsg (with a [PtoCommand] of
NoAction/Engage/Disengage/SetSpeed),
AuxValveCommandMsg (with a [ValveCommand] of
Extend/Retract/Float/Block, PGN-routed per valve index by pgn() /
try_pgn()), MachineSpeedCommandMsg for a target
travel speed, and TractorControlModeMsg carrying the
per-axis [TractorMode] (manual / automatic). The standard is explicit that the
tractor is not required to execute any command — it applies its own logic and
constraints and may negative-acknowledge. machbus gives you the wire codecs;
the accept/reject decision is yours.
Lifecycle: power and shutdown
A TECU’s other major job is power management. machbus models the lifecycle as
PowerState:
| State | Meaning |
|---|---|
PowerOff | The boot/default state; nothing powered. |
IgnitionOn | Normal operation, key on. |
ShutdownInitiated | Key off; a bounded window of power remains. |
FinalShutdown | Power-down complete. |
The interesting transition is key-off. When the operator turns the key off, the
tractor does not cut power instantly — implements may need a moment to save
settings or move actuators to a safe rest position. So the TECU keeps power up
for a bounded window and broadcasts the remaining time. An implement that needs
more time sends a maintain power request asking the TECU to hold ECU power, or
both ECU power and actuator power, a little longer. The timing and current
limits live in PowerConfig — shutdown_max_time_ms
(default three minutes), maintain_timeout_ms (default two seconds), and the
ECU/PWR current minimums. The TECU side of a single request is tracked by
TecuMaintainPowerRequest, whose is_expired tells
you when a requester’s hold has lapsed. SafeModeTrigger
enumerates the reasons a node might fall back to fail-safe behaviour — power
loss, ECU-power loss, CAN failure, lost communication with the TECU, or a manual
trigger.
machbus deliberately ships the building blocks rather than one monolithic
orchestrator: you compose the classification, the facilities response, the
status codecs, and the power state machine into whatever control loop your
product needs. TecuConfig is a convenience record
bundling the classification, power config, and broadcast intervals.
Doing it with machbus
The example builds a classification, prints it, and walks the power states. The classification piece:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tractor_ecu_demo.rs:9:25}}
}
TecuClassification is a plain struct of fields, so you set the base class and
flip on the addenda you support. Printing it yields the canonical class string
(Class 2NF here). The power-state walk:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tractor_ecu_demo.rs:27:37}}
}
To advertise facilities, build a TractorFacilities, fill the tiers you offer,
and encode it onto the response PGN — the shape is:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled call.
let facilities = TractorFacilities::default()
.with_class1_all()
.with_class2_all();
let payload = facilities.encode(); // [u8; 8]
let pgn = TractorFacilitiesRole::Response.pgn();
// send `payload` on `pgn` …
}
To publish wheel-based speed, fill the struct in SI units and encode:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled call.
let msg = WheelBasedSpeedDist {
speed_mps: 3.2,
distance_m: 1234.5,
direction: MachineDirection::Forward,
..Default::default()
};
let payload = msg.encode(); // [u8; 8]
}
Inbound commands run the same way in reverse: HitchCommandMsg::decode(&data)
returns Some(cmd) on a well-formed payload and None otherwise. You inspect
cmd.command, decide whether your interlocks permit it, and only then drive the
actuator.
Events and responsibilities
Whatever orchestration you build around these codecs, the responsibilities are yours:
| Event | Typical TECU responsibility |
|---|---|
| Power-up | Claim an address, then broadcast the facilities response. |
| Required-facilities received | Narrow what you broadcast to what implements asked for. |
| Periodic tick | Re-send speed/distance/hitch/PTO status at the right cadence. |
| Command received | Validate against interlocks; act or negative-acknowledge. |
| Key-off | Enter the shutdown window; honour maintain-power requests until they expire. |
| Sensor unavailable | Send the message with the “not available” sentinel, not silence. |
The non-negotiable one: a command is never an obligation. The library hands you a decoded request; your application decides whether moving that actuator is safe.
Edge cases and failures
- Sensor not available. A feature the tractor lacks (no front PTO, no
ground-radar) is reported, not omitted. The default constructors seed the
“not available” sentinels —
0xFFbytes, theNotAvailableenum variants — so an unconfiguredPtoStatusorHitchStatusalready says “I don’t have this” on the wire. Keep advertising the message; just mark it unavailable. - Command for a facility you don’t offer. If your class or facilities bits don’t include a command family, an implement should not send it — but if one arrives, decline it. There is no requirement to act, and acting on a command for hardware you lack is a fault.
- Malformed payload. Every
decodeis defensive: wrong length, bad reserved bits, or corrupt padding yieldNone. A peer’s garbage frame returnsNoneand must not be allowed to mutate your cached state. - Speed-source switching under control. When the tractor is steering by
MachineSelectedSpeedFulland the underlying source changes, the transition is the tractor’s problem to smooth — the message reports the source so consumers can see it shift. - Safe defaults at power loss or comms loss. On lost power or lost
communication with the tractor, the implement is expected to assume fail-safe
operation.
SafeModeTriggernames the causes; the actual safe state belongs in the implement’s product logic, not in this library.
Advanced
- Required vs optional facilities. Class 1 is the floor; class 2 adds the measurement set; class 3 adds the command set. Addenda (N/F/G/P/M) are independent options layered on any base class. Advertise exactly what you support — over-claiming invites commands you cannot honour.
- Version-2 limit and exit reporting. Newer tractors add limit-status and
exit-reason reporting so an implement learns why a command was constrained
or aborted.
machbuscarries these as the*_limit_statusand*_exit_codefacility bits and theLimitStatus/ExitReasonCodefields on the status codecs;with_class3_v2_allandwith_front_v2_allset the facility bits. - Multiple TECUs and function instance. When several TECUs share a bus, the
one at function instance 0 is primary and owns power, lighting, and language;
higher instances fill only the gaps.
TecuClassification::instancerecords which you are. - Repetition and update timing. Status messages are periodic; the speed
message in particular runs on a tight cadence.
TecuConfigcarriesfacilities_broadcast_interval_msandstatus_broadcast_interval_msas a place to record your chosen rates — the library does not run the timer for you, so wire it into your own loop.
Validate locally
make run EXAMPLE=tractor_ecu_demo
make test
The example constructs a Class 2NF classification, prints the class strings
for all three base classes, and walks the four power states from the boot
default. The wide test suite round-trips every codec on this page — speed,
distance, hitch, PTO, facilities, and each command family — and asserts that
malformed payloads decode to None. To wire these surfaces together with
address claim and GNSS, plug them into a session — the presets::tractor() group
bundles the usual tractor-side subsystems (see
The session facade).
What this proves / does not prove
Proves: the classification renders the right class string, the facilities and status/command codecs round-trip their eight-byte payloads, the decoders reject malformed input, and the power-state and maintain-power types behave as specified in software.
Does not prove: real-hardware timing, that a specific third-party implement interoperates with your TECU, or any conformance or certification claim. A shipping TECU still needs official standards, real hardware, interoperability evidence, and — above all — machine-control interlocks that this library does not provide.
See also
- Implement ECU — the other side of every message here.
- Powertrain — the P-addendum speed-control surface in depth.
- Guidance — the G-addendum steering surface.
- TIM and automation — implement-driven tractor automation.
- Address claim — the prerequisite every TECU runs first.
Implement ECU
An implement ECU is the brain of the towed, mounted, or self-propelled machine
behind the tractor: the sprayer, seeder, baler, or mower. On the bus it plays a
very different role from the tractor. It mostly consumes tractor state —
ground speed, distance travelled, hitch position, PTO speed — and reacts to it,
while publishing its own status and, when the operator drives it through a
UI, asking the tractor to do limited things on its behalf. This tutorial shows
how the implement sees the bus, what message groups it cares about, and how to
drive all of it through machbus.
If you have read the Tractor ECU tutorial, this is the mirror
image: the same hitch/PTO/speed/lighting messages, viewed from the consumer
side instead of the producer side. The two pages share one set of machbus
codecs under isobus::implement, used in opposite directions.
Why this exists
The implement and the tractor are built by different manufacturers, yet they have to cooperate in real time in a field. The implement needs to know how fast the ground is moving to meter product correctly, when the hitch is raised so it can stop spraying on a headland, and whether the PTO is turning before it expects power. None of that works if every pairing needs a custom wire harness. ISO 11783-7 (Implement Messages) gives the implement a fixed vocabulary for reading tractor state and, where the tractor allows it, for requesting limited actions — so any compliant implement can ride behind any compliant tractor.
The safety posture is built into the design: automatic control of an implement from the bus is something you do deliberately and carefully, never as a default. The implement may ask; the tractor decides whether to honour the request.
Mental model
TRACTOR ECU IMPLEMENT ECU
┌──────────────────┐ ┌──────────────────┐
│ broadcasts state │ ── speed ────► │ meters product │
│ speed/distance │ ── distance ─► │ to ground speed │
│ hitch / PTO │ ── hitch ────► │ lifts on headland│
│ lighting │ ── PTO ──────► │ reacts to power │
│ │ │ │
│ may accept │ ◄── hitch cmd │ requests actions │
│ commands │ ◄── PTO cmd │ (if allowed) │
│ (Class 3 / TIM) │ ◄── aux valve │ │
└──────────────────┘ └────────┬─────────┘
│
also speaks │ VT (UI)
│ TC (process data)
▼
operator + agronomic record
The implement’s day job is to listen. It pulls a steady stream of tractor status frames off the bus, caches the latest of each, and feeds them into its control logic. Talking back — commanding the hitch, the PTO, or a hydraulic valve — is the exception, and only works when the tractor advertises that it accepts those commands.
The implement’s view of the bus
The implement is rarely alone on the wire. It typically holds three conversations at once:
| Conversation | Direction | Purpose |
|---|---|---|
| Implement messages (this page) | mostly tractor → implement | Read ground speed, distance, hitch, PTO, lighting; optionally command them. |
| Virtual terminal | implement ↔ VT | Draw the operator UI and receive button/soft-key input. |
| Task controller | implement ↔ TC | Report and log process data (rates, as-applied, section state). |
The implement messages give it machine reality — how fast, how far, hitch up or down. The VT gives it the operator. The TC gives it the agronomic record. A real seeding or spraying control loop closes across all three: the speed off the implement-message stream sets the metering rate; the section state comes from the operator (VT) or an automation (TC); and the as-applied result goes back to the TC for logging.
Anatomy: the implement-side message groups
machbus exposes each group as a small codec struct or enum under
isobus::implement, re-exported at the module root. None of them embed the
network — you compose them through the Implement plugin (below) or with raw
IsoNet sends. The names below are the actual machbus types.
Speed and distance (the implement reads these)
The core of “machine reality”. The tractor broadcasts speed and accumulated distance; the implement decodes and caches them.
| Type | What it carries |
|---|---|
WheelBasedSpeedDist | Speed from the driveline (speed_mps), accumulated distance_m, travel direction, key-switch and start/stop state. Basic, available from most tractors. |
GroundBasedSpeedDist | Speed/distance from a ground sensor (radar/GNSS), independent of wheel slip. Richer tractors only. |
MachineSelectedSpeedFull | The tractor’s single “this is the speed to use” value plus its source and an exit/limit reason code. |
speed_mps and distance_m are f64 in SI units; the codec handles the wire
scaling. direction is a MachineDirection (Forward, Reverse, Error,
NotAvailable). MachineSelectedSpeedFull tells you which source the tractor
chose via SpeedSource (WheelBased, GroundBased, NavigationBased,
Blended).
Hitch and PTO status (the implement reads these)
HitchStatus and PtoStatus are the feedback frames the tractor broadcasts for
its front and rear three-point hitch and power take-off. The implement uses
hitch position to know when it has been lifted out of work, and PTO speed/engaged
state to know whether driven tools have power.
Hitch, PTO, and valve commands (the implement may send these)
These are the implement’s lever for limited tractor control, defined in the command portion of ISO 11783-7. They only do anything if the tractor accepts them.
| Type | Command set |
|---|---|
HitchCommand | NoAction, Lower, Raise, Position (with a target). |
HitchCommandMsg | The wire message: command, target_position (0.0025 % per bit), rate. |
PtoCommand | NoAction, Engage, Disengage, SetSpeed. |
PtoCommandMsg | command, target_speed_rpm, ramp_rate. |
ValveCommand | NoAction, Extend, Retract, Float, Block. |
AuxValveCommandMsg | A valve_index (0–15), a ValveCommand, and a flow_rate. |
Defaults follow the wire convention: unset numeric fields encode as the
“not available” sentinel (0xFFFF / 0xFF), so a bare command says “do this
action, I’m not specifying a target”.
Lighting
LightingState is a snapshot of every standard lighting channel (turn signals,
beams, work lights, beacon, hazards, stop lamps), each a 2-bit LightState
(Off, On, Error, NotAvailable). The same struct rides two PGNs: the
tractor broadcasts implement-relevant lighting data, and a controller can send
a lighting command the implement is expected to obey.
Drive/work strategy and combined commands
For tighter coordination, DriveStrategyCmd (with DriveStrategyMode:
MaxPower, MaxEconomy, MaxSpeed) lets an implement hint at how the tractor
should manage its powertrain, and HitchPtoCombinedCmd packs hitch and PTO into
one request. These are advanced, optional, and tractor-dependent.
Required vs. available facilities
Before commanding anything, an implement should know what the tractor can do.
TractorFacilities is a bit-packed capability set; TecuClass summarises it
as Class1 (basic speed/hitch/PTO), Class2 (full measurements: distance,
direction, draft, lighting, aux flow), or Class3 (accepts commands). The same
TractorFacilities payload travels in two roles via TractorFacilitiesRole:
Response is what the tractor advertises, Required is what the implement
declares it needs. If the tractor is only Class 1, the implement must not expect
a Raise command to do anything.
How the implement combines this with VT and TC
The implement-message stream is one input to a loop that also touches the VT and TC:
- Speed in. Decode
WheelBasedSpeedDist/MachineSelectedSpeedFulleach tick; cache the latest. - Operator in. The VT client delivers button/soft-key events — sections on/off, target rate changes.
- Compute. Convert ground speed plus target rate into a metering setpoint per section.
- Act on the machine. Drive the implement’s own actuators; optionally request hitch/PTO action via the command messages if the operation needs it.
- Record out. Push as-applied values and section state to the TC client as process data.
Keep the three views consistent: the section count you show on the VT, the DDOP sections you declare to the TC, and the section state your control logic acts on must all describe the same machine.
Doing it with machbus
There are two layers. The codec structs above are pure encode/decode. The
session facade wires them to the bus for you through the Implement plugin.
Building an implement-capable session
Plug the Implement plugin into a Session. Both sides of an
implement/tractor pairing plug it — the codecs are symmetric, so “send a hitch
command” and “receive a hitch command” use the same machinery:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::Implement};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(Implement::new())
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
}
After the usual address claim, ctrl.with_mut::<Implement, _>(|imp| ...) reaches
the plugin: its outbound methods encode and send in one call, and its cached
getters let you read the latest state without draining events.
Sending commands
From the tractor (or any node allowed to command), the outbound side is direct:
#![allow(unused)]
fn main() {
ctrl.with_mut::<Implement, _>(|imp| {
imp.command_hitch(Hitch::Rear, HitchCommand::Raise);
imp.command_pto_speed(Pto::Rear, 540, 0);
imp.command_aux_valve(0, ValveCommand::Extend, 100)
})?;
}
command_hitch(Hitch, HitchCommand) and command_hitch_position(...) send to
the rear or front hitch by PGN; command_pto / command_pto_speed(Pto, rpm, ramp_rate)
do the same for the PTO; command_aux_valve(index, ValveCommand, flow_rate)
addresses one of up to 16 valves (MAX_AUX_VALVES) and rejects an out-of-range
index.
Publishing status
The implement side broadcasts its own feedback with the broadcast_* methods:
broadcast_hitch_status, broadcast_pto_status, broadcast_wheel_speed,
broadcast_ground_speed, broadcast_machine_selected_speed,
broadcast_lighting_data, and command_lighting for the lighting-command PGN.
Reading what arrived
Inbound frames are decoded on each driver.poll()? and surface two ways. Cached
getters give you the latest of each — last_front_hitch_status(),
last_rear_pto_status(), and last_wheel_speed() — read through
ctrl.with::<Implement, _>(|imp| ...). Or drain the event stream with
ctrl.drain::<ImplementEvent>(), matching on ImplementEvent.
Events and responsibilities
Each decoded inbound frame becomes an ImplementEvent:
| Event | Fired when | Typical implement action |
|---|---|---|
HitchCommand { hitch, msg } | A hitch command arrives. | Act if you are the actuator; otherwise ignore. |
PtoCommand { pto, msg } | A PTO command arrives. | Same. |
AuxValveCommand(msg) | A valve command arrives. | Drive the addressed valve. |
HitchStatus { hitch, msg } | Tractor reports hitch feedback. | Detect raised/in-work to gate application. |
PtoStatus { pto, msg } | Tractor reports PTO feedback. | Confirm driven tools have power. |
WheelSpeed / GroundSpeed | Speed/distance broadcast. | Update the metering setpoint. |
MachineSelectedSpeed | Selected-speed broadcast. | Use as the authoritative ground speed. |
Lighting { command, source, state } | Lighting data or command. | Mirror or obey lighting. |
Your responsibilities: decide which side of each message you are (consumer or actuator), keep your cached view fresh by ticking, and never act on a stale value as if it were current.
Edge cases and failures
- Tractor facility unavailable. If the tractor is Class 1, it does not accept
hitch/PTO/valve commands. Read
TractorFacilities/TecuClassfirst; if the capability is absent, do not command it and tell the operator why. - Command rejected or ignored. A command message is a request, not a
guarantee. The tractor may decline. Confirm intent by watching the
corresponding
HitchStatus/PtoStatusfeedback, not by assuming the command took effect. - Sentinel / “not available” values. Speed, distance, position, and rate
fields all have a not-available encoding (
0xFFFF/0xFF, and enumNotAvailable/Errorvariants). Treat these as “no data”, never as zero. AdirectionofNotAvailableis not “stopped”. - Stale tractor data. Broadcasts are periodic. If the stream stops — bus fault, tractor reset, disconnect — your last cached speed goes stale fast. Time-stamp what you cache and fall back to a safe default (stop metering, hold sections off) when data ages out, rather than metering to a value frozen minutes ago.
- Data loss safe defaults. On loss of speed, the only safe assumption is that you do not know the speed: stop applying product. On loss of hitch status, assume nothing about whether you are in work.
- Index out of range.
command_aux_valvevalidatesvalve_indexagainstMAX_AUX_VALVESand returns an error rather than sending a bad PGN.
Advanced
- Coordinating VT + TC + implement messages. These three streams update at
different rates and arrive interleaved. Drive them from one
driver.poll()?loop, cache the latest of each, and compute on a fixed cadence rather than reacting frame by frame — that keeps your control loop stable when one stream stutters. - Update timing. Status broadcasts are periodic (commonly around 100 ms for speed and lighting). Send your own status at the expected cadence; consumers rely on it being fresh, and a slow or bursty publisher looks like a fault.
- TIM authority for active control. Reading tractor state needs no special authority. Actively controlling the tractor — steering, speed, sustained hitch/PTO automation — is the domain of TIM, which adds the authority handshake and timeouts that make automatic control safe. Use the implement command messages for the limited, operator-initiated actions ISO 11783-7 covers; reach for TIM when the implement is genuinely driving the tractor. See TIM for that boundary.
- Session facade vs. the bare codecs. The
Implementplugin is right for applications: it sends, caches, and fans out events. The bare codecs are right for tests and tightly controlled loops where you own every byte and every send.
Validate locally
make test
The session tests build an 8-section sprayer from the Implement plugin, toggle
section state, and exercise the alarm panel and diagnostics. They also put a
tractor and an implement on one virtual bus, have the tractor command a rear
hitch raise, a rear PTO speed, and an aux-valve extend, and show the implement
receiving each as an ImplementEvent plus reading it back from the cached
getters.
What this proves / does not prove
Proves: the implement-side codecs encode and decode the speed/distance, hitch,
PTO, valve, and lighting messages correctly, and the Implement plugin ships and
receives them across a virtual bus with the right caching and event fan-out.
Does not prove: real-hardware timing, interoperability with a specific tractor or
implement, or any conformance/certification claim. machbus is not certified;
real deployment still needs official standards, real hardware, and
interoperability evidence.
See also
- Tractor ECU — the producer side of the same messages.
- Virtual terminal client — the operator UI.
- Task controller client — process-data logging.
- TIM — authority and timeouts for actively controlling the tractor.
Powertrain
The powertrain messages are how the engine and the transmission tell the rest
of the bus what they are doing right now: how fast the engine is turning, how
hard it is working, how hot it is running, how many hours it has logged, which
gear the transmission is in, and whether a shift is under way. Nothing on this
page commands the powertrain in normal operation — these are status reports
that a tractor ECU, a display, a logger, or an implement reads so it can
coordinate its own behavior. This tutorial explains what each message group
carries, how raw CAN bytes become engineering units, and how to produce and
consume them with machbus at both the codec level (j1939::engine /
j1939::transmission) and through the session facade’s Powertrain plugin.
These messages come from the shared J1939 application layer; ISO 11783-8 (Power Train Messages) adopts that set for agricultural and forestry machines, so the same frames you would see on a truck appear on an ISOBUS.
Why this exists
A working machine is a team of ECUs that must agree on what the engine and driveline are doing. A task controller deciding whether application can start, a virtual terminal drawing a tachometer, an implement that must back off when the engine bogs down, a fleet logger recording duty hours — all of them need a trustworthy, periodically broadcast picture of powertrain state. Putting that picture on the bus in a fixed, scaled binary format means every node decodes the same numbers the same way, regardless of who built the engine.
The design choice that makes this robust is that almost every field has a defined not-available encoding. A sensor that is missing, warming up, or faulted does not vanish or send a misleading zero — it sends the reserved “I don’t know” pattern, and every well-behaved receiver treats that as no reading rather than a real value.
Mental model
engine ECU transmission ECU
│ │
broadcasts EEC1, ET1, broadcasts ETC1,
hours, fuel, ... oil temp, ...
│ │
└──────────────┬─────────────────┘
▼
the CAN bus
│
┌─────────────┼───────────────┐
▼ ▼ ▼
tractor ECU display logger
(coordinates) (tachometer) (duty hours)
Each producer broadcasts its frames to the whole bus on a fixed cadence; each consumer keeps the latest decoded value per source address and acts on it. A frame is a snapshot, not a stream of deltas: if a newer frame never arrives, the last one simply ages, and it is the receiver’s job to notice staleness.
When J1939 and ISOBUS disagree, ISOBUS wins
ISO 11783-8 is three pages and its whole substance is a precedence rule:
§4.2 — “Any parameters defined in SAE J 1939/71 that are also given in ISO 11783-7 shall use the parameter definitions according to ISO 11783-7.”
§4.3 says the same for parameter groups. This is not academic. Wheel-based
speed and distance (0xFE48) and ground-based (0xFE49) are ISO 11783-7
parameters — ISO 11783-9 §4.4.2 classifies a TECU on them — and under part 7
their trailing two bytes carry key switch state and maximum time of tractor
power, the fields part 9’s ignition-off sequence depends on. Read them the
plain J1939 way and those bytes look like 0xFF padding.
So SpeedAndDistance::decode — which demands that padding — is the wrong entry
point for real tractor traffic. Use decode_measurement_prefix for these three
PGNs; it takes the speed/distance prefix and leaves the status bytes alone.
Anatomy: the message groups
machbus ports each powertrain frame as a small Copy struct with encode
returning the 8-byte wire form and decode returning Option<Self> — None
when the bytes are malformed or carry a not-available value the struct cannot
represent. The groups below are the ones you will reach for most.
Engine speed, torque, and load
| Type | Carries |
|---|---|
Eec1 | Engine speed (rpm), actual/demanded/driver-demand torque as a percent, starter mode, and the source address that owns the speed signal. |
Eec2 | Accelerator pedal position, engine load percent, low-idle and kickdown switches, road-speed limit. |
Eec3 | Nominal friction percent, desired operating speed, operating-speed asymmetry. |
Tsc1 | A request to control speed or torque, with an override mode (OverrideControlMode). This is a command, not a status — treat it with care. |
Eec1 is the workhorse. Its engine_speed_rpm field is the value a tachometer
reads; the three percent fields express torque relative to a reference and are
stored with an offset so they can go negative (engine braking shows as a
negative percent).
Engine temperature and fluids
| Type | Carries |
|---|---|
EngineTemp1 | Coolant, fuel, oil, turbo-oil, and intercooler temperatures in °C. |
EngineTemp2 | A second set of oil/turbo/intercooler temperatures at finer resolution. |
EngineFluidLp | Oil and coolant pressure (kPa), oil and coolant level (percent), fuel-delivery and crankcase pressure. |
DashDisplay | Fuel level and washer-fluid level (percent), fuel- and oil-filter differential pressure, cargo/ambient temperature. |
AmbientConditions | Barometric pressure, ambient/intake/road-surface temperature. |
Vep1 | Battery, charging-system, and key-switch voltage; alternator current. |
Engine hours and totals
| Type | Carries |
|---|---|
EngineHours | Total engine hours and total engine revolutions — lifetime counters. |
FuelEconomy | Instantaneous and average fuel rate (L/h) and throttle position. |
FuelConsumption | Trip and total fuel used (litres). |
Aftertreatment1 / Aftertreatment2 | DEF tank level, NOx readings, DPF pressure/soot/regeneration status. |
These are large, slowly changing counters. They are 32-bit on the wire so they do not roll over for the life of the machine.
Transmission state
| Type | Carries |
|---|---|
Etc1 | Current gear and selected gear, output-shaft speed (rpm), shift-in-progress flag, torque-converter lockup flag. |
TransmissionOilTemp | Transmission oil temperature (°C). |
CruiseControl | Wheel-based vehicle speed, cruise/brake/clutch/park-brake switch states, cruise set speed. |
In Etc1 the gears are signed: reverse gears are negative, neutral sits near
zero, and forward gears are positive. The struct exposes them as i8
(current_gear, selected_gear) so you read a gear number directly rather
than a biased byte. selected_gear is what the operator/controller asked for;
current_gear is what is actually engaged — they differ during a shift, which
is exactly when shift_in_progress is set.
Identity
ComponentIdentification (make / model / serial / unit) and
VehicleIdentification (a VIN string) are *-delimited text rather than scaled
numbers. They are diagnostic identity, not a security credential — never treat a
VIN read off the bus as proof of who you are talking to.
Sentinels, scale, and offset
Raw CAN carries unsigned bytes; the engineering value comes from three rules applied per field: a scale (units per bit), an optional offset, and a reserved not-available code at the top of the range.
- Scale. A 2-byte field at 0.125 rpm/bit means a raw count of
12000decodes to1500.0rpm. Temperatures often use 0.03125 °C/bit for fine resolution, pressures 4 kPa/bit, hours 0.05 h/bit. - Offset. Signed quantities are stored unsigned with a bias. Torque percent
uses an offset of −125, so a raw
0is −125 % and raw250is +125 %. The oil temperatures use an offset of −273, which is why a fresh-default value reads as a deeply negative °C rather than zero. - Not-available. An all-ones field (
0xFF, or0xFFFFfor two bytes) means no reading.machbusencodes the not-available pattern into unused bytes and, on decode, returnsNonefor frames whose mandatory fields are not-available rather than handing you a fake number.
Two consequences worth internalizing:
encodeclamps away from the sentinel. If you ask for a value at or beyond the top of a field’s range, the encoder writes the largest real code, not the not-available code, so your own frames are never mistaken for “no reading”. A non-finite input (NaN/inf) encodes as the bottom of the range.decodeis strict. Wrong length, reserved bits set where they must be zero, or an unrepresentable not-available value all yieldNone. TheDefaultfor several of these structs deliberately is the not-available state (for exampleEtc1::default()reports gears at −125 and both flags as0x03, the “not available” code).
Doing it with machbus
There are two layers, and they suit different jobs.
The codecs (for tests and direct decode)
Each struct round-trips on its own with no network state. The powertrain demo
builds an Eec1, encodes it, decodes it back, and prints the engineering
values — the canonical pattern for a callback that received a raw frame:
#![allow(unused)]
fn main() {
{{#include ../../../examples/engine_powertrain_demo.rs:11:24}}
}
EngineHours works the same way, carrying the lifetime counters:
#![allow(unused)]
fn main() {
{{#include ../../../examples/engine_powertrain_demo.rs:54:63}}
}
To wire these into a low-level loop, register a callback for the relevant PGN
with IsoNet::register_pgn_callback and call the matching decode inside it.
That is the pump-style path used throughout the crate; the codecs hold no
IsoNet reference themselves.
The Powertrain plugin (recommended for applications)
The Powertrain plugin routes every supported powertrain PGN for you. Reach it
through ctrl.with_mut::<Powertrain, _>(|p| ...), which gives you two ways to
read state:
- A snapshot of the latest decoded value per message.
snapshot()returns aPowertrainSnapshotwhose fields (eec1,etc1,engine_hours, …) areOption, populated only once a valid frame has arrived. There are shortcuts likelatest_eec1()andlatest_etc1()/latest_cruise_control(). - An event drain. Newly decoded frames arrive as
PowertrainEventon the event stream, one entry per frame, each tagged with thesourceaddress and the decodeddata. Use the snapshot when you only care about “the current value” and events when you must react to every update or distinguish two sources.
The shape of a read loop is:
#![allow(unused)]
fn main() {
// illustrative shape, not a verbatim compiled call
driver.poll()?;
for event in ctrl.drain::<PowertrainEvent>() {
if let PowertrainEvent::Eec1 { source, data } = event {
println!("0x{source:02X} engine {:.0} rpm", data.engine_speed_rpm);
}
}
let rpm = ctrl.with_mut::<Powertrain, _>(|p| p.snapshot().eec1.as_ref().map(|e| e.engine_speed_rpm));
}
To produce frames, the plugin exposes broadcast helpers:
broadcast_eec1, broadcast_etc1, and broadcast_vehicle_identification.
Each encodes the struct and sends it to the broadcast address at the default
priority, from the session’s own control function. You only do this when your
node is the engine or transmission ECU (or a test stand emulating one).
Events and responsibilities
| Event family | Meaning | Typical action |
|---|---|---|
Eec1 / Eec2 / Eec3 | New engine speed/torque/load snapshot. | Update displays; gate work on load/rpm. |
EngineTemp1 / EngineTemp2 / EngineFluidLp | New thermal/fluid reading. | Warn or derate on over-temperature; flag low fluid. |
EngineHours / FuelEconomy / FuelConsumption | New counters. | Log duty and consumption. |
Etc1 / TransmissionOilTemp | New transmission state. | Coordinate with gear/shift; watch oil temp. |
CruiseControl | Vehicle speed and driveline switches. | Combine with TECU speed; honor park-brake. |
Your responsibilities as a consumer:
- Key on the source address. Two engines (or an engine plus a test stand)
can both broadcast
Eec1. Keep state persource, not globally. - Honor not-available. A
Nonefromdecode, or anOptionfield still empty in the snapshot, means no reading. Do not substitute zero. - Do not broadcast what you are not. Only the real producer should call the broadcast helpers for a given message.
Edge cases and failures
- Not-available decodes to
None. A frame whose required field is all-ones is rejected, not silently zeroed. Plan fordecodereturningNoneon perfectly valid “sensor absent” frames. - Reserved bits.
Eec1rejects a frame whose starter-mode nibble has the upper bits set;Etc1rejects a frame whose mode byte uses reserved bits or whose padding is not all-ones. Garbled frames do not corrupt your cache. - Out-of-range on encode. Asking for a gear of 127 or an rpm of 99 999 does not overflow — the encoder clamps to the largest representable real code, staying clear of the not-available pattern.
- Wrong length. Anything other than the exact 8-byte payload (too short or
too long) decodes to
None. The codecs never read past the buffer. - Stale data. Nothing in the snapshot times out for you. If a producer goes quiet, the last value lingers. Track arrival time yourself and treat an un-refreshed value as stale past the expected update interval.
- Identity is not trust. A decoded VIN or component string is a label a peer chose to send; it is not an authenticated identity.
Advanced
- Update rates. Engine speed/torque (
Eec1) and transmission state (Etc1) refresh quickly — on the order of tens of milliseconds — because control loops depend on them. Temperatures, hours, and fuel totals change slowly and broadcast far less often. Size your staleness windows per message, not with a single global timeout. - Deriving values. Some quantities you want are not on the bus directly.
Output-shaft speed from
Etc1combined with a known final-drive ratio approximates ground speed;EngineHourssampled over wall-clock time yields a duty ratio. Derive deliberately and label derived numbers as estimates. - Combining with TECU speed. The tractor ECU publishes its own
ground/wheel-based speed (see the tractor-ECU tutorial). Cross-check it
against
CruiseControlwheel speed: agreement builds confidence, a persistent gap points at a slipping wheel or a miscalibrated sensor. - Codec vs the session facade. Reach for the bare codecs in unit tests and
tight embedded loops where you own every callback. Reach for the
Powertrainplugin in applications: it registers the PGNs, decodes, caches per message, and fans out events so you write only the reaction.
Validate locally
make run EXAMPLE=engine_powertrain_demo
make test
The example builds Eec1, EngineTemp1, FuelEconomy, and EngineHours,
round-trips each through encode/decode, and prints the engineering values
so you can confirm the scaling. The test suite includes round-trip,
not-available-sentinel, reserved-bit, out-of-range, wrong-length, and
property-based fuzz tests for the engine and transmission codecs, plus
session-level tests that a malformed payload does not overwrite the last good
cached value.
What this proves / does not prove
Proves: the engine and transmission codecs map raw bytes to engineering units
with the correct scale and offset, reject malformed and not-available frames,
clamp on encode, and that the Powertrain plugin caches and fans out the latest
value per message without being corrupted by bad input.
Does not prove: real-hardware timing or broadcast cadence, interoperability with a specific engine or transmission ECU, sensor accuracy, or any conformance/certification claim. A real deployment still needs official standards, real hardware, and interoperability evidence.
See also
- Tractor ECU — ground speed, PTO, hitch, and the messages a tractor ECU publishes alongside the powertrain.
- Implement ECU — the implement side that consumes powertrain and tractor state to coordinate work.
- Address claim — every producer and consumer here assumes a claimed source address first.
AutoDrive (steering + speed)
AutoDrive is the combined autonomous-driving controller: one engage lifecycle,
one setpoint, one stop latch across both axes a moving machine has — where it
steers and how fast it goes.
It is the only guidance controller in machbus: an earlier Guidance plugin
covered the same two messages with a weaker model and was removed. This page
covers the lifecycle, the heartbeat contract, every way it stops, and the
question that trips almost everyone up first: do I need TIM for this?
Safety first
Autonomy moves a machine with a person on it:
- Operator-supervised, not autonomous. A human is in the seat.
- machbus is not a safety system and is not certified. It moves setpoints on the wire. It does not plan paths, close a loop, or supervise anything.
This plugin owns the protocol half of that: preconditions, a latching stop and
refusals. The operator input half — a held dead-man, a deliberate arm latch,
and treating a lost controller as a release — sits above it, in whatever drives
the plugin. See machbus drive’s safety model for a worked
example you can read and copy.
Do I need TIM?
No — not for what AutoDrive does. This is the single most common confusion,
so it is worth being precise.
There are two unrelated protocols in ISOBUS that can influence a tractor’s speed and steering. They are not two implementations of one idea; they are different messages on different PGNs with different rules.
ISO 11783-7 native — what AutoDrive speaks | AEF TIM — src/isobus/tim/ | |
|---|---|---|
| Steering | PGN 0xAD00 Guidance System Command | PGN 0x2400/0x2300, function ExternalGuidance (0x46) |
| Speed | PGN 0xFD43 Machine Selected Speed Command | same PGN pair, function VehicleSpeed (0x44) |
| Gate before you may command | none — broadcast | assignment table + authentication + heartbeat counter |
| Also covers | nothing else | PTO, hitch, auxiliary valves |
AutoDrive has no TIM coupling at all. It never consults a TIM authority,
never waits for an assignment, and never sends a TIM message. It claims an
address and broadcasts.
NoAuthorityis still unused.AutodriveRefusal::NoAuthoritysounds like TIM, and nothing in the crate constructs it. It is a placeholder for a TIM-gated path that does not exist yet — do not read it as evidence that one does. (FacilityNotAdvertisedis now produced; see below.)
The tractor has to advertise the facility first
This is the part that decides whether any of it works, and it is not TIM.
ISO 11783-9:2012 §4.4.2 splits tractor capability into classes with letter addenda, and steering, speed and starting to move are three separate ones:
| Addendum | Clause | What it means |
|---|---|---|
| G | §4.4.2.7 | The tractor “shall support the external control of the guidance system” — curvature command, estimated curvature, command status, request reset status, steering input position status, steering system readiness, mechanical lockout. |
| P | §4.4.2.8 | The tractor is “capable of accepting speed and/or drive strategy commands from an implement controller”. |
| M | §4.4.2.9 | The tractor accepts “commands to initiate motion of the vehicle (forward or reverse)”. |
So a class 2G tractor steers on command but need not accept a speed command
at all, and even a class 2GP tractor need not start moving on one — §4.4.2.8
says outright that bringing the tractor to a stop (speed 0.0) is optional
and “can be determined by an implement via the tractor facilities response
message”.
That message is the discovery mechanism, and it runs both ways:
- PGN 0xFE09 Tractor Facilities Response — what the TECU has installed. The TECU sends it at power-up and on request.
- PGN 0xFE0A Required Tractor Facilities — what you need. §4.4.2 is blunt about why this matters: “A facility is not required if its corresponding bits are set to 0 in the implement CF required tractor facilities message. The Tractor ECU can then stop the transmission of this implement message to reduce bandwidth.”
In other words, a conforming TECU may simply not broadcast Guidance Machine Info
to a node that never asked for it — and AutoDrive refuses to engage without
Machine Info, so the symptom is a permanent link_down with a tractor that is
working perfectly.
AutoDrive therefore broadcasts Required Tractor Facilities every
REQUIRED_FACILITIES_INTERVAL_MS (1 s) once its address is claimed, asking for
guidance + machine selected speed + speed command, and decodes the response:
- Response says no guidance →
arm()andengage()refuse withfacility_not_advertised, and a tractor that revokes it mid-drive trips a safe stop. - Response says no speed command → a
DriveCommandcarrying a speed is refused; a steer-only command still goes through. - No response at all → nothing is refused. Silence is not a denial, and a bench, a replay or a retrofit box may never send one.
So which do you actually need?
- A retrofit guidance system, a test bench, or a machine that implements the
11783-7 messages directly will act on
AutoDrive’s broadcasts. - A tractor that advertises G steers on 0xAD00 without any TIM involvement. That is the plain reading of §4.4.2.7 and it is why TIM is not required for steering.
- An AEF-certified TIM tractor may still ignore a bare 0xFD43 from an unauthenticated implement. Speed is the function OEMs guard hardest, and TIM exists precisely so that handing over speed authority is explicit and revocable.
TIM is where these messages are heading
ISO 11783-7:2022 Clause 11 says so directly, naming both PGNs AutoDrive
uses:
“In future editions of the ISO 11783 series, the command messages concept will be moved to a new concept termed ‘Tractor-Implement Management’ (TIM). The new concept is expected to eventually supersede or, at least, render redundant, some of the tractor control related messages… The messaging most likely to be impacted, and potentially deprecated, are: … Guidance system command (PGN 44288) … Machine selected speed command (PGN 64835) … Drive strategy command (PGN 64718).”
So: TIM is not required today for a tractor that advertises G (and P), but the native command messages are explicitly flagged as the path being superseded. Plan for TIM on new work; do not assume you need it to steer a current machine.
Also worth knowing: machbus’s TimAuthority currently guards PTO, hitch and
auxiliary valves only. There is no plugin wiring TIM speed or TIM steering, so
“use TIM instead” is not currently a thing you can do for these two axes.
What the design gives you
Four properties are worth knowing about before you write a control loop, because each replaced something weaker.
1. An automation status, not a boolean
AutoDrive reports the ISO 11783-7 Table 45 AutomationStatus:
NotReady ──arm()──► ReadyToEnable ──engage()──► ActiveNotLimited
├─► ActiveLimitedHigh
├─► ActiveLimitedLow
└─► Fault (any safe stop)
This is not cosmetic. The plugin mirrors the machine’s own limit status into
its status: when the steering ECU reports LimitedHigh / LimitedLow, it
moves to ActiveLimitedHigh / ActiveLimitedLow and you can read it back with
status(). That is the anti-windup signal an outer control loop needs — a
tracker that does not know the steering ECU is saturated will wind up against a
limit it cannot see.
arm() before engage() also keeps “preconditions are met” and “I am now asking
for the wheel” as distinct states.
2. Commands are refused, not clamped
d.command(DriveCommand {
speed_mps: Some(2.0),
curvature_km_inv: Some(12.5),
})?;
command returns Result<(), AutodriveRefusal> and has no infallible
sibling, so the wire codec is never the only range check on a steering
command. An out-of-range curvature would otherwise be clamped by the encoder to
±8031.75 km⁻¹ — a 12 cm turn radius — and transmitted as a perfectly valid
maximum-curvature command. It refuses instead, with CurvatureOutOfRange,
SpeedNotFinite, SpeedBelowMinimum, StopLatched or StatusNotActive.
DriveCommand’s two Option fields also let you express steer only (leave
speed to whoever owns it) with DriveCommand::steer(k), or drive straight,
without coupling the two axes through a twist.
3. The stop latch is fed only from inside
There is no public way to trip the latch. Every producer is inside the plugin,
and a source-scanning test enforces that each SafeStopTrigger variant has a
real producer — so a trigger cannot be declared and then quietly never fire.
4. GNSS latches a stop
GnssHazards blocks engage() and clear_stop(), and additionally trips a
latching stop on PositionStale / FixDegraded, so a receiver that stops
reporting halts the machine rather than only preventing re-engagement.
The lifecycle
Plug it and claim, exactly like any other plugin:
{{#include ../../../examples/autodrive_keyboard.rs:build}}
Then arm, then engage. Both report the first unmet precondition rather than failing silently, because an autonomy client that asks to steer and is ignored cannot tell “commanded” from “declined”:
{{#include ../../../examples/autodrive_keyboard.rs:arm}}
arm() and engage() check, in order: no latched stop, no held shortcut button,
no live GNSS hazard, link alive, machine info present, then the machine’s own
report — mechanical lockout clear and operator engage switch active.
Those machine conditions are re-checked on every Machine Info broadcast, not just at engage. The operator dropping the engage switch or asserting the lockout mid-drive stops this node asking for the wheel.
disengage() is deliberately infallible and idempotent: a disengage must never be
refused.
The command is a heartbeat
This is the contract most likely to surprise you.
While engaged, AutoDrive re-transmits at MIN_TX_INTERVAL_MS (100 ms, the ISO
11783-7 §5.2.7.2 minimum for the guidance group) even when the setpoint has not
changed, because the command is the heartbeat the steering ECU times out on.
That turns a fail-silent path into a fail-active one. An application that dies used to emit no frames and let the ECU time out; with a heartbeat it would instead keep the machine steering at the last curvature forever. So:
You must call
command()at least everyCOMMAND_STALE_MS(300 ms) while engaged, even with an unchanged setpoint. Miss it andAutoDrivetripsSafeStopTrigger::CommandStale, falls toDriveCommand::halt()and latches.
Tune with with_command_stale_ms(ms); 0 disables the watchdog, which is only
appropriate when something else guarantees liveness.
The driving loop, then, refreshes the setpoint every cycle:
{{#include ../../../examples/autodrive_keyboard.rs:heartbeat}}
Releasing the dead-man disengages. disengage() is infallible and falls back to
DriveCommand::halt() — and losing the dead-man has to land here too, not
leave the last setpoint running:
{{#include ../../../examples/autodrive_keyboard.rs:deadman}}
The stop is latching, so re-engaging is refused until it is explicitly cleared:
{{#include ../../../examples/autodrive_keyboard.rs:clear}}
Cadence and tuning
| Constant | Default | Meaning |
|---|---|---|
MIN_TX_INTERVAL_MS | 100 ms | Conformance minimum; with_cadence clamps to it |
MAX_TX_INTERVAL_MS | 2000 ms | Idle re-broadcast when not active |
COMMAND_STALE_MS | 300 ms | Unrefreshed setpoint → CommandStale |
LINK_TIMEOUT_MS | 300 ms | Three missed 100 ms Machine Info → GuidanceLinkTimeout |
DEFAULT_MIN_SPEED_MPS | 0.05 m/s | Below this a yaw rate does not define a curvature |
AutoDrive::new()
.with_cadence(100, 2000) // min is clamped up to MIN_TX_INTERVAL_MS
.with_command_stale_ms(300)
.with_min_speed(0.05)
Every way it stops
On any of these the plugin latches, sets AutomationStatus::Fault, replaces the
setpoint with DriveCommand::halt() (speed 0, curvature 0) and emits
AutodriveEvent::SafeStop { trigger }.
| Trigger | Cause |
|---|---|
GuidanceLinkTimeout | 300 ms without Machine Info |
IsbStop | Operator holds the shortcut button, a seen ISB source goes silent, or the machine reports a mechanical lockout |
OperatorOverride | Engage switch dropped, or the ECU reports OperatorLimitedControlled / NonRecoverableFault |
CommandStale | You stopped refreshing the setpoint |
SendFailed(pgn) | The network layer refused one of the two command PGNs |
PositionStale, FixDegraded | GNSS events, via GnssHazards |
BusOff, AddressClaimLost, HeartbeatError, ClockWentBackwards, KeySwitchOff | Session events |
The latch is latching, not momentary — autonomy must not resume by itself because a button was released or a link came back.
clear_stop() is the only way out, and it is deliberately explicit: clearing a
fault is not consent to move. It returns Err(StopConditionLive) while the
shortcut button is still held or a GNSS hazard is still live, so an HMI cannot
show “cleared” against a stop that is still asserted.
What you need plugged
| Plugin | Why |
|---|---|
AutoDrive | required |
Gnss | strongly recommended — without it GnssHazards never fires, so clear_stop() can re-arm autonomy against a receiver that stopped reporting |
ShortcutButton | the ISO stop-all path |
Diagnostics | DTCs for the faults above |
Path → curvature is still your job
The plugin moves a curvature value; it does not produce one. Your pure-pursuit or Stanley tracker turns the planned line, the GNSS pose and the cross-track error into the single curvature that steers back onto the line, and it lives in your code. See Serial GNSS and NMEA 2000 for pose, and TC geo for where planned lines come from.
Other languages
Both bindings expose the same surface behind an enable_autodrive flag, as
session-level autodrive_* functions and methods: arm, engage, disengage,
command, clear stop, and read back status / engaged / stop reason.
In C, machbus_session_autodrive_stop_reason returns a
MachbusSafeStopTrigger enum. Note that codes 2 and 3 are permanently
retired — reusing them would shift every value above for callers built against
an older header. See ABI stability.
Validate locally
cargo run --example autodrive_keyboard # the full driving lifecycle
cargo run --example guidance_autosteer # the curvature conversation alone
make test
The example claims an address, shows an engage refused before any ECU answers, drives for 500 ms at the 100 ms cadence, releases the dead-man into a latched stop, and clears it. See the AutoDrive example for a line-by-line reading of the output.
What this proves / does not prove
Proves: AutoDrive commands curvature on PGN 0xAD00 and speed on PGN 0xFD43
behind one engage lifecycle, refuses out-of-range and out-of-state requests
before encoding, and latches a safe stop on each trigger above.
Does not prove: anything about closed-loop steering or speed control, path planning, operator supervision, actuator safety, real-machine timing, interoperability with any specific tractor, whether a given tractor will act on an unauthenticated speed command, or any certification. machbus is not a safety system and is not certified.
See also
machbus drivesafety model — the operator-input layer: dead-man, arm latch, and what happens when the controller is lost.- Automatic guidance — the curvature model and the two PGNs.
- TIM and automation — the authority-gated path, for PTO/hitch/aux.
- TIM (AEF) — why authority exists at all.
Sequence Control
A sequence is a stored series of implement actions that a master can replay
on operator command. Think of a headland turn: lift the hitch, fold the
sections, stop the PTO. Doing that by hand every pass is tedious and
error-prone. Sequence Control (defined in ISO 11783-14) lets one control
function — the master — drive that series step by step, while each
participating implement — the client — actually performs the actions. This
tutorial explains the master/client relationship, the full lifecycle as a state
machine, the messages involved, what machbus supports today versus what it
intentionally rejects, and how to drive it from the session facade.
Why this exists
On a working machine, the same handful of operator actions repeat at every headland or waterway. The standard’s idea is to let the operator perform those actions once and have a master replay them afterward — each time the trigger point is reached — so the machine repeats the operation without the operator re-doing every input. The actions themselves still live in the implements; the master only coordinates when each one fires and who has acknowledged it.
machbus implements the playback half of that picture: a master that drives
a predefined list of steps through ready, active, pause, resume, and abort, and
a client that follows along and reports what it is doing. The recording half —
capturing a fresh sequence from live operator inputs — is a deliberate gap; see
What this supports below.
Mental model
master (SCM) client (SCC)
┌──────────────┐ ┌──────────────┐
│ list of steps│ master status │ executes the │
│ + lifecycle │ ──────────────────► │ client │
│ timers │ │ function │
│ │ ◄────────────────── │ │
└──────────────┘ client status └──────────────┘
│ │
│ "start step 7" │ "ready / playing back /
│ "pause" / "resume" / "abort" │ aborting" + step echo
▼ ▼
Two control functions, two status messages, one shared cadence. The master
broadcasts its lifecycle state and the current step on PGN_SC_MASTER_STATUS.
Each client broadcasts its own state and the step it is acting on via
PGN_SC_CLIENT_STATUS. Neither side commands the other with a one-shot request:
the whole exchange is a pair of periodic status messages that each side reads
and reacts to. Clients are independent of one another — they only watch the
master, never each other.
The master/client relationship
| Role | What it owns | What it broadcasts |
|---|---|---|
| Master (SCM) | The list of steps, the lifecycle, the ready/active timeouts, who has acknowledged. | Its master state, the current step id, and busy flags. |
| Client (SCC) | The actual implement function and whether it is busy. | Its client state, the step it is executing, and a function-error byte. |
Only one master is meant to be active at a time. A client follows whichever
active master it sees and mirrors the sequence state back so the master can tell
that the step landed. The master treats a step as truly active only once enough
clients have acknowledged it — configurable through
SCMasterConfig::required_client_count (a 0 is treated as 1, so a sequence
never starts with no participation).
Lifecycle as a state machine
machbus runs both ends on one unified internal state, sc::SCState, with these
variants: Idle, Ready, Active, Paused, Complete, Error. The master
and client map this onto the wire-level master state (SCMasterState) and
sequence state (SCSequenceState) when they encode a status frame.
add_step()* (client ack ≥ required_count)
┌──────┐ ┌──────────────┐ start() ┌───────┐ ─────────────────► ┌────────┐
│ Idle │──►│ steps loaded│ ────────► │ Ready │ │ Active │
└──────┘ └──────────────┘ └───────┘ ◄───── resume() ── └────────┘
▲ │ │ (Paused) │ │
│ │ │ ready-timeout │ │ pause()
│ │ └──────────────┐ │ ▼
│ │ │ ┌────────┐
│ ▼ │ │ Paused │
│ (master Idle/Inactive seen) ┌───────┐ │ └────────┘
└─────────────────────────────── │ Error │ ◄────────────┘
└───────┘ abort() / timeout /
step_completed() advances the step; client abort
last step ──► ┌──────────┐
│ Complete │
└──────────┘
* steps may only be added while Idle.
The transitions, in words:
- Load and start. You add steps while the master is
Idle(add_steprefuses once you leaveIdle).startrequires at least one step and moves the master toReady, clearing its ready/active timers and its ready/ack client sets. - Ready → Active. The master sits in
Readyadvertising the sequence as ready. As clients report Ready, the master collects their addresses; once it hasrequired_client_countunique clients it transitions toActiveand emits the first step. A client moves itself toReadythe moment it sees an active master advertising a ready sequence. - A step advances. In
Active, the master broadcasts the current step’s wire-visible id. The client executes and acknowledges by playing back that same step number. When the application callsstep_completed(step_id)on the master, the step is marked done, the index advances, and either the next step is emitted or the master transitions toCompleteafter the last one. - Pause / resume. The master may
pauseonly fromActive(→Paused) andresumeonly fromPaused(→Active, re-arming the active timer and clearing the per-step ack set). A client infers pause when an active master drops the sequence back to ready while the client was active, and infers resume when playback returns. - Abort and errors.
abortfrom any live state forcesErrorand makes the very next status carry an Abort sequence state, so the abort is visible on the bus, not just locally. Ready and active timeouts also land inError, as does a client reporting Abort.
Anatomy of the messages
Both status messages are classic 8-byte CAN payloads; machbus rejects shorter
or overlong reassembled buffers rather than prefix-decoding them.
Master status (PGN_SC_MASTER_STATUS) carries:
- byte 0 — a fixed message code (
SC_MSG_CODE_MASTER) that identifies the frame; - byte 1 — the master state (
SCMasterState:Inactive,Active, or the reservedInitialization); - byte 2 — the current sequence/step number, or the
0xFFnot-applicable sentinel when the sequence is merely ready; - byte 3 — the sequence state (
SCSequenceState); - byte 4 — two busy flags in bits 1–2 (non-volatile-memory busy, SCD-parsing
busy); bits 3–8 are unallocated, transmitted as
1and ignored on receive; - bytes 5–7 — reserved, held at
0xFFon transmit and ignored on receive.
Client status (PGN_SC_CLIENT_STATUS) mirrors it: a client message code in
byte 0, the client state (SCClientState: Disabled, Enabled, or reserved
Initialization) in byte 1, the echoed step number in byte 2, the sequence state
in byte 3, and a function-error byte (SCClientFuncError: NoErrors,
NoChange, Changed, NeedsConfirm) in byte 4, with the same reserved tail.
A few rules the decoders enforce, all from sc::types:
- The selected sequence number is a small standard-defined wire range. Library
step ids therefore run
0..=SC_MAX_SEQUENCE_STEP_ID(0x31); higher byte values are reserved, and0xFFis the ready / not-applicable sentinel.add_steprejects a larger id, and rejects duplicate ids in the same sequence. - A Ready sequence state must carry the
0xFFsentinel; a PlayBack state must not. Mismatched combinations are rejected. - The reserved tail bytes must all be
0xFF, or the frame is rejected.
What this supports vs rejects
This is the part to be precise about. SCSequenceState names all of the
standard’s sequence states — Reserved, Ready, Recording,
RecordingCompletion, PlayBack, Abort — so the decoders can recognize them on
the wire. But only Ready, PlayBack, and Abort are supported as active
behavior. When an active master or an enabled client advertises Recording or
RecordingCompletion, machbus treats it as an unsupported sequence state and
rejects the frame; the local state machine does not move. Likewise, the
Initialization master and client states are recognized as names but rejected as
inputs.
The practical consequence: machbus plays back sequences; it does not record
them. There is no API to capture a fresh sequence from live operator inputs.
You define steps yourself (as SequenceStep values) and the master replays them.
This is a current limitation, stated plainly rather than implied — the recording
phase, the SCD object definitions, and the VT object pools that a full
implementation would use are out of scope here.
Doing it with machbus
There are two layers. The pump-style sc::SCMaster / sc::SCClient are codecs
plus state machines: you feed them frames and elapsed time and they hand back
[u8; 8] payloads to transmit. The session facade wires those into the unified
event loop so you do not dispatch frames by hand.
You plug one or both roles into a Session at build time:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::ScMaster};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(ScMaster::new(SCMasterConfig::default()))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
// load steps and start the sequence through fine control:
ctrl.with_mut::<ScMaster, _>(|m| {
m.add_step(SequenceStep { step_id: 7, ..Default::default() })?;
m.start()
})?;
}
You reach the master through ctrl.with_mut::<ScMaster, _>(|m| ...), which
returns the master so you can call add_step, start, pause, resume,
abort, step_completed, and state. A client plugs
ScClient::new(...) and reaches it with ctrl.with_mut::<ScClient, _>(|c| ...),
exposing state, is_busy, set_busy, and report_step_complete.
The session owns the I/O: inbound SC status PGNs are routed in, the master/client
update functions run on each driver.poll()?, and any emitted payloads are
broadcast for you. You react through typed events with ctrl.drain::<ScEvent>(),
described next.
Sequence Control has a natural integration point with section state: after a
master announces a wire-visible step id, you feed that id through a
SectionRouter to toggle implement sections. You drain the master’s
ScEvent::MasterStepStarted { step_id }, bind sections to VT indicator objects,
and map step ids such as 7 and 8 onto section changes. The router
deduplicates (setting the same value twice is a no-op) and skips the VT push when
no VT plugin is present.
Events and responsibilities
The session translates internal master/client events into ScEvent variants you
drain from the queue:
| Event | Side | Meaning |
|---|---|---|
MasterStateChanged { from, to } | master | The master’s lifecycle state moved. |
MasterStepStarted { step_id } | master | A new step was dispatched. |
MasterStepCompleted { step_id } | master | A step was recorded complete. |
MasterSequenceComplete | master | The last step finished. |
MasterTimeout { reason } | master | A ready or active timeout struck. |
MasterClientStatus { source, state } | master | A valid client status arrived. |
ClientStateChanged { from, to } | client | The client’s state moved after a master status or timeout. |
ClientSequenceStart | client | The client observed a new sequence start. |
ClientStepRequest { step_id } | client | The client was asked to execute a step. |
ClientPause / ClientResume | client | The client inferred pause or resume. |
ClientAbort | client | The client aborted or entered error. |
Your responsibilities split cleanly by role. As a master application you load
steps, call start, and — crucially — call step_completed(step_id) once your
logic decides a step is done; the master does not advance on its own. As a
client application you act on ClientStepRequest, set set_busy(true) while
the function is in progress, and call report_step_complete(step_id) to
acknowledge. The one rule the client enforces for you: report_step_complete
only works while the client is Active and only for the step it was actually
asked to run.
Edge cases and failures
- Abort mid-sequence. Calling
abortfromReady,Active, orPausedforcesErrorand emits a visible Abort status immediately. Calling it fromIdle,Complete, orErroris rejected — there is nothing to abort. The standard’s intent is that an abort halts motion; your application is responsible for actually bringing the machine to a safe state when it sees the abort. - Client not ready. If a client never reports Ready (or fewer than
required_client_countclients do), the master never leavesReady. The ready timeout eventually fires and drops it toError. - Client stuck busy. A client that stays busy past its
busy_pause_timeout_mswhile Active or Paused drives itself toError, emits an Abort status, and the master picks that up as a sequence-level abort. - No ack for a step. In
Active, if the required clients do not acknowledge the current step within the active timeout, the master times out toError. An ack only counts when it echoes the current step number. - Unsupported state on the wire. A frame advertising
Recording,RecordingCompletion,Initialization, reserved state bytes, bad busy bits, a wrong message code, a wrong length, or a non-0xFFtail is rejected without moving the state machine. Use thetry_*handlers (try_handle_master_status/try_handle_client_status) when you need the explicit validation error rather than a silent no-op. - Master disappears. A client that sees the master go Inactive/Idle returns
to
Idleand emits an abort event, so a vanished master is not mistaken for a paused one.
Advanced
- Multi-client coordination. Set
required_client_countabove 1 to demand that several distinct implements participate. The master keys on client source address, so a duplicate Ready from one client does not count as a second participant, and every required client must echo the current step before its ack timer is disarmed. - Safe state on abort. The library makes the abort visible; it does not move
hydraulics. Treat
MasterTimeout,ClientAbort, and any transition intoErroras a cue to put your functions in a safe state. See Shortcut Button and safe-state thinking. - Timing and cadence. The status messages are paced: clients hold a minimum
spacing between sends (
min_status_spacing_ms) and defer a status to the nextupdateif a send would come too soon. The master emits on itsstatus_interval_ms. The standard expects a faster cadence in active states than in ready; the timeout and spacing constants insc::typesencode those expectations, andSCMasterConfig/SCClientConfiglet you tune them. - Session facade vs the bare codecs. Use the
ScMaster/ScClientplugins for applications — they route frames, run the update loop, and fan out events. Drop toSCMaster/SCClientdirectly for unit tests or tightly controlled embedded loops where you own every elapsed millisecond.
Validate locally
make test
The session tests run entirely in software: they plug the SC roles into a
Session, bind sections, and apply step ids through the router. The unit tests
under sc::master and
sc::client cover the lifecycle directly — ready-to-active on client ack,
step advance and completion, pause/resume, every timeout path, the abort paths,
the multi-client count, and the rejection of unsupported and malformed frames.
What this proves / does not prove
Proves: the playback lifecycle — ready, active, step advance, pause, resume, complete, and the abort/timeout error paths — behaves deterministically in software, the master/client status messages round-trip, and the malformed and unsupported-state frames are rejected rather than silently accepted.
Does not prove: recording of live operator sequences (not implemented), the SCD object and VT object-pool machinery a full system uses, real-hardware timing, interoperability with a specific third-party master or implement, or any conformance/certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- Sequence Control and TIM — how sequence playback relates to TIM (automated control of tractor functions by an implement) and to tractor-implement management.
- Shortcut Button and safe-state thinking — what to do when a sequence aborts or a function must stop.
Tractor-Implement Management (TIM)
Tractor-Implement Management lets an implement ask the tractor for bounded, supervised, revocable authority to command certain tractor functions — vehicle speed, front and rear hitch, front and rear PTO, and auxiliary valves — so the implement can coordinate the whole machine instead of just itself. A baler that senses a heavy windrow can request authority and ask the tractor to slow down; a planter can ask the rear hitch to lift at a headland. The implement never seizes control: it requests, the tractor grants within limits it chooses, and either side can take the authority back at any instant.
This tutorial explains why TIM exists, the authority lifecycle as a state
machine, the interlocks the tractor uses to bound what it accepts, and how to
drive the whole thing through machbus’s isobus::tim types and the
Tim session plugin.
Safety framing — read this first
TIM commands physical motion on a real machine, so it is safety-critical. Everything below is written assuming these non-negotiable properties:
- Operator-supervised, never unattended. TIM is an operator-assistance feature. A present, attentive operator is part of the system, not optional.
- Bounded. The tractor only ever accepts commands inside limits it enforces. The implement cannot exceed what the tractor allows.
- Instantly revocable. Either side can withdraw authority at any moment, and the machine must fall back to a safe state on revocation, timeout, or loss of communication.
machbusis not certified and is not a safety system. The types here model the protocol shape of TIM authority for development and testing on a virtual bus. They are not a substitute for certified hardware, functional- safety engineering, official AEF automation conformance, or the safety interlocks of a real tractor. Do not usemachbusto control an actual machine without that engineering in place.
Why this exists
An implement often knows things the tractor cannot: how heavy the current load is, where the row ends, whether the bale chamber is full. Without TIM, a human operator is the only channel for that knowledge to reach the tractor’s controls, and reaction time and attention become the limit. TIM gives the implement a disciplined way to act on what it knows — but only inside a frame the tractor and operator define.
The design follows the AEF automation principles and ISO 11783 Tractor-Implement Management: the implement is a client asking for control; the tractor is a server that owns the actuators and decides what it is willing to delegate. The server is always in charge. The client only ever borrows authority, and only for the specific functions it negotiated.
Mental model
implement (client) tractor (server)
│ │
│ 1. request authority (a set of options) │
│ ─────────────────────────────────────────►│ checks: supported?
│ │ interlocks clear?
│ 2a. granted (within tractor limits) │
│ ◄─────────────────────────────────────────│
│ │
│ 3. bounded commands (speed/hitch/PTO/aux) │
│ ─────────────────────────────────────────►│ clamps to limits,
│ │ actuates
│ 4. status broadcasts │
│ ◄─────────────────────────────────────────│
│ │
either side may revoke at any time ──► machine returns to a safe state
│ │
│ 2b. denied (not supported / blocked) │
│ ◄─────────────────────────────────────────│ no authority granted
Authority is a lease, not a transfer. The implement holds it only while every condition stays true: the option was negotiated, the operator is present, no stop is active, the machine is not in road transport, and messages keep flowing. The moment any of those fails, the lease ends and the tractor returns to a safe state.
Anatomy: options, commands, status
machbus splits TIM into three concerns, each with its own types in
isobus::tim.
Options — what may be controlled
A TIM option names one controllable capability, such as “rear hitch position”
or “vehicle speed in the forward direction”. They are listed in the TimOption
enum (for example TimOption::RearHitchPositionIsSupported,
TimOption::VehicleSpeedInForwardDirectionIsSupported,
TimOption::GuidanceCurvatureIsSupported). The currently defined options occupy
a fixed set of bits; TimOptionSet packs them into a three-byte bitset
(TIM_OPTION_BYTES).
| Type | Role |
|---|---|
TimOption | One named controllable function (PTO, hitch, speed, guidance). |
TimOptionSet | A bitset of options — what a node supports, requests, or is granted. |
You build a set from options and reason about it set-wise:
#![allow(unused)]
fn main() {
// Illustrative shape — verify exact calls against isobus::tim.
let available = TimOptionSet::from_options(&[
TimOption::RearHitchPositionIsSupported,
TimOption::VehicleSpeedInForwardDirectionIsSupported,
]);
let requested = TimOptionSet::from_options(&[TimOption::RearHitchPositionIsSupported]);
assert!(requested.is_subset_of(&available)); // can only request what is supported
let missing = requested.missing_from(&available); // what would be refused
}
TimOptionSet::try_from_bytes rejects reserved bits via
TimValidationError::ReservedOptionBits, so a malformed capability payload
cannot be smuggled in as granted authority.
Commands — the bounded actions
A TimCommand is a concrete action the authority guard reasons about, such as
TimCommand::RearHitchPosition or TimCommand::RearPtoSpeedCw. Every command
maps to exactly one required option through TimCommand::required_option(), so
the guard can check “is this command covered by what was granted?” without
guesswork.
Status — what the tractor reports
The state of each actuator travels as a small fixed-layout payload. machbus
encodes and decodes these:
PtoState— engaged flag, direction, shaft speed in RPM.HitchState— motion-enabled flag plus a position0..=MAX_HITCH_POSITION(10_000, i.e.0.00%–100.00%at0.01%/bit); out-of-range positions are rejected byvalidate()/try_encode()withTimValidationError::HitchPositionOutOfRange.AuxValveCommand— a valve index0..MAX_AUX_VALVES(32), state, and flow; an out-of-range index yieldsTimValidationError::AuxValveIndexOutOfRange.
These are deliberately strict: the decoders reject wrong lengths, bad padding, and non-boolean flag bytes, so a corrupt frame becomes “no value” rather than a plausible-looking wrong value.
Lifecycle and state machine
The local authority guard is TimAuthority, and its lifecycle is the heart of
TIM. Its states are TimAuthorityState:
| State | Meaning |
|---|---|
Idle | No request outstanding. Nothing may be commanded. |
Requested | The client asked for a set of options; awaiting a grant. |
Granted | Authority is active. Covered commands may be issued. |
Denied | The request was refused. |
Revoked | A previously granted authority was withdrawn. |
The transitions and their triggers:
request(set) grant()
Idle ───────────────────► Requested ───────► Granted
▲ │ │ │
│ │ │ deny() │ revoke()
│ │ └──────────────►│ or interlock trips
│ │ ▼
│ └─────────────► Denied / Revoked
└──────────────────── request(set) again ◄────┘
- request.
TimAuthority::request(set)movesIdle → Requested. It fails withUnsupportedOptionsif the set is not a subset of what is available, orReservedOptionBitsif either set has undefined bits set. You cannot request what you do not support. - grant.
grant()movesRequested → Granted— but only if no interlock is blocking. If a stop, road mode, or missing-operator condition is active, the grant is refused withInterlockActiveand the machine stays safe. Agrant()from any state other thanRequestedfails withAuthorityNotRequested. - deny.
deny()recordsDenied. The client must not command anything. - command. While
Granted,ensure_command(cmd)(orensure_option) checks four things in order: the option is supported, it was part of the request, no interlock is blocking, and the state is stillGranted. Only if all four hold does the command proceed. - revoke / forced safe state.
revoke()moves toRevoked. Crucially,set_interlocks(...)also forcesGranted → Revokedautomatically the instant a blocking interlock appears — the client does not have to notice and react; the guard revokes for it. A revoked authority blocks every command until a freshrequest+grantcycle re-establishes it.
The one-way rule that makes this safe: anything that can go wrong drops you
out of Granted, and nothing climbs back into Granted except an explicit new
grant under clear interlocks.
Interlocks and limit enforcement
Two independent layers bound what TIM can do.
Local interlocks live in TimInterlocks, a snapshot the guard consults
before granting and before every command. The blocking conditions, expressed as
TimInterlock, are:
| Condition | TimInterlock | Why it blocks |
|---|---|---|
| Operator absent | OperatorNotPresent | TIM requires a supervising operator. |
| Road transport mode | RoadTransportMode | Implement control on the road is unsafe. |
| External stop active | ExternalStop | A stop request (e.g. a safe-state input) overrides everything. |
| Implement not ready | ImplementNotReady | The implement itself is not in a state to be controlled. |
TimInterlocks::all_clear() is the default “everything permits TIM” snapshot;
you opt into a blocking condition explicitly
(all_clear().with_external_stop(true)), and blocking_reason() returns the
first active block, or None when clear. Because set_interlocks revokes a
live grant the moment a block appears, an interlock change is the safe-state
trigger.
Tractor-side limits are the second layer and they live on the tractor, not
in the client. The implement can only ever ask for an option the tractor
published as available, and the tractor clamps each command to the range it is
willing to actuate. The client’s TimAuthority proves a command is allowed to
be sent; the tractor still decides what it is willing to do. Never assume a
sent command was accepted at full value — read the status broadcasts back.
Doing it with machbus
The pure guard (TimAuthority, TimOptionSet, the codecs) has no networking.
The Tim plugin wires it to real PGNs when you plug it into a Session with the
options the node supports.
A typical client flow — request, grant, command, observe — looks like this:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::Tim};
let rear_hitch = TimOptionSet::from_options(&[TimOption::RearHitchPositionIsSupported]);
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(Tim::new(TimAuthority::new(rear_hitch)))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
// ... after address claim ...
ctrl.with_mut::<Tim, _>(|tim| {
// Before authority, a command is refused locally and never hits the bus:
assert!(tim.command_hitch_position(Hitch::Rear, 20_000, 12).is_err()); // OptionNotRequested
// Negotiate, then command:
tim.request_authority(rear_hitch)?;
tim.grant_authority()?;
tim.command_hitch_position(Hitch::Rear, 20_000, 12) // now crosses the bus
})?;
}
The guarded command helpers on the Tim plugin are command_hitch_position,
command_pto_engage, and command_pto_disengage. Each one calls the guard
first: if ensure_command fails,
the helper emits a local TimEvent::CommandBlocked and returns an error
without sending any CAN frame. Authority changes are surfaced as
TimEvent::AuthorityStateChanged. Status the node observes from peers arrives as
TimEvent::PtoStatus, HitchStatus, PtoCommand, HitchCommand, and
AuxValveCommand, cached on the plugin (last_front_pto_status,
last_rear_hitch_status, and so on). Drain them with ctrl.drain::<TimEvent>().
A tractor (server) side plugs TIM the same way and uses the broadcast helpers
broadcast_pto_status, broadcast_hitch_status, and
broadcast_aux_valve_command to publish actuator state; invalid payloads are
rejected before send, so a bad value never leaves the node.
Events and responsibilities
Whatever role you play, these duties are not optional:
| Responsibility | What you must do |
|---|---|
| Request only what you support | Build the requested TimOptionSet as a subset of available options. |
| Honor revocation immediately | Stop commanding the instant authority leaves Granted. |
| Go to a safe state on loss | On timeout, revocation, or interlock trip, command the machine to a defined safe state, not “last value”. |
| Keep messages flowing | TIM is periodic; the tractor expects fresh authority/command/status traffic (around TIM_UPDATE_INTERVAL_MS). Silence must be treated as loss. |
Never command from Denied/Revoked | Re-run request + grant before commanding again. |
The guard helps with the first and the last by construction, but timeout and safe-state behavior are your application’s responsibility — the pure guard does not run a clock for you.
Edge cases and failures
- Authority denied. The request was not granted (
deny()), orgrant()failed because an interlock was active. The client must treat the function as unavailable and command nothing. - Revoked mid-command. The operator, the tractor, or an interlock withdrew
authority while you were commanding. The next guarded helper returns an error
and emits
CommandBlocked; you must immediately drive the machine to a safe state rather than repeating the last command. - Timeout / loss of communication. If authority, command, or status traffic stops, both sides must assume the worst and fall back to safe — the tractor releases delegated control, the client stops asserting it.
- Out-of-limit command. A position past
MAX_HITCH_POSITIONor a valve index pastMAX_AUX_VALVESis rejected before encoding; even a well-formed command is still clamped by the tractor to its own limits, so observed state may differ from what you asked for. - Operator override. The operator can always take back control. An operator
action that clears
operator_presentor asserts an external stop revokes the grant throughset_interlocks, and the machine returns to a safe state.
Advanced
- Combining with guidance and speed.
TimOption::GuidanceCurvatureIsSupportedand the vehicle-speed options let an implement coordinate steering and travel speed together — for example holding a guidance line while slowing for load. Each function is still negotiated and bounded independently; granting hitch control says nothing about speed control. See Guidance. - Who is liable. Because the implement is borrowing authority over a machine
it does not own, the boundaries matter legally as well as technically. The
tractor’s published limits and interlocks define what the implement is able
to do; the operator’s supervision defines what is permitted to happen. The
AEF automation framework and the official conformance process exist precisely
to pin down these responsibilities —
machbuscarries none of that certification. - Why bounded authority instead of full control. A lease that the server clamps and either party can revoke means no single failure (a buggy implement, a dropped message, an inattentive operator) can drive the machine outside the envelope the tractor and operator agreed to. Bounded, revocable authority is what makes implement-driven automation tolerable on a safety-critical machine.
- Pure guard vs. the session facade. Use
TimAuthoritydirectly in unit tests and embedded loops where you own timing and wiring. Use theTimplugin in applications: it couples the same guard to the real PGNs and the unified event queue for you.
Validate locally
make test
The tests/standard/aef_tim_automation.rs and tests/standard/session_harness.rs
checks drive the workflow over a two-node virtual bus: a command blocked before authority stays local and emits no frame, a
granted command crosses the bus and is decoded by the peer, an external stop
revokes authority and blocks the next command, and status/aux payloads round-
trip and update the peer’s caches. The pure-guard unit tests live alongside the
code in src/isobus/tim.rs. There is no dedicated examples/ binary for TIM;
the tests are the runnable reference.
What this proves / does not prove
Proves: the option negotiation, the request → grant → command → revoke
lifecycle, interlock-driven revocation, and the strict status/command codecs
behave deterministically in software, and that machbus refuses to emit a TIM
command frame unless authority is granted and interlocks are clear.
Does not prove — and this matters most for TIM — that any of this is safe
for real, unattended, or production control. It proves nothing about real-
hardware timing, functional-safety integrity, interoperability with a specific
tractor, operator-presence enforcement on a real machine, or AEF automation
certification. Controlling an actual tractor requires certified hardware, a
functional-safety case, the official AEF conformance process, and a supervising
operator — none of which machbus provides.
See also
- Sequence control — ordered, conditional command sequences that often drive TIM functions.
- Guidance — curvature and steering, frequently combined with TIM speed control.
- Shortcut button and safe state — the operator’s always-available path to stop automation and return the machine to safe.
Diagnostics
When something goes wrong on a machine — a sensor reads out of range, a valve
stops responding, a supply voltage sags — the ECU that noticed needs a way to
tell the rest of the network, and a service technician needs a way to read that
fault back out later. Diagnostics is the shared language for that. This tutorial
explains the diagnostic message family (the “DM” messages), shows how a fault is
shaped on the wire, and walks the machbus types that publish and read faults at
both the low (codec) level and through the session facade.
Diagnostics on ISOBUS reuses the J1939 diagnostic layer almost verbatim; the
ISO 11783-12 (Diagnostics services) part adds a few ISOBUS-specific wrinkles such
as a sixth ECU-Identification field and the control-function functionalities
advertisement. machbus ships the codecs for both worlds in one place.
Why this exists
A fault that only one ECU knows about is useless to everyone else. The whole point of network diagnostics is to make a fault visible and durable:
- Visible now. A failing node broadcasts its currently active faults so a dashboard, a logger, or a supervising controller can react in real time — light a lamp, slow the machine, or refuse to start a task.
- Durable for service. A fault that came and went still matters. Diagnostics keeps a previously active history so a technician who plugs in an hour later can see what happened, how often, and under what conditions.
- Serviceable. A service tool needs to clear codes after a repair, read identification strings to confirm which ECU and software it is talking to, and sometimes read or write raw memory. Diagnostics covers all of that with one family of messages.
Without a common diagnostic language, every manufacturer would invent its own, and a single mixed-vendor implement train would be unserviceable.
Mental model
Think of each node as keeping two lists of faults plus a lamp panel:
┌──────────────── one ECU ─────────────────┐
sensor says │ │
"out of range"│ raise ──► ACTIVE list ──► broadcast DM1 │──► bus (every 1s)
──────────► (spn, fmi, count) │
│ │ │
│ clear / repaired │
│ ▼ │
│ PREVIOUSLY-ACTIVE list ──► DM2 │──► on request
│ │
│ lamp panel: MIL / red-stop / amber / EP │
└──────────────────────────────────────────┘
service tool ──► request DM1/DM2 ──────────────────────► read codes
service tool ──► DM3 / DM11 / DM22 ────────────────────► clear codes
service tool ──► DM14 (read/write) ◄── DM15 / DM16 ─────► raw memory
service tool ──► request ECU Ident ◄── strings ─────────► identify the ECU
A node publishes faults; a service tool reads and clears them. Most read traffic is either a periodic broadcast (DM1, roughly once a second) or a request/response pair driven by the PGN Request mechanism. See PGN request for that request/response primitive — diagnostics leans on it heavily.
Anatomy of a DTC
A Diagnostic Trouble Code (DTC) is the unit of fault information. In
machbus it is j1939::Dtc, a 4-byte field with three meaningful parts:
| Part | Type | Width | Meaning |
|---|---|---|---|
| SPN — Suspect Parameter Number | u32 | 19 bits | What is faulty: a numeric handle for the parameter or component (engine speed, a specific valve, a supply rail). Values above the 19-bit ceiling are clamped on encode. |
| FMI — Failure Mode Indicator | Fmi | 5 bits | How it is faulty: above normal, below normal, voltage high/low, mechanical failure, root cause unknown, condition exists, and so on. |
| Occurrence count | u8 | 7 bits | How often it has happened since the code was first set. |
So a DTC reads as “parameter X is failing in manner Y, and it has happened N
times.” machbus represents the FMI as the j1939::Fmi enum (VoltageLow,
MechanicalFail, AbnormalRateChange, RootCauseUnknown, ConditionExists,
and the rest of the J1939-73 set). Encoding clamps the SPN to its 19-bit range
and masks the occurrence count to 7 bits, so a DTC always round-trips through a
valid wire field:
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:48:61}}
}
Two helpers matter for fault bookkeeping. Dtc::matches compares two DTCs by
(spn, fmi) only, ignoring the occurrence count — that is the right notion of
“is this the same fault?” when you decide whether to bump a counter versus add a
new code. Full PartialEq compares the count too.
The lamp panel
A DTC says what is wrong; the lamp status says how loud to be about it.
j1939::DiagnosticLamps carries four lamps — malfunction (MIL), red-stop,
amber-warning, and engine-protect — each as a LampStatus (Off, On,
Error, NotAvailable) plus a matching LampFlash (SlowFlash, FastFlash,
Off, NotAvailable). The same two-byte lamp block rides at the front of every
active/previously-active DTC message, so a reader learns both the codes and how
the operator should be alerted from one frame.
The DM message family
The diagnostic PGNs are conventionally named DM1, DM2, and so on. machbus
gives each a codec type. You do not have to use all of them; pick the ones your
node needs.
| Message | machbus type | Role |
|---|---|---|
| DM1 | DmDtcList | Active DTCs + lamp panel. Broadcast periodically. |
| DM2 | DmDtcList | Previously active DTCs + lamps. Sent on request. |
| DM3 | DmClearAllRequest (Dm3ClearPreviouslyActiveRequest) | Clear previously active codes. |
| DM11 | DmClearAllRequest (Dm11ClearActiveRequest) | Clear active codes. |
| DM4 | Dm4Message | Driver information (lamps + DTCs, alternate layout). |
| DM6 / DM12 / DM23 | Dm6Message / Dm12Message / Dm23Message (aliases of DmDtcList) | Pending, emissions-related, and previously-MIL-off DTC lists. |
| DM7 / DM8 | Dm7Command / Dm8TestResult | Command a non-continuous monitor test and report its result. |
| DM13 | Dm13Signals | Suspend / resume broadcasts network-wide. |
| DM20 | Dm20Response | Monitor performance ratios. |
| DM21 | Dm21Readiness | Diagnostic readiness counters. |
| DM22 | Dm22Message | Clear/reset a single DTC, with Ack/Nack. |
| DM25 | FreezeFrame / Dm25Request | Freeze-frame / expanded snapshot of a DTC. |
| DM5 | DiagnosticProtocolId | Which diagnostic protocols the ECU speaks. |
| DM9 / DM10 | Dm9VehicleIdentificationRequest / Dm10VehicleIdentification | Request / return the VIN. |
| DM14 / DM15 / DM16 | Dm14Request / Dm15Response / Dm16Transfer | Memory access: request, response, data transfer. |
| ECU / Software / Product ID | EcuIdentification, SoftwareIdentification, ProductIdentification | *-delimited identification strings. |
Active and previously active lists (DM1 / DM2)
DmDtcList is the workhorse. It holds a DiagnosticLamps panel and a
Vec<Dtc>. encode always produces at least 8 bytes: lamps, then the DTCs (or a
zero placeholder when the list is empty), padded with 0xFF. decode filters
out the all-zero SPN/FMI placeholder so an empty list decodes back to an empty
list. The same type serves DM2 for the previously-active history.
Clearing and resetting
There are two clearing styles. Clear-all (DM3 for previously-active, DM11 for
active) carries no selector — DmClearAllRequest is just the reserved all-0xFF
payload, and the PGN decides which list is cleared. Individual clear (DM22,
Dm22Message) names one (spn, fmi) and asks for it specifically; the responder
answers with an Ack or a Nack. Dm22Control enumerates the request and
Ack/Nack variants for both active and previously-active targets, and
Dm22NackReason explains a refusal (AccessDenied, UnknownDtc,
DtcNoLongerActive, DtcNoLongerPrevious, GeneralNack).
Freeze-frame and expanded information (DM25)
A freeze-frame captures the state of the machine at the moment a fault latched.
j1939::FreezeFrame pairs the Dtc with a timestamp and a list of SpnSnapshot
values (each an SPN plus its captured value). A service tool asks for one with
Dm25Request, naming the (spn, fmi) and a frame_number (0 = most recent).
Memory access (DM14 / DM15 / DM16)
Memory access lets a tool read, write, or erase ECU memory by address — the foundation of calibration and reflashing. The flow is request/response:
Dm14Request(tool → ECU) names aDm14Command(Read,Write,StatusRequest,Erase,BootLoad,EdcpGeneration), aDm14PointerType, a 24-bitaddress, alength, and a securitykey. The encoder rejects an address that will not fit the 24-bit wire field.Dm15Response(ECU → tool) returns aDm15Status(Proceed,Busy,Completed,Error,EdcpFault), echoes the address and length, and carries aseedbyte for the security handshake.Dm16Transfercarries the actual bytes. A single frame fits 7 data bytes; larger transfers ride the transport protocol underneath.
Identification strings
Identification messages are *-delimited printable-ASCII fields:
EcuIdentification— part number, serial number, location, type, manufacturer, and (in the ISO 11783 six-field form) a hardware ID. Encode withencode_j1939for the five-field form,encode_iso11783for six, orencodeto follow whichever the hardware-ID field implies.SoftwareIdentification— one or more version strings.ProductIdentification— make, model, serial number.Dm10VehicleIdentification— the VIN, returned in response to aDm9VehicleIdentificationRequest.
All of these reject embedded * and non-printable bytes on encode, so a
malformed string never silently corrupts the field boundaries.
How a node publishes faults; how a tool reads them
The two roles use the same messages from opposite ends.
A node publishes by keeping an active list and broadcasting DM1. The broadcast is periodic (about once per second) and also sent on demand when a tool requests DM1 with a PGN Request. When a fault clears, the node moves the DTC into its previously-active list, where it stays available for DM2.
A service tool reads by either listening for the periodic DM1 broadcast or by sending a PGN Request for DM1 or DM2 and decoding the response. It clears codes by sending DM3 / DM11 (clear-all) or DM22 (one code), and identifies the ECU by requesting ECU Identification.
The functionality advertisement
ISO 11783-12 also defines a control function functionalities message
(PGN 0xFC8E): a node advertises which protocol roles it implements so peers can
discover capabilities without trial and error. In machbus this is
isobus::functionalities::Functionalities. You declare a set of Functionality
values — UniversalTerminalServer, TaskControllerBasicClient, FileServer,
TractorImplementManagementServer, and so on — each with a generation and an
options bitfield, then serialize it into the PGN payload. Fluent helpers make
the common cases short:
#![allow(unused)]
fn main() {
// Illustrative shape — advertise a UT-server + TC-basic-client node.
let functionalities = Functionalities::new()
.with_ut_server(4)
.with_tc_basic_client(4);
}
MinimumControlFunction is always present by default, since every ISO 11783
device supports it. The model decodes strictly: it rejects unknown functionality
codes, duplicates, wrong option-block lengths, and trailing junk, so a malformed
advertisement is never accepted as valid.
Doing it with machbus
Pick the layer that suits you. For applications, use the session facade; drop to the codec layer when you own every byte.
The session facade (recommended)
Plug the Diagnostics plugin into a Session. It
owns the active/previous lists, the periodic DM1 broadcast, and request handling;
you raise faults through fine control and read inbound diagnostics off the event
stream. examples/session_minimal.rs shows exactly this — build with the plugin,
raise a DTC, and watch a peer receive the DM1:
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:build}}
}
#![allow(unused)]
fn main() {
{{#include ../../../examples/session_minimal.rs:finecontrol}}
}
The DM1 a peer sends back arrives as Event::Diag(DiagEvent::Dm1Received { .. })
on driver.poll() (or filtered via controls.drain::<DiagEvent>()). For the
service-tool messages, plug DmMemory (DM14/15/16 + ECU/Software/Product
identification) and ControlFunctionalities (the 0xFC8E advertisement)
alongside it.
The codec layer (j1939::diagnostic)
Every type above is a pure encoder/decoder: build the struct, call encode, put
the bytes on the wire; or take bytes off the wire and decode. There is no state
machine, no timer, no list management — you compose the codecs into your own
logic. The example builds a DM1 with two DTCs, encodes it, and decodes it back:
#![allow(unused)]
fn main() {
{{#include ../../../examples/diagnostic_demo.rs:10:46}}
}
This layer is right for tests, for embedded loops where you own every byte, and for any node that wants full control over which messages it speaks.
More fine control through the plugins
The Diagnostics, DmMemory, and ControlFunctionalities plugins offer the
same bookkeeping the codec layer omits — active/previous lists, the periodic DM1
broadcast, request handling — without writing any of it yourself. Plug each piece
on the builder:
Diagnostics::every(ms)turns on the diagnostics subsystem with a broadcast interval. Through fine control you reach a handle withraise(spn, fmi),clear(spn, fmi),set_lamps(..),broadcast_dm1(),active(),previous(), and senders for DM7/DM8/DM13/DM22.raiseis idempotent by(spn, fmi); the next poll (or scheduled broadcast) puts the fault on the wire. The plugin answers inbound PGN Requests for DM1 and DM2, honors DM3/DM11 clear-all requests, and processes DM22 individual clears with an Ack/Nack — all without extra code from you.DmMemoryhandles DM14/DM15/DM16 plus ECU/Software/Product identification, withsend_dm14,send_dm15,send_dm16,request_ecu_identification, andsend_ecu_identification. When you supply anEcuIdentification, the session answers PGN Requests for it automatically.ControlFunctionalitiesinstalls the PGN0xFC8Eresponder; you adjust the advertised set later through fine control, and subsequent requests reflect the change.
A minimal diagnostics-enabled flow looks like this:
#![allow(unused)]
fn main() {
// Illustrative shape, not a compiled call.
let (ctrl, mut driver) = Session::builder(my_name, 0x80)
.plug(Diagnostics::every(1000))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
while !ctrl.is_claimed() {
driver.poll()?;
}
ctrl.with_mut::<Diagnostics, _>(|d| d.raise(520, Fmi::VoltageLow)); // active, goes out on DM1
// ... later, after the repair ...
ctrl.with_mut::<Diagnostics, _>(|d| d.clear(520, Fmi::VoltageLow)); // moves it to previously-active
}
Events and responsibilities
When the diagnostics plugins are enabled, inbound diagnostic traffic surfaces as events you drain and act on.
Event (DiagEvent via ctrl.drain::<DiagEvent>() or driver.poll()) | Meaning | Typical action |
|---|---|---|
Dm1Received { source, active, lamps } | A peer broadcast its active faults. | Log / display; react to the peer’s lamps. |
Raised(dtc) | You added a fault locally. | Update your own UI / logging. |
Cleared(dtc) | A fault moved to previously-active (locally or by a tool). | Confirm the repair / reset state. |
Dm7Command / Dm8Result | A peer ran a non-continuous monitor test. | Run the test / record the outcome. |
Dm13Signals | A peer asked to suspend or resume broadcasts. | The plugin already gates DM1; observe if needed. |
Dm22Message | An individual clear request/response. | Usually handled for you; observe for auditing. |
Memory-access traffic surfaces on the same event stream as Dm14Request,
Dm15Response, Dm16Transfer, and EcuIdentification events.
The one rule that mirrors every other ISOBUS workflow: do not broadcast before you are claimed. The DM1 broadcaster checks the claim state and stays silent until the node owns an address — see Address claim.
Edge cases and failures
- No active fault. An empty
DmDtcListstill encodes to a valid 8-byte DM1 with a zero placeholder; a healthy node broadcasts “nothing wrong,” which is itself useful information. - Lamp logic. Lamps and DTCs are independent fields. A node can set a lamp with no matching code, or carry codes with all lamps off. Decide your lamp policy deliberately; do not assume a reader infers one from the other.
- Memory access denied. A
Dm14Requestdoes not guarantee aProceed. A responder may answerBusy,Error, or refuse via the seed/key handshake. Treat theDm15Statusas authoritative and never assume a write landed. - Large fault lists. A long DTC list or a multi-field identification string will not fit one CAN frame. Those payloads ride the transport protocol; see Transport protocol. DM16 transfers over 7 bytes do the same.
- Suspended broadcasts. A received DM13 suspend command stops the periodic
DM1 until it resumes or its timer expires. While suspended, the plugin’s
dm1_suspended()(viactrl.with_mut::<Diagnostics, _>) is true and no periodic DM1 goes out. - Malformed input. Every decoder is strict: wrong length, reserved bits set,
or a bad placeholder returns
None(or anErr) rather than a half-parsed value. Check the result; do not unwrap blindly on untrusted bytes.
Advanced
- Service-tool scenarios. To act as a tool rather than a faulting node, request DM1/DM2 with a PGN Request and decode the responses, send DM22 to clear a single code and inspect the returned Ack/Nack, and request ECU Identification to confirm which unit you are talking to before writing memory.
- Occurrence counting. The 7-bit occurrence count is yours to manage at the
codec layer. Use
Dtc::matchesto recognize a recurring(spn, fmi)and bump the count instead of pushing a duplicate. The plugin’sraiseis idempotent by(spn, fmi)and does not auto-increment, so counting policy stays explicit. - Persistence. The
Diagnosticsplugin keeps active and previously-active lists in memory for the life of the process. If you need codes to survive a power cycle, you own that: snapshot the lists and restore them on the next boot. The codec layer gives you the encode/decode primitives to store them however you like. - Codec vs the session facade. Reach for the codec layer when you need full
control or are writing tests; reach for the
Diagnosticsplugin when you want the active/previous bookkeeping, the periodic broadcast, and request handling done for you.
Validate locally
make run EXAMPLE=diagnostic_demo
make test
The example builds a DM1 with two DTCs, round-trips it through encode/decode, and asserts a single DTC field round-trips byte-exact. The test suite exercises every DM codec — round-trips, SPN clamping, lamp packing, and the strict-decode rejections described above.
What this proves / does not prove
Proves: the DTC field layout, the DM message codecs, the identification strings, the memory-access request/response shape, and the functionality advertisement all encode and decode correctly in software, and the diagnostics plugins manage the active/previous lists and periodic broadcast as described.
Does not prove: real-hardware timing, interoperability with a specific third-party ECU or service tool, or any conformance/certification claim. A real deployment still needs official standards, real hardware, and interoperability evidence.
See also
- Diagnostics basics — the conceptual primer for DTCs and the DM family.
- PGN request — the request/response primitive most diagnostic reads depend on.
- Transport protocol — how multi-frame DTC lists and identification strings move.
File Server
A File Server (FS) is a control function that owns a file-based storage
device and lets any other control function on the implement bus store and
retrieve data. The data plates of a tractor, the screenshots of a virtual
terminal, a stored object pool, a harvest log a task controller wants to keep
between power cycles — all of it can live on one shared server that speaks a
small request/response protocol over the bus. This tutorial covers both roles:
the client that browses and reads/writes files, and the server that
exposes a namespace and enforces the rules. It explains the operation set,
the file-handle lifecycle, the status and error model, and how to drive each
side with machbus at the low level and through the session facade.
The protocol is defined in ISO 11783-13 (File Server). machbus ports it as a
pump-style client and an enhanced, TAN-idempotent server.
Why a File Server exists
A CAN bus moves short frames, not files. Implements still need durable, shareable storage: a place to drop logs, configuration, calibration tables, and large blobs that outlive a single message. Building a flash chip into every ECU is wasteful and fragments the data across the machine. The FS model instead puts one storage device behind a network service. Any client can open a file, read or write at byte offsets, and close it again, exactly as if it had a local disk — except the disk is shared, the access is mediated, and the server decides what is visible and what is writable.
Because the namespace is server-owned, the FS is also a safety boundary. A
client only sees the paths the server chooses to expose, cannot escape that
namespace with .., and cannot write to files the server marks read-only. The
server is the single point that mounts media, names volumes, and reports when
removable media comes and goes.
Mental model
client server
────── ──────
connect ──────── GetProperties ─────────► reply: version, caps, max files
◄────────── (Connected) ─────────── (status broadcasts begin)
open "\\logs\\a.txt" ───────────────────► validate path, allocate handle
◄────────── handle = 7 ────────────
write @pos / read @pos (by handle) ─────► advance file position, reply
◄────────── bytes + status ─────────
close 7 ────────────────────────────────► release handle
... every 2 s: CCM keepalive ───────────► refresh connection liveness
Every transaction is client-initiated and server-terminated: the client sends a request tagged with a transaction number (TAN), the server does the work and answers with the same TAN. The client keeps a keepalive (CCM) flowing so the server knows it is still there; the server broadcasts its status so clients learn whether it is busy and how many files are open.
The operation set
machbus models each operation as an FSFunction code. The client builds the
request payload; the server decodes it, executes, and encodes a response. The
function set, in your own words:
| Operation | FSFunction | What it does |
|---|---|---|
| Get file-server properties | GetFileServerProperties | Read the server version, the max number of simultaneous files, and capability bits (directories, volume management, attributes, move, delete). This is the first request the client sends on connect. |
| Get file-server status | FileServerStatus | Read the busy flag and the count of currently open files. Also broadcast periodically. |
| Get current directory | GetCurrentDirectory | Ask which directory this client’s session is currently in. |
| Change current directory | ChangeDirectory | Move the client’s session to another directory, including . (stay), .. (up), and \ (root). |
| Open file | OpenFile | Open or create a file (or open a directory for listing) and get back a handle. Carries the access mode and the create/append/exclusive flags. |
| Seek | SeekFile | Set the absolute byte position of an open handle. |
| Read | ReadFile | Read up to N bytes from an open handle at its current position; the position advances by the bytes returned. For a directory handle, returns listing entries instead. |
| Write | WriteFile | Write a payload to an open handle at its current position; the file grows if needed and the position advances. |
| Close | CloseFile | Release a handle. |
| Move | MoveFile | Rename/relocate a file within the namespace. |
| Delete | DeleteFile | Remove a file. |
| Get attributes | GetFileAttributes | Read a file’s attribute byte (read-only, hidden, system, directory, archive, volume). |
| Set attributes | SetFileAttributes | Set a file’s attribute byte. |
| Get date/time | GetFileDateTime | Read the packed filesystem date and time for a path. |
| Initialize volume | InitializeVolume | Reset the volume to an empty namespace (a service-tool operation). |
| Volume status | VolumeStatus | Server-to-client broadcast of volume presence/removal state. |
Access modes and open flags
OpenFile carries an OpenFlags byte. The low two bits are the access mode;
the upper bits are independent flags you OR in:
| Flag | Bits | Meaning |
|---|---|---|
Read | mode 0x00 | Open for reading. |
Write | mode 0x01 | Open for writing. |
ReadWrite | mode 0x02 | Open for both. |
OpenDir | mode 0x03 | Open a directory for listing (read its entries with ReadFile). |
Create | 0x04 | Create the file if it does not exist. |
Append | 0x08 | Start the position at end-of-file. Invalid for read-only or directory opens. |
Exclusive | 0x10 | With Create, fail if the file already exists. |
OpenFlags::Write | OpenFlags::Create is the idiom for “create or open a file
to write into”; adding Exclusive turns it into “create, but only if new”.
The server rejects reserved bits with InvalidAccess and rejects an
unsupported directory open with NotSupported.
The file-handle lifecycle
A handle is a one-byte token (FileHandle) the server assigns on a successful
OpenFile. 0x00 and 0xFF are reserved sentinels (RESERVED_FILE_HANDLE_0,
INVALID_FILE_HANDLE), so live handles run 0x01..=0xFE. The handle is the
only thing read/write/seek/close refer to — paths are resolved exactly once, at
open time.
open(path, flags)
│ server validates path, checks caps,
│ allocates handle (or errors out)
▼
┌───────────┐ seek(pos) ┌───────────┐
│ OPEN │ ─────────────────► │ OPEN │ position moved
│ (handle) │ ◄───────────────── │ (handle) │
└───────────┘ read/write └───────────┘
│ (position advances by bytes moved)
│
│ close(handle) ──► handle released, slot freed
▼
┌───────────┐
│ CLOSED │ handle is now stale; reuse → InvalidHandle
└───────────┘
The handle is owner-scoped: the server records the owning client address on
each OpenFile and only honours read/write/seek/close from that same client.
A handle from one client is meaningless to another. The handle also dies if the
client times out (no CCM) or the volume is removed — in both cases the server
drops the open file and the next use of that handle returns InvalidHandle.
On the client side, FileClient mirrors the server’s bookkeeping in an
OpenFileInfo per handle (path, flags, local position) so the application can
track where each file cursor sits without a round trip.
The status and error model
Every server response carries an FSError byte. machbus exposes the standard
set as the FSError enum; Success is 0. The categories, and how a client
should react:
FSError | Category | Client reaction |
|---|---|---|
Success | OK | Proceed; use the returned data. |
NotFound | Path | The file or directory is not there. Create it (if you meant to) or correct the path. |
WrongType | Path | You asked for a file but the path is a directory, or vice versa. Pick the right operation. |
InvalidSourceName / InvalidDestName | Path | The name is illegal (bad characters, traversal, host-absolute). Fix the path before retrying. |
AccessDenied | Permission | The file is read-only, is open elsewhere, or the target already exists for an exclusive create. Do not retry blindly. |
InvalidAccess | Permission | The requested access mode or flag combination is not allowed. Fix the flags. |
TooManyOpen | Resource (per client) | You hit your own open-file cap. Close something and retry. Retryable. |
MaxHandles | Resource (server-wide) | The server is out of handle slots. Back off and retry later. Retryable. |
InvalidHandle | Handle | The handle is unknown, stale, or not yours. Re-open the file. |
NoSpace | Storage | The volume is full. Free space or stop. |
WriteFail | Storage / I/O | A write failed at the media. Retryable. |
MediaNotPresent | Volume | Removable media is gone. Fatal for the in-flight transfer; wait for the volume to return. |
NotInitialized | Volume | The file system did not mount. Fatal. |
NotSupported | Capability | The server does not implement this operation (check the properties first). |
InvalidLength | Framing | A length field in the request or response is wrong. |
OutOfMemory | Resource | The server could not allocate. Fatal. |
EndOfFile | Read | The read started at or past end-of-file. machbus surfaces this on the client as Ok(empty) so a read loop ends cleanly. |
TANError | Protocol | The transaction number was the reserved sentinel or otherwise invalid. |
MalformedRequest | Framing | The request could not be parsed. Fix the payload shape. |
OtherError | Catch-all | Unspecified failure. |
FSError::is_fatal() flags OutOfMemory, NotInitialized, and
MediaNotPresent — conditions a retry will not fix. FSError::is_retryable()
flags TooManyOpen, MaxHandles, and WriteFail — transient conditions where
a backoff-and-retry is the right move.
Idempotency: the TAN
The protocol cannot tell a lost request from a lost response, so every request
carries a transaction number. The client increments its TAN for each new
request (wrapping 0..=0xFE; 0xFF is the INVALID_TAN sentinel). The server
caches the last response per TAN per client: if the same TAN arrives again it
replays the cached response instead of re-executing. That is what makes a
retry safe — re-sending a ReadFile with the same TAN cannot accidentally
advance the file twice. In machbus the server keeps a TANResponse cache
that expires on a timer; the client tracks each outstanding request and matches
the reply by TAN before firing the event.
Large files over the transport
A single CAN frame holds eight data bytes. Anything longer — a read of more than
a few bytes, a directory listing, a write payload — is segmented by the
transport layer. The FS messages ride PGN_FILE_CLIENT_TO_SERVER and
PGN_FILE_SERVER_TO_CLIENT; when a payload exceeds eight bytes the stack uses
the transport or extended transport protocol automatically. Two consequences
for your code: large transfers take time (so the per-request timeout matters,
and the server sends a busy status if it cannot answer promptly), and you
should size each ReadFile/WriteFile to the chunk your buffer can hold rather
than trying to move a whole file in one call. See
Transport protocol for the segmentation details and
File Server and large data
for the conceptual primer.
Doing it with machbus
The client (low level)
fs::FileClient is pump-style. Operation methods build the outbound payload and
return it as FSClientOutbound; you ship it on the bus. Responses arrive
through handle_server_response, which decodes the frame and fires a
per-operation Event carrying Result<T, FSError>. update paces the CCM
keepalive, retries expired requests, and disconnects on a server-status
timeout. The fallible try_* variants return a precise local error instead of
a silent None when a request cannot even be built (not connected, bad path,
unknown handle, oversized payload).
The handshake is: connect_to_server (which emits the initial
GetFileServerProperties request), then a properties response transitions the
client from WaitingForStatus to Connected and fires on_connected. Only
then will open_file, directory operations, and the rest produce frames.
The server (low level)
fs::FileServer is the enhanced, TAN-idempotent server. You pre-load files and
directories with add_file / add_directory, name the volume with
set_volume_name, and tune limits through FileServerConfig (per-client cap,
server-wide cap, CCM timeout, status cadence). Feed inbound frames to
handle_client_message, which returns the response frame(s) to ship; call
update to advance timers, run the volume state machine, prune timed-out
clients, and emit periodic status broadcasts. The server also exposes volume
management — prepare_volume_for_removal, set_volume_removed,
reinsert_volume — and fires events (on_client_connected, on_file_opened,
on_volume_removed, and so on).
The file_server_demo example drives a full open/write/seek/read round trip
against a FileServer directly. The client (0x42) creates a file with
Write | Create, gets a handle back, and writes into it:
#![allow(unused)]
fn main() {
{{#include ../../../examples/file_server_demo.rs:15:24}}
}
It then writes five bytes, seeks back to the start, and reads them again:
#![allow(unused)]
fn main() {
{{#include ../../../examples/file_server_demo.rs:26:35}}
}
#![allow(unused)]
fn main() {
{{#include ../../../examples/file_server_demo.rs:42:56}}
}
The session facade (recommended for applications)
The session facade exposes both roles
through the FsClient and FsServer plugins. Inbound frames are routed and
keepalives / status broadcasts are shipped automatically on each
driver.poll()?. The client plugin turns each operation into an async-style
request: open, read, write, seek, close, current_directory, and
change_directory each return the TAN immediately, and the matching reply
arrives later as an FsEvent (OpenResponse, ReadResponse, WriteResponse, …)
you drain with ctrl.drain::<FsEvent>(). The shape on the client side:
#![allow(unused)]
fn main() {
use machbus::session::{Session, EndpointTransport, plugins::FsClient};
let (ctrl, mut driver) = Session::builder(name, 0x80)
.plug(FsClient::new(FileClientConfig::default()))
.spawn(EndpointTransport::new(0, endpoint))?;
ctrl.start()?;
ctrl.with_mut::<FsClient, _>(|fs| fs.connect_to(server_addr))?;
// ... poll until FsEvent::Connected ...
let tan = ctrl.with_mut::<FsClient, _>(|fs| fs.open("\\logs\\a.txt", OpenFlags::Read.bit()))?;
// ... poll; then match FsEvent::OpenResponse { tan, result } ...
}
The server side plugs the FsServer plugin, configured with a root, a
volume_name, and a max_clients, then populated through fine control. A session
with FsServer plugged pre-loads two files and a directory, claims an address,
and polls the server idle. ctrl.with_mut::<FsClient, _>(|fs| ...) and
ctrl.with_mut::<FsServer, _>(|fs| ...) reach the underlying FileClient /
FileServer for the methods not surfaced directly.
Events and responsibilities
Client responsibilities. Connect before doing anything else and wait for the properties response. Keep ticking so CCM keepalives flow — if they stop for the timeout window the server drops you and your handles. Match every response to its request by TAN, and on a timeout re-send with the same TAN so the server’s idempotency cache protects you. Close handles you open; on disconnect the client emits close-file frames for anything still open, but you should not rely on that as your only cleanup.
Server responsibilities. Validate every path and reject traversal, host-absolute paths, and illegal characters. Scope each handle to its owner and never honour a cross-client handle. Enforce both the per-client and the server-wide open-file caps, and keep the advertised properties consistent with the real limits. Cache responses by TAN for idempotent retries. Broadcast status on cadence (slower when idle, faster when busy) and announce volume state changes so clients can react to removal.
Edge cases and failures
- Handle exhaustion. When a client hits its per-client cap the open returns
TooManyOpen; when the whole server is out of slots it returnsMaxHandles. Both are retryable after closing files or waiting. - Path not found. A non-existent path returns
NotFoundunless the open carriesCreate. A..or host-absolute path is rejected as an invalid name before it ever reaches the filesystem. - Permission denied. Writing or deleting a read-only file, moving onto an
existing name, or an exclusive create over an existing file all return
AccessDenied. So does touching a file that is currently open elsewhere. - Volume removed mid-transfer. Once the volume goes to the removed state the
server clears all open handles and rejects file operations with
MediaNotPresent. In-flight handles are gone; the client must wait for the volume to return and re-open. - Busy server. If a request takes long enough, the server flips to busy and broadcasts that status faster, so the client knows the delay is the server working, not a lost message — and should keep waiting rather than retry early.
- Stale handle. Using a handle after close, after a client timeout, or after
a volume removal returns
InvalidHandle. Re-open to get a fresh one. - Lost request or response. Indistinguishable to the client; the cure is the same — retry with the same TAN and let the server replay or execute exactly once.
Advanced
- Multiple clients. The server tracks each client independently: separate current directory, separate handle set, separate TAN cache. There is no interference between sessions, and the connection manager caps the number of simultaneous clients.
- Handle ownership scope. Handles never cross client boundaries. Two clients can hold handles to the same file, but each has its own position; the server blocks a move/delete/attribute change while a file is open by anyone.
- Concurrency. Both the client and the server are single-threaded pump
state machines — you advance them with
update/tickand they never block. Concurrency between nodes is mediated entirely by the request/response and TAN rules, not by locks. - Persistence.
machbus’s server keeps files in memory (pre-loaded withadd_file); a real deployment backs the namespace with actual media and is responsible for mounting, naming volumes, and reporting removal. The protocol surface is identical either way. - Session facade vs the bare codecs. Use the
FsClient/FsServerplugins for applications — they route frames, ship keepalives, and fan out events for you. Use the rawFileClient/FileServerfor tests and tightly controlled loops where you own every frame and every millisecond.
Validate locally
make run EXAMPLE=file_server_demo
make test
file_server_demo runs an open/write/seek/read round trip against a
FileServer and asserts the bytes come back intact. The session tests build the
FsServer plugin on a virtual bus, pre-load files, claim an address, and poll
idle without a client. make test runs the unit and protocol-fixture
suites, including the path-safety, owner-scoping, TAN-idempotency, and
malformed-request rejection tests in the FS modules.
What this proves / does not prove
Proves: the FS operation set, the handle lifecycle, the TAN-idempotency cache,
the path-safety rules, and the error mapping behave correctly in software, and
the machbus client and server drive each other to a clean round trip.
Does not prove: real-hardware media behavior, real transport timing across a loaded bus, interoperability with a specific third-party file server or client, or any conformance/certification claim. Those still require official standards, real hardware, and interoperability evidence.
See also
- File Server and large data — the conceptual primer on why files (not frames) need their own service.
- Transport protocol — how multi-frame FS payloads are segmented and reassembled.
- VT object pools — a common use of the FS: a stored object pool a virtual terminal can load.
- Address claim — the claim every FS node does before it may send or answer.
NMEA 2000
On a lot of machines the position fix does not arrive over a serial cable into
your application — it arrives on the CAN bus, broadcast by a receiver that
speaks NMEA 2000. This tutorial is about consuming that traffic: how GNSS
position, course, speed, heading, fix quality, and date/time show up as messages
on the wire, how machbus decodes them into engineering units, and how you
read the result through the session facade’s GNSS handle without writing a byte
parser yourself.
NMEA 2000 rides the same physical CAN network as ISOBUS and J1939 and uses the same parameter-group (PGN) addressing. That means a GNSS receiver, an ISOBUS task controller, and a tractor ECU can share one bus, and your node can listen to all of them at once.
Why this exists
A guidance system, a section controller, or a yield logger all need to know where the machine is and which way and how fast it is moving. On a vehicle network it is cheaper and more robust to publish the fix once and let every interested node read it than to wire a serial cable to each consumer. NMEA 2000 is the convention for doing that on agricultural and marine CAN buses: the receiver broadcasts a small set of well-known PGNs at a steady rate, and consumers subscribe.
The catch is that the data on the wire is packed: latitudes are scaled integers,
angles are radians times a fixed factor, and “no value” is a reserved sentinel
rather than a blank. You must decode correctly and never treat a sentinel as a
real reading. That decode-and-validate step is what machbus does for you.
Mental model
GNSS receiver on the bus
│ broadcasts PGNs at a steady rate
▼
┌──────────────────────────────────────────┐
│ position rapid COG/SOG heading │
│ position detail attitude system time │ ← single-frame + fast-packet
│ DOPs rate-of-turn variation │
└──────────────────────────────────────────┘
│ decode scaled ints, drop sentinels
▼
NMEAInterface ── cache the latest fix ──► GNSSPosition
│ re-emit as events
▼
Gnss plugin ──► latest_position() / GnssEvent
The decoder keeps one cached fix (latest_position) and updates it field by
field as each PGN arrives. Position comes from one PGN, course and speed from
another, heading and attitude from yet others — they merge into the same cached
GNSSPosition. Your application reads the cache or subscribes to events; it
never sees raw bytes.
Anatomy: the message groups
machbus decodes the common navigation PGNs and a wider set of environmental,
engine, and power groups. The navigation ones are what a guidance or logging
node cares about. Each maps to a typed struct or a cached field.
Position
Two PGNs carry position, at two precisions:
- Position rapid update (PGN 129025) is a single CAN frame: just latitude
and longitude as scaled 32-bit integers, sent often.
machbusdecodes it intoGNSSPosition.wgs(aconcord::Wgswithlatitude,longitude,altitude). Because it is so small it can be broadcast several times a second, which is why it is the workhorse for steering. - GNSS position data (PGN 129029) is the detailed report: latitude,
longitude, altitude, the fix method, satellites used, and DOP values, plus
optional reference-station entries. It is much larger than 8 bytes, so it is a
Fast Packet message (see below).
machbusfills inaltitude_m,fix_type,satellites_used,hdop, andpdopfrom it.
Both feed the same cached GNSSPosition. When the detailed report lands it
carries altitude and fix detail the rapid one cannot; when the rapid report
lands between detailed ones it keeps the cached position moving smoothly.
There is also a position delta rapid update (PGN 129027) that carries a tiny
time-and-position increment rather than an absolute fix. machbus applies the
delta on top of the cached position, which lets a receiver publish high-rate
movement cheaply between full fixes.
Fix quality, mode, and satellites
A position is only as trustworthy as its fix. machbus exposes the fix method
through GNSSFixType, with variants spanning no-fix, plain GNSS, differential,
precise, RTK fixed, RTK float, and dead-reckoning, plus error and
unavailable. GNSSPosition gives you two helpers built on it:
has_fix()— true unless the fix type isNoFix.is_rtk()— true forRTKFixedorRTKFloat, the centimetre-class modes guidance usually requires.
Dilution-of-precision values (hdop, pdop, vdop) and satellites_used
round out the quality picture and arrive in the detailed position PGN and in the
standalone GNSS DOPs report (PGN 129539), decoded into GNSSDOPData with
desired and actual GNSSDOPMode plus horizontal, vertical, and time DOP.
Course, heading, and speed over ground
Direction and speed come from several PGNs that machbus folds into the cached
fix:
| What | PGN group | Where it lands |
|---|---|---|
| Course over ground + speed over ground | COG/SOG rapid (129026) | cog_rad, speed_mps |
| Heading / track control | heading-track (127250) | heading_rad |
| Attitude (yaw, pitch, roll) | attitude (127257) | heading_rad, pitch_rad, roll_rad |
| Rate of turn | rate-of-turn (127251) | rate_of_turn_rps |
| Magnetic variation | magnetic variation (127258) | event only |
Course over ground is the direction of travel; heading is where the machine is
pointed. They differ under side-slip, so machbus keeps them as separate
fields. All angles are radians.
Date and time
System time (PGN 126992) decodes into SystemTimeData: a TimeSource
(GPS, GLONASS, a local clock, …), days since the epoch, and seconds since
midnight. It is the bus-published clock you stamp logs against when you do not
trust the local one.
Network management and product info
Separate from navigation, NMEA 2000 has a small network-management layer that
machbus models in n2k_management. It is not about position at all — it is
how nodes describe themselves and prove they are alive:
- Product information (
N2KProductInfo, PGN 126996) — the NMEA 2000 protocol version, product and model identifiers, software version, serial code, certification level, and load equivalency. A fixed-layout Fast Packet payload. - Configuration information (
N2KConfigInfo, PGN 126998) — two installation description strings plus a manufacturer string. - Heartbeat (
N2KHeartbeat, PGN 126993) — a tiny periodic “I am still here” frame carrying an update interval and a rolling sequence counter.
N2KManagement builds these outbound, answers requests, tracks pending requests
with a timeout, and emits events (on_product_info_received,
on_heartbeat_received, and so on) when peers report theirs. Think of it as the
courtesy layer that lets a tool enumerate who is on the bus.
Decoding scaled integers and sentinels
Every numeric field on the wire is a scaled integer. To get an engineering value
you multiply the raw integer by a fixed resolution. machbus keeps those
factors as named constants, for example:
- latitude / longitude:
LAT_LON_RESOLUTION(1e-7 degrees per count) - altitude:
ALTITUDE_RESOLUTION - speeds:
SPEED_RESOLUTION - headings and courses:
HEADING_RESOLUTION,COG_RESOLUTION - DOP values:
DOP_RESOLUTION
The crucial rule: a field can declare itself not available with a reserved
all-ones value (0xFFFF for a 16-bit field, 0x7FFFFFFF for a signed 32-bit
one, and so on). machbus checks for these sentinels before scaling and skips
the field rather than producing a bogus number. So if a receiver has no altitude
solution, altitude_m stays None — it does not become a wildly wrong figure.
This is why so many fields on GNSSPosition are Option<f64>: present means
real, None means the receiver said “not available”. Inbound frames from the
null or broadcast source address are also rejected outright before any decode.
Each PGN has its own transmit priority
Appendix B.1 gives every parameter group a “Priority Default”, and they are not the same. Across the groups machbus sends they span four levels:
| Priority | Groups |
|---|---|
| 2 | 129025 position rapid, 129026 COG/SOG, 129027 position delta, 127250 heading, 127251 rate of turn, engine/transmission rapid |
| 3 | 129029 GNSS position data, 126992 system time, 127257 attitude, switch state |
| 5 | 127497 engine trip |
| 6 | 129539 DOPs, 129540 satellites in view, 127258 magnetic variation |
| 7 | 126993 heartbeat |
Lower wins arbitration, so this is not cosmetic: the 10 Hz position stream an
autonomy consumer gates on is meant to outrank the once-a-second housekeeping
groups. Sending everything at one priority — the J1939 general default of 6 is
the easy mistake — puts a position fix behind its own dilution-of-precision
report on a loaded bus. nmea2000_default_priority() holds the table, and the
GNSS plugin applies it per frame.
A reserved Sequence ID costs the binding, not the data
DD056 defines the SID as a correlation tag: “identical SID values within two or more different PGN transmissions identifies those PGN transmissions as a single related data set”. Values 0–252 bind, 253–254 are reserved, 255 means “No binding provided”.
Because the SID carries no measurement, machbus does not discard a parameter group over a reserved one — it maps it to 255 and decodes the rest. You still cannot correlate that frame with a matching COG/SOG, which is the honest outcome; you do not lose the position to punish its label.
Why Fast Packet for the big ones
A classic CAN frame carries at most 8 bytes. The position-rapid and COG/SOG
PGNs fit in one frame, which is exactly why they exist as separate “rapid” PGNs.
But the detailed GNSS position report and the product-info payload are far
larger than 8 bytes. NMEA 2000 reassembles those from a sequence of frames using
Fast Packet: the first frame announces the total length and the rest carry
ordered fragments. machbus validates the reassembled payload length before
decoding the detailed position PGN, and rejects a partial or malformed
reassembly rather than reading past the end. For the full mechanics of the
fragmentation and reassembly, see Fast Packet.
Position freshness and fix-quality gating
A cached fix has two failure modes that the decoder cannot decide for you: staleness and low quality. Both are your application’s call.
- Staleness. Every
GNSSPositioncarriestimestamp_us, set from the message that produced it. If the receiver drops off the bus, the cache keeps the last good fix — it does not blank out. You must compare the timestamp against “now” and refuse to act on a fix older than your control loop tolerates. A guidance node steering off a five-second-old position is worse than one that stops. - Quality. Before you feed a fix to steering, gate on it: require
has_fix(), often requireis_rtk(), and optionally boundhdoporsatellites_used. ANoFixor a float fix with high DOP is not good enough for centimetre guidance even if it is fresh.
The decoder gives you the raw material — fix type, DOPs, satellite count, timestamp — and never pretends a weak fix is strong. Turning that into a go/no-go decision is your responsibility.
Doing it with machbus
There are two entry points: the standalone NMEAInterface codec, and the
Gnss plugin that wraps it for applications.
The NMEAInterface codec
NMEAInterface is a pump-style codec with a built-in cache. You construct it
with an NMEAConfig of listen toggles, subscribe to the native events you care
about (on_position, on_cog, on_sog, on_attitude, on_system_time,
on_gnss_dops, …), and feed inbound Messages to handle_message. It updates
the cached latest_position() and fires the matching events. The GNSS monitor
example wires up the listeners and drives a position-rapid, a COG/SOG, and an
attitude message through it:
#![allow(unused)]
fn main() {
{{#include ../../../examples/gnss_monitor.rs:31:49}}
}
After those three messages the cache holds a single merged fix — position from the first, course and speed from the second, attitude angles from the third — which the example then prints.
NMEAConfig defaults to the navigation profile and can be narrowed or widened.
with_gnss_navigation(true) enables exactly the position, COG/SOG, heading,
attitude, rate-of-turn, DOPs, magnetic-variation, and system-time groups without
turning on the environmental, engine, battery, rudder, or depth decoders.
with_all(true) turns on everything. Leaving a group off means its frames are
ignored.
The Gnss plugin
For an application that has already claimed an address, the Gnss plugin owns
the codec. Reach it through ctrl.with_mut::<Gnss, _>(|g| ...); its surface is
small and task-focused:
latest_position()— the cachedGNSSPosition, orNoneif no fix yet.broadcast_position,broadcast_cog_sog— broadcast your own fix on the bus when your node is the source.interface_mut()— the underlyingNMEAInterfacefor configuration the plugin does not expose.
GNSS updates (GnssEvent::Position, Cog, Sog, Heading,
MagneticVariation, Attitude, Dops, SystemTime) arrive on the event
stream via driver.poll() or ctrl.drain::<GnssEvent>(). The plugin registers
inbound PGN callbacks that stage raw messages; each driver.poll()? drains them
through the codec, so the cached position stays current as you pump the bus.
Events and responsibilities
| Event | Meaning | Typical action |
|---|---|---|
GnssEvent::Position | The cached fix changed. | Re-evaluate freshness and quality; act if good. |
GnssEvent::Cog / Sog | Course / speed over ground updated. | Update motion model. |
GnssEvent::Heading | Heading updated. | Feed steering / display. |
GnssEvent::Attitude | Yaw/pitch/roll updated. | Terrain compensation, tilt. |
GnssEvent::Dops | DOP report arrived. | Update the quality gate. |
GnssEvent::SystemTime | Bus clock update. | Stamp logs if trusting the bus clock. |
on_*_received (n2k_management) | A peer reported product/config/heartbeat. | Update the node inventory. |
Your responsibilities: poll the driver so messages drain, gate on freshness and fix quality before acting, and — if your node is a source — only broadcast position once you own an address.
Edge cases and failures
- No fix yet.
latest_position()returnsNoneuntil the first decodable position arrives. Handle theNonecase explicitly; do not unwrap blindly. - Stale fix. The cache never expires on its own. A receiver that goes silent
leaves the last fix in place with its old
timestamp_us. You must time it out. - Sentinel “not available”. A field set to its reserved all-ones value is
skipped, so optional fields stay
Noneand required ones leave the cache unchanged. TreatNoneas “unknown”, never as zero. - Partial or malformed Fast Packet. The detailed position decoder checks the reassembled length (including any reference-station entries) and rejects a payload that does not add up, rather than reading garbage.
- Wrong source address. Frames claiming the null or broadcast address as their source are dropped before decode — they cannot poison the cache or fire events.
- Disabled group. If the relevant
NMEAConfigtoggle is off, those frames are silently ignored. A “missing” event is often just a config gap.
Advanced
- Feeding guidance and TC-GEO. A gated, fresh
GNSSPositionis the input to guidance and to geo-referenced prescription. Convert the WGS84 fix to a local frame withto_enu,to_ned, orto_ecf(using a chosen reference origin) before doing planar geometry. See TC-GEO prescription. - Batching. When you process a trajectory rather than a single fix, collect
positions into a
GNSSBatchand convert them all at once withto_enu_batch,to_ned_batch, orto_ecf_batch. The batch demo builds a short trajectory around a reference origin:
#![allow(unused)]
fn main() {
{{#include ../../../examples/gnss_batch.rs:17:28}}
}
- Rate handling. Position-rapid and COG/SOG arrive faster than the detailed report. Decide which cadence your control loop runs at and read the cache at that rate rather than reacting to every frame; the cache always holds the most recent merged state.
- Source vs. consumer. Most nodes only consume. If your node produces a
fix, the
send_*methods on theGnssplugin (and thebuild_*builders onNMEAInterface) encode the same PGNs back onto the bus. - Network management. Use
N2KManagementto broadcast heartbeats on a cadence, answer product- and config-info requests, and discover peers. It is independent of the navigation decode path.
Validate locally
make run EXAMPLE=gnss_monitor
make run EXAMPLE=gnss_batch
make test
The GNSS monitor drives position, COG/SOG, and attitude messages through the
codec in software and prints the merged cached fix. The batch demo converts a
WGS84 trajectory to local ENU/NED/ECEF frames. make test runs the round-trip
and sentinel-rejection tests for the codec and the network-management layer.
What this proves / does not prove
Proves: machbus decodes the common NMEA 2000 GNSS and navigation PGNs into
typed engineering values, merges them into one cached fix, rejects sentinels and
bad source addresses, and surfaces the result through the Gnss plugin —
all verifiable in software.
Does not prove: behaviour against a specific third-party receiver, real-bus
timing and rates, or any NMEA 2000 certification or approval. A real deployment
still needs official standards, real hardware, and interoperability evidence.
machbus ships no NMEA certification and implies none.
See also
- NMEA and GNSS basics — the conceptual primer on GNSS-on-CAN.
- Serial GNSS — the same fix arriving over a serial link instead of the bus.
- Fast Packet — how the larger PGNs are fragmented and reassembled.
- TC-GEO prescription — feeding a gated position into geo-referenced application maps.
Serial GNSS
Many GNSS receivers do not speak NMEA 2000 on a CAN bus. Instead they emit a
stream of short text lines over a plain serial port (UART/RS-232/USB). Each line
is a self-contained, human-readable sentence such as a position fix or a
course-and-speed report. This tutorial shows how machbus turns that raw byte
stream into typed GNSS fixes with nmea::SerialGNSS, how the parser validates
each line, and how the parsed result connects to the rest of the stack.
If you are looking for satellite navigation carried as binary PGNs on the CAN bus instead of text on a wire, that is a different path — see NMEA 2000. This page is specifically about the serial, sentence-based receivers.
Why this exists
A receiver chip and the application that consumes its position usually live on different boards, connected by a UART. The receiver cannot send a Rust struct down a wire, so it sends text: an ASCII line per measurement, terminated by a carriage return and/or line feed. The format is a long-standing, public de-facto convention; almost every consumer and survey-grade receiver can produce it.
The job of a serial GNSS layer is therefore narrow and well defined:
- accept bytes as they arrive, in whatever chunk sizes the OS hands you,
- find complete lines and reject corrupt ones,
- recognise the sentence kinds you care about, and
- decode their fields into engineering units and a fix-quality classification.
machbus does exactly this and nothing more: it is a pump-style parser with no
I/O of its own. You own the serial port; you feed the bytes; you receive typed
fixes and events.
Mental model
serial port bytes ──► SerialGNSS::feed_bytes(&[u8])
│
▼
split on CR / LF into lines
│
▼
validate one line (a "sentence")
$ prefix · ASCII · length · checksum
│
┌───────────┴───────────┐
valid invalid
│ │
look at the 3-char drop it; the last
sentence type good fix is untouched
│
▼
decode fields → update cached GNSSPosition
│
▼
emit on_position / on_cog / on_sog
The parser holds one “latest known” GNSSPosition and updates it field by field
as sentences arrive. A bad or unknown line never disturbs that cache, so a
glitch on the wire cannot erase a good fix.
Anatomy of a sentence
A serial GNSS sentence is one line of printable ASCII with a fixed shape:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
└┬┘└┬┘ └────────────── comma-separated fields ──────────────┘ └┬┘
│ │ checksum
│ sentence type (3 chars): what this line carries
talker ID (2 chars): which system produced it (e.g. GP, GL, GN)
The pieces, in the order the parser reads them:
| Part | Shape | Meaning |
|---|---|---|
| Start | $ | Marks the beginning of a sentence. Lines that do not start with it are dropped. |
| Talker ID | two characters after $ | Which navigation system emitted the line (GPS, GLONASS, a combined solution, and so on). machbus skips it — it keys only on the sentence type. |
| Sentence type | three characters | The structural identity: position fix, course/speed, satellite list, etc. |
| Fields | comma-separated text | The payload. Empty fields are allowed and common; their commas still appear. |
| Checksum | * then two hex digits | XOR of every byte between $ and *, written as uppercase hex. Optional on the wire, but verified when present. |
The whole line is ASCII and conventionally short. machbus caps a buffered
sentence at NMEA0183_MAX_SENTENCE_BYTES (96 bytes) so a receiver that never
emits a terminator cannot grow the buffer without bound.
Which sentence kinds machbus parses
SerialGNSS recognises the sentence type from the three characters after the
talker ID and decodes exactly these:
| Type | Carries | What machbus extracts |
|---|---|---|
GGA | Primary position fix | Latitude, longitude, altitude, fix quality, satellites used, HDOP, geoidal separation, UTC time. |
RMC | Recommended minimum | Latitude, longitude, speed over ground, course over ground, UTC time; only when the line reports a valid fix. |
VTG | Track and ground speed | Course over ground and speed over ground. |
GSA | Active satellites / DOPs | Fix dimensionality (no-fix vs fixed) plus PDOP, HDOP, VDOP. |
GLL | Geographic lat/lon | Latitude, longitude, UTC time; only when the line is marked valid. |
GSV | Satellites in view | The satellites-in-view count (kept separate from the satellites used in the fix). |
ZDA | Date and time | A full UTC date/time plus the receiver’s local-zone offset. |
Any other sentence type is ignored without touching the cache. Do not assume support for sentence kinds not in this table — the parser silently skips them.
A note on two satellite counts: GGA/RMC-style fixes populate
satellites_used (how many birds contributed to the solution), while GSV
populates a separate latest_satellites_in_view() count. They mean different
things and the parser keeps them apart on purpose.
The parse → typed fix workflow
The result type is nmea::GNSSPosition, a plain value with the fields the
sentences above can fill in. The most relevant ones:
| Field | Unit | Source sentences |
|---|---|---|
wgs (concord::Wgs: latitude, longitude, altitude) | degrees / metres | GGA, RMC, GLL |
fix_type (GNSSFixType) | enum | GGA, RMC, GSA, GLL |
satellites_used | count | GGA |
speed_mps | metres per second | RMC, VTG |
cog_rad | radians | RMC, VTG |
hdop / pdop / vdop | dimensionless | GGA, GSA |
geoidal_separation_m | metres | GGA |
timestamp_us | microseconds since midnight UTC | GGA, RMC, GLL, ZDA |
Unit conversion happens inside the parser so you never see raw NMEA encodings:
- Coordinates arrive as
ddmm.mmmm(degrees and decimal minutes) plus a hemisphere letter. The parser splits degrees from minutes, divides the minutes by 60, and applies the sign from the N/S or E/W letter to produce signed decimal degrees inwgs. - Speed from
RMCarrives in knots and fromVTGin km/h; both are converted to metres per second inspeed_mps. - Course arrives in degrees and is converted to radians in
cog_rad. - Time arrives as
hhmmss.sssand becomes microseconds-since-midnight intimestamp_us;ZDAadditionally fills a full calendar date.
Fix quality
GGA carries a numeric quality flag that the parser maps onto GNSSFixType:
quality 0 means no fix (the position is not updated); 1 is a standard GNSS
fix; 2 is a differential fix; 4 and 5 are RTK fixed and RTK float
respectively. GSA and the valid-flag in RMC/GLL can also lift a stale
no-fix up to a basic fix. You can ask a GNSSPosition whether it has any fix
with has_fix() and whether it is centimetre-grade with is_rtk().
Checksum validation and partial-line handling
Two robustness properties matter most in practice.
Checksum. When a line contains a *, the parser XORs every byte between the
$ and the *, compares it to the two hex digits that follow, and drops the
whole line on a mismatch. A malformed checksum field — non-hex digits, a missing
digit, or trailing junk after the two digits — is treated as a bad sentence,
not as “no checksum present”, so corrupted lines cannot sneak through. A line
with no * at all is still accepted, which supports receivers and loggers that
omit the field; the field-level validation below is what protects you there.
Partial lines. feed_bytes is a pump: it does not require a whole sentence
per call. Bytes accumulate in an internal line buffer and a sentence is only
parsed once a CR or LF terminates it. A sentence split across two, three, or
more feed_bytes calls reassembles correctly. If a line ever exceeds the byte
cap, or a non-ASCII byte appears mid-line, the parser drops the current line and
keeps discarding until the next terminator — so one corrupt line cannot poison
the line that follows it.
Doing it with machbus
The whole surface is nmea::SerialGNSS. Construct it, subscribe to the events
you care about, and feed it bytes.
#![allow(unused)]
fn main() {
{{#include ../../../examples/serial_gnss.rs:20:46}}
}
Three events fire as sentences land:
on_position— a new or updated position fix is available.on_cog— a fresh course-over-ground value (radians).on_sog— a fresh speed-over-ground value (metres per second).
You do not have to use events. At any time you can pull the cached state
directly: latest_position() returns the current fix or None if no fix has
been seen yet, latest_satellites_in_view() returns the most recent GSV
count, and latest_utc_datetime() returns the most recent ZDA date/time.
The example also demonstrates the chunked-feed property by splitting one GGA
across three feed_bytes calls and confirming the fix still parses:
#![allow(unused)]
fn main() {
{{#include ../../../examples/serial_gnss.rs:63:75}}
}
The serial port itself is yours to open. SerialGNSSConfig carries the usual
UART settings (baud, data_bits, stop_bits, parity) as plain pass-through
values for whatever backend you use; the parser consumes only bytes and never
touches hardware.
Events and responsibilities
| Event / accessor | Fires / returns when | Typical action |
|---|---|---|
on_position | A position-bearing sentence updated the fix. | Forward the fix to guidance, logging, or a position PGN. |
on_sog | RMC or VTG produced a ground speed. | Update a speed display or feed wheel/ground-speed logic. |
on_cog | RMC or VTG produced a track. | Update heading-derived behaviour. |
latest_position() | Polled; None until a first fix. | Read current position without subscribing. |
latest_satellites_in_view() | Polled; None until first GSV. | Report signal availability. |
latest_utc_datetime() | Polled; None until first ZDA. | Stamp records with calendar time. |
Your responsibilities: open and read the serial port, hand the bytes to
feed_bytes, and decide what a fix is good enough to act on. The parser will
not tell you that a fix is “too old” — that policy is yours, using
timestamp_us, fix_type, and the DOP fields.
Edge cases and failures
- Bad checksum. The sentence is dropped; no event fires and the cache is untouched.
- Truncated line. An unterminated line simply stays buffered until a terminator arrives; it never parses as a partial sentence.
- Oversized line. A line past the byte cap is discarded up to the next terminator, and so is its tail — the next line still parses cleanly.
- Missing or empty fields. Empty fields are normal. A sentence with too few fields, or with a coordinate/time field that fails validation, is rejected and the previous good fix survives. Invalid coordinates never overwrite a valid cached position.
- No fix. A
GGAwith quality0, or anRMC/GLLflagged invalid, marks the fix asNoFixand does not emit a position.latest_position()then returnsNone. - Mixed talker IDs. Because the parser keys only on the three-character sentence type and ignores the talker ID, a stream that mixes GPS, GLONASS, and combined sentences is parsed uniformly. If several systems report into the same cache, the fields you read reflect whichever valid sentence arrived last.
- Non-ASCII noise. A non-ASCII byte aborts the current line; the parser resynchronises at the next terminator.
Advanced
- Feeding guidance and TC-GEO. A parsed
GNSSPositioncarries aconcord::Wgsand convenience conversions (to_enu,to_ned,to_ecf) to local frames. That is exactly the position input a geo-referenced task controller flow consumes — see TC-GEO prescription for turning position into per-zone setpoints. - Combining serial and CAN sources. Some machines carry a serial receiver
and satellite PGNs on the CAN bus. Keep them as separate inputs and pick a
policy: prefer one source, or fuse them by timestamp and fix quality. Do not
blindly let the latest of either overwrite the other; compare
fix_typeand DOP first. - Rate. The fix rate is set by the receiver, not the parser;
feed_bytesimposes no rate of its own. Read the serial port often enough that the OS buffer never overflows, and letfeed_bytesabsorb bursty reads — it is built to take bytes in arbitrary chunk sizes. - Polling vs events. For a tight control loop, polling
latest_position()once per cycle is often simpler than event callbacks; for a reactive pipeline, theon_*events fan out cleanly. Both read the same cache.
Validate locally
make run EXAMPLE=serial_gnss
make test
The example parses synthetic GGA, RMC, and GSA sentences, prints the
decoded fix and the course/speed events, and demonstrates split-buffer parsing
across UART-style chunk boundaries. The crate tests cover checksum rejection,
malformed-field handling, oversized and non-ASCII lines, the satellites-in-view
vs satellites-used distinction, and ZDA calendar validation.
What this proves / does not prove
Proves: machbus decodes the listed serial sentence kinds into typed fixes with
correct unit conversion, rejects corrupt and malformed input without disturbing
the last good fix, and reassembles sentences across arbitrary byte chunks.
Does not prove: real-receiver behaviour, end-to-end timing on hardware, accuracy
of any given fix, or any conformance/certification claim. machbus ships no
NMEA certification. A real deployment still needs the actual receiver, the wiring
and serial configuration it expects, and field validation.
See also
- NMEA and GNSS basics — the conceptual primer on satellite navigation in this stack.
- NMEA 2000 — the same navigation data carried as binary PGNs on CAN instead of text on a serial wire.
- TC-GEO prescription — where a parsed position feeds geo-referenced task control.
machbus drive — the operator safety model
machbus drive is the CLI’s driving mode: it owns an AutoDrive
plugin and turns operator input into curvature and speed commands on a real bus.
machbus drive keyboard # WASD, TUI
machbus drive keyboard --daemon # headless
machbus drive joystick # gamepad, TUI
machbus drive joystick --daemon # headless
Both modes share the same physics, the same arm latch and the same single gate to the bus. They differ only in what the operator holds.
This is a development and bench tool. machbus is not a safety system and is not certified. The model below is defence in depth against the obvious failure modes, not a substitute for a rated interlock.
The shape of that defence is not arbitrary. ISO 11783-1 §6.13 (“Safe mode operation”) defers to ISO 11783-9 §4.7, whose eight clauses this page and the AutoDrive plugin between them try to honour:
| Clause | Requirement | Where |
|---|---|---|
| §4.7.1 | fail-safe on loss of power or communication | link timeout, bus-off → safe stop |
| §4.7.2 | “The implement shall not start unexpectedly.” | arm latch, latching stop, deliberate clear |
| §4.7.3 | not “prevented from stopping once the command has been given” | disengage is infallible and idempotent |
| §4.7.7 | stop automatically when a failure prevents remote control | dead-man, lost-controller handling below |
| §4.7.8 | “The operator shall have the ability to override” | emergency stop, ISB, operator engage switch |
Honouring the wording is not the same as being certified against it — §4.7.4, §4.7.5 and §4.7.6 are about the physical machine and are not machbus’s to meet.
Three layers
Nothing reaches the wire unless all three agree.
operator input │ dead-man held? arm latch satisfied?
───────────────────── │
AutoDrive plugin │ preconditions met? (link, lockout, engage switch,
│ no latched stop, no GNSS hazard)
───────────────────── │
stop latch │ nothing tripped?
───────────────────── ▼
PGN 0xAD00 + 0xFD43
Layers 2 and 3 are documented on the AutoDrive page. This page is about layer 1 — the part the tool owns.
The dead-man and the arm latch
A dead-man switch has one job: losing it must read as released. Both modes implement the same two-stage latch.
| Joystick | Keyboard | |
|---|---|---|
| Dead-man | R2 held (analog, > 0.3) | SPACE held (auto-repeat) |
| Arm | hold R2 for ARM_HOLD_SECS (1.5 s) | hold SPACE for 1.5 s |
| Release | let go of R2 | stop pressing SPACE |
| Emergency stop | A / Cross — zero motion, disarm | ENTER — zero motion, disarm |
| Clear a latched stop | complete a fresh 1.5 s arm hold | C |
| Re-arm after a disarm | must release R2 fully first | must release SPACE fully first |
Arming is deliberate. Until the dead-man has been held continuously for
1.5 s, nothing is commanded — no throttle, no steer. The UI shows a fill bar
(⚠ HOLD R2 TO ARM [███░░░]) so the operator can see the hold accumulating.
This exists so that a controller left with a trigger pressed, or a stuck key,
cannot steer the moment the tool starts.
Re-arming is deliberate too. disarm() sets a block that is only released
once the dead-man is seen fully released. Hitting emergency stop while still
holding R2 cannot silently re-arm the instant the latch clears — the UI says
⚠ RELEASE R2 TO RE-ARM until you let go.
Losing the controller counts as releasing it
This is the failure that motivated most of the model, and it is a normative requirement rather than a design preference. ISO 11783-9:2012 §4.7.7:
“Implements remotely controlled by an operator shall be designed and constructed to stop automatically in the event a detectable failure prevents the operator from remotely controlling the implement.”
A gamepad that has been unplugged is exactly that detectable failure.
Joystick. gilrs emits no button-release events when a pad disconnects. The
tool therefore treats a disconnect — unplugged cable, flat battery, dropped
Bluetooth link — as a full release: it clears the active pad, zeroes every axis
and button, zeroes motion, and disarms. There is also a liveness check
(gamepad.is_connected()) each poll, so an unplug that produces no event at all
is still caught.
Before that, the last trigger value persisted: unplugging the pad while R2 was held left the dead-man reading pressed indefinitely, and the machine kept steering at its last curvature.
Note that
AutoDrive’sCommandStalewatchdog does not cover this. That watchdog fires when the application stops refreshing the setpoint — but the tool’s loop was still running and still refreshing, so the watchdog stayed fed. A dead-man that cannot be released is not protected by a liveness timer on the thing holding it.
Keyboard. SPACE is a held key, not a toggle. Each press refreshes a
window; if no press arrives within DEADMAN_WINDOW_S, the dead-man reads
released and the tool disengages. A released dead-man also zeroes the W/A/S/D
key intensities, so a still-decaying keypress cannot keep feeding the physics —
the setpoint decays to a stop rather than freezing at its last value.
The keyboard window is coarser, on purpose
DEADMAN_WINDOW_S is 0.9 s, and that is a compromise you should understand
before driving anything real from a keyboard.
A terminal reports key repeats, not holds. X11 defaults to roughly a 660 ms delay before the first repeat, then a fast rate. The window has to outlast that initial gap or holding SPACE would drop out immediately after the first press.
So the keyboard dead-man can take up to ~0.9 s to notice you let go, where the joystick’s analog trigger is effectively instant. It is bounded and correct — not “forever”, which is what a toggle gives you — but it is not equivalent.
Use the joystick on a real machine. The keyboard mode is for a bench, a virtual CAN, or a replay.
(The fix for this is the kitty keyboard protocol, which reports true release events. It is terminal-dependent, so it would need a runtime probe and a fallback to the window above.)
Refusals are shown, not swallowed
AutoDrive refuses rather than silently ignoring, so the tool surfaces the
refusal. The telemetry pane carries a line with the ISO 11783-7 Table 45
automation status, any latched stop, and the last refusal:
aut ACTIVE:LIM-HI ■ STOP:position_stale refused:stop_condition_live
- status —
ACTIVE,ACTIVE:LIM-HI/LIM-LO(the steering ECU reporting saturation, which is the anti-windup signal),READY,FAULT,not-ready. - STOP — the latched trigger. Latching: it stays until explicitly cleared.
- refused — why the last arm/engage/command was rejected, e.g.
link_down,mechanical_lockout,operator_not_engaged,stop_condition_live,curvature_out_of_range,facility_not_advertised(the TECU answered and does not have the class “G” guidance or class “P” speed facility being commanded — see AutoDrive → the tractor has to advertise the facility).
An operator pressing engage and seeing nothing happen is the failure mode this line exists to prevent.
Clearing a latched stop
AutoDrive latches: once a stop trips, engage() refuses until the latch is
explicitly cleared. Without an affordance for that, the first stop would end the
session’s ability to drive.
- Keyboard —
C. A dedicated key, deliberately not folded into engage: clearing a fault is not by itself consent to move. - Joystick — a fresh arm hold. The pad has no spare button, so completing the 1.5 s hold is the gesture: you already released the dead-man and deliberately held it again.
Either way AutoDrive::clear_stop still refuses with stop_condition_live while
the Auxiliary Shortcut Button is held or a GNSS hazard is live, so neither path
can re-arm against a condition that is still asserted.
Key and button map
Keyboard
| Key | Action |
|---|---|
SPACE | dead-man — hold to drive |
W / S | accelerate / brake |
A / D | steer left / right |
ENTER | emergency stop + disarm |
C | clear a latched safe stop |
I / K | speed limit ± |
H / J | hitch raise / lower |
P / O | PTO on / off |
X | cycle counter multiplier |
Q, Ctrl+C | quit |
Joystick
| Control | Action |
|---|---|
R2 | dead-man — hold to drive (hold 1.5 s to arm) |
| Left stick | throttle (Y) and steer (X) |
A / Cross | emergency stop + disarm |
B / Circle | hitch raise |
X / Square | hitch lower |
Y / Triangle | PTO engage |
| D-pad ↑ / ↓ | speed limit ± |
Start | cycle counter multiplier |
Watching without driving
machbus live has an AutoDrive tab (hotkey 6, or -T autodrive) that
decodes the same conversation read-only — commanded vs estimated curvature, the
intent-to-steer flag, readiness, lockout, limit status and speed, each with a
staleness age. It transmits nothing. Use it to see what another controller is
doing, or to check your own commands from a second terminal.
What this proves / does not prove
Proves: the tool gates commands behind a held dead-man and a deliberate arm latch, treats a lost controller as a release, and surfaces every refusal and latched stop.
Does not prove: any safety rating, any certification, real-machine timing, that 0.9 s is an adequate reaction window for your application, or that the layers above it behave as documented on a specific tractor. This is a development tool.
See also
- AutoDrive (steering + speed) — the plugin, its stop triggers and the heartbeat contract.
- Automatic guidance — the curvature model.
SocketCAN replay
Trace replay lets you turn a capture into repeatable test evidence.
Use it for:
- compact trace fixtures
- bracketed candump fixtures
- malformed-line parser tests
- standard-ID rejection
- CAN FD token rejection in classic-only paths
Always record interface, bitrate, device list, command, and expected result.
Local replay command
make trace-replay-demo
Capture metadata
For a new trace, record:
- interface name, bitrate, and sample-point policy
- kernel/driver or adapter details
- connected devices and source addresses
- exact command used to capture
- expected accepted/rejected frame counts
- whether the trace is virtual CAN, bench hardware, or field capture
Fuzz and validation
Fuzz/property smoke tests feed broad byte ranges into decoders and parsers.
They are good at finding:
- panics
- unchecked indexing
- length overflows
- non-canonical re-encoding
They are not a replacement for official conformance. Use fuzzing together with golden fixtures, stack tests, trace replay, and hardware evidence.
Local commands
make fuzz-smoke
make trace-replay-demo
Adding a new failure as evidence
- Reduce the failing input to the smallest useful fixture.
- Add a unit, property, or replay test that fails without the fix.
- Document the expected behavior in the relevant tutorial/reference chapter.
- Run
make verify.
Examples overview
Examples are the safest way to learn the current API shape because they compile with the repository. Prefer them over freehand snippets.
Run examples through the Makefile:
make run EXAMPLE=session_minimal
session_minimal demonstrates the
session facade — building a node from plugins,
claiming an address, and routing events across a virtual bus.
Special binding examples have named targets:
make c-demo
make c-full-demo
make python-demo
Each example chapter explains:
- command to run
- expected output shape
- what it proves
- what it does not prove
Current example families
| Family | Example stems |
|---|---|
| session facade | session_minimal |
| basics | address_claim, heartbeat_demo, virtual_can_demo, transport_demo |
| diagnostics/powertrain | diagnostic_demo, engine_powertrain_demo, tractor_ecu_demo |
| VT/TC/FS | vt_client_demo, vt_server_demo, tc_client_demo, tc_server_demo, file_server_demo |
| GNSS/NMEA | gnss_monitor, gnss_batch, serial_gnss, speed_monitor |
| bindings | examples/c_abi, examples/python_binding |
Minimal example
Start with examples/session_minimal.rs.
Run it with:
make run EXAMPLE=session_minimal
What it proves:
- the crate builds
- a minimal node can be composed from plugins and spawned over a transport
- basic event and diagnostic flow is usable
What it does not prove:
- real CAN hardware behavior
- VT/TC interoperability
- official conformance
How to read the output
The exact text may change as examples evolve, but the output should show a node claiming an address, an event crossing the virtual bus, and a clean exit. If the example panics or times out during address claim, debug the virtual endpoint and NAME/address setup before moving to a larger scenario.
Tractor example
Use the tractor examples when you need a tractor-side role with GNSS, diagnostics, implement command, or facility behavior.
Look for:
examples/tractor_ecu_demo.rsexamples/session_minimal.rspluspresets::tractor()(see The session facade)
Expected learning:
- how a tractor node claims an address
- how tractor-side subsystems are plugged in (or pulled in with
presets::tractor()) - how events are drained
Implement example
Implement examples show the implement side of tractor/implement workflows.
Look for:
examples/session_minimal.rspluspresets::implement(pool, ws, ddop)(see The session facade)
Expected learning:
- implement-side setup with the
Implementplugin (orpresets::implement(...)) - section state
- receiving tractor commands
- participating in VT/TC/FS workflows
AutoDrive example
The driving loop machbus drive keyboard runs, with the terminal taken out so
it is readable and runnable offline.
cargo run --example autodrive_keyboard
Look for:
examples/autodrive_keyboard.rs
Expected output shape
=== AutoDrive: steering + speed behind one lifecycle ===
[refused] arm before any ECU answered: link_down
[engaged] status = ActiveNotLimited
[heartbeat] 5 guidance commands in 500 ms of driving
[released] status = Fault stop = Some("operator_override")
[refused] engage while latched: stop_latched
[cleared] latch released; re-engage is allowed again
Each line is one of the four things that are easy to get wrong:
link_down— arming before any steering ECU answers is refused, and the refusal names the precondition.AutoDrivereturns refusals rather than silently ignoring, because a client that asks to steer and is ignored cannot tell “commanded” from “declined”.- 5 commands in 500 ms — the 100 ms cadence (
MIN_TX_INTERVAL_MS, the ISO 11783-7 §5.2.7.2 minimum). The command is the heartbeat the steering ECU times out on, so it goes out even when the setpoint has not changed. Fault+operator_override— releasing the dead-man disengages and falls back toDriveCommand::halt(). Losing the dead-man must land in the same place; see the drive tool safety model.stop_latchedthen cleared — the stop latches, so re-engaging is refused until an explicit, separateclear_stop(). Clearing a fault is not by itself consent to move.
The example feeds itself Agricultural Guidance Machine Info (PGN 0xAC00), because
AutoDrive will not engage without a steering ECU answering — that refusal is
the first line of output.
What this proves
AutoDrivegates arm/engage on preconditions and reports which one failed.- The command cadence is a heartbeat at the conformance minimum.
- A safe stop latches and needs a deliberate clear.
What this does not prove
Anything about closed-loop steering or speed control, path planning, operator supervision, actuator safety, real-machine timing, interoperability with a specific tractor, or whether a given tractor acts on an unauthenticated speed command at all — see AutoDrive → Do I need TIM?. machbus is not a safety system and is not certified.
See also
- AutoDrive (steering + speed) — the plugin.
machbus drivesafety model — the operator-input layer around it: dead-man, arm latch, and losing the controller.
VT server example
Use the VT server example to understand status advertisement, upload handling, and the protocol/state boundary.
Look for:
examples/vt_server_demo.rs- the
VtServerplugin on the session facade (see The session facade)
Remember: VTServer is the protocol/state machine. Hosted Rust code can replay
its accepted render effects through VtRenderRuntime and the framebuffer/GTUI
backends, but the server itself is not a GUI window or product UI.
TC server example
Use the TC server example for topology, labels, DDOP upload/activation, and process data workflows.
Look for:
examples/tc_server_demo.rs- the
TcServerplugin on the session facade (see The session facade)
Validate section/boom/channel configuration before advertising it.
File Server example
Use the File Server example for connect/properties/status/read/write directory workflows.
Look for:
examples/file_server_demo.rs- the
FsServerplugin on the session facade (see The session facade)
The file server rejects traversal and invalid volume/path values before they mutate state.
Full scenario example
The full scenario combines multiple roles and subsystems. It is useful after the small examples make sense.
Look for:
examples/session_minimal.rsextended with several plugins or a preset group (see The session facade)examples/c_abi/full_demo.c
What it proves:
- broad API surfaces still build together
- multiple subsystems can coexist on one node
- event flow can be observed across a combined node
Bindings overview
machbus is Rust-first, and both the C and Python bindings are now built on the
session facade. They wrap the sans-IO Session
core: you drive the node explicitly by feeding received CAN frames, ticking a
millisecond clock, and draining the frames and events it wants to emit. There is
no hidden bus or internal IO; the binding hands you bytes and you move them.
Reach for a binding when you have:
- a C application that needs machbus through an ABI-stable boundary,
- Python tooling that needs demos, tests, or integration scripts,
- a product that uses Rust internally but exposes a C ABI to another runtime.
Both surfaces expose the same model:
- one node behind a single object (
MachbusSession*in C,machbus.Sessionin Python), - subsystems plugged in at construction (diagnostics, GNSS, implement, VT client, TC client),
- a four-step drive loop: feed inbound frames, tick the clock, drain
poll_transmit, drainpoll_event.
Read C ABI for the machbus_session_* surface, Python
for the machbus.Session class, and ABI stability for the
versioning contract.
C ABI
The C ABI is built on the session facade. Every
symbol is prefixed machbus_session_, and a node is one opaque
MachbusSession* handle wrapping the sans-IO Session core. The header
include/machbus.h is generated from src/ffi.rs with cbindgen.
This is ABI version 3. Probe it at runtime with
machbus_session_abi_version() and fail fast if it does not match what you
compiled against.
The model: sans-IO
The core does no IO. You bridge it explicitly:
- feed received CAN frames with
machbus_session_feed, - advance the virtual clock with
machbus_session_tick, - drain outbound frames with
machbus_session_poll_transmitand write them to your bus, - drain application events with
machbus_session_poll_event.
This replaces the old internal virtual-bus topology: there is no bundled bus and no implicit transmit. The caller owns IO.
Conventions
- Handles are
Box-backed and opaque. Create withmachbus_session_new, release exactly once withmachbus_session_free.machbus_session_free(NULL)is a no-op; double-free is outside the contract. Set your pointer toNULLafter freeing. - Errors: fallible calls return
bool(or a sentinel). On failure the reason is in the thread-localmachbus_session_last_error(), valid until the next ABI call on the same thread. Afalsefrompoll_transmit/poll_eventmeans “queue drained”, not an error, and does not set the error string. - POD types are
#[repr(C)]structs and enums (MachbusConfig,MachbusEvent,MachbusGnssPosition,MachbusClaimState,MachbusEventKind, the command enums). Borrowed byte/string views stay owned by the handle they came from.
Surface
Lifecycle and config
| Function | Purpose |
|---|---|
machbus_session_default_config() | Returns an MachbusConfig with defaults to override. |
machbus_session_new(cfg) | Build a node (NULL cfg = defaults). Returns NULL on failure. |
machbus_session_free(h) | Release a node. Accepts NULL. |
machbus_session_abi_version() | Current ABI version (3). |
machbus_session_last_error() | Thread-local last error string, or NULL. |
MachbusConfig carries the raw 64-bit ISO 11783-5 NAME, the preferred address,
and enable_* flags that plug subsystems: enable_diagnostics
(diagnostics_interval_ms, 0 = 1000 ms default), enable_gnss,
enable_implement, enable_vt_client, enable_tc_client.
CAN-config validation
| Function | Purpose |
|---|---|
machbus_session_validate_can_bus_config(...) | Returns an MachbusCanBusValidation of per-field and overall checks. |
machbus_session_enforce_iso_can_config(...) | Returns false and sets the error if the config is not ISO-conformant. |
Drive and IO
| Function | Purpose |
|---|---|
machbus_session_start_address_claim(h) | Begin address claiming. |
machbus_session_tick(h, dt_ms) | Advance the clock by dt_ms and run timers/cadences. |
machbus_session_feed(h, port, raw_id, data, len) | Feed one received frame (29-bit id, up to 8 bytes) on port. |
machbus_session_poll_transmit(h, out_port, out_raw_id, out_data, out_len) | Drain one outbound frame. out_data needs room for 8 bytes. false = drained. Call until it returns false. |
machbus_session_send_raw(h, pgn, data, len, dst, priority) | Queue an application message from the local control function (priority 0 = highest, 6 = default). |
machbus_session_poll_event(h, out) | Drain one event into an MachbusEvent. false = drained. |
Introspection
| Function | Purpose |
|---|---|
machbus_session_address(h) | Current source address (NULL_ADDRESS if unclaimed). |
machbus_session_claim_state(h) | MachbusClaimState enum. |
machbus_session_is_claimed(h) | Whether the address is claimed. |
Diagnostics — ISO 11783-12 (Diagnostics)
Require the diagnostics subsystem.
| Function | Purpose |
|---|---|
machbus_session_diag_raise(h, spn, fmi, occurrence_count) | Raise a DTC (broadcast on the next DM1 cadence). |
machbus_session_diag_clear(h) | Clear all active DTCs. |
machbus_session_diag_active_count(h) | Count of active local DTCs (0 if not plugged). |
GNSS — NMEA 2000
Require the GNSS subsystem.
| Function | Purpose |
|---|---|
machbus_session_gnss_broadcast_position(h, pos) | Broadcast an MachbusGnssPosition. |
machbus_session_gnss_broadcast_cog_sog(h, cog_rad, sog_mps) | Broadcast course/speed over ground. |
Implement — ISO 11783-7 / ISO 11783-9 (Tractor-implement)
Require the implement subsystem.
| Function | Purpose |
|---|---|
machbus_session_implement_command_hitch(h, hitch, command) | Front/rear hitch raise/lower/no-action. |
machbus_session_implement_command_hitch_position(h, hitch, target_position, rate) | Hitch to a target position (0..=1000 per mille). |
machbus_session_implement_command_pto(h, pto, command) | Front/rear PTO engage/disengage/no-action. |
machbus_session_implement_command_pto_speed(h, pto, rpm, ramp_rate) | PTO target speed with a ramp rate. |
machbus_session_implement_command_aux_valve(h, valve_index, command, flow_rate) | Auxiliary valve command. |
VT client — ISO 11783-6 (Virtual Terminal)
Require the VT client subsystem.
| Function | Purpose |
|---|---|
machbus_session_vt_connect(h, server) | Connect to a VT server address. |
machbus_session_vt_disconnect(h) | Disconnect. |
machbus_session_vt_state(h) | Connection state code (0 = disconnected). |
machbus_session_vt_is_connected(h) | Whether the client is connected. |
machbus_session_vt_show(h, object_id) / machbus_session_vt_hide(h, object_id) | Show/hide an object. |
machbus_session_vt_set_value(h, object_id, value) | Set a numeric value. |
machbus_session_vt_set_string(h, object_id, value) | Set a string value (UTF-8, NUL-terminated). |
TC client — ISO 11783-10 (Task Controller)
Require the TC client subsystem.
| Function | Purpose |
|---|---|
machbus_session_tc_connect(h) | Begin connection / DDOP upload. |
machbus_session_tc_disconnect(h) | Disconnect. |
machbus_session_tc_state(h) | Connection state code (0 = disconnected). |
machbus_session_tc_is_connected(h) | Whether the client is connected. |
Events
machbus_session_poll_event flattens one event into an MachbusEvent: a kind
discriminant (MachbusEventKind) plus generic payload fields (source,
spn_or_pgn, fmi_or_sub, d0, d1, u0) whose meaning depends on the kind.
Subsystem events that have no stable C payload yet collapse to
MachbusEventKind::Other; reach for the Rust API when you need their full
detail.
Drive loop sketch
MachbusConfig cfg = machbus_session_default_config();
cfg.name_raw = my_name_raw;
cfg.enable_diagnostics = true;
MachbusSession *s = machbus_session_new(&cfg);
if (!s) { fprintf(stderr, "%s\n", machbus_session_last_error()); return 1; }
machbus_session_start_address_claim(s);
for (;;) {
/* feed frames you received from the bus */
machbus_session_feed(s, port, rx_id, rx_data, rx_len);
machbus_session_tick(s, 10 /* ms */);
uint8_t out_port; uint32_t out_id; uint8_t out[8]; size_t out_len;
while (machbus_session_poll_transmit(s, &out_port, &out_id, out, &out_len)) {
bus_write(out_port, out_id, out, out_len);
}
MachbusEvent ev;
while (machbus_session_poll_event(s, &ev)) {
handle_event(&ev);
}
}
machbus_session_free(s);
Regenerate the header with make bind-c and prove it is stable with
make bind-c-check.
Python
The Python binding is built on the session facade
and exposed as a single machbus.Session class. It wraps the sans-IO Session
core, so the Python side drives the node explicitly: stamp inputs against a
millisecond clock, advance timers with tick, feed received CAN frames, and
drain the outbound frames and application events.
The Python bindings (pyo3, abi3 for Python 3.9+) are compiled into every hosted
(std/default) build. Build an importable wheel with make bind-py
(maturin build --features pyo3/extension-module).
Constructing a session
import machbus
s = machbus.Session(
name=machbus.name(0x100, 0x80, True),
preferred_address=0x80,
enable_diagnostics=True,
)
Constructor signature:
Session(
name=0,
preferred_address=0x80,
preset=None, # "tractor" | "implement" | "diagnostic_node"
enable_diagnostics=False,
diagnostics_interval_ms=1000,
enable_gnss=False,
enable_implement=False,
enable_vt_client=False,
enable_tc_client=False,
)
When preset is set it is plugged first, then the enable_* flags add any extra
subsystems on top. Each subsystem type may only be plugged once.
Driving the node
| Method | Purpose |
|---|---|
start() | Begin address claiming. |
tick(dt_ms) | Advance the clock by dt_ms and tick the session. |
now_ms() | Current monotonic time cursor, in milliseconds. |
run_until_claimed(timeout_ms) | Tick in 50 ms steps until claimed; returns the address. |
feed(port, can_id, data) | Feed one received CAN frame (raw 29-bit id + payload bytes) on port. |
With no bus contention the claim completes purely by ticking:
s.start()
addr = s.run_until_claimed(2000)
print(addr, s.claim_state())
Outputs
| Method | Purpose |
|---|---|
poll_transmit() | Next (port, can_id, data) to transmit, or None. |
poll_transmit_all() | Drain every queued outbound frame as (port, can_id, data) tuples. |
poll_event() | Next application event as a dict, or None when drained. |
drain_events() | Drain all queued events as dicts. |
Each event dict carries a kind and a sub, plus kind-specific fields. Branch
on those:
for (port, can_id, data) in s.poll_transmit_all():
bus_write(port, can_id, data)
while ev := s.poll_event():
if ev["kind"] == "diag" and ev["sub"] == "raised":
print(ev["spn"], ev["fmi"])
Address claim
address(), claim_state() (a string such as "claimed"), and is_claimed().
Raw send
s.send_raw(pgn, data, dst=0xFF, priority=6)
dst is a destination address (0xFF for broadcast), priority is 0..=7.
Diagnostics — ISO 11783-12 (Diagnostics)
diag_raise(spn, fmi), diag_clear(), diag_active_count(), and
diag_active() (a list of dicts). Require the diagnostics subsystem.
GNSS — NMEA 2000
gnss_broadcast_position(latitude, longitude, altitude_m=None, speed_mps=None, heading_rad=None), gnss_broadcast_cog_sog(cog_rad, sog_mps), and
gnss_latest_position() (a dict or None). Require the GNSS subsystem.
Implement — ISO 11783-7 / ISO 11783-9 (Tractor-implement)
Require the implement subsystem. Hitch and PTO take "front"/"rear"; commands
are strings such as "raise", "lower", "engage", "disengage".
| Method | Purpose |
|---|---|
imp_command_hitch(hitch, command) | Front/rear hitch command. |
imp_command_pto(pto, command) | Front/rear PTO command. |
imp_command_pto_speed(pto, rpm, ramp_rate) | PTO target speed with a ramp rate. |
imp_command_aux_valve(valve_index, command, flow_rate) | Auxiliary valve command. |
VT client — ISO 11783-6 (Virtual Terminal)
Require the VT client subsystem.
| Method | Purpose |
|---|---|
vt_connect_to(server) | Connect to a VT server address. |
vt_is_connected() | Whether the client is connected. |
vt_state() | Connection state as a string. |
vt_show(object_id) / vt_hide(object_id) | Show/hide an object. |
vt_set_value(object_id, value) | Set a numeric value. |
vt_set_string(object_id, value) | Set a string value. |
vt_change_active_mask(ws, mask) | Change the active mask of a working set. |
TC client — ISO 11783-10 (Task Controller)
Require the TC client subsystem: tc_connect(), tc_disconnect(),
tc_is_connected(), tc_state() (a string), and tc_address().
Module functions
| Function | Purpose |
|---|---|
machbus.name(identity_number, function_code, self_configurable=True) | Build a J1939/ISOBUS NAME, returns the raw 64-bit value. |
machbus.validate_can_bus_config(...) | Returns a dict of per-field and overall checks. |
machbus.enforce_iso_can_config(...) | Raises if the config is not ISO-conformant. |
ABI stability
The C ABI has an explicit version surface and a generated header. ABI stability means C callers can rely on ownership and layout rules within the documented version boundary.
Current version: 5
The ABI is version 5, reported by machbus_session_abi_version().
C examples intentionally fail fast if the runtime reports a different version.
That guard is the only thing standing between a stale header and undefined
behaviour, so it is load-bearing: a caller built against v3 that skipped it
would call the five-argument machbus_session_fs_client_seek with four
arguments, reading out_tan from an uninitialised stack slot and writing
through it.
v4 → v5
The largest change is a removal: the Guidance subsystem is gone, superseded
by AutoDrive.
-
All twelve
machbus_session_guidance_*functions are removed, along withMachbusConfig::enable_guidanceand theMACHBUS_EVENT_KIND_GUIDANCE_STOP_REQUESTEDevent kind.GuidanceandAutoDrivewere mutually exclusive authors of PGN 0xAD00 with ~80 % duplicated safety logic; every audit round had to fix both, identically. Port to themachbus_session_autodrive_*family:removed replacement guidance_engageautodrive_armthenautodrive_engageguidance_disengageautodrive_disengageguidance_command_curvature(k)autodrive_command(NAN, k)guidance_command_radius(r)autodrive_command(NAN, 1000.0 / r)guidance_command_velocity(v, ω)autodrive_command(v, (ω / v) * 1000.0)guidance_command_straight()autodrive_command(NAN, 0.0)guidance_is_engagedautodrive_is_engagedguidance_stop_reasonautodrive_stop_reasonguidance_clear_stopautodrive_clear_stopguidance_estimated_curvaturepoll the GuidanceMachineInfoeventMachbusConfigis unchanged in size (48 bytes) and no surviving field moved, so the layout assertions still hold — but a caller that setenable_guidancewill no longer compile. -
MACHBUS_EVENT_KIND_GUIDANCE_MACHINE_INFO,_LINK_LOSTand_LINK_RESTOREDare unchanged and still fire —AutoDrivenow emits them, so an event-driven C caller reading the steering ECU’s feedback needs no change._GUIDANCE_STOP_REQUESTEDis removed becauseMACHBUS_EVENT_KIND_AUTODRIVE_SAFE_STOPsupersedes it and reports which trigger fired rather than only that something did.
Beyond that removal, the error contract of two functions changed.
machbus_session_autodrive_clear_stopandmachbus_session_guidance_clear_stopcan now returnfalse. Releasing a latched safe stop has been a conditional no-op inside the plugins since the ISB and GNSS hazard interlocks landed, but both C functions returnedtrueunconditionally and cleared the last error. A caller that showed the fault as cleared on atruereturn re-enabled Engage with the latch still set. They now returnfalseand set a last-error string naming the refusal (stop_condition_live) when the operator is still holding the Auxiliary Shortcut Button or a GNSS hazard is live.
The Python autodrive_clear_stop / guidance_clear_stop raise RuntimeError
in the same case.
-
MachbusLanguageDataunit fields now carry0xFFfor an unrecognised code instead of silently substituting the metric/English default. A Language Command whose unit codes this edition does not define is no longer discarded whole either, so a caller now receives the operator’s language code with0xFFin the fields it could not interpret. Treat0xFFas “not specified” rather than as a unit value. -
machbus_session_autodrive_stop_reasonandmachbus_session_guidance_stop_reasonreturnMachbusSafeStopTriggerinstead of a bareuint32_t. The values are unchanged — the enum mirrorsSafeStopTrigger::as_code, withMACHBUS_SAFE_STOP_TRIGGER_NONE = 0for “no stop latched” — but the return type is now named, so a C HMI no longer has to hardcode codes read out of the Rust source. Codes 2 and 3 are permanently retired (they wereTimStatusTimeoutandFunctionRequestTimeout, which had no producer); they must never be reused, or every value above them shifts for callers built against an older header. -
MachbusEventKindgainedTcServerClientDisconnected(100). Additive: existing discriminants are unchanged. It fires when the TC server drops a client after six seconds without a Client Task message (ISO 11783-10 §6.6.3);sourceis the client address. A caller with an exhaustive switch over the enum must add an arm.
v3 → v4
Conformance work against ISO 11783-13 and ISO 11783-7 changed four things a C caller must audit:
machbus_session_fs_client_seekgained an argument. It was(handle, position: uint32_t, out_tan); it is now(handle, mode: uint8_t, offset: int32_t, out_tan).modeis the ISO 11783-13 B.17 Position Mode — 0 from the start, 1 from the current pointer, 2 from the end — and the offset is signed, so a rewind is a negative value.- File Server error codes were renumbered to Annex B.9. Everything from 5
upward moved:
InvalidHandleis now 5 (was 7),MediaNotPresent10 (was 12),NotSupported12 (was 20). A caller switching on the numeric value must be re-read against the table. MachbusEvent’s FS seek payload carries the resulting position rather than a unit success, per C.3.3.3.repr(C)PODs widened where a decoder gained a field (notably the GNSS position, which now carries DD209 integrity).
v2 → v3
The rewrite onto the session facade: the entire
symbol set was renamed to the machbus_session_* prefix and the model changed
to sans-IO (the caller bridges IO with feed/tick/poll instead of an internal
virtual bus). A deliberate breaking change; stability guarantees start fresh
from v3, and older machbus.h headers and pre-v3 symbol names do not apply.
What the version covers
Bump the ABI version whenever any of these change in a way C callers must audit:
- exported function signatures,
#[repr(C)]POD struct layouts (MachbusConfig,MachbusEvent, …),- enum discriminants (
MachbusClaimState,MachbusEventKind, the command enums), - ownership or error contracts.
What is checked
- generated header drift (
include/machbus.hagainstsrc/ffi.rs), - exported function compile surface,
- C POD layout assertions,
- Rust-side FFI contract tests,
- C demo workflows.
When changing ABI
- Update the Rust FFI code in
src/ffi.rs. - Bump
MACHBUS_C_ABI_VERSIONif the change is caller-visible. - Regenerate and check the header (
make bind-c,make bind-c-check). - Update examples.
- Run
make verify. - Document the change in release notes.
Reference overview
Reference chapters are for lookup after you understand the concepts.
For the recommended application API, see
The session facade; the Crate map
shows where session and the codecs sit.
Use them to find:
- module locations
- role boundaries
- feature flags
- embedded/no-std boundaries
- validation gates
- error handling conventions
- VT render coverage
- glossary terms
- protocol evidence summaries
For MCU or firmware integration, start with
no_std on microcontrollers,
then cross-check Feature flags and
Validation gates.
Crate map
| Area | Path | Purpose |
|---|---|---|
| Low-level CAN/J1939 | src/net/ | identifiers, address claim, TP/ETP, sessions |
| CAN transport seam | src/net/can_transport.rs, src/net/can_adapter.rs | crate-owned CanTransport boundary plus hosted adapter isolation |
| J1939 diagnostics | src/j1939/ | DM messages and diagnostic helpers |
| ISOBUS services | src/isobus/ | VT, TC, FS, SC, AUX, guidance, implement data |
| NMEA | src/nmea/ | NMEA 2000 and NMEA 0183 GNSS/navigation helpers |
| Hosted session facade | src/session/ | sans-IO Session core, Plugins, Driver/Controls, presets, typed events in hosted builds |
| Embedded session facade | src/embedded_session.rs | no_std + alloc Session, Driver::poll_at, caller-owned transport loop |
| Fixed-capacity helpers | src/fixed.rs | embedded queues, slots, byte buffers, and bounded message helpers |
| Time | src/time.rs | Instant — the monotonic timestamp injected into the sans-IO core |
| Lightweight geo | src/geo.rs | protocol-facing WGS/ECEF/local-frame types, with optional hosted concord conversions |
| VT storage blobs | src/vt_storage.rs | storage-agnostic VT stored-pool encode/decode for embedded-owned persistence |
| C ABI | src/ffi.rs | exported C surface |
| Python | src/python/ | Python extension bindings |
| Examples | examples/ | runnable API demonstrations |
How to choose the right layer
| Need | Start here | Why |
|---|---|---|
| Decode or encode a single frame/payload | src/net/, src/j1939/, src/isobus/, src/nmea/ | lowest surface with byte-level types |
| Build an ECU-like application | src/session/ | plugin-composed, sans-IO core + driver/handle split — see The session facade |
| Build MCU firmware | src/embedded_session.rs, src/net/can_transport.rs, src/fixed.rs | board-owned clock/CAN/storage with no_std + alloc; see no_std on microcontrollers |
| Build a common tractor/implement/VT/TC role | src/session/presets.rs | curated plugin groups for a role |
| Expose to C | src/ffi.rs and include/machbus.h | stable opaque-handle API |
| Expose to Python | src/python/ | Pythonic wrappers and event dictionaries |
| Prove behavior with executable samples | examples/ | commands are documented in the examples chapters |
Session plugins
In hosted/default machbus::session, each subsystem is a Plugin you
.plug(...) and reach by type with session.get_mut::<P>() /
controls.with_mut::<P>(...):
| Subsystem | Plugin (session::plugins) |
|---|---|
| Diagnostics (DM1) | Diagnostics |
| GNSS / NMEA 2000 | Gnss |
| Virtual Terminal | VtClient, VtServer |
| Task Controller | TcClient, TcServer |
| File Server | FsClient, FsServer |
| Implement messages | Implement |
| Sequence Control | ScMaster, ScClient |
| TIM | Tim |
| Powertrain | Powertrain |
| Heartbeat / Maintain Power | Heartbeat, MaintainPower |
| Shortcut Button / Language | ShortcutButton, LanguageCommand |
| Auxiliary / DM memory | Auxiliary, DmMemory |
| Functionalities / Group fn / Request2 / NAME mgmt | ControlFunctionalities, GroupFunction, Request2, NameManagement |
See The session facade for the full surface.
Embedded builds do not expose every hosted plugin wrapper. They expose the
caller-driven Session loop plus protocol components that can compile without
std, including core network/J1939/NMEA helpers and heap-backed VT/TC/FS pump
state. Use Feature flags and
no_std on microcontrollers
for the current embedded boundary.
Reaching a plugged subsystem
You reach any plugged subsystem by type and call its own methods:
controls.with_mut::<P>(...) (or session.get_mut::<P>()). Both return None
when that plugin was not plugged, so check the result rather than assuming the
subsystem is present.
Role boundaries
This page is the ISO 11783-1 orientation map for this crate. It names the application roles the library models, where those roles live in the code, and what evidence exists before a role should be trusted outside the local test environment.
It is intentionally a boundary document, not a replacement for the standard. The official documents define the requirements. This page only explains how the repo is organized in original wording.
Core terms used in this crate
| Term | Meaning in machbus | Main code/docs |
|---|---|---|
| Control Function | One named participant on the bus. It owns a NAME and must claim a source address before sending application traffic. | src/net/name.rs, src/net/address_claimer.rs, NAME and address claim |
| ECU | The software/hardware node hosting one or more Control Functions. In machbus this is a Session composed from plugins. | src/session/ (The session facade, First node) |
| NAME | The 64-bit identity used for arbitration and partner tracking. | src/net/name.rs, Glossary |
| Source address | The claimed 8-bit address used as the sender field in CAN identifiers. | src/net/identifier.rs, src/net/address_claimer.rs |
| Working Set | A functional group used by Virtual Terminal clients and object pools. | src/isobus/vt/, Working sets and object pools |
| Virtual Terminal | The display/input role and the implement client role around object pools and runtime commands. | src/isobus/vt/, Virtual Terminal concepts |
| Task Controller | The role that manages DDOP upload, process data, peer control, and TC-GEO helpers. | src/isobus/tc/, Task Controller concepts |
| Tractor ECU | Tractor facilities, maintain-power, hitch/PTO, speed, lighting, and related messages. | src/isobus/implement/, src/session/presets.rs |
| Implement ECU | Implement-side control/status surfaces, including sections, guidance helpers, File Server, VT client, TC client, and diagnostics. | src/isobus/, src/session/presets.rs |
| File Server | The ISO file-access client/server role. | src/isobus/fs/, File Server and large data |
| Sequence Control | Master/client workflow for ordered implement actions. | src/isobus/sc/, Sequence Control and TIM |
| TIM | Automation authority and interlock helpers. | src/isobus/tim.rs, TIM and automation |
| NIU | Network interconnect/routing helper. | src/net/niu.rs, Network routing |
Boundary rules
These rules keep examples, tests, and docs aligned:
- A node must claim an address before it sends normal application traffic.
- Address arbitration belongs to the network-management layer, not to VT, TC, FS, SC, or diagnostics code.
- Protocol roles stay separate from machine-safety decisions. TIM and shortcut button helpers expose protocol state; applications still own the real safety policy.
- A binding is a facade decision, not a new protocol definition. Rust, C, and Python should all point back to the same role behavior.
- A local test or virtual-bus example is evidence for this repository only. It is not a vendor interoperability result and not product approval.
Where to check the current status
- Claim boundary says what the project does and does not claim.
- What is tested summarizes the local test surface.
- Protocol matrix lists implemented protocol surfaces and remaining external-evidence needs.
- Standard gap roadmap tracks gaps, completed hardening slices, and binding decisions.
- Hardware evidence explains what is required before a physical-bus or peer-device result counts as repository evidence.
How this affects new work
When adding a feature, first decide which role owns it. Then update the gap matrix and add tests in the standard-derived suite before making a completion claim. If a feature spans roles, such as VT object pools over TP or TC DDOP upload over TP, test both the codec and the cross-role workflow.
If a role is intentionally not exposed through C or Python yet, record that in the binding matrix. That makes the absence explicit instead of accidental.
Feature flags
machbus separates hosted integration surfaces from embedded protocol surfaces.
The default build is intentionally convenient for Linux/desktop development,
while embedded users opt into a smaller no_std + alloc surface by disabling
default features.
Current feature model
[features]
default = ["wirebit", "dep:pyo3", "dep:concord", "tracing/std"]
embedded = []
wirebit = ["dep:wirebit", "wirebit/socketcan"]
async = ["dep:futures-core"]
There are exactly four features: default, embedded, wirebit, and async.
There is no separate std or alloc knob — the no_std switch is keyed off
embedded, so a normal build is std (with an allocator) automatically, and an
embedded build is no_std + alloc. An allocator is always available.
Unless you select embedded, the C ABI, the Python bindings (pyo3), and the
rich geo conversions (concord) are always compiled — they are part of the
default hosted build, not separate features.
The feature table
| Feature | What it enables | What it pulls in | When to use it |
|---|---|---|---|
default | Full hosted stack: std, C ABI, Python bindings, rich geo conversions, and the wirebit host CAN backend | wirebit, pyo3, concord, tracing/std | Desktop/Linux development, bindings, simulator workflows |
embedded | no_std + alloc protocol/session core plus allocation-free fixed-capacity helper primitives | (nothing; no_std) | Microcontrollers or embedded Linux code that owns time, CAN IO, and storage |
wirebit | Host CAN backend: virtual bus / simulation adapter and Linux SocketCAN | wirebit, wirebit/socketcan | Real or virtual Linux CAN interfaces and host-adapter examples |
async | Runtime-agnostic async event stream pieces | futures-core | Local-executor event consumption |
Hosted/default mode
With normal dependency syntax:
[dependencies]
machbus = { path = "../machbus" }
you get the hosted default surface. This is the right mode for examples that use
the virtual bus, C ABI work, Python binding work, rich concord conversions, and
ordinary Linux/desktop development.
Embedded no_std + alloc mode
Embedded users should disable defaults:
[dependencies]
machbus = { path = "../machbus", default-features = false, features = ["embedded"] }
This compiles the embedded public surface as no_std + alloc. The embedded loop
owns:
- monotonic time (
machbus::time::Instantvalues supplied by the caller); - CAN receive/transmit through
machbus::net::CanTransport; - storage for NIU config text, IOP bytes, and VT stored-pool blobs;
- scheduling and task wakeups.
The embedded feature does not compile:
- the C ABI;
- the Python bindings;
- SocketCAN /
wirebit; concord;- host file load/save helpers;
- host-clock
Driver::poll()convenience.
Use Driver::poll_at(now) or the embedded Session loop shape instead.
The embedded Session enables IsoNet’s direct message-capture queue, so
decoded messages are drained without a boxed callback listener at the session
boundary. Hosted callback dispatch remains explicit and opt-in.
Selected ISO 11783 application codecs are also embedded-available, including
AUX, Functionalities, Group Function, guidance codecs,
TIM, implement/tractor message codecs, and Sequence Control core
state/recording/TAN helpers. machbus::isobus::tc also builds in embedded
mode for heap-backed DDOP/object codecs, TC client/server pump state, TC-GEO,
grids, task lifecycle/logging, rate limiting, outstanding-request tracking,
ISOXML parsing, and task totals. machbus::isobus::fs builds in embedded mode
for File Client / File Server pump state, ISO 11783-13 operation/type/property
codecs, path validation, in-memory file storage, and volume helpers; real media
persistence stays application-owned. machbus::isobus::vt builds in embedded
mode for object pools, VT Client / VT Server pump state, update helpers,
storage-agnostic stored-version blobs, and working-set state; VT render/GTUI
remains hosted.
Fixed-capacity helpers (embedded)
The embedded profile also ships allocation-free fixed-capacity helper
primitives for bounded-memory critical paths:
machbus::fixed::FixedQueue<T, N>,FixedFrameQueue<N>,FixedSlots<T, N>,FixedBytes<N>,FixedMessage<N>;machbus::net::TpCmdtTx<'_>,TpRxFixed<N>,EtpCmdtTx<'_>,EtpRxFixed<N>;machbus::session::FixedEvent<N>;Session::poll_fixed_event::<N>()/Driver::poll_fixed_at::<N>();examples/embedded_fixed_queue.rs, which uses fixed RX/TX transport buffers.
In no_std builds IsoNet uses a fixed-capacity pending TP/ETP transmit queue
for the deferred multi-frame send path; TP and ETP active-session tables use
fixed-capacity slot storage; Fast Packet receive-session slots and reassembly
payloads are fixed-capacity. FastPacketProtocol::process_frame_fixed::<N>()
returns a bounded FixedMessage<N> without allocating, and
FastPacketProtocol::send_fixed::<N>() avoids a growable frame vector. TP/CMDT
and ETP/DPO pending transmit windows have fixed variants
(get_pending_data_frames_fixed::<N>()), broadcast BAM transfers can use
send_bam_fixed::<N>(), and the borrowing transmit/receive helpers
(TpCmdtTx, TpRxFixed, EtpCmdtTx, EtpRxFixed) reassemble or emit
fixed-capacity batches without copying into the heap-backed session stores.
Oversized fixed events are reported explicitly instead of being truncated.
This is not yet a full no-alloc profile: the session core still uses the
heap-backed embedded profile. The fixed-capacity helpers are the
allocation-free critical-path building blocks introduced ahead of the larger
internal queue/cache/reassembly migration.
Validation targets
Use the Makefile targets rather than ad-hoc Cargo commands:
make no-std-check
make no-std-target-check
make no-std-surface-check
make embedded-examples-check
make wirebit-examples-check
make no-std-target-check checks the embedded feature on the documented
embedded target. make no-std-surface-check compiles the embedded public API
imports and minimal loop shape in a dedicated test.
For dependency audits:
cargo tree --no-default-features --features embedded -e normal
The embedded graph should stay free of wirebit, pyo3, and concord.
Combining features
Hosted features combine freely when they make sense. wirebit implies the
hosted transport path and is not part of the embedded profile. async is
runtime-agnostic, but it does not make the session Send or thread-safe; it
remains a local, explicitly pumped model.
What this proves / does not prove
Feature flags describe compile-time surface area and dependencies. They do not claim official ISO 11783, SAE J1939, NMEA, or AEF certification. A real deployment still needs official standards access, hardware evidence, and interoperability testing.
See also
Error handling
This page describes the error model of the machbus crate: one Result
alias, one Error value carrying an ErrorCode, and the conventions that codecs,
sessions, and the session facade follows so that failures stay explicit and
inspectable. It is a lookup reference; read it after you have a feel for how the
code is layered (see the crate map).
Why a single error model
Agricultural bus code runs in places where panics are unwelcome: long-lived
control loops, embedded targets, and bindings that must not unwind across a
language boundary. So machbus makes failure a value, not an exception. Every
fallible function returns Result<T, Error>, and the Error it returns always
has a discrete ErrorCode plus an optional human-readable message. Callers can
branch on the code without parsing strings, and the message is there for logs.
fallible call ──► Result<T, Error>
│
Err(Error { code: ErrorCode, message: String })
│
match on code ─► retry / fail-fast / safe-state
ErrorCode::Ok exists as the zero value (it mirrors a numeric C++ enum so stored
or wire-adjacent values stay aligned), but a successful Rust call is Ok(value),
not an Error with code Ok. You will not normally construct or match Ok as an
error in Rust.
The pieces
Result<T>— a crate-local alias forcore::result::Result<T, Error>. Most signatures in the codebase use the alias, soResult<()>means “fallible, no payload” andResult<Frame>means “fallible, returns a frame”.Error— a struct ofcode: ErrorCodeandmessage: String. Build it withError::new(code)for a bare code,Error::with_message(code, text)for context, or one of the factory helpers (Error::timeout(),Error::invalid_pgn(pgn),Error::invalid_address(addr),Error::not_connected(),Error::invalid_state(msg), and so on).ErrorCodealsoimpl From<ErrorCode> for Error, socode.into()yields a message-less error.Display— anErrorprints as just the code description when the message is empty, orcode: messagewhen it is set. The code’s ownas_strgives a short static label suitable for logs.
The error codes
The variants group by theme. Every name below is a real ErrorCode variant; the
crate defines no others.
Addressing and claim
| Code | Plain meaning | Typical cause |
|---|---|---|
AddressClaimFailed | A node could not secure a source address. | Claim arbitration did not resolve in this node’s favour. |
AddressConflict | A requested source address already belongs to another NAME. | Two nodes target the same address; the lower-priority NAME loses. |
InvalidAddress | A supplied address is out of range or reserved. | Passing the null or global address where a real one is required. |
Transport sessions
| Code | Plain meaning | Typical cause |
|---|---|---|
Timeout | A timed operation did not complete in its window. | No expected response arrived before the deadline elapsed. |
TransportTimeout | A multi-frame transfer stalled. | A TP/ETP peer stopped sending or acknowledging mid-transfer. |
TransportAborted | A multi-frame transfer ended early by abort. | A connection-abort condition was raised by either side. |
SessionExists | A transport session is already active for that key. | A second transfer is started for a PGN/direction/port already in flight. |
NoResources | No session slot or buffer was available. | The session table is full and cannot admit another transfer. |
Protocol identity and parse
| Code | Plain meaning | Typical cause |
|---|---|---|
InvalidPgn | A PGN value is malformed or not handled here. | A frame’s parameter group does not match what the decoder expects. |
InvalidData | A payload failed validation. | Wrong length, an out-of-range field, or a structurally invalid message body. |
Capacity and buffers
| Code | Plain meaning | Typical cause |
|---|---|---|
BufferOverflow | Data exceeded the space a codec can hold. | Encoding more bytes than the target frame or assembly buffer allows. |
Object pools
| Code | Plain meaning | Typical cause |
|---|---|---|
PoolError | A generic object-pool failure. | A pool operation failed in a way that is not a specific validation issue. |
PoolValidation | A pool or descriptor object failed validation. | A DDOP or object-pool field is malformed (bad text encoding, bad reference). |
State and lifecycle
| Code | Plain meaning | Typical cause |
|---|---|---|
NotConnected | An operation needs an established link that is not there. | Calling a client method before its server connection completed. |
InvalidState | An operation was requested in the wrong state. | Driving a state machine through a transition it does not allow yet. |
Driver and interface
| Code | Plain meaning | Typical cause |
|---|---|---|
DriverError | A lower CAN driver or setup step failed. | Bus construction or a driver call reported a failure. |
SocketError | A socket-backed transport call failed. | A read/write on the underlying socket returned an error. |
InterfaceDown | The network interface is not usable. | The bus interface is down or has not come up. |
How errors propagate
There are two surfaces, and they treat errors a little differently.
- Low-level codecs and pumps (
src/net/, the J1939 and ISOBUS encoders, TP/ETP sessions) returnResultdirectly. A decode that sees a wrong length returnsErr(Error)withInvalidDatarather than panicking; a session that is full returnsNoResources. Because the failure is in the return value, you decide what to do at the call site. - The session facade (
src/session/) drives those pumps on a tick and turns most asynchronous protocol outcomes into events you drain, not into a thrown error. A plugin control method still returnsResultwhen the caller can recover immediately (for exampleTimeoutfrom a blocking wait), but ongoing conditions — a VT object-pool rejection, a connection coming and going — reach you through the event stream. Using a subsystem handle that the builder never enabled is treated as a programming error, not a recoverableError; check setup at construction instead.
Patterns for handling them
- Match on the code, not the message. The
messageis for humans and may change;error.codeis the stable contract. - Retry vs fail-fast.
Timeout,TransportTimeout, andNoResourcesare often transient — a bounded retry or back-off is reasonable.InvalidPgn,InvalidData, andPoolValidationdescribe malformed input or a programming mistake; retrying the same bytes will fail the same way, so fail fast and fix the source. - Map to a safe state. For codes that indicate the link itself is gone or
unusable —
NotConnected,InterfaceDown,AddressClaimFailed,DriverError,SocketError— the right response is usually to stop commanding motion and move the application to its defined safe state rather than to retry blindly. - Add context as you go. When wrapping a lower call,
Error::with_messagelets you attach where it happened without losing the code.
Across the bindings
The C and Python layers expose the same model, narrowed to each ABI.
- Over the C ABI, opaque handles keep Rust ownership intact and functions report
outcome through documented status channels rather than by unwinding; a Rust
Errorbecomes a status the caller checks. See the C ABI page. - In Python, a failed call surfaces as a clear exception or an explicit
empty/false result instead of undefined behaviour, so the
ErrorCodemeaning carries across. See the Python page.
Neither binding invents new error categories; they re-present the same codes.
Common confusions
Okis not a success return. It is the zero variant of the enum for numeric compatibility. Success in Rust isOk(value)fromResult.TimeoutvsTransportTimeout. The first is any timed wait; the second is specifically a multi-frame transport transfer that stalled.TransportTimeoutvsTransportAborted. A timeout means silence past the deadline; an abort means an explicit end-of-transfer condition.PoolErrorvsPoolValidation. Validation means a pool object’s contents failed a check;PoolErroris the broader catch-all for other pool failures.InvalidDatavsInvalidPgn.InvalidPgnis about the message identity being wrong or unhandled;InvalidDatais about the body of a message that was otherwise addressed correctly.NotConnectedvsInvalidState.NotConnectedmeans a required link is absent;InvalidStatemeans the link may exist but the requested step is not allowed from the current state.- The code is stable; the message is not. Branch on
code. Log the message.
See also
- Crate map — which layer returns errors and which emits events.
- Feature flags — how disabled subsystems change the surface.
- Behavior differences — where bindings narrow the model.
- C ABI and Python — error surfacing per binding.
Validation gates
Use Makefile targets. The Makefile is the contract for this repository: when a chapter says “build”, “test”, “run an example”, or “check a binding”, prefer the target below instead of typing the underlying Cargo, C compiler, or Python commands by hand.
| Target | Purpose |
|---|---|
make build | Build the crate. |
make test | Run default tests. |
make verify | Full local validation gate. |
make no-std-check | Check the transitional embedded no_std + alloc surface. |
make no-std-target-check | Check the embedded surface on NO_STD_TARGET (default thumbv7em-none-eabihf). |
make no-std-surface-check | Check embedded public imports and loop shape through a dedicated no-std surface test. |
make embedded-examples-check | Compile embedded-shaped examples without requiring host IO. |
make bind-c-check | Check generated C header. |
make c-demo | Build/run C demo. |
make c-full-demo | Build/run full C demo. |
make python-demo | Build/install/run Python smoke. |
make trace-replay-demo | Run trace replay examples. |
make fuzz-smoke | Run arbitrary-input decoder smoke tests. |
make wirebit-examples-check | Compile SocketCAN examples without requiring live CAN. |
make standard-suite-check | Run the standard-derived ISO 11783, AEF TIM, and NMEA 2000 test suite. |
make book | Build this mdBook. |
make whitespace-check | Run git diff --check. |
If in doubt, run make verify.
Typical development loop
For ordinary source or documentation work:
make build
make test
make book
make whitespace-check
For protocol, binding, or release-sensitive work:
make verify
make verify is intentionally broader than a normal unit-test run. It checks
default and all-feature builds, clippy, rustdoc, generated C header drift, C
demos, Python smoke/regression behavior, trace replay, fuzz smoke, SocketCAN
example compilation, the standard-derived coverage suite, and whitespace.
For embedded/no-std work, also run:
make no-std-check
make no-std-target-check
make no-std-surface-check
make embedded-examples-check
Those targets are separate from make verify while the embedded profile is
transitional, so run them explicitly when changing feature gates, protocol core
imports, CAN transport boundaries, or embedded examples.
Running examples through the Makefile
The generic example target is:
make run EXAMPLE=session_minimal
make run EXAMPLE=vt_server_demo
make run EXAMPLE=tc_server_demo
The target wraps the repository’s chosen Cargo command. If an example chapter
names an example file, use the file stem after EXAMPLE=.
What a green local gate proves
A green make verify proves that the code and documentation passed the
repository’s checked evidence. It catches stale generated headers, broken
examples, binding drift, trace parser regressions, malformed-payload test
failures, and public wording that crosses the stated claim boundary.
It does not prove vendor interoperability, physical CAN timing on your machine, or official product approval. For those, add trace captures and reports through the hardware evidence process described in Hardware evidence.
Protocol matrix
The machine-readable audit matrix lives in
book/src/reference/assets/protocol_matrix.csv. This book keeps the readable
summary here and links to detailed evidence when needed.
Current summary
- Rows marked
complete-by-testsare locally complete for their documented scope. - Rows marked
implemented-needs-external-oraclehave local implementation and evidence but still require external traces, hardware, or official review before a stronger claim. - No row should be read as ISO/AEF certification.
When updating this page, prefer readable tables and links to evidence over pasting the entire CSV into the book.
Protocol coverage
This page is the map of what machbus exercises today. Read it as an evidence guide, not as a promise about every device or every corner of ISO 11783, J1939, or NMEA 2000.
The short version:
- a lot of low-level wire formats have fixture and property coverage;
- the in-process virtual bus covers many multi-node workflows;
- the C and Python bindings are tested against the same virtual-bus behavior;
- real vcan and physical-bus captures are planned, but the required capture rows are still open until reports and reduced traces are checked in.
The machine-readable detail lives in
assets/protocol_matrix.csv. This prose page is
the human version: it explains how to read that matrix and where to go next.
Reading the evidence levels
| Level | Meaning in this repository |
|---|---|
| Fixture | A test checks exact bytes, decoded fields, rejected malformed bytes, or a named regression case. |
| Virtual bus | Two or more machbus stacks exchange frames through the in-memory bus. |
| Binding smoke | C or Python calls exercise the same Rust behavior through the public facade. |
| Trace replay | A checked-in candump-style file is parsed and summarized by the replay tooling. |
| Hardware evidence | A reduced real vcan or physical-bus trace plus a report is checked in. |
If a row has only fixture or virtual-bus coverage, treat it as local software
evidence. For the hardware-evidence contract, future completed rows must link to a named reduced-hardware trace and a capture report before they are used as hardware evidence.
Areas with strong local coverage
These areas are currently the best exercised parts of the port. The evidence is still local, but it is broad enough that regressions should be caught quickly.
| Area | What is covered | Main files |
|---|---|---|
| CAN identifiers and PGNs | 29-bit identifier layout, PDU1/PDU2 PGN rules, raw-ID helpers, invalid frame rejection, and driver-frame conversion. | src/net/identifier.rs, src/net/pgn.rs, src/net/frame.rs, tests/protocol_fixtures.rs, tests/standard/iso11783_03_datalink.rs |
| NAME and address claim | NAME packing, address arbitration, cannot-claim behavior, request-for-address-claim responses, duplicate/self echo cases, and commanded address handling. | src/net/name.rs, src/net/address_claimer.rs, src/net/network_manager.rs, tests/agisostack_compat.rs, tests/standard/iso11783_05_network_management.rs |
| Transport Protocol and ETP | BAM/RTS/CTS/DT flows, abort paths, receive limits, timing/cadence fixtures, large receive profiles, malformed CM/DT handling, and generated receive streams. | src/net/tp.rs, src/net/etp.rs, tests/protocol_fixtures.rs, tests/protocol_fixtures.rs |
| Fast Packet | NMEA-style fast-packet transmit/receive, sequence handling, malformed stream handling, and generated receive streams. | src/net/fast_packet.rs, tests/protocol_fixtures.rs |
| Diagnostics | DM1/DM2 request behavior, DTC packing, DM3/DM11 clear flows, DM13/DM22/Product/Software/FreezeFrame-style codecs, memory-access helpers, and malformed payload rejection. | src/j1939/diagnostic.rs, src/j1939/dm_memory.rs, tests/standard/iso11783_12_diagnostics.rs, tests/protocol_fixtures.rs |
| Utility PGNs | Heartbeat, Maintain Power, Language Command, Shortcut Button, Request2/Transfer, Acknowledgment, Group Function, and Control Function Functionalities. | src/j1939/, src/isobus/, tests/protocol_fixtures.rs, tests/standard/session_harness.rs |
ISOBUS application families
The library also has higher-level session plugins for common ISOBUS roles. In most cases, the Rust side is richer than the C/Python facade, and the docs explain the facade separately.
| Family | Current shape | Evidence style |
|---|---|---|
| Virtual Terminal client/server/render runtime | Object-pool upload helpers, status/events, auxiliary capability discovery, visibility helpers, server/client roles, standard-aware retained attribute/value replay including Output Line Change End Point replay, Get Attribute Value responses from current body/retained state with invalid-object/invalid-attribute error bits, Working Set Special Controls AID 1 byte-count reporting, Number Variable value reads, and String Variable fixed-length reads, standard shape-object AID ordering, open Output Ellipse arc/segment/section command emission and framebuffer rasterisation, output-shape line-attribute width-zero no-stroke handling through command emission and framebuffer replay, Input Number/List enabled-bit overlays that preserve real-time-editing bits, and Container-only Hide/Show with Hidden AID 3 synchronization across Hide/Show, Change Attribute, hosted runtime replay, Macro replay, and parent-to-child scene visibility, numeric-target validation for Change Numeric Value, server-side Change Attribute AID admission that rejects unsupported/read-only AIDs, wrong-type reference-valued AIDs, Button Options static latchable-bit mutation, and out-of-range scalar option/format/justification/boolean/shape-angle/state-relative min-max values before retained-state mutation, Graphics Context payload/reference validation before replay retention, F.57 response/error emission with client-side event parsing, plus line-attribute stroke-width and line-art command expansion, Alarm Mask Change Priority metadata replay, bounds-checked Change List Item and Change Polygon Point replay, Soft Key Mask Key/ObjectPointer/ExternalObjectPointer child resolution with NULL-slot reservation, runtime user-layout placement plus host-persistable recall snapshots for Window Mask and Key Group nodes, VT On User-Layout Hide/Show emission for placed nodes, VT Pointing Event emission for non-interactive Data Mask / free-form Window Mask touch areas, checked VT-to-ECU bus-message payload/full-message parsing with VT6 TAN constructors for activation and numeric-value notifications, H.3/H.5/H.7/H.9/H.11/H.13/H.14/H.15/H.16/H.17/H.19/F.57 ECU response/error helpers for Soft Key Activation, Button Activation, Pointing Event, Select Input Object, VT ESC, Numeric Value Change, Change Active Mask, Change Soft Key Mask, String Value Change, and Graphics Context, Lock/Unlock Mask deferred refresh with host-driven timeout expiry, and a hosted render runtime that turns pools into backend-neutral command streams plus a shared backend trait and runtime-aware software framebuffer snapshot/export backend. | Fixture tests, virtual-bus tests, C/Python smoke coverage for client/server, and Rust-only standard-suite/book-ledger coverage for rendering. |
| Task Controller client/server | DDOP/object model helpers, value commands, TC-GEO rate conversion, prescription-rate helpers, structure/localization labels, and direct-dispatch validation. | Fixture tests, virtual-bus tests, C/Python smoke coverage. |
| File Server | Connect, properties/status, directory, open/read/write/close-style workflows. | Virtual-bus tests and C/Python smoke coverage. |
| Section Control | Master/client lifecycle, section routing, ready/playback/ack/completion events. | Virtual-bus tests and C/Python smoke coverage. |
| TIM | Authority and command/status logic with interlock-oriented tests. | Local session tests; independent-peer captures are still required. |
| Tractor/implement/GNSS | TECU, implement status, powertrain, guidance, NMEA 2000, and serial GNSS helpers. | Fixture tests, virtual-bus tests, and binding smoke where exposed. |
For a tutorial-style entry point, start in the Tutorials section of the book
instead of this reference page.
Binding coverage
The Rust API is the implementation surface. The C and Python APIs expose a stable subset:
- C is checked by
make bind-c-check,make c-demo, andmake c-full-demo(theexamples/c_abi/demos against the generatedinclude/machbus.h). - Python is checked by
make python-demo, which runs the basic example, regression runner, wheel build, wheel install, and the same regression smoke from a disposable environment.
The binding contracts are described in
audit/bindings.md. When a Rust feature is not yet exposed
through C or Python, do not infer a binding promise from the Rust module alone.
Trace and hardware path
Trace tooling exists now:
examples/candump_replay.rsparses compact and bracketedcandumptext;tests/fixtures/traces/manifest.txtrecords provenance for checked-in traces;make trace-replay-demoreplays the current trace fixtures;- the hardware-evidence fixtures under
tests/fixtures/hardware/andtests/fixtures/traces/track required future capture rows (maintained by hand).
The hardware-facing documentation is in
hardware-evidence.md. The current open capture IDs
are:
| ID | Evidence class |
|---|---|
vcan_address_claim | vcan development capture |
vcan_dm1 | vcan development capture |
physical_address_claim | isolated physical-bus capture |
peer_request_address_claim | independent-peer capture |
tp_bam_transfer | independent TP observer capture |
etp_connection_transfer | independent ETP observer capture |
network_interconnect_router | multi-segment router capture |
diagnostic_request_response | service-tool style peer capture |
vt_object_pool_upload | independent VT upload capture |
vt_object_pool_upload_failure | independent VT failure-path capture |
implement_message_broadcast | independent implement decoder capture |
powertrain_engine_trace | independent powertrain decoder capture |
tractor_ecu_facility_trace | TECU peer capture |
tc_ddop_upload | independent TC capture |
file_server_read_write | independent File Server peer capture |
section_control_lifecycle | independent Section Control peer capture |
tim_authority_interlock | independent TIM peer capture |
nmea2000_gnss_environment_trace | independent NMEA 2000 observer capture |
The public-data evidence ID is ddi_public_database_refresh. It is separate
from the capture list because DDI freshness is proven by source provenance,
refresh metadata, generated-file diffs, and a public-data report rather than by
a CAN trace.
How to use the CSV matrix
Use assets/protocol_matrix.csv when you need exact row-level status. The CSV
is intentionally boring and machine-readable; keep it updated by hand as
coverage changes.
Use this page when you need the story:
- find the protocol family above;
- check whether evidence is fixture-only, virtual-bus, binding, trace, or hardware evidence;
- open the named source/test files;
- add new evidence to the CSV, tests, and docs together.
When in doubt, prefer narrower wording. A single passing byte fixture proves that byte fixture. It does not prove a whole protocol family.
VT render coverage
This page is the repo-owned coverage ledger for the ISO 11783-6 Virtual Terminal renderer. It is deliberately a claim boundary, not a certification statement.
The current renderer is a retained command-list renderer with an optional
software framebuffer consumer. Hosted backends share the small
VtRenderBackend scene-consumer contract:
ObjectPool -> LayoutEngine -> Scene -> GtuiRenderer -> RenderCommand[]
└── FramebufferRenderer -> RGB pixels
VtRenderRuntime -> RenderCommand[] -> FramebufferRenderer -> RGB pixels
That is enough to load a pool, choose a mask, lay out many common objects, and
produce deterministic draw commands for a host backend. The hosted
FramebufferRenderer can also rasterise those commands into an RGB buffer for
snapshots or framebuffer experiments, then export packed RGB888 or RGB565 bytes
for display-driver handoff. Scenes retain the effective palette produced by the
base renderer palette, active Colour Palette object, and active Colour Map, so
mask/group background indices, Graphics Context colours, and hosted
Picture/Scaled Graphic framebuffer expansion use the same palette selection
rather than falling back to the repo default approximation. When callers have a
live VtRenderRuntime,
FramebufferRenderer::render_runtime uses the runtime command stream so
runtime-only Graphics Context replay/primitive expansion is included. Backends
consume Scene/runtime command output; they do not parse object pools or own
VT protocol state. This is still not a calibrated pixel/font/bitmap VT
terminal, and profile/display calibration remains future backend work.
Status vocabulary
| Status | Meaning |
|---|---|
drawable | The object becomes a scene node and draw command. |
interactive | The object is drawable and also participates in input/focus handling. |
soft-key | The object is resolved into the soft-key area rather than the data-mask node list. |
reference-resolved | The object is consumed as value, style, palette, label, or pointer metadata. |
parsed-but-not-rendered | The object model exists, but faithful visual output is still missing or placeholder-only. |
missing-object-model | The object family is in the render inventory but has no ObjectType model yet. |
out-of-scope | The renderer intentionally does not draw this family; another VT protocol layer owns it. |
Current ledger
The machine-readable ledger lives at:
book/src/reference/assets/vt_object_render_coverage.csv
The ledger now includes first-class rows for the formerly missing standard families:
OutputListis parsed, resolves its selected index from its inline value or Number Variable, materialises selected drawable items as scene nodes clipped to the Output List rectangle, including selectedKeyobjects as display-only key designators, updates that rectangle through Change Size, treats the one-byte Value AID as Get-Attribute-only/read-only for Change Attribute while accepting Change Numeric Value for selected-index changes, follows the standard blank/no-display cases without fabricating an index/count fallback label, and draws compact text for simple selectedOutputString/OutputNumberitem values. SelectedObjectPointerentries may also target anExternalObjectPointer; when the host-registered external pool grants access, the resolved external object is materialised inside the Output List clip, while an unavailable external item with no local default stays blank instead of creating an unsupported placeholder. Upload validation, Change List Item replay, and Object Pointer retargeting now reject Output List item references to style/reference metadata objects that cannot be presented.OutputNumberandInputNumberfixed-field Change Attribute replay now includes the raw Value AIDs as well as formatting/geometry fields, so variable-reference-NULL numeric fields redraw from the new inline value. Their formatted text also rejects non-finite scale values before retained mutation, rejects decimal counts outside the standard0..=7range, rejects the reserved non-standard hexadecimal format selector, and observes the standard fixed/exponential, leading-zero, zero-as-blank, and truncate option bits.InputAttributesandExtendedInputAttributesare parsed as reference metadata and enforced for hostedInputStringedits. Change String Value against anInputAttributesobject updates its validation string and rebuilds the hosted input-validation state without increasing the fixed validation string length; shorter transfers are padded with spaces. When an Input String references a String Variable, classic Input Attributes are applied only to 8-bit strings and Extended Input Attributes only to ISO WideStrings. Classic validation remains byte-oriented in the hosted edit path, so blacklist rules cannot admit non-single-byte characters into an 8-bit field, while Extended Input Attributes enforce both whitelist and blacklist code-plane ranges. Validation type is readable through Get Attribute Value, but it is not Change Attribute mutable for either validation-reference object. Extended Input Attributes reject duplicate code-plane records during encode and decode so each Unicode plane is represented at most once.WorkingSetSpecialControlsapplies initial colour-map and colour-palette selections before first render, updates retained colour selections through Select Colour Map/Palette and Change Attribute, and exposes advertised language/country pairs onSceneso a host VT can make a deterministic language-selection decision; pool validation rejects malformed language pairs before render/runtime use while accepting the standard two-space country not-applicable sentinel. Scene language matching now treats that sentinel as language-only support in either the advertised pair or the host query, while still requiring exact country matches when both sides provide country codes.Scene::select_languagegives hosts a deterministic first-preference match with exact-country matches preferred over language-only fallback. The object parser also honours the standard number-of-bytes-to-follow extension shape: known VT6 language pairs are decoded, unknown trailing extension bytes are preserved and skipped so the next object in the pool is still found, and Get Attribute Value AID 1 reports the retained byte count including those extension bytes. Server Get Attribute Value AID 2 and AID 3 report the live retained colour selection, so a later Select Colour Map/Palette command clears or replaces earlier WSSC Change Attribute overlays instead of returning stale colour references.ColourPaletteuses the ISO 11783-6:2018 Table B.73 body shape: reservedOptions, a two-byte ARGB-entry count, and repeated little-endian ARGB entries in B,G,R,A byte order. Pool decoding rejects nonzero reserved options, count/body mismatches, and counts above the 256-entry palette range; Change Attribute admits only the reserved Options AID with value zero. The hosted palette overlay applies entries from colour index 0 upward; alpha is retained in the object model but ignored by the current opaque RGB renderer.- AUX object families (
AuxFunction,AuxInput,AuxFunction2,AuxInput2, andAuxControlDesignator) are deliberatelyout-of-scopefor retained rendering. They are object-pool/protocol state used by auxiliary routing and assignment; they are not data-mask draw nodes or placeholder graphics to complete in the render backend. WorkingSetnow uses the standard object/macro/language tail order for serialization and pool walking. Its two-byte language-code list is parsed, validated as ASCII letter pairs, and exposed as language-onlySceneLanguageentries unlessWorkingSetSpecialControlssupplies one or more language/country pairs, in which case the special-controls list supersedes the Working Set list.ObjectLabelRefis parsed as a counted object-label list, validated for one list per pool, unique labelled targets, String Variable label references, standard Annex K font-type values, and output/drawable graphic-designator references, and materialised as runtime metadata for VT popup/editor labels.ExternalObjectDefinition,ExternalReferenceName, andExternalObjectPointernow use the standard VT5 record shapes: enabled/NAME metadata for definition/reference objects, counted object IDs on definitions, and default-object/reference-NAME/external-object-id fields on pointers. Change Attribute updates the mutable metadata fields, Change List Item updates External Object Definition object-list entries, and the four-byte External Object Pointer Change Numeric Value payload updates both the External Reference NAME object ID and the referenced external object ID; Change Attribute AID 2 may set the External Reference NAME to NULL to restore the local default fallback. Hosted layout and live render runtimes can be given the local Working Set NAME plus referenced Working Set pools; an External Object Pointer placed in the scene then materialises the referenced object only when the local External Reference NAME is enabled, the registered referenced pool NAME matches, an enabled External Object Definition grants that local NAME access to the requested object, and the target object exists. Live runtime registration is keyed by the referenced NAME, so refreshed pools replace stale copies, and hosts can unregister a referenced pool on disconnect. Local Working Set NAME changes revalidate external grants, repeated same-NAME announcements, identical referenced-pool registrations, and missing-pool unregisters are no-ops, and referenced-pool register/unregister events respect active-mask locks: the resolver state changes immediately, but a locked active mask keeps its current pixels until unlock or timeout materialises the external/default object swap. If any check fails, or if the validated external target is anObjectPointerchain ending at NULL, the pointer draws its local default object. A NULL local default draws nothing without creating an unsupported placeholder, matching the same standard no-object handling used for localObjectPointerNULL values.
The object-type bytes were also aligned to ISO 11783-6:2018 Table A.1 for the
VT4/VT6 range: Graphics Context is type 36, Output List is 37, Extended Input
Attributes is 38, Colour Palette is 45, Graphic Data is 46, Working Set Special
Controls is 47, and Scaled Graphic is 48. The repo still carries
ScaledBitmap and GraphicsContext compatibility-extension rows; those are not
full standard-rendering claims.
The legacy plural GraphicsContext row is scoped as an machbus compatibility
extension rather than an ISO 11783-6 completion target. The standard graphics
context family is the singular GraphicContext row.
The standard GraphicContext row now exposes its visible canvas surface,
Change Background Colour, including macro replay, updates the opaque canvas
backing colour and the canvas command’s initial indexed background, accepted
subcommands expand to backend-neutral primitives, and Copy
Canvas/Viewport can emit indexed Picture Graphic pixel payloads when the
bounded software canvas can replay the supported point/line/rectangle/ellipse/
polygon/text-cell/Draw-VT-object subset. Replay honours the placed scene
position of the GraphicContext, while Copy Viewport samples from the canvas
viewport instead of using placed screen coordinates and applies the current
viewport zoom to the copied pixel payload. Full persistent pixel-raster
semantics still need a real raster/graphics backend before a full VT display
claim is safe.
PictureGraphic now emits backend-neutral indexed-image commands for
uncompressed payloads and valid count/value RLE payloads whose decoded byte
length covers the standard source dimensions, using per-row byte strides for
packed 1-bit and 4-bit rows. Unused packed bits at the end of each row are
ignored instead of becoming pixels in the next row. Short decoded payloads stay
explicit placeholders; extra bytes beyond the expected row-padded bitmap
length are ignored/truncated as the Picture Graphic raw-data rule requires
instead of inventing additional pixels. Its target display width drives the
render rectangle while the actual bitmap dimensions remain the source image
dimensions in the command. The command stream carries the
transparent/opaque option separately from the transparency colour index, so
opaque pictures still draw pixels that happen to equal the transparency colour.
Change Attribute updates to Picture Graphic options preserve the uploaded
raw/RLE data-shape bit and only change the runtime transparent/flashing bits,
so command replay cannot reinterpret the stored bitmap payload.
ScaledGraphic now resolves PictureGraphic and valid ObjectPointer graphic
value chains into the same command shape after optional RLE decode and the same
row-padded decoded length check plus Picture Graphic extra-byte truncation,
applies the standard ScaleType byte for scaling mode
and horizontal/vertical justification inside the Width/Height field, preserves
the source bitmap dimensions in the command, and keeps a Picture Graphic
source’s transparent option distinct from RLE compression. Upload,
server-retained mutation, and direct hosted-runtime
replay all reject Scaled Graphic value chains whose ObjectPointer indirection
reaches a non-graphic object or cycles. Standard GraphicData objects are now
decoded with their PNG/u32-length body shape; a ScaledGraphic that points at
GraphicData can emit backend-neutral RGBA image commands for non-interlaced
or Adam7-interlaced grayscale, indexed-colour, grayscale-alpha, RGB, or RGBA
PNG payloads, including 16-bit grayscale/grayscale-alpha within the 32-bit
RGBA maximum, using
zlib DEFLATE streams, including stored, fixed-Huffman, and dynamic-Huffman
blocks with literal and length/distance-copy data plus
PNG chunk CRC checks, scanline filters 0..4, and
palette/grayscale/true-colour transparency metadata,
and it keeps unsupported PNG format variants, including 16-bit RGB and RGBA
payloads that exceed the standard 32-bit RGBA maximum, as explicit placeholders
instead of misinterpreting PNG bytes as indexed bitmap data. The placeholder
path inspects the PNG signature, IHDR, basic chunk shape, dimensions, bit
depth, colour type, and the 32-bit-per-pixel limit, so malformed PNG payloads
and unsupported-but-well-formed PNG payloads report different reasons.
ScaledBitmap is still an machbus compatibility extension, not a
standard-completeness claim. The coverage ledger marks it out of scope, and
standard PNG GraphicData is likewise kept as unsupported there rather than
drawn through the old indexed shortcut.
Indexed-image commands carry the producing VT object ID so backends
can correlate later resource updates with the visible object that draws them.
The hosted framebuffer consumes copied GraphicsContextPictureData in
command-stream order, so a Copy Canvas/Viewport update affects subsequent
Picture Graphic draws without retroactively changing an earlier draw of the
same Picture Graphic object. When the command stream contains only bare
Copy Canvas/Viewport intent, including the standard Graphics Context copy
replay subcommands, it also keeps a per-Graphics Context indexed canvas for
direct replay, so later Picture Graphic updates use GC-owned pixels rather than
framebuffer outline/background pixels, including viewport zoom carried on the
copy intent or remembered from earlier viewport/zoom replay. Those updates are
applied in target Picture Graphic pixel coordinates, not stretched over the
later display rectangle, so copied sources clip to smaller Picture Graphics and
extra pixels in larger Picture Graphics keep their existing bitmap contents. If
the direct framebuffer path has no valid GC-owned backing canvas, such as an
oversized canvas refused by the bounded software-canvas guard, Copy
Canvas/Viewport produces no Picture Graphic update rather than sampling visible
framebuffer/debug pixels.
It can also consume bare GraphicsContextCanvas plus
GraphicsContextReplay command slices for the basic cursor/colour,
erase-rectangle, point, line, rectangle-outline, ellipse-outline, and polygon
polyline subset plus default-cell DrawText and viewport position/size updates.
For text replay, a standard NULL Font Attributes selector resets DrawText to
the default text colour instead of leaking the current drawing foreground into
text or copied Picture Graphic pixels. NULL/non-NULL line-attribute selection
is clipped to the current Graphic Context viewport. Non-NULL fill-attribute
selection fills rectangle, ellipse, and closed polygon primitives into the
visible framebuffer and the GC-owned copy canvas. This gives simple pixel
backends a direct replay path even when the hosted runtime has not pre-expanded
the replay into ordinary drawing commands.
The active scene palette is carried with the retained scene; hosted command and
framebuffer paths therefore apply Select Colour Map/Palette and Working Set
Special Controls palette selections to indexed Picture/Scaled Graphic pixels,
not just to style attributes. Colour Map object-pool records use the standard
two-byte colour-index count and reject non-standard entry counts outside the
defined 2, 16, and 256-entry forms.
The public client API exposes both select_colour_map and
select_colour_palette; both helpers emit the same standard function code while
leaving object-family validation to the VT server/runtime admission path.
When GraphicContext Draw VT Object replays those image commands into the
hosted bounded software canvas, the canvas expands packed 1-bit, packed 4-bit,
and byte-per-pixel 8-bit indexed Picture Graphic payloads, quantises decoded
standard PNG/RGBA GraphicData pixels to the nearest active VT palette entry,
normalises colours outside the Graphics Context canvas format to the configured
transparency/no-copy index, then uses nearest-neighbour scaling before Copy
Canvas/Viewport emits concrete pixel data. Change Generic Attribute now updates the render-affecting fixed fields for
PictureGraphic and ScaledGraphic; standard GraphicData has no render-time
Change Attribute path in the server/runtime. Payload-byte replacement remains a
separate object-pool/update operation. Malformed compressed payloads still fall
back to explicit placeholders. Decoded bitmap payloads are also length-checked against
their 1-bit, 4-bit, or 8-bit source dimensions before an image command is
emitted, so short payloads and unsupported bitmap formats produce explicit
placeholders instead of malformed image commands.
Animation now uses the standard fixed body and positional child-list layout:
Value, Enabled, first/default/last child indices, sequence mode, and the
disabled-behaviour option bits are decoded from the object body while frame
object IDs and X/Y locations come from the standard child records. The selected
frame is rendered through the same backend-neutral node path as a normal child
object and clipped to the Animation area, so a frame that is still
placeholder-backed remains an explicit placeholder. Change Numeric Value updates
the selected child index, Change List Item updates positional Animation frame
slots through server/runtime/macro replay, and Change Attribute covers the
standard Animation AIDs. Upload validation plus server/runtime Change Attribute
and Change Numeric Value admission now reject selected/default/first/last child
indices that do not resolve inside the positional child list, while still accepting
Value 255 as the standard no-selected-item value. A valid selected child
outside the First/Last animation sequence remains visible until the next
refresh tick; at refresh time, the animation value is range-checked before the
standard single-shot or loop advancement is applied. VtRenderRuntime owns the
per-object animation clocks and exposes a refresh-interval hint plus
clock-advance APIs so hosted backends can schedule deterministic redraws.
Animation clocks advance only for visible, enabled Animation objects, so an
inactive or hidden animation resumes from the frame where it was suspended.
Multiple visible instances of the same Animation object share the same
referenced frame. Hosts can also call
tick_animation from a terminal/UI/display timer to advance elapsed time and
receive the next scheduler
hint with the render update. Clock advances that stay inside the same visible
active frame update elapsed time without dirtying/rebuilding the scene; crossing
to a different visible frame rebuilds deterministically. The vt_gtui_server
example now uses VtRenderRuntime and tick_animation directly, giving the
host-loop scheduler path a concrete command-list backend demonstration while
leaving real UI/window/GPU product work deferred.
Input String generic attribute replay now covers the standard options AID and the object codec accepts the VT4+ wrap-on-hyphen option bit. Those transparency/wrapping option bits are not treated as the field enable flag; runtime Enable/Disable overlays control whether the field is operator-enabled. The Enable/Disable Object command path is now target-gated the same way in the server, direct hosted runtime replay, and macro helper reports: only input fields, Buttons, and Animation objects can create enabled-state overlays. Other drawable/style/reference objects are ignored before retained replay state changes, avoiding stale inert enable metadata. Input Boolean generic attribute replay covers geometry and the typed foreground/variable-reference fields. Its fixed value AID 5 and enabled-state AID 6 are Get-Attribute-only/read-only for Change Attribute; value changes use Change Numeric Value and enabled-state overlays use Enable/Disable Object. Input List and Output List selected-value AID 4 are likewise Get-Attribute-only/read-only for Change Attribute, so selected-index changes stay on the standard Change Numeric Value path. Output List width/height now also participate in the Change Size path instead of relying only on generic attributes. Server-side Change List Item replay is now bounded by the uploaded Input List, Output List, or External Object Definition item count before retaining a mutation, and Output List item retargets additionally preserve the renderer-presentable item type set instead of accepting inert metadata objects. Change Polygon Point is likewise bounded by the uploaded Polygon point list before the server retains the point update for runtime replay. Selected Object Pointer items resolve to their pointed object text, and the standard no-display cases for NULL placeholders, NULL Object Pointers, and hidden Container items produce an empty selected item instead of a misleading index label. For Input List operator navigation, true NULL item slots remain invisible and unselectable, while the standard empty Object Pointer-NULL and hidden-Container item positions remain selectable even though they draw blank. Placed Object Pointer objects also rebuild through the standard Change Numeric Value path, so the numeric pointer value can retarget the materialised child just like the generic target-id attribute. Scaled Graphic also consumes Change Numeric Value as its standard graphic Value-object reference update; setting that value to NULL removes the graphic without reporting an unsupported/missing Graphic Data placeholder, while setting it to an ObjectPointer is accepted only if that pointer chain still resolves to NULL, GraphicData, or PictureGraphic. Hosted runtime and pool-owned macro-helper Change Numeric Value replay now use the same one-/two-/four-byte numeric target width and scalar/pointer checks as the server path, so direct runtime tests and macro helper tests cannot truncate high bytes or bypass value-source validation for Input List, Output List, Object Pointer, Scaled Graphic, External Object Pointer, Meter, bar-graph, or Animation state. Output Meter, Linear Bar Graph, and Arched Bar Graph min/max semantics now follow the standard render clamp: object pools and Change Attribute replay may retain min values that are not less than max values, and the framebuffer draws the value and target-value indicators as the minimum rather than rejecting the pool or mutating to an arbitrary range. Macro Change String Value replay also uses strict UTF-8 admission before retained text mutation in both the hosted runtime and the pool-owned macro helper, matching the server-side wire path instead of applying lossy replacement decoding. The pool-owned helper also uses the same fixed-length target set as the server/runtime command path: String Variable, inline Output String, and Input Attributes validation string; variable-backed Output String objects are not silently retargeted through the display object. Fill Attributes pattern references are typed at upload time, during fixed Change Fill Attributes, during macro replay, and during server/runtime Change Attribute replay: non-NULL pattern IDs must resolve to PictureGraphic objects before they can be retained as fill-pattern state. When Fill Type 3 is active, monochrome pattern rows must end on whole bytes and 16-colour pattern rows must contain an even number of pixels, so row widths that would leave unused packed bits are rejected before upload or retained runtime mutation. For normal output shapes, Fill Type 1 now follows the standard line-colour fill rule instead of using the Fill Colour field or skipping the fill; Fill Type 2 keeps using the Fill Colour field; and Fill Type 3 now tiles the referenced PictureGraphic raw/RLE pixel data through backend-neutral commands, the hosted framebuffer, and the Graphics Context canvas path. Pattern tiling is anchored to the active mask origin rather than each shape’s local origin, and PictureGraphic transparency/flashing options are ignored for fill-pattern use. Font Attributes font-type values are likewise kept on the standard-coded path: object-pool validation and Change Attribute replay reject reserved font-type codes before a retained style can drift away from ISO-defined values. Font size validation now follows the proportional-style split: fixed non-proportional sizes stay on the 0..=14 enumeration, while proportional font style admits standard height values from 8 upward and rejects state transitions that would leave size/style inconsistent.
Macro objects are reference objects, not direct drawables. The hosted render
runtime decodes and applies the modelled render-affecting command subset in
order: visibility, enabled state, numeric/string values, child
location/position, size, Output Line end-point geometry/direction, background
colour, option- and target-checked input selection, mask-type-checked soft-key mask/list-item changes,
Alarm Mask priority,
Object Label metadata, mask-lock runtime state, generic-attribute replay,
Font/Line/Fill Attributes object-value mutations, audio terminal side effects,
Delete Object Pool lifecycle clearing, Macro-object-gated bounded nested Execute Macro calls,
polygon point/scale changes, colour-map/palette selection, and
server/runtime current-working-set-checked Change Active Mask. The standalone
macro helper rejects wrong-family colour-selection targets and active-mask
targets before reporting hosted runtime replay work. Macro
Change Attribute replay now shares the same supported-AID table used by server
admission and direct hosted runtime replay, so unsupported or missing generic
attribute targets are skipped instead of leaking inert replay records. The
Data Mask and Alarm Mask Soft Key Mask attribute accepts NULL as the standard
“no associated Soft Key Mask” clear in all three paths, and the server updates
its active Soft Key Mask selection state as well as the retained Get Attribute
Value result.
Text and numeric field Font Attributes references are treated as required
object IDs: Change Attribute replay now rejects NULL for AID 4 on
Output String, Output Number, Input String, and Input Number, and for
Input Boolean foreground AID 3, instead of silently retaining a non-standard
nullable style selector.
Output Polygon Line Attributes are also treated as a required object ID:
Change Attribute replay rejects NULL for Polygon AID 3 in server admission,
direct runtime replay, and macro reporting, matching the standard
0..=65534 range while leaving nullable fill references to mean no fill.
Fill Attributes generic replay now applies the same pattern-buffer gate before
retaining type-3 pattern updates or macro reports: monochrome Picture Graphic
patterns must have byte-aligned raw rows, and 16-colour patterns must have
whole-byte row pairs, so invalid packed rows do not leak into render replay.
The standalone macro helper also rejects invalid one-/two-byte payload widths,
typed references, scalar/range values, and Window Mask generic-attribute
values, including type changes whose retained required-object list does not
match the target typed-window form and wrong-family Window Mask
Name/Title/Icon designators, before reporting runtime replay work. Macro
Change Soft Key Mask replay now uses the standard Mask Type parameter
(1 Data Mask, 2 Alarm Mask) rather than the older object-id-at-byte-1
shortcut, so wrong mask-type/object combinations are skipped before retained
pool mutation. Get Attribute Value for Data Mask / Alarm Mask AID 2 reads that
retained soft-key-mask selection, including NULL clears, instead of reporting
only the uploaded object-pool body. The client command surface has helpers for
both the common Data Mask form and the standard Alarm Mask form, so hosts do not
need to hand-build a Mask Type 2 frame. Object
macro-reference lists can also be executed for a caller-supplied
raw VT event byte, preserving the object-pool macro order while leaving
profile-specific event-name mapping to the host. Recursive macro loops are
guarded instead of recursing forever. Unknown or too-short macro commands
remain explicit unsupported effects instead of being guessed.
The standalone macro helper applies the same pool-owned value, geometry,
background, attribute-object, and polygon mutations that it can represent
without runtime state, reports generic Change Attribute effects for the render
runtime’s validated replay path, applies Delete Object Pool by clearing the
pool, and reports overlay/input/audio/object-label/mask/nested-macro changes
for the scene/runtime layer. Those reports are target-gated for the modelled
standard overlay effects: Hide/Show remains Container-only and Lock/Unlock Mask
is limited to Data Mask / Window Mask objects before the hosted runtime
consumes the effect. Colour-map and palette selection is reported as runtime
state rather than mutating the object pool.
Lock/Unlock Mask follows the ISO command-byte polarity (0 unlocks and 1
locks), admits only data/user-layout mask targets, and does not apply to Soft
Key Masks or Alarm Masks. In the hosted render runtime, locking the active data
mask freezes the retained visible scene while accepted ECU commands continue to
mutate the backing pool; explicit unlock or host-driven timeout expiry rebuilds
the scene and materialises the queued visual changes. A zero timeout remains
locked until the ECU sends an explicit unlock, while non-zero timeouts are
advanced deterministically through VtRenderRuntime::advance_mask_lock_time.
The server records the same lock state in the no_std-safe working-set state so
hosted render replay can observe it without making VTServer depend on
renderer types.
Text drawing now carries a renderer-ready TextLayout in each DrawText
command. The layout decodes ISO WideString UTF-16LE values, substitutes
non-printing control characters deterministically, normalises CR/LF line
endings, applies horizontal and vertical offsets, hard-wraps where enabled,
clips to the object box, and records hidden rows/columns for host backends
that need faithful text placement. The hosted framebuffer consumes those
resolved font metrics instead of a fixed-size debug cell and reflects the basic
bold, italic, inverted, underline, and strikethrough decoration bits in
deterministic text cell coverage. Output String, Output Number, Input String,
and Input Number now admit the VT4+ horizontal/vertical justification bit pairs
instead of only the VT3 horizontal values; reserved horizontal or vertical value
3 is still rejected. Those text fields also apply their background colour
only when the transparent-background option is clear. Input String now also
feeds the standard auto-wrap option into TextLayout so command and
framebuffer backends see wrapped input-field rows instead of a clipped
single-line preview. Numeric text fields now admit only the standard 0..=7
decimal counts and fixed/exponential format selector values; the old
hosted-renderer hexadecimal shortcut is rejected during pool upload and Change
Attribute replay.
Graphics Context objects still do not fully rasterise every primitive into a
GTUI canvas, but accepted canonical Graphics Context subcommands are now
exposed as backend-neutral GraphicsContextReplay command-stream entries from
VtRenderRuntime; malformed payloads for known subcommands are rejected before
they mutate server/runtime state, and object-reference payloads for known
line/fill/font selector, Draw VT Object, Copy Canvas, and Copy Viewport
subcommands are type-checked before replay state is retained; Draw VT Object
requires a drawable non-recursive standard/rendered target, explicitly rejects
the machbus-only ScaledBitmap compatibility object, and copy subcommands
require a non-NULL Picture Graphic target. Unknown subcommand IDs are rejected
before they can become inert replay records. The VT server now also emits the
F.57 Graphics Context response for commands whose object/subcommand bytes are
present, using the standard invalid object, invalid subcommand, invalid
parameter, and invalid-result bits, and accepts single-frame FF16 padding
without retaining that padding in replay state. F.56 Draw Text payloads use the
standard counted-string range, so a zero-byte text body is accepted and retained
as a no-text replay command rather than rejected as malformed. The standard GraphicContext
line/fill/font attribute selectors, cursor/colour, erase-rectangle, point,
line, rectangle, closed-ellipse, polygon, and DrawText subcommands are also
expanded into backend-neutral primitives using the object’s viewport.
Line-attribute colour
selectors, non-zero stroke widths, and full two-byte line-art paintbrush
patterns are preserved in those backend commands and in the bounded indexed canvas that
produces Copy Canvas/Viewport Picture Graphic pixel payloads. The type-36
GraphicContext object body now follows the full standard fixed record:
viewport/canvas size and position, initial zoom/cursor, foreground and
background colours, font/line/fill attribute references, canvas format,
options, and transparency colour. Pool validation rejects missing or wrong-type
font/line/fill attribute references before upload/runtime use. Options bit 1 is
accepted, so an initial Graphics Context can draw from its referenced
line/font/fill attribute colours instead of only from object foreground/
background colours. Pan, zoom,
pan-and-zoom, and viewport-size subcommands now update and emit
backend-neutral viewport commands, so later primitives use the adjusted
viewport origin/size; non-finite, zero, and out-of-range zoom values are
rejected before command replay or retained-state mutation. Draw VT Object
materialises the referenced drawable standard/rendered
object at the current graphics cursor, advances the cursor to that object’s
bottom right corner, and replays the referenced object’s supported backend
primitives into the bounded software canvas, treating colours outside the
Graphics Context canvas format as transparent. Copy Canvas and Copy Viewport now
emit explicit
GraphicsContextCopyToPicture commands carrying the target Picture Graphic,
source selection, effective viewport, and raw zoom bits; when the supported
primitive/text-cell/Draw-VT-object subset was replayed into the bounded
software canvas, the runtime also emits GraphicsContextPictureData with
concrete indexed pixels for the target Picture Graphic. Copy Viewport uses the
current viewport zoom for that copied pixel payload, so zoomed viewport copies
do not collapse back to unzoomed canvas coordinates. That software canvas now
covers point, line, rectangle, ellipse, polygon, text-cell coverage, and Draw
VT Object primitives that lower to those same backend-neutral commands.
Generic attribute changes to the standard GraphicContext writable viewport,
initial zoom/cursor, colours, attribute references, format, options, and
transparency colour now also rebuild the visible canvas surface. Canvas
Width/Height AIDs 5 and 6 are read-only and remain Get-Attribute-only, so
Change Attribute cannot resize the persistent canvas allocation.
The canvas command carries the transparency colour, and hosted runtime/
framebuffer copy paths initialize transparent Graphics Context backing pixels
with that colour. Copy Canvas/Viewport payloads also carry that colour as the
standard do-not-copy index, so copied pixels leave the target Picture Graphic’s
existing pixels in place instead of overwriting them with palette index 0.
Copied palette indexes outside the target Picture Graphic format are also
normalised to that do-not-copy colour before the runtime payload is emitted.
The hosted FramebufferRenderer can consume the resulting command stream for
deterministic RGB snapshots and display-handoff byte exports, including via the
runtime-aware framebuffer path. It preloads copied Picture Graphic pixels from
GraphicsContextPictureData only for later target-object IndexedImage
commands, can also copy bare Copy Canvas/Viewport intent, including standard
Graphics Context copy replay subcommands, from the current per-Graphics Context
indexed canvas into later Picture Graphic updates while applying copy-time or
remembered viewport zoom, and expands indexed
Picture/Scaled Graphic pixels
through the active scene palette while still preserving the copy intent command
for backends with their own canvas/resource cache. Opaque Graphics Context
canvas surfaces fill their visible backing area in the software framebuffer;
transparent canvases leave existing framebuffer pixels untouched apart from the
debug/coverage outline. Basic Graphics Context replay records can also be
interpreted directly by the framebuffer for cursor/colour, erase-rectangle,
point, line, rectangle-outline, ellipse-outline, polygon polyline, and
default-cell counted DrawText drawing, including zero-length no-text replay,
with viewport position/size updates; NULL
line-attribute selection disables subsequent line primitives until a non-NULL
line-attribute selector restores them, and non-NULL fill-attribute selection
fills rectangle, ellipse, and closed polygon primitives into both the visible
framebuffer and the GC-owned copy canvas. Copy Canvas/Viewport intent can
update later visible Picture Graphic draws from GC-owned indexed canvas pixels
instead of sampling the visible framebuffer, while preserving Picture Graphic
target dimensions for clipping, unchanged extra target pixels, and transparent
handling of copied colours outside the target bitmap format. Direct copy
intent without a valid owned canvas backing is ignored rather than converted
from visible framebuffer pixels. Framebuffer
text-cell coverage
uses resolved font metrics and basic decoration bits including bold, italic,
inverted, underline, and strikethrough; it is still not a calibrated glyph
rasterizer.
Both
it and GtuiRenderer implement the shared VtRenderBackend trait. Full
calibrated display/font/graphics rasterisation remains a backend gap.
Soft-key masks support an initial physical-key paging model. LayoutConfig
can reserve physical soft-key positions for navigation and render only the
selected application-key page; VtRenderRuntime tracks and clamps the current
soft-key page. Reserved navigation cells are exposed separately from
application keys, and host navigation events now move between pages with a
SoftKeyPageChanged event. Visible soft-key cells also carry a zero-based
physical cell index, so keypad/backlit-button hosts can send
PhysicalSoftKey events without synthesising pointer coordinates; application
cells emit SoftKeyActivated, while enabled navigation cells page locally.
Hosts that need hardware-style activation timing can instead send
PhysicalSoftKeyDown and PhysicalSoftKeyUp: application cells emit
Pressed, host-driven Held, and matching Released transitions, while
navigation cells wait until physical release before changing the page and keep
that pending cell from being overwritten by another pointer, tap, one-shot
physical, direct semantic soft-key, or direct navigation activation. Stray
pointer releases do not clear a hardware-origin application or navigation
soft-key press; only the matching physical release completes or aborts it.
Two-cell navigation profiles keep both previous/next cells visible for stable
hardware-cell layout, but disable the previous cell on the first page and the
next cell on the final page so boundary physical-key presses cannot synthesize
a local page turn. Navigation-cell reservation is clamped so malformed or
over-reserved host profiles cannot consume every physical soft-key position and
overlap the remaining application key cell. Pointer hits on visible soft-key
cells are routed through the render runtime too: application cells emit
SoftKeyActivated, and navigation cells change pages on tap/matching release
instead of on press; stray or drag-off releases are ignored. The renderer does
not reserve
navigation keys when the whole Soft Key Mask fits on the reported physical
keys, and validation rejects Soft Key Masks above 64 virtual keys. Soft Key
Mask children may now be direct Keys, Object Pointers, or External Object
Pointers; local pointers resolve to Key objects, and External Object Pointers
can resolve through the host-registered referenced Working Set pool when the
external NAME, local Working Set NAME, enabled External Object Definition, and
target Key all validate. Invalid or unavailable external soft-key targets fall
back to the local default Key/NULL-slot rule. NULL pointers reserve the physical
slot without drawing a visible key, and trailing NULL slots are trimmed before
paging so they do not create empty pages; the runtime page-count and
next/previous helpers use the same trimmed view as the renderer. Soft Key Mask
background changes update the backing colour for cells, while a Key’s own
background colour overrides the mask background for that cell. Soft-key cell
geometry now follows the configured VT profile for both vertical side columns
and horizontal/landscape soft-key rows; normal Soft Key Mask cells, navigation
cells, placed Key Group cells, pointer hit-testing, and zero-based physical-key
events all use the same configured physical-cell rectangles. The render runtime
now has an initial VT/operator-selected user-layout mapping layer:
available Window Masks and Key Groups can be placed into data-mask grid cells
or soft-key cells, unavailable interactive selections and out-of-grid
placements are rejected, and Key Groups cannot claim active Soft Key Mask
application cells or the active profile’s VT-reserved soft-key navigation cells
when the current Soft Key Mask requires
paging. That exclusion now follows the navigation cells actually rendered after
Soft Key Mask child resolution and trailing NULL trimming, not the raw child
count, so non-paged profiles keep the remaining soft-key cells available for
Key Groups. Accepted placement overrides survive normal scene rebuilds while
they remain valid; if a later active Soft Key Mask claims those cells as
application or navigation soft keys, the runtime removes the stale Key Group
mapping before exposing the rebuilt scene. If a changed Window Mask or Key
Group grows or otherwise makes its remembered placement invalid, rebuild
revalidation removes that changed mapping before stable unchanged mappings are
reconsidered, preserving still-valid operator placements in their original
cells; the changed-object priority is retained across Lock/Unlock Mask
deferrals and applied when the locked scene finally refreshes. If several
deferred changed placements conflict with each other, the runtime uses Object
ID order as the tie-break rather than hash-map or ECU command order. Hosts can
export and restore deterministic logical-cell placement snapshots for
their own non-volatile storage; exported snapshots are sorted by object ID
rather than hash-map iteration order, while restore still admits currently
unavailable objects so recalled cells can be blanked. Available Key Group
objects can now be placed by the layout engine as one-to-four normal
soft-key-sized cells; their children may be direct Keys, Object Pointers that
resolve to Keys, or External Object Pointers that resolve through a
host-registered referenced Working Set to a granted external Key. The resulting
user-layout cells activate the resolved local or external Key IDs. Runtime
operator-placement validation and overlap rectangles use the same resolver, so
Key Groups whose only available child is a valid external Key can still be
placed into user-layout soft-key cells. If an External Object Pointer Key Group
child cannot currently resolve because the referenced Working Set pool is not
registered and its local default is NULL, that child now remains a blank
non-activating physical cell instead of collapsing later Key slots upward.
Runtime Object Pointer retargeting keeps those slot rules after upload: Key
Group pointer children must continue to resolve to non-NULL Keys, while Soft
Key Mask pointer children may resolve to NULL reserved cells or Keys only.
External Object Pointer children in Soft Key Masks and Key Groups keep the same
local fallback-slot rule during Change Attribute replay: default-object AID 1
accepts NULL or Key objects and rejects arbitrary uploaded objects in both
server and direct hosted-runtime paths.
Key Group mapping-screen designators are typed too: the Name field must
resolve to an Output String or an Object Pointer to an Output String, while the
Icon field is NULL or an Object Label graphic-representation output object.
Upload validation and Generic Attribute replay use the same checks.
Window Mask mapping-screen designators use the same hosted admission checks:
non-NULL Name and Window Title references must resolve to Output String
directly or through an Object Pointer, while non-NULL Window Icon references
must resolve to Object Label graphic-representation output objects. Upload,
server-retained replay, direct runtime replay, and macro replay all reject
wrong-family designators before they become retained render state.
Soft-key and Key Group labels now prefer display text from child output
objects, including Object Pointer indirection, before falling back to the Key
code. Pointer taps on those cells activate the contained Key IDs with the same
backend-neutral SoftKeyActivated event used by normal
application soft keys. Zero-based PhysicalSoftKey host events also activate
placed Key Group cells when no normal Soft Key Mask cell owns that physical
position, so hardware/backlit-button hosts do not need to synthesize pointer
coordinates for operator-placed Key Groups. Resolved external soft-key and Key
Group slots retain the referenced Key Number, so Soft Key Activation payloads
do not lose the external Working Set’s Key Code. Select Input Object focus
replay can now focus visible Key IDs inside available Key Group cells as
focus-only targets, matching the VT4+ Key focus path without opening an edit
transaction. Explicit Commit on a selected visible soft-key or Key Group key
now activates that focus-only target, or changes pages for a selected
navigation cell, while open input edits still keep their normal commit path.
Pointer press/move/release paths now emit the same
activation-code Pressed/Released/Aborted events as normal application
soft-key cells, including immediate Aborted emission when the pointer slides
off the pressed Key Group key. The physical soft-key down/up path works for
placed Key Group cells too, so real key hardware can get Pressed, held
repeat, and Released transitions without manufacturing pointer coordinates;
stray pointer move/release events do not abort that hardware-origin Key Group
press.
Unavailable Key Groups are placed but do not emit key cells or activation
events; the renderer blanks/fills the remembered cells even when the Key Group
has the transparent option set. Transparent available Key Groups still leave
the underlying user-layout/window area unpainted and emit their key cells. Window Mask
bodies now use the VT4+ user-layout record shape;
available free-form windows size themselves from the fixed 2 × 6 user-layout
cell grid. Width, height, window-type, and options updates are admitted only
inside their standard scalar ranges before server/runtime state is mutated;
window-type updates also have to match the retained required-object list for
the target typed-window form. Standard typed windows 1..18 materialise their
required objects into VT-controlled slots both when nested and when selected as
the active mask. Active and nested Window Mask children are now lowered with a
Window Mask clip rectangle, so oversized free-form or typed-window child output
cannot paint outside the assigned window region. Window clips are intersected
with object-local clips such as Output List selected-item viewports and
Animation frame boxes.
Available transparent windows preserve the underlying user-layout pixels instead
of painting their background colour. Unavailable windows blank their cell
region without rendering children. The runtime can also generate VT On
User-Layout Hide/Show
notifications for currently placed Window Mask and Key Group nodes, packing two
visibility records per payload, sorting those Window Mask / Key Group records
by Object ID before packing, and preserving optional VT v6 TAN bits in the
full-message path. A separate active-mask helper emits the same H.20 message
shape for the active Data Mask plus active Soft Key Mask, so hosts can notify
an inactive-but-still-visible Working Set or hide those masks before making it
active again. The matching H.21 response is modelled as a separate
UserLayoutHideShowResponse helper that parses/builds checked ECU-to-VT
payloads, including NULL-second-record, status-bit, VT5 reserved-byte, and VT6
TAN-nibble validation. Runtime placement helpers reject overlapping physical
placement rectangles before mutating the operator layout snapshot, including
custom VT profiles where the soft-key area overlaps the data-mask/user-layout
area rather than sitting outside the main canvas. VTServer now also answers
the standard Get Window Mask Data technical-data request with configurable
VT-owned user-layout Data Mask and user-layout Soft Key cell background colours,
so Working Sets can colour-match free-form Window Mask and Key Group content
without guessing from one of their own object colours. The same technical-data
path now distinguishes the standard Get Supported Objects query from the AUX
type-2 capability subquery: the standard response returns a numerically sorted
supported-object byte list, omits Auxiliary Function/Input type-1 objects, and
does not advertise local reserved compatibility object codes as standard VT
objects. Get Supported WideChars now reports the ISO WideChar minimum
character set ranges for code plane 0, clipped to the requested inquiry range,
and returns standard error bits for invalid code planes or inverted ranges
instead of falling through to Unsupported Function. The client-side
get_supported_widechars builder emits a canonical code-plane-0 full-range
query, and get_supported_widechars_range exposes the standard code-plane plus
first/last range fields. Parameterless technical-data requests on the server
side are canonical as well: Get Hardware, Get Number of Soft Keys, Get Text
Font Data, and Get Window Mask Data only answer fixed [code][FF×7] requests,
so malformed reserved bytes cannot be accepted as prefix-compatible capability
queries. Pool/session-control requests reject hidden reserved-byte garbage too:
Get Memory must preserve the 0xFF tail after its requested memory-size field,
while Get Versions and End Of Object Pool must arrive as [code][FF×7].
Malformed forms cannot open the upload window, return a prefix-compatible
versions response, or activate a pending pool.
Input handling now separates selected input state from open edit transactions.
Typing emits edit-preview events, while Commit emits the final value-change
event and Cancel aborts the open edit with a VT ESC semantic event instead of
a final value. Rebinding the hosted input runtime to a rebuilt scene preserves
the selected/open transaction by VT object ID rather than by the previous
focus-order index, so mask changes, user-layout placement, or external object
materialisation cannot move an in-progress edit to a different reordered node.
Disabled input nodes are also rejected before tap handling can focus them, open
an edit transaction, or emit a list/boolean value event; runtime Enable/Disable
overlays use the same path after scene rebind. If a previously focused/open
input later disappears or becomes hidden/disabled, typing, backspace, and
commit clear the stale selected/open/edit state before any value event can be
emitted.
Hosted InputString
edits enforce both resolved input-validation attributes and the object’s fixed
maximum string length before mutating the edit buffer. The resolved validation
keeps classic Input Attributes byte-oriented and uses Extended Input Attributes
for WideString code-plane ranges, including blacklist ranges. Validation
failures are rejected before opening the input transaction, and hardware
characters delivered to a focused non-editable object stay ignored without
creating open input state. Hosted InputNumber
now uses the standard secondary options byte for enabled and real-time-editing
state and replays the raw Value attribute when the field stores its value
inline: host key input accepts only decimal digits before mutating the edit
buffer, normal number edits preview until commit, commits reject raw values
outside the declared min/max range without closing the transaction, and
real-time number edits emit complete numeric value-change events immediately
while rejecting non-decimal or out-of-range edits without mutating the edit buffer. Input
String enabled state now follows runtime Enable/Disable overlays rather than
the object’s transparency/wrapping options byte, while the transparent bit still
controls whether the field paints its background colour and the auto-wrap bit
controls text layout wrapping. Tapping an
enabled InputList respects its real-time-editing option too: normal lists emit
a backend-neutral selection preview until commit/cancel, while real-time lists
emit a complete next-selection event immediately. Input List upload validation
now requires its optional variable reference to resolve to a Number Variable
and each non-NULL item slot to resolve to an uploaded object before
render/runtime use, while preserving NULL slots as standard no-item
placeholders.
Input List rendering now resolves the selected item’s display text through the
same output/variable/pointer/external-pointer/container paths as list-like
display objects and leaves non-displayable selections blank instead of
fabricating an index/count diagnostic label. Host-registered referenced pools
can supply External Object Pointer item text; unresolved external items with no
local default stay blank. Selected non-interactive drawable items can also
materialise as clipped display-value scene nodes, with the parent Input List
remaining the interactive hit target and the compact text fallback suppressed.
Accepted Change List Item effects rebuild that visible label or materialised
item when the selected item slot is retargeted. Accepted
Change Numeric Value effects for an Input List now retain the selected index
and rebuild the visible selected item too. Value 255 and out-of-range
Number Variable values now preserve the standard blank selected-item display
instead of clamping to a valid item; the next operator selection restarts at the
first selectable list entry. Selected NULL item slots are blank as well, and
operator selection skips true NULL and unavailable external slots while keeping
the standard empty Object Pointer-NULL and hidden-Container item positions
selectable; all of those positions still preserve their indexes for Working
Set values. Input List bodies also carry the standard inline selected
value and one-byte item count, so lists with
a NULL variable reference use that inline value for rendering, Change Numeric
Value replay, and Get Attribute Value. Enabled Button objects use
activation-code-aware pointer sequencing: press latches the candidate button and
emits Pressed, matching release emits Released, pointer slide-off or
drag-off emits Aborted, and stray releases without a preceding press are
ignored. Button
labels now resolve display text from child output objects, including Object
Pointer indirection, before emitting backend-neutral DrawText. Button
activation payloads use the scene-resolved Key Number, so Buttons materialised
from a registered external Working Set do not lose their external Key Code.
Button option bit 4 (0x10) is treated as the standard Disabled bit for
initial scene state, while bit 6 (0x40) remains reserved object-pool data.
Button rendering resolves the Button background and border colour fields
through the active palette, skips the Button face fill when the transparent
background option is set, and suppresses the hosted border stroke when either
the suppress-border or no-border option is set.
Hardware Enter and explicit Commit on a focused/selected enabled Button emit
the same ButtonActivated semantic event as a one-shot button activation,
without opening an edit transaction.
Application
soft-key pointer paths follow the same Pressed/Released/Aborted model.
Direct host-provided SoftKeyActivate(id) events are checked against the
active scene first, so hidden, disabled, or stale Key IDs cannot produce a
semantic activation event.
VtRenderRuntime now has a first bus-message bridge for completed semantic
events: boolean/list/number final changes lower to Numeric Value Change
payloads, final strings lower to String Value Change payloads, focus and
edit-open/close state changes lower to Select Input Object payloads, and
accepted soft-key or button activations lower to deterministic press+release
activation payload pairs using scene/object-pool parent IDs and key-code fields.
Pointer activation-code events lower directly to the matching activation-code
payload. Direct semantic soft-key/button activation lowering re-checks the
active scene’s visible/enabled state before building payloads, so caller-built
VtEvent values cannot bypass disabled Buttons, stale Soft Key Mask cells, or
unavailable Key Group slots. Held-repeat timing is runtime-owned through
ActivationHoldTiming and
advance_activation_hold_time: after a press, host event loops advance the
timer and receive deterministic repeated Held events plus optional bus
payloads or full PGN/addressed messages. Invalid full-message endpoints are
rejected before repeat timing is consumed.
The stateful handle_operator_event_with_bus_messages path emits selected/open
input transitions before edit previews or value changes, lowers cancel aborts
to VT ESC payloads, and emits close transitions after commit/cancel. The cancel
sequence is VT ESC followed by a selected-but-not-open Select Input Object
notification. Edit previews, local soft-key page navigation, and ignored events
otherwise remain local-only. The ECU-side client also parses VT ESC as an
aborted-input event and can build matching VT ESC response payloads, including
explicit error-code variants for hosts that need to acknowledge a concrete
abort/error reason. The no_std-safe VTServer also responds to a canonical
ECU ESC Input command with a VT ESC response frame for the selected input
object, and rejects malformed reserved bytes without mutating retained input
escape state.
VtBusMessage can also wrap these payloads in full PGN_VT_TO_ECU messages
with explicit VT source and ECU destination addresses, and
VtRenderRuntime exposes full-message lowering helpers for both direct
semantic events and stateful operator events. Those helpers preserve the same
event order as the payload-only path. The checked full-message path rejects
null/broadcast VT sources and null/broadcast ECU destinations before stateful
operator-event mutation or emission, so a host cannot accidentally turn a valid
payload into an unusable VT message envelope. Payload-only and full-message
lowering also reject semantic value/input/activation/ESC events that target the
NULL object ID before any bus payload is emitted. The stateful operator-event
and held-activation bus helpers snapshot the runtime before applying local state
changes and restore it if semantic-event lowering fails, keeping focus, edit,
and held-repeat state atomic with bus emission.
The bus-message helper is split into the semantic
src/isobus/vt/render/bus_message.rs module and now supports the receive-side
admission path too: command bytes map back to typed message families,
payload-only and addressed PGN_VT_TO_ECU messages parse through one checked
path, Soft Key/Button/Numeric Value Change have explicit VT6 TAN constructors,
and malformed reserved bytes, bad TAN low nibbles, string length/UTF-8
mismatches, inconsistent Select Input Object state, NULL concrete-object IDs,
and invalid Pointing Event parent-mask/touch-state shapes are rejected before a
host treats the payload as render-runtime bus evidence.
ECU-to-VT Select Input Object and ESC Input effects are replayed into the
hosted InputRuntime: option FF selects input fields or VT4+ Button/Key
targets for focus only, NULL+FF removes focus, option 0 opens only input
field objects for data input, and invalid option/target combinations do not
mutate retained state. The ECU-side client decodes VT Select Input Object
messages using the standard byte-4 selection plus byte-5 open bit layout, and
the VT server emits/VT client parses the paired Select Input Object response
with selected/opened response codes plus disabled, invalid-object,
not-on-active-mask-or-hidden (0x04), busy, and invalid-option error bits.
Targets outside the active Data/Alarm Mask or inside a hidden Container are
rejected without mutating retained focus/open state. ESC aborts an open edit
transaction without producing a final value. Pointer and
hardware key events are present as backend-neutral host
inputs, including Enter/Commit activation for focused Buttons and selected
focus-only soft-key or Key Group keys. The stateful bus bridge lowers those
hardware/profile activations to the same checked Button/Soft Key press+release
payload pairs as pointer/tap activations. Pointer activity in non-interactive
Data Mask or free-form Window Mask areas is reported as VT Pointing Event
semantic events and can be lowered to payload-only or addressed VT-to-ECU
messages; button, input, soft-key, and Key
Group hits keep their activation/select-input semantics instead of being
misreported as pointing. VT ESC carries optional VT v6 transfer sequence
numbers through the semantic event, VT-to-ECU payload builder, client parser,
and ECU-to-VT response builder; the response builder also preserves an explicit
error-code byte when requested. VTServer emits the corresponding VT ESC
response for accepted ESC Input commands. Ordered full-message lowering keeps
VT ESC TAN payloads in caller order and rejects malformed TAN values instead of
returning a partial addressed-message prefix. User-layout hide/show
notifications use the same
payload-only and addressed-message lowering path, including NULL object-id and
four-bit TAN validation. Control Audio Signal Termination has checked
payload/full-message helpers too: H.22 VT-to-ECU notifications support the VT5
reserved-byte and VT6 TAN forms, and the H.23 response helper is VT6-only with
strict cause-byte, reserved-byte, TAN-nibble, and destination-specific envelope
validation. Operator-event ECU responses are checked too:
ControlActivationResponse builds/parses H.3/H.5 Soft Key/Button Activation
responses with VT5 reserved-byte or VT6 TAN payloads, and
PointingEventResponse builds/parses H.7 Pointing Event responses across VT3
implied-press, VT4/VT5 touch-state, and VT6 TAN-plus-parent-mask forms before
wrapping them in addressed PGN_ECU_TO_VT messages. H.9 Select Input Object
and H.11 VT ESC responses are modelled as strict helpers too, including the
VT4/prior versus VT5+ open-input byte split, ESC reserved bytes, selected/open
consistency, VT6 TAN handling, and full-message envelope validation. H.13
Numeric Value Change and H.19 String Value Change ECU responses are also
checked helpers: numeric responses preserve the exact four value bytes and the
VT5/VT6 reserved/TAN split, while string responses enforce the standard
reserved-byte-only shape before full-message wrapping/parsing. H.14/H.15
Change Active Mask and H.16/H.17 Change Soft Key Mask error
notification/response helpers cover active-mask and soft-key-mask drawing or
reference failure reports, reserved error-bit/tail-byte checks, and
destination-specific VT-to-ECU / ECU-to-VT envelopes.
Change Object Label handling is now gated by the uploaded Object Label
Reference List and by the same output/drawable graphic-designator type check
used during pool validation. A hosted VtRenderRuntime starts with labels from
the object pool, and accepted label changes override that metadata without
forcing a data-mask redraw. Server, direct runtime, and macro replay all reject
undeclared label targets, bad String Variable references, invalid graphic
designators, and reserved Annex K font-type bytes even when the label string
reference is NULL.
Working Set Special Controls colour startup is now part of both hosted render runtime construction and server upload acceptance. The renderer uses the specified initial Colour Palette object, overlays its standard B,G,R,A ARGB entries from index 0 upward, and then routes colours through the specified initial colour map unless a later Select Colour Map/Palette command overrides or resets those selections. When the Working Set Special Controls Colour Palette attribute is NULL or absent, the hosted renderer now keeps the VT default palette instead of falling back to the first Colour Palette object in the pool. The retained Working Set Special Controls colour references are updated as the standard requires. Change Attribute updates to the object’s colour-map and colour-palette attributes also rebuild the hosted scene and update server-retained render state. Server Get Attribute Value for AID 2 and AID 3 now reads that live retained selection, so a later Select Colour Map/Palette command overrides any earlier Change Attribute overlay instead of returning stale colour references. Its advertised language/country pairs are exposed on the scene with the two-space country sentinel preserved; host preference matching is deterministic and ASCII-case-insensitive, while the returned pair keeps the Working Set’s uploaded casing.
Command-trace replay has initial fixture coverage. The repo-owned
tests/fixtures/isobus/vt_render_trace.hex trace is replayed through
VTServer, its accepted ServerRenderEffect stream, and
VtRenderRuntime::from_server_working_set to prove that post-activation
ECU-to-VT command bytes can update rendered scene state. The same constructor
now folds the server-retained selected input object back into the hosted input
runtime, so server snapshots preserve focus as well as draw state. It also
materialises retained Change Priority state into Alarm Mask metadata; priority
changes affect alarm ordering metadata, not the retained data-mask draw list.
Runtime Hide/Show or Enable/Disable commands that match the current effective
object state, plus Enable/Disable commands aimed at non-enableable object
families, including the same effects executed from Macro commands, repeated
overlays, background/size/style updates, child position, soft-key mask/end-point
changes, list item replacement, polygon point changes, same-extents polygon
scaling, and mapped Change Generic Attribute replay, Change Numeric Value, and
Change String Value with the same encoded object body now stay Unchanged, so
replayed duplicate or wrong-target state does not force a scene rebuild or dirty
flag. Server-side Change Child Location/Position admission also now requires
the parent object to actually own the target child before retaining geometry
state, matching the hosted runtime helper instead of preserving inert
non-parent overlays.
Server-side Change Attribute admission now checks that the target object owns a
mutable AID, that reference-valued AIDs point to admissible objects, and that
scalar option-bit, format, justification, boolean-state, shape
type/direction/suppression, half-degree angle, and state-relative min/max values
stay inside the supported ranges before storing retained attribute state or
appending render replay effects; unsupported AIDs, wrong-type object references,
out-of-range scalar values, and read-only value AIDs such as Number Variable
Value stay on their dedicated command paths instead of becoming inert retained
state. Hosted runtime Change Generic Attribute replay now applies the same
reference/scalar checks for the mapped render-affecting AIDs before mutating its
retained pool, including macro replay and server-state import paths; the
Graphics Context fixed-field gate includes 0..=32767 writable viewport
dimensions, read-only canvas-size rejection, canonical two-byte signed
viewport/cursor positions, finite in-range zoom, typed style references, and
standard NULL style selectors.
Server snapshot import applies retained generic attributes as
a converged final state, so state-relative ranges such as Input Number min/max
survive folding into a fresh hosted runtime even when their canonical map order
differs from the command order that the server originally accepted.
This is a local reduced fixture, not a substitute for the still-required
independent .iop and reference-tool traces. The standard-suite also loads the
reviewable tests/fixtures/isobus/vt_object_pool.hex pool through
IopDocument, lowers it to backend commands, and renders it through the hosted
framebuffer so the byte-walker and render pipeline stay covered by fixture
bytes instead of only hand-built pools. The iop_inspect example can also load
candidate raw .iop / .bin pools directly from disk, or a named entry from a
reviewable .hex fixture file, so independent pools can be inspected before
they are promoted into Phase 10 evidence fixtures. The report includes both the
lowered GTUI command preview and a hosted framebuffer snapshot summary with
RGB888/RGB565 export sizes, placeholder-pixel count, and a deterministic RGB888
FNV-1a hash plus deterministic RGB565 big-/little-endian FNV-1a hashes. The
inspector also records a deterministic pool-buffer FNV-1a hash and can run
against an explicit target layout profile using --canvas,
--soft-key-area, --physical-soft-keys, --navigation-soft-keys, and
--soft-key-page, which is needed when promoting soft-key-paging or
target-display evidence. iop_inspect --expect-rgb888-fnv64 ...,
--expect-rgb565-be-fnv64 ..., and --expect-rgb565-le-fnv64 ... exit
nonzero when the rendered snapshot hashes differ. --expect-unsupported-records
and --expect-placeholder-pixels also let a promoted fixture pin explicit
caveat counts instead of silently accepting renderer drift; --write-rgb888,
--write-rgb565-be, and --write-rgb565-le write raw packed framebuffer
snapshots for archiving, byte-for-byte comparison, or display-driver smoke
tests. --write-report-json writes a stable machine-readable report with
source, pool/object counts, layout profile, canvas, coverage totals, GTUI
command count, framebuffer hash/size data, requested raw artifact paths in its
artifacts object, and check failures for provenance notes or CI artifacts.
The framebuffer hash data includes RGB888 plus both RGB565 byte orders so a
promoted fixture can compare host snapshots and common display-driver handoff
bytes without recomputing them out of band.
iop_inspect --strict
exits nonzero when unsupported scene records, framebuffer render errors, or
placeholder pixels are present, making candidate pools usable as a repeatable
pre-promotion evidence gate. The external-evidence manifest at
tests/fixtures/isobus/vt_external_evidence_requirements.txt names the
required independent pool categories. vt_external_pool_basic is now backed by
tests/fixtures/isobus/VT3TestPool.iop, strict iop_inspect JSON, and
checked-in RGB888/RGB565 raw framebuffer artifacts whose hashes are pinned in
tests/fixtures/isobus/vt_external_reports/vt_external_pool_basic_vt3.md;
vt_external_pool_graphics uses the same independent pool with
--active-mask 0x07D0 so its alarm mask renders a visible indexed
PictureGraphic, with hashes pinned in
tests/fixtures/isobus/vt_external_reports/vt_external_pool_graphics_vt3_alarm.md.
vt_external_command_trace is now backed by a reduced ISO11783-CAN-Stack VT3
alarm soft-key callback trace,
tests/fixtures/isobus/vt_external_trace_vt3_alarm.hex, replayed through
vt_trace_inspect after uploading VT3TestPool.iop; it records one accepted
ChangeActiveMask effect, pinned initial/final RGB888/RGB565 hashes, and raw
initial/final framebuffer artifacts in
tests/fixtures/isobus/vt_external_reports/vt_external_trace_vt3_alarm.md.
Reduced open-license seeder fixtures now close the remaining rows:
vt_external_pool_soft_keys_seeder_reduced.hex covers more keys than physical
soft-key cells, vt_external_pool_inputs_seeder_reduced.hex covers
InputBoolean plus InputList, and
vt_external_pool_user_layout_seeder_reduced.hex covers free-form
WindowMask plus KeyGroup placement. The standard suite validates this
manifest shape and checks every complete row for checked-in fixture/report
artifacts plus source, licence, layout, RGB888/RGB565 hashes, strict-result,
and caveat fields, so synthetic in-repo fixtures cannot be mistaken for
Phase 10 completion evidence. For command traces,
vt_trace_inspect replays named ECU-to-VT command payload rows through
VTServer, builds VtRenderRuntime from the accepted server state, renders
initial/final framebuffer snapshots, and writes
machbus-vt-trace-inspect-report-v1 JSON with pool/trace FNV-1a hashes,
the explicit target layout profile, accepted render effects, and initial/final
RGB888 plus RGB565 big-/little-endian hashes. It can also write tightly packed
initial/final RGB888 plus RGB565 big-/little-endian frame dumps, and its JSON
artifacts object records the requested report and raw frame paths, so
promoted command traces can archive byte-for-byte display evidence and
display-driver handoff bytes next to their JSON report. Its
--expect-accepted-effects,
--expect-initial-placeholder-pixels, and --expect-final-placeholder-pixels
flags pin replay/caveat counts in the same promotion command. It also accepts
named pools from reviewable .hex files via
--pool-fixture, so promoted command traces can name the same checked-in pool
fixture as their starting point. make vt-evidence-smoke runs the static pool
inspector and command-trace inspector with strict/hash gates and raw trace frame
dumps against repo-owned reduced fixtures; those smokes are repeatability
checks, not external certification evidence. The hosted runtime also maps
Change Attribute for the common visible shell objects: Data/Alarm/Window masks,
Containers, Soft Key Masks, Keys, and Key Groups now update their retained body
fields and rebuild the scene or soft-key area where those fields are visible;
Key objects now reject non-standard AID 3 so Key Group options are not confused
with Key fields.
Placed Object Pointers now dereference their target into the retained scene, and
Change Numeric Value on the pointer target field retargets the materialised
object. Their target value remains Get Attribute Value readable; Change
Attribute is rejected for Object Pointer retargeting. Server-side Change
Numeric Value admission also rejects invalid render-affecting scalar/pointer
values before retaining replay state, including non-boolean Input Boolean
values, missing Object Pointer targets, invalid Scaled Graphic value-source
chains, Animation selected-frame values outside the positional child list
except 255, and invalid External Object Pointer External Reference NAME
targets. Server-side Input Boolean fixed value and enabled-state Change
Attribute admission is also boolean-gated before retained replay state changes,
and Input Boolean object-pool encode/decode rejects non-boolean fixed values.
Input Boolean foreground/variable references are typed to Font Attributes /
Number Variable.
Alarm Mask fixed Priority and Acoustic Signal object-pool fields reject
reserved values at encode/decode time, and AID 4 Acoustic Signal generic
attribute replay is range-gated in the same server/runtime/macro paths.
External Object Pointer reference-NAME Change Attribute replay accepts NULL as
the standard fallback case, so a live pointer can stop resolving an external
pool and redraw from its local default object. External Object Pointer
default-object Change Attribute replay also preserves Soft Key Mask
fallback-slot semantics by allowing only NULL or Key targets when the pointer is
used as a soft-key child. Direct hosted-runtime replay uses the same checks
before mutating its retained pool.
Validation
The ledger is checked by the VT render tests:
make standard-suite-check
The relevant assertions prove that:
- every implemented
ObjectTypehas a render status; - newly modelled standard families stay visible in the static ledger;
- document-specific coverage reports only the object types present in a loaded pool;
- unsupported/placeholder objects are recorded instead of silently dropped.
Run the book and whitespace gates after editing this page or the CSV:
make book
make whitespace-check
Standard gap roadmap
This page is the human-readable companion to
assets/standard_gap_matrix.csv.
Binding exposure decisions are tracked separately in
assets/standard_binding_matrix.csv.
The matrix is deliberately written in repository-owned language. It names broad behavior families, source modules, test status, external-trace status, and the next engineering action. It does not copy licensed standard prose, tables, examples, diagrams, or generated text extracts.
How to read the matrix
| Column | Meaning |
|---|---|
part | The standard area or evidence family. |
area | A short repo-owned behavior name. |
repo_module | The current code, docs, or evidence surface. |
status | Whether the area is planned, implemented but still needs standard-suite tests, or later complete. |
test_status | Whether existing local tests exist and whether the new standard suite has caught up. |
external_trace_status | Whether independent traces or hardware reports exist. |
next_action | The next non-leaking implementation or evidence step. |
The binding matrix uses the same non-leaking style. It records whether a standard feature family is currently Rust-only, intentionally internal, or available through the C/Python facades. A binding row is a maintenance contract, not a conformance claim: a feature can be exposed through a facade and still require more standard-suite tests or external trace evidence.
Current priorities
- Keep this matrix and the older protocol matrix in sync by hand.
- Add standard-derived code tests under
tests/standard/. - Promote a row only when implementation, tests, documentation, and evidence all support the claim.
- Do not use a green local test as a replacement for external interoperability evidence.
- Update the binding matrix when a standard feature moves between Rust-only, facade-exposed, partial-facade, or intentionally internal.
Status vocabulary
The first implementation pass uses conservative statuses:
plannedmeans the repo has a roadmap item but not enough checked evidence.implemented-needs-standard-testsmeans code and local tests exist, but the standard-derived test suite and/or external evidence still needs fuller coverage before a completion claim is safe.standard-suiteintest_statusmeans at least one repo-owned standard-derived test file now covers the part; it is not by itself a conformance claim.- Future
completerows must name exact tests and, when the claim requires it, reduced traces or hardware reports.
The point of this page is to make the hardening work auditable without turning the private documents into public documentation.
Hardware, vcan, and trace evidence
This chapter is the operator guide for moving from local software tests toward real bus evidence. It explains what to run, where to record the result, and how the repository prevents accidental overclaiming.
The important rule is simple: a capture is not evidence until the reduced trace, manifest row, test or replay path, and Markdown report are all checked in.
Where the evidence contract lives
The executable contract is split across four places:
tests/fixtures/hardware/capture_requirements.txttests/fixtures/hardware/capture_playbook.txttests/fixtures/hardware/can_adapter_matrix.txttests/fixtures/evidence/gap_external_evidence_map.txttests/fixtures/evidence/public_data_requirements.txttests/fixtures/hardware/capture_reports/tests/fixtures/traces/manifest.txt
The hardware-evidence fixtures (tests/fixtures/hardware/,
tests/fixtures/traces/) are the manual contract: every required flow should
have a concrete playbook row, completed rows should point at a reduced-hardware
trace, and reports should include the required fields. Maintain these by hand as
real captures are added.
The gap map is stable: it ties each broad matrix row listed in
tests/fixtures/isobus/iop_parser.hex to concrete evidence
requirements. Keep a gap-map row even after evidence is collected. Promotion
from external_trace_status=missing requires every referenced hardware or
public-data requirement to be complete, with reports and traces where the row
requires them. Most rows map to hardware or vcan capture requirements. The DDI
row is different: it requires public-data refresh evidence, because the safe
proof is source provenance and refresh metadata rather than a CAN trace.
Adapter matrix
The adapter matrix is a small evidence checklist for the interface class used
to collect a trace. It is not a list of certified devices. It records whether a
row is a virtual vcan setup, a native SocketCAN can adapter, or an slcan
bridge, and what must be written down before the trace can be used.
The key boundary is:
vcanis useful development and replay evidence, but it is not physical timing proof.- physical adapter rows must be configured at
250000bit/s, run on an isolated bench with termination checked, and produce a reduced trace plus a capture report before they count as repository evidence.
The standard-suite test for ISO 11783-2 checks this matrix so adapter evidence cannot drift away from the capture requirements.
Start with Linux vcan
For development, use a disposable virtual CAN interface:
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set vcan0 up
Record with the same timestamped format used by the playbook:
candump -td -L vcan0
In another terminal, run the monitor:
MACHBUS_SOCKETCAN_IFACE=vcan0 \
MACHBUS_SOCKETCAN_MONITOR_SECONDS=10 \
cargo run --features wirebit --example socketcan_capture
In a third terminal, emit the address-claim and DM1 smoke frames:
MACHBUS_SOCKETCAN_IFACE=vcan0 \
cargo run --features wirebit --example candump_replay -- <trace>
Expected local observation:
candumpshows an extended Address Claimed frame;- the monitor prints an Address Claimed summary;
- the example later emits a DM1 frame for SPN
100/ FMI1.
This is useful development feedback. It becomes repository evidence only after
you reduce the trace, list it in tests/fixtures/traces/manifest.txt, add a
report under tests/fixtures/hardware/capture_reports/, and update the matching
row in tests/fixtures/hardware/capture_requirements.txt.
Required capture flows
The current required IDs are listed below. Keep these IDs stable because the docs, playbook, tests, and future reports join on them.
| ID | What the capture should prove |
|---|---|
vcan_address_claim | machbus emits Address Claimed on a Linux vcan SocketCAN interface. |
vcan_dm1 | machbus emits the DM1 smoke frame after the example raises SPN 100 / FMI 1. |
physical_address_claim | machbus emits Address Claimed on an isolated physical 250 kbit/s CAN bus. |
peer_request_address_claim | an independent peer sends Request Address Claimed and machbus responds. |
tp_bam_transfer | an external observer sees at least one multi-packet TP/BAM transfer. |
etp_connection_transfer | RTS/CTS/DPO/DT/EOMA traffic is captured for an ETP-sized transfer. |
network_interconnect_router | translated NIU/router traffic is captured across two observed bus segments. |
diagnostic_request_response | an external peer participates in a diagnostic request/response or clear workflow. |
vt_object_pool_upload | object-pool upload traffic is captured against an independent VT or reference tool. |
vt_object_pool_upload_failure | a failed VT replacement upload is captured without accepting a stale active pool as the new upload. |
implement_message_broadcast | selected implement/tractor message PGNs are decoded by an independent observer. |
powertrain_engine_trace | selected EEC/TSC/transmission PGNs are decoded by an independent observer. |
tractor_ecu_facility_trace | tractor facility and maintain-power traffic is captured with source-scoped peer observations. |
tc_ddop_upload | DDOP upload or activation traffic is captured against an independent TC or reference tool. |
file_server_read_write | connect/open/read/write/close style File Server traffic is captured with an independent peer. |
section_control_lifecycle | Ready/PlayBack/pause/resume/abort style Section Control traffic is captured with an independent peer. |
tim_authority_interlock | TIM authority grant/revoke and blocked-command behavior is captured with an independent peer. |
nmea2000_gnss_environment_trace | selected NMEA 2000 GNSS/navigation/environment PGNs are decoded by an independent observer. |
Promoting a capture
Use this checklist every time a run looks worth keeping:
- capture with
candump -td -L; - trim the file to the smallest reproducible sequence;
- store the reduced trace under
tests/fixtures/traces/; - add a
tests/fixtures/traces/manifest.txtrow with provenancereduced-hardware; - add or update a replay/test target that states what the trace proves;
- write a report under
tests/fixtures/hardware/capture_reports/; - update
tests/fixtures/hardware/capture_requirements.txtfrommissingtocompleteand point it at the trace ID and report path; - update
book/src/reference/protocol-coverage.mdif the capture changes the human evidence story; - run
make verify.
Reports must include the requirement ID, trace ID/path, machbus commit, interface, bitrate, exact capture command, peer tool/device, exact behavior proven, and caveats.
Replaying checked-in traces
For local trace parsing, use:
make trace-replay-demo
For one capture:
cargo run --example candump_replay -- tests/fixtures/traces/time_date_agisostack.candump
The replay helper accepts compact candump -L lines and bracketed classic
formats. It intentionally rejects malformed lines before building driver frames
and rejects standard 11-bit IDs instead of silently treating them as J1939 or
ISOBUS traffic.
Physical-bus notes
For an isolated physical CAN setup, configure the adapter for the bus speed the test requires, normally 250 kbit/s for ISOBUS/J1939 work in this repository. Use an analyzer or independent tool when possible, then record both the machbus-side command and the external observation in the capture report.
Do not connect an experimental stack to a machine network where it can affect motion, hydraulics, steering, PTO, implement sections, or other safety-relevant behavior. Use an isolated bench, simulator, or reference peer first.
Release checklist
Release work is a paperwork-and-evidence exercise. The code may already be merged, but a tag should wait until the versions, generated files, package payload, docs, and validation logs tell the same story.
Before changing the version
- Read the current
CHANGELOG.md. - Check whether the release is Rust-only, Python-only, C-header relevant, or all three.
- Inspect
Cargo.toml,pyproject.toml,PROJECT, and generated C header metadata for version drift. - Check
book/src/reference/audit/conformance.mdbefore writing any release notes that mention protocol evidence.
Required commands
Run these from the repository root:
make fmt
make bind-c
make verify
make standard-suite-check
git diff --check
make bind-c-check
make verify already runs most of the named checks, but the release checklist
keeps the high-risk steps visible. make bind-c updates the generated header;
make bind-c-check confirms it is not drifting afterward. The repeated standard
suite is deliberate: a release reviewer should see it even when they do not
expand the full make verify target.
Standard and evidence audit before a tag
Before tagging, inspect book/src/reference/assets/standard_gap_matrix.csv and
record the current status counts in the release note or validation log. In
particular, count:
- rows still marked
implemented-needs-standard-tests; - rows with
external_trace_status=missing; - rows marked
complete.
Rows with external_trace_status=missing are not release blockers by
themselves, but they are claim blockers. The release note must not turn those
rows into broad hardware, external interoperability, or certification claims.
The stable evidence map lives at
tests/fixtures/evidence/gap_external_evidence_map.txt; hardware capture
requirements live at tests/fixtures/hardware/capture_requirements.txt.
Promoting a broad row requires the mapped hardware or public-data evidence
requirements to be complete, with reports and reduced traces where required.
Package contents inspection
Publishing requires package contents inspection, not just tests. Inspect the list before tagging:
cargo package --allow-dirty --list
Confirm that release metadata, the book/ mdBook sources, generated headers,
examples, tests, fixtures, and Python metadata are present while local build
artifacts (target/, .git/, virtualenvs, proptest regressions) are absent.
The [package].include list in Cargo.toml is the source of truth for what
ships.
The current crate still has publish = false. If that changes, check the
registry story for path dependencies before upload. The workspace currently
uses path dependencies, and local packaging does not prove those path
dependencies are available from the target registry.
Changelog rules
Write concrete entries:
- Added
- Changed
- Fixed
- Hardened
- Validation
Avoid placeholder migration language. If the old C++ project motivated a change, describe the actual behavior that was ported and the tests that now cover it.
Tagging rule
Tag only after the above evidence is recorded. At minimum, record the commit, the version numbers, the command list, and any caveats about hardware or protocol evidence.
Validation history
This page is the human log of the hardening gate. It is not a changelog and it is not a marketing page; it records what the local repository has actually run.
The top-level Makefile is the source of truth. When in doubt, inspect the
target body and run the make target rather than copying individual commands.
Current full gate
Run:
make verify
The current gate runs:
make checkmake testmake check-allmake test-allmake clippymake rustdocmake bind-c-checkmake c-demomake c-full-demomake python-demomake trace-replay-demomake fuzz-smokemake wirebit-examples-checkmake standard-suite-checkmake whitespace-check
Those names are intentionally explicit. They make CI and local logs readable: generated C header drift, C compile surfaces, Python wheel smoke, trace replay, fuzz smoke, SocketCAN example typechecking, the standard suite, and whitespace checks are all visible without reading the entire Rust test log.
Gate scope change. Earlier passes wired several repo/doc-governance “tests” — claim-boundary, gap- and protocol-matrix, package-contents, and hardware-evidence manifest checks. Those
.rsfiles policed documentation prose, the README,Cargo.toml, and workflow YAML rather than code behavior, so they were removed. The gate now focuses on code and the standard suite. The gap/protocol matrices, hardware-evidence fixtures, and conformance docs remain as maintained-by-hand references, not test-enforced contracts.
What the gate currently proves
The gate combines these kinds of evidence:
- default and all-feature Rust builds/tests;
- Clippy warnings denied;
- rustdoc warnings denied;
- generated C header drift checked by
make bind-c-check; - C examples and C ABI compile surfaces built with warnings denied;
- Python extension build/install smoke plus wheel-install smoke;
- replay of compact, bracketed, malformed, and standard-ID rejection candump fixtures;
tests/fuzz_targets.rsrun throughmake fuzz-smokeas an arbitrary-input decoder smoke;cargo check --features wirebit --examplesrun throughmake wirebit-examples-check, which keeps SocketCAN examples buildable and does not require a live vcan;tests/standard.rsrun throughmake standard-suite-check, which runs the standard-derived ISO 11783, AEF TIM, and NMEA 2000 tests;git diff --checkrun throughmake whitespace-check.
The test suite now exercises code behavior only. The earlier repo/doc-governance
“tests” — which policed README/Cargo.toml/workflow prose, the gap and protocol
matrices, package contents, hardware-evidence manifests, and a private-standard
leak scan — were removed so .rs tests cover code, not documentation or repo
layout. The non-disclosure boundary is now a maintained convention, not an
automated scan.
Review against the standards text
A pass over the implementation with licensed copies of ISO 11783-1 … -14, AEF 023 RIG 2 and the NMEA 2000 appendices, checking field orders, ranges, timings, default priorities and reserved-bit rules against the documents.
- Nine defects found and fixed; each carries a test that fails when the fix is reverted, and the full gate ran green before every commit.
- Seven of the nine only misbehave against a conformant peer — over-strict receive paths and uniform defaults where the standard varies — so no self-consistent test could have surfaced them.
- Four miscited clauses corrected; three judgement calls documented in place rather than changed.
- Recorded limitation: no PGN value in the crate is evidenced by the standards, because ISO 11783-1 §7, -7 §4.2 and -11 §4.1 all place the assignments in the electronic database at isobus.net.
Detail, including what each document could and could not evidence: audit against the standards text.
Focused gates retained
| Gate | Why it exists |
|---|---|
make whitespace-check | Runs git diff --check so whitespace damage is caught by make, not only by manual review. |
make fuzz-smoke | Runs tests/fuzz_targets.rs so arbitrary-input decoder coverage is visible in logs. |
make wirebit-examples-check | Runs cargo check --features wirebit --examples; this keeps SocketCAN helpers compiling and does not require a live vcan. |
make standard-suite-check | Runs tests/standard.rs to execute the standard-derived coverage suite. |
VT object-pool wire-format conformance
- Dropped the non-standard per-object
[len:u16]prefix;ObjectPoolnow serializes/deserializes the ISO 11783-6 wire layout ([id][type][body…]) with a parse-by-type walker (object_body_total_len) covering all 48 object types. The same path now decodes real.iopfiles and third-party VT-client uploads. - Made
InputAttributes,Macro, andStringVariableself-delimiting per the standard (length/num_bytesfields), so the prefix-free format is unambiguous. net::iop_parsernow delegates to the conformant codec (one source of truth); legacy naive per-type lengths removed.- Evidence: full
cargo testsuite green (incl. an all-48-types serialize→deserialize round-trip and regenerated VT object-pool / command / working-set-storage fixtures).
VT renderer runtime
- InputString edits enforce InputAttributes character-set validation.
- VT6 Colour Palette objects override the render palette.
- Macro runtime: event→macro dispatch, command-stream decode, and apply (Change Numeric/String Value to the pool; Hide/Show, Enable/Disable via runtime overlay; Change Active Mask reported for rebuild).
- Corrected
MacroCommand::get_command_lengthto ISO 11783-6 (Virtual Terminal) data lengths (fixed commands are 8-byte frames; the legacy table had impossible values such as0xA7 = 9). - Evidence: full
cargo testgreen; clippy/rustdoc/whitespace clean.
Earlier hardening milestones
The broad 2026 hardening pass added or tightened coverage around:
- root release-metadata package gates;
- repo-local generated-artifact
.gitignorepolicy; - source-persistence-free proptest/fuzz-smoke configuration;
- fallible ingress paths for Section Control and TC-GEO handlers;
- DDI-aware TC-GEO prescription-rate engineering conversion;
- Process Data Value payload helpers;
- Task Controller client/server direct-dispatch validation;
- zero-capacity/default-capacity event-queue behavior;
- C ABI layout assertions;
- NMEA 2000 management heartbeat/configuration fixtures;
- fallible typed access to plugged subsystems (
with/with_mutreturningOption); - 4096-byte ETP receive-profile fixture coverage;
- TP BAM cadence fixture coverage;
- ETP CTS hold/resume and receiver Abort cancellation fixtures;
- malformed TP/ETP CM/DT corpora;
- generated/arbitrary Fast Packet receive streams;
- arbitrary Identifier/PGN/NAME/DataSpan/Message decoder coverage.
Keep future entries short and evidence-oriented. Prefer “target X passed after change Y” over long copied terminal logs.
Rust-port behavior differences
The original C++ code used several push-style callbacks and duplicate type names that do not map cleanly into a Rust crate. These are the intentional differences in the Rust port.
ISO 11783-6 VT object-pool codec conformance
The VT object-pool codec in src/isobus/vt/objects.rs was audited against
ISO 11783-6 (Virtual Terminal) and brought into conformance on the major
divergences. This section records what changed and what remains.
Resolved divergences (now conformant)
- Number display math:
(value + offset) * scale(was a division). - Scale field type: IEEE-754
f32(wasi32). - Child positions: signed
i16X/Y (wasu16); each child record is the standard 6 bytes[oid:u16][x:i16][y:i16](was 2 bytes, OID only). - Child counts:
u8per the standard (wasu16). - Macro reference lists:
[num_macros:u8][num × (event:u8, macro:u8)]round-trip through the codec (were absent). - Per-type child record size: 6-byte positional records for WorkingSet/DataMask/AlarmMask/Container/Key/Button/WindowMask; 2-byte OID-only records for SoftKeyMask/KeyGroup.
- Body field layouts corrected for: WorkingSet (gained
background/selectable/active_mask), AlarmMask (dropped non-standard
optionsbyte), InputBoolean (background, width, foreground as a Font Attributes ref, variable_ref, value, enabled — no height/options), InputString (Lengthisu8; justification precedes length), InputNumber (gainedvalue/justification/standard Options 2; dropped non-standardinput_attributes), OutputNumber (gainedvalue/justification), OutputLine/OutputRectangle/OutputEllipse (line_attributesfirst), Meter/LinearBarGraph/ArchedBarGraph (u16min/max +value; ArchedBarGraphbar_widthnotnumber_of_ticks), PictureGraphic (actual_width/actual_height/transparency/u32 raw-data length), StringVariable (gainedLengthfield). - No per-object length prefix: the codec now serializes each object as
the ISO 11783-6 wire layout
[id:u16][type:u8][body…](the earlier[len:u16]field is gone). Object boundaries are recovered by a parse-by-type walker (object_body_total_len) that knows every body’s length, so the sameObjectPool::deserializepath consumes machbus-produced pools, real.iopfiles, and pools uploaded by third-party VT clients.net::iop_parserdelegates to this codec — one source of truth. - Self-delimiting bodies: to support the prefix-free format,
InputAttributesgained its standardlength:u8field ([type][len][string]),Macrogained its standardnum_bytes:u16prefix ([num_bytes][commands…]), andStringVariableemits the actual value length on the wire — all three now match ISO 11783-6.
Remaining (intentional) gaps
- Hosted VT rendering is still partial evidence, not certification:
Working Set language codes and Working Set Special Controls language/country
pairs are modelled for hosted selection policy, but text-resource selection
and profile-specific editor behaviour still need more evidence. Standard
GraphicContextsurfaces, PictureGraphic-backedScaledGraphic,Animation, and the bounded PNGGraphicDatasubset now have backend-neutral render coverage where their referenced image/frame payloads are valid. Unsupported PNG format variants and unsupported graphics-context canvas operations remain explicit placeholders rather than certification-quality rendering. - Protocol-state object bodies: Aux* objects remain outside the retained data-mask renderer, even though they are modelled by the VT object pool/protocol layers. Compatibility extension rows remain compatibility claims, not ISO standard-completeness claims.
- Palette / font metrics: the render layer’s colour palette RGB values and font-size→pixel mapping are repo-owned approximations (the standard’s exact values are licensed material, not reproduced here).
Wire codecs return values instead of pushing frames
Most ISOBUS subsystem codecs return Vec<Outbound> or typed values instead of
directly pushing into a global network manager. The session facade is
responsible for routing those outbound frames onto the transport.
Why:
- unit tests can assert exact frame sequences without a live bus;
- no hidden global state is required;
- C/Python bindings can expose fallible calls with a stable error channel.
Duplicate C++ names were disambiguated
Some C++ headers used the same public name for different layouts. Rust requires one canonical item per module path, so the port uses explicit names:
- classic FS
FileServerPropertieslives insrc/isobus/fs/types.rs; - v2 FS properties use
FileServerPropertiesV2insrc/isobus/fs/properties.rs; - v2 volume state uses
VolumeStateV2.
Section Control is split by role
The Section Control master and client logic live under src/isobus/sc/master.rs
and src/isobus/sc/client.rs. This keeps role-specific timing and state
separate while allowing a facade to compose them later.
DTC memory is explicit
Diagnostic occurrence/history handling is centralized in DmMemory. The diagnostics plugins expose active and previous DTC lists; it does not hide occurrence-count
changes behind unrelated message helpers.
Implement pump bridge is explicit
The Implement plugin has concrete inbound PGN callbacks for the supported
hitch, PTO, and auxiliary-valve command PGNs. Unsupported implement-message
families remain codec-only until wired through an explicit facade method; no
no-op event bridge is kept to imply hidden behavior.
C ABI is an explicit facade
The C header is generated from src/ffi.rs only. Internal Rust constants,
helper modules, and protocol tables are not part of the C ABI unless they are
wrapped by an Machbus* type or machbus_* function in that file.
Glossary
Plain-words definitions for the ISOBUS, J1939, NMEA, and machbus terms used
throughout this book. Definitions are written in the book’s own words and aim to
match how each idea is used in the surrounding pages. Where a topic has a
dedicated chapter, a “(see …)” pointer is given.
A
Address claim — The handshake by which a device tells the bus it intends to use a particular one-byte address, and defends that choice against any other device that wants the same address. (see NAME and address claim)
AEF — Agricultural Industry Electronics Foundation, the group that runs the
interoperability test and certification program for ISOBUS products. machbus
ships no AEF certification.
Alarm mask — A Virtual Terminal screen layout that the operator’s terminal shows when a working set raises an alarm, typically grabbing attention and optionally sounding the terminal. (see Virtual Terminal concepts)
Arbitration — The mechanism on a CAN bus that decides which message goes first when two devices transmit at once: the lower identifier wins, bit by bit, without corrupting either frame. The same idea decides address-claim contests.
B
BAM — Broadcast Announce Message: the transport mode that sends a large message to everyone on the bus, one numbered chunk at a time, with no handshake or flow control. (see Transport Protocol)
Broadcast address — The destination value (255) meaning “every device on the bus.” A message addressed there is for all listeners rather than one partner.
C
CAN — Controller Area Network: the low-level serial bus that carries every ISOBUS and J1939 frame. It provides short frames, built-in priority, and collision-free arbitration. (see CAN and J1939)
CMDT — Connection-mode data transfer: the handshaked transport mode that moves a large message to a single destination using request-to-send and clear-to-send exchanges. (see Transport Protocol)
Commanded address — A network management instruction that tells a specific device (identified by its NAME) to move to a new address. The device obeys and re-claims at the commanded value. (see NAME and address claim)
Control function (CF) — Any addressable participant on the bus: a logical sender/receiver identified by a NAME and an address. One physical box can host several control functions.
CTS — Clear To Send: the receiver’s reply in a handshaked transport that tells the sender how many chunks it may send next, and from which point. (see Transport Protocol)
D
DDI — Data Dictionary Identifier: a standardized code naming one quantity a device can report or accept, such as an application rate or a section state. DDIs make process data portable across vendors. (see DDOP and process data)
DDOP — Device Descriptor Object Pool: the structured self-description an implement uploads to a Task Controller, listing its elements, the quantities it exposes, and how to present them. (see DDOP and process data)
Device element — One node in a DDOP tree that represents a real or logical part of the machine (the whole device, a boom, a single section) and carries the process-data quantities tied to that part. (see DDOP and process data)
DTC — Diagnostic Trouble Code: a fault report combining a parameter number and a failure-mode code, used to surface problems to operators and tools. (see Diagnostics basics)
E
ECU — Electronic Control Unit: a physical computing box on the machine. An ECU may host one or more control functions.
EOM — End-of-message acknowledgement: the receiver’s confirmation, after a handshaked transport, that it got every chunk and reassembled the whole message.
ETP — Extended Transport Protocol: the transport used for messages too large for the ordinary transport protocol, using larger sequence numbers and byte-offset bookkeeping. (see Transport Protocol)
F
Fast Packet — The NMEA 2000 multi-frame scheme that strings several CAN frames together to carry a single larger marine/navigation message. (see NMEA and GNSS basics)
Fix quality — The reported trustworthiness of a GNSS position, distinguishing no fix, a basic fix, and corrected high-accuracy modes. Guidance and section control care a great deal about this value. (see NMEA and GNSS basics)
FMI — Failure Mode Identifier: the code that says how a parameter is failing (too high, too low, open circuit, and so on) inside a fault report.
FS — File Server: a device that offers shared storage on the bus so other control functions can read and write files. (see File Server and large data)
G
GNSS — Global Navigation Satellite System: the umbrella term for satellite positioning (GPS and its peers) that feeds position, speed, and heading into guidance and mapping. (see NMEA and GNSS basics)
I
Identifier (29-bit) — The extended CAN frame header that ISOBUS uses. It packs priority, the parameter group, the destination (when applicable), and the source address into one value that also decides arbitration order. (see PGNs, priority, source, destination)
Industry group — A field inside a NAME that says which family of machinery the device belongs to (for example, agricultural equipment), helping classify participants on a shared bus.
Internal vs partner CF — From your stack’s viewpoint, an internal control function is one your own code owns and operates; a partner control function is another device you have chosen to talk to. The distinction drives filtering and routing. (see Control functions and partners)
ISB (Shortcut Button) — The ISOBUS Shortcut Button: a single operator control whose press commands every listening implement to drop into a safe, stopped state. (see Shortcut Button and safe-state thinking)
ISOBUS — The agricultural networking standard defined by the ISO 11783 family of parts, built on top of J1939, that lets tractors, implements, and terminals from different makers interoperate.
M
Manufacturer code — A field in a NAME identifying which company made the device, assigned so that NAMEs stay globally distinct.
Mask (data / alarm / soft key) — A Virtual Terminal screen region. The data mask is the main working area an implement draws into, the alarm mask interrupts with a warning, and the soft key mask holds the row of programmable buttons. (see Virtual Terminal concepts)
N
NAME — The 64-bit identity of a control function. It encodes who made it, what function it performs, its industry group, and whether it can move its own address; its numeric value also sets priority in address-claim contests. (see NAME and address claim)
NIU / router — Network Interconnection Unit: a node that bridges two CAN
segments, forwarding the traffic that belongs across the boundary. In machbus
the routing layer plays this role. (see Network routing)
NMEA — The marine-electronics body behind the NMEA 0183 serial sentences and the CAN-based NMEA 2000 messages, several of which carry the GNSS and navigation data ISOBUS machines consume. (see NMEA and GNSS basics)
Null address — The placeholder source address (254) a device uses while it has no valid claimed address, for example after losing an address contest. A device at the null address cannot do normal traffic.
O
Object pool — The bundle of drawing and interaction objects a Virtual Terminal client uploads so the terminal can render and run its user interface. (see Working sets and object pools)
ObjectID — The numeric handle that names one object inside an object pool, so later messages can reference, change, or read that exact object. (see VT object pools)
P
PDU1 / PDU2 — Two header formats for a parameter group. PDU1 carries a destination address (a directed message to one device), while PDU2 has no destination field and is always broadcast. (see PGNs, priority, source, destination)
PGN — Parameter Group Number: the identifier of a kind of message, telling you what the payload means regardless of who sent it. (see PGNs, priority, source, destination)
Priority — The few header bits that bias arbitration: lower-numbered priority wins the bus sooner, so urgent messages can preempt routine ones. (see PGNs, priority, source, destination)
Process data — The live, named quantities an implement and a Task Controller exchange during work: measured values flowing up and setpoints flowing down, each tagged by a DDI. (see DDOP and process data)
R
RTS — Request To Send: the opening message of a handshaked transport, naming the message to come and its size so the receiver can agree to take it. (see Transport Protocol)
S
Safe state — The defined, predictable condition a device falls back to when something goes wrong or an operator demands a stop: outputs off, motion halted, no surprises. (see Shortcut Button and safe-state thinking)
SC — Sequence Control (and, by extension, section control work): the mechanism for running ordered, scripted automation steps between cooperating devices. (see Sequence Control and TIM)
Section control — Automatically switching individual implement sections on and off based on position, coverage, and boundaries, so you avoid double-applying or treating outside the field. (see Task Controller concepts)
Self-configurable address — A flag in a NAME marking a device that is allowed to pick a different address on its own if its first choice is taken. Devices without it must keep a fixed address. (see NAME and address claim)
Sequence control — See SC: coordinated, step-by-step command sequences between an initiator and the devices it drives. (see Sequence Control and TIM)
Setpoint — A target value a controller asks an implement to achieve, such as a commanded application rate, expressed as process data. (see DDOP and process data)
Soft key — A programmable on-screen button on a Virtual Terminal whose meaning is defined by the current object pool and whose presses are reported back to the working set. (see Virtual Terminal concepts)
Source address — The one-byte field in every frame saying which control function sent it. A device must own a claimed address before using it as a source. (see PGNs, priority, source, destination)
SPN — Suspect Parameter Number: the code naming which parameter a fault report is about. Paired with an FMI it identifies a specific problem.
T
TAN — Transaction number: a small rolling counter carried in some Virtual Terminal exchanges so a request and its matching response can be paired unambiguously. (see VT updates)
TC — Task Controller: the device that drives documentation and control of field work, receiving process data, applying prescriptions, and coordinating section control with implements. (see Task Controller concepts)
TECU — Tractor ECU: the tractor-side control function that publishes machine data (speed, distance, power-takeoff, hitch, and similar) for implements to use. (see Tractor ECU)
TIM — Tractor Implement Management: the framework that lets an approved implement request limited control over tractor functions, under operator oversight and safety conditions. (see Sequence Control and TIM)
Transport protocol — The set of rules for splitting a message larger than one CAN frame into numbered chunks and reassembling them, whether broadcast (BAM) or handshaked (CMDT). (see Transport Protocol)
V
Value presentation — The formatting metadata in a DDOP that says how to turn a raw process-data number into something human-readable: scale, offset, decimal places, and units. (see DDOP and process data)
VT — Virtual Terminal: the shared operator display in the cab that renders the user interface uploaded by each implement and reports operator input back to it. (see Virtual Terminal concepts)
W
Working set — The identity an implement presents to services such as the Virtual Terminal: a root object that groups the device’s interface and its member control functions. (see Working sets and object pools)
Working-set master — The control function that speaks for a working set, performing the upload and interaction handshakes on behalf of any members behind it. (see Working sets and object pools)
Reminder: these definitions explain how the book uses each term. machbus
carries no ISO, SAE, NMEA, or AEF certification; real deployment still requires
the official standards, hardware, and interoperability evidence.
Audit: review against the standards text
A pass over the implementation with the ISO 11783 series, AEF 023 RIG 2 and the NMEA 2000 appendices open beside it, checking field orders, ranges, timings, default priorities and reserved-bit rules against what the documents actually say.
This is a distinct kind of evidence from the rest of the suite. Unit tests and fixtures prove the code does what its author intended; trace replay proves it matches a capture. Neither catches a wire rule that was reconstructed rather than read — and reconstructions are exactly where this pass found problems.
The standards are not redistributed in this repository and never will be. This page records what was checked and what was found; reproducing it needs your own licensed copies.
What each document could evidence
The series is not uniform. Three of its parts turn out to be short pointer documents that define no messages at all, and that shapes what can be checked.
| Document | Substantive for this audit |
|---|---|
| ISO 11783-1:2017 | §6.13 safe mode (defers to part 9), §7 electronic database |
| ISO 11783-2:2019 | bit rate and sample point, bus power minima, §9.6 fail-safe |
| ISO 11783-3:2018 | transport timeouts, size limits, Table 8 abort reasons |
| ISO 11783-4:2011 | Table 2 NIU function codes |
| ISO 11783-5:2019 | NAME self-configurable bit and claim behaviour |
| ISO 11783-6:2018 | VT object attribute tables, Annex D queries, Annex J auxiliary |
| ISO 11783-7:2022 | §5.2.4 Table 1 SLOT bands, §5.4 reserved bits, Clause 11 TIM |
| ISO 11783-8:2006 | 3 pages — §4.2/§4.3 precedence over J1939-71 only |
| ISO 11783-9:2012 | tractor classes, facilities handshake, §4.7 safe mode |
| ISO 11783-10:2015 | process data commands, TC/client status, DDOP, TimeLog |
| ISO 11783-11:2011 | 3 pages — §4.2 DDI entry shape only |
| ISO 11783-12:2019 | DM1/DM2, diagnostic protocol, B.9 functionalities |
| ISO 11783-13:2022 | error codes, flags, volume and file operations |
| ISO 11783-14:2013 | F.3 SCClientStatus and its timeouts |
| AEF 023 RIG 2 | TIM function messages, SLOTs and facility blocks |
| NMEA 2000 App. A/B | per-PGN priorities, GNSS data dictionary items |
The limit worth knowing about
No PGN number in this crate is verifiable from the standards. That is the series’ own design, stated three times over:
- ISO 11783-1 §7 — “The electronic database with the ISO 11783-1 parameter group, address and identity assignments is accessible at: www.isobus.net”, listing PGNs, industry groups, preferred addresses, NAMEs and manufacturer codes as living there.
- ISO 11783-7 §4.2 — the same for the part 7 PGN and SPN assignments.
- ISO 11783-11 §4.1 — the same for the DDIs, maintained by VDMA as the ISO-appointed maintenance agency.
So this audit evidences message definitions — field order, widths, ranges,
reserved-bit rules, timings, priorities — and not the numbers those messages
travel under. A citation beside a PGN constant names the clause defining the
message, never one stating its value. The same applies to every DDI range in
ddi_database: only the five-attribute shape mandated by ISO 11783-11 §4.2 is
checkable.
What was found
Nine defects, each fixed with a test that fails when the fix is reverted.
| Area | Defect | Clause |
|---|---|---|
| AutoDrive | never sent Required Tractor Facilities, so a conforming TECU may never broadcast the Machine Info it refuses to engage without | 11783-9 §4.4.2 |
| AutoDrive | facility request set reserved bits to 1, asking for every undefined facility | 11783-7 §5.4, 11783-9 §4.4.2 |
| File Server | rejected volume requests whose reserved bits were set | 11783-13 B.29/B.30, §4.9 |
| Diagnostics | one unknown functionality code discarded the whole message | 11783-12 B.9 |
| Task Controller | every Process Data message sent at priority 6 | 11783-10 B.2 |
| Task Controller | TimeLog encoder omitted five declared position columns | 11783-10 Table 3 |
| Powertrain | Python exposed only the strict speed decoder, which rejects real frames | 11783-8 §4.2 |
| NMEA 2000 | every PGN transmitted at priority 6 | NMEA 2000 App. B.1 |
| NMEA 2000 | a reserved Sequence ID discarded the whole parameter group | NMEA 2000 DD056 |
The pattern
Seven of the nine only misbehave against a conformant peer. They are receive paths that were stricter than the standard allows, or defaults that were uniform where the standard varies. Testing a stack against itself cannot surface either: both sides share the same wrong assumption.
The reserved-bit cases share one root. ISO 11783-7 §5.4 says both halves of the rule, and only the first half had been applied:
“All undefined and reserved bits shall be transmitted with a value of ‘1’ … All undefined bits should be received as ‘don’t care’ (either masked out or ignored). This permits them to be defined and used in the future without causing any incompatibilities.”
That clause also carries an exception which is easy to miss and which the facility messages fall under — feature-availability messages where “the default value is zero (‘0’) for forward compatibility. The value of zero indicates ‘not supported’”.
The two priority defects are the same mistake in different subsystems: a per-message default collapsed into one constant. Worth checking wherever a plugin passes a literal priority.
Corrections to citations
Four places named a clause that does not say what was claimed:
- Auxiliary functions were attributed to ISO 11783-11, which contains no mention of them; they are ISO 11783-6 Annex J. (The module body said “Annex G”, which is “Status Messages” in the 2018 edition.)
- The 100 ms guidance cadence cited ISO 11783-7 §5.2.7.2, which defines the form of an on-change rate but states no numbers — §5.2.7.1 puts every rate in the electronic database. Now cited to AEF 023 §D.7.1, which does state “2000 ms periodic, 100 ms on change”.
Dm5Messagewas labelled DM5. J1939-73’s DM5 is Diagnostic Readiness 1 on PGN 65230; this is ISO 11783-12 B.5 “Diagnostic protocol” on PGN 64818.- Wheel/ground/machine speed were attributed to J1939-71 alone, but ISO 11783-8 §4.2 gives ISO 11783-7 precedence wherever both define a parameter.
Judgement calls left as they are
Recorded rather than changed, because the normative text supports current behaviour and changing it would reject data that works today:
- DDOP designators carry two limits: Table A.1’s normative range is 128 bytes (enforced), while the description column says 32 characters. Annex A derives one from the other at 4 bytes/character. A 128-character ASCII designator therefore serializes.
- Element number 4095 is inside B.3.2’s stated 0–4095 range but is also how B.8.1/B.8.2 encode “element number not available”.
- Auxiliary PGN constants are evidenced by Annex J for their message definitions only; the values are not printed in part 6.
What this evidence is not
Reading the text is not conformance testing. It cannot show how a specific
tractor behaves, cannot substitute for AEF validation, and does not move any row
in the conformance boundary. Several areas were confirmed
correct against the text and are still untested against real hardware —
ISO 11783-7 §5.2.4 curvature banding and the AEF 023 TIM SLOTs among them.
Its value is narrower and real: it is the only layer here that can catch a rule the implementation never got right in the first place.
See also
- Evidence model — where this layer sits.
- Conformance and claim boundary — what may be claimed.
machbus drivesafety model — the ISO 11783-9 §4.7 clauses the operator layer answers to.- AutoDrive — the facilities handshake this pass added.
Binding contracts
This page explains how the Rust, C, and Python surfaces relate. It is written for maintainers who need to change a binding without accidentally promising more than the repository tests.
Both the C and Python bindings are built on the
session facade: they wrap the sans-IO Session
core behind one object per node, driven with feed/tick/poll.
The three surfaces
| Surface | Role | Main files | Main gate |
|---|---|---|---|
| Rust | Canonical implementation API. New behavior should land here first. | src/lib.rs, src/session/, src/isobus/, src/j1939/, src/nmea/, src/net/ | make check, make test, make check-all, make test-all, make clippy, make rustdoc |
| C | Opaque-handle ABI (machbus_session_*) for C callers and downstream generated bindings. | src/ffi.rs, include/machbus.h, examples/c_abi/ | make bind-c-check, make c-demo, make c-full-demo |
| Python | Ergonomic pyo3 facade for examples and scripting. | src/python/mod.rs, examples/python_binding/, pyproject.toml | make python-demo |
Rust is the design surface. C and Python are facades. Do not expose a Rust internal type directly just because it exists; add a stable facade operation and a test first.
VT rendering exposure
The Virtual Terminal client/server session traffic is visible through the C and
Python session facades where the corresponding VT subsystems are enabled. The
hosted VT rendering stack is different: IopDocument, VtRenderRuntime,
RenderCommand, GtuiRenderer, and FramebufferRenderer are currently
Rust-only APIs.
That split is intentional until a stable binding contract exists for object pool ownership, render-command snapshots, framebuffer byte buffers, and error reporting. C and Python callers should not be documented as rendering VT object pools today; they can drive the protocol facade and consume session events, while hosted object-pool layout/rendering remains in Rust.
C ABI versioning
machbus_session_abi_version() is the C caller’s fast compatibility check.
Increase the ABI contract deliberately when exported layouts or functions change
in a way downstream bindings need to know about.
The current public C ABI contract is version 3, which marks the rewrite onto
the session facade. The C examples intentionally fail fast if the runtime
reports a different version.
The generated include/machbus.h comes from src/ffi.rs. Regenerate it with
make bind-c, then prove it is stable with make bind-c-check.
Opaque ownership table
The session ABI exposes a single caller-owned handle, released by its matching function.
| Handle | Constructor | Free function | Lifetime note |
|---|---|---|---|
MachbusSession* | machbus_session_new | machbus_session_free | One node wrapping the sans-IO Session core; the caller drives it with feed/tick/poll. |
Ownership rules:
machbus_session_free(NULL)is a no-op.- Double-freeing a handle is outside the C ABI contract.
- C callers should set caller variables to
NULLimmediately after freeing.
Error shape
Rust returns typed Result values. C uses boolean/sentinel returns plus the
thread-local machbus_session_last_error(). Python raises exceptions or returns
ergonomic objects/dicts depending on the operation.
When adding a new binding call, make the failure shape boring:
- validate pointers, lengths, enum ranges, and subsystem state before mutating the session;
- report a clear error through the surface’s normal error channel;
- add a negative test for the bad input;
- add a happy-path test that proves the event or state is visible to callers.
What the facades intentionally hide
The facades should not leak Rust layout, borrowed Rust storage, or internal state machines. In C, use POD structs, fixed output buffers, explicit lengths, and opaque handles. In Python, prefer small classes and dict-like event payloads that are stable for examples.
If C or Python needs a richer event later, add a new accessor or event shape instead of changing the meaning of an existing field silently.
Conformance and claim boundary
This page is the repository’s wording guardrail. It exists so the rest of the book can stay readable without accidentally making a bigger promise than the checked-in evidence supports.
What we can say
Safe wording is narrow and evidence-based:
- machbus contains ISO 11783, J1939, and NMEA 2000 related codecs and stack components;
- listed flows are fixture-tested, virtual-bus-tested, binding-smoked, or trace-replayed exactly where the docs say they are;
- local validation is performed with
make verify; - SocketCAN and vcan tooling exists for operator-run captures.
Phrases that must not appear as public claims
The test suite keeps these phrases out of README/package/public reference text unless they appear here as explicit non-claims:
- AEF certified
- ISOBUS certified
- conformant implementation
- fully conformant
- fully compliant
- production-ready
- production ready
- hardware-tested
- hardware tested
- interoperability tested
- PlugFest validated
- field-proven
- speaks the same wire as a real
Those phrases are dangerous because each one sounds like external evidence or certification. Local unit tests, virtual-bus tests, vcan smokes, or trace replay do not create that external evidence by themselves.
Evidence classes
| Evidence class | Current status | Where to look |
|---|---|---|
| Local build/test gate | Present | Makefile, book/src/reference/validation-history.md |
| Standards-text review | Present for the areas named in the audit page | book/src/reference/audit/standards-text-audit.md |
| Public claim boundary | Present | this page and book/src/conformity/ |
| Protocol fixtures | Present for selected flows | tests/protocol_fixtures.rs, tests/fixtures/, book/src/reference/assets/protocol_matrix.csv |
| AgIsoStack/reference-style bytes | Present for selected rows only | tests/agisostack_compat.rs, tests/fixtures/oracle/agisostack_manifest.txt |
| C ABI behavior | Present for exposed facade calls | src/ffi.rs, include/machbus.h, examples/c_abi/ |
| Python behavior | Present for exposed facade calls | src/python/mod.rs, examples/python_binding/regression.py |
| SocketCAN/vcan tooling | Present | examples/socketcan_capture.rs, examples/candump_replay.rs |
| Physical-bus reports | Evidence contract exists; no completed reports/traces yet | tests/fixtures/hardware/capture_requirements.txt, tests/fixtures/hardware/capture_reports/ |
| AEF-style external validation | Not present in this checkout | no report checked in |
Current hardware statement
There is a checked-in physical-bus report directory, but it currently contains
no completed reports/traces yet. The requirement rows are all missing, which
is deliberate. It prevents a future reader from mistaking planned capture work
for completed evidence.
A row can move out of missing only when it names a reduced trace with
reduced-hardware provenance and a schema-complete capture report.
Explicit non-claims
- No AEF certification is claimed.
- No real machine safety claim is made.
- Nothing in this checkout is currently AEF-tested.
- No official ISO 11783 text is embedded in this repository.
- Reviewing behaviour against the standards text is not conformance testing. The standards-text audit checked field orders, ranges, timings and reserved-bit rules against licensed copies of the documents; that says nothing about how a given tractor behaves and moves no row in this table. “Checked against the text” is safe wording; “conformant” remains on the forbidden list above.
- No PGN value in this crate is evidenced by the standards. ISO 11783-1 §7, ISO 11783-7 §4.2 and ISO 11783-11 §4.1 each place the assignments in the electronic database at isobus.net instead. Citations beside PGN constants name the clause defining the message, not one stating its number.
- No broad external peer compatibility statement is made beyond the exact local fixtures, virtual-bus tests, checked-in traces, and reports named in the docs.
- No binary compatibility is promised for Rust internals; the documented Rust API and generated C/Python facades are the intended public surfaces.
Hardening plan narrative
This page is the human-readable version of the hardening plan. The old audit notes were useful while the port was being brought under control, but they were too raw for the book. This chapter keeps the same intent and organizes it as a maintainer story.
North star
machbus should be boring to maintain:
- the protocol bytes are checked by fixtures and property tests;
- stack behavior is exercised through the virtual bus before it reaches a binding;
- C and Python expose stable facades, not Rust internals;
- every release command is a make target;
- hardware evidence is added only with trace provenance and reports;
- public docs say exactly what has been proven and no more.
Phase 1: make the repo self-checking
This phase is mostly complete. The repository now has named gates for the things that previously lived in informal notes:
- formatting and whitespace;
- default and all-feature Rust builds;
- Clippy and rustdoc warnings;
- generated C header drift;
- C demos and C ABI compile surfaces;
- Python develop and wheel-install smokes;
- trace replay;
- fuzz smoke;
- SocketCAN example typechecking.
Earlier passes also wired repo/doc-governance “tests” for the public
claim-boundary, package contents, and hardware-evidence manifest. Those policed
documentation prose and repo layout rather than code behavior and have since
been removed (see validation-history.md); the claim boundary, package
contents, and hardware-evidence manifest are now maintained by hand as
conventions, no longer test-enforced.
The rule for future work is: if a release checklist item can be checked by a machine, give it a make target.
Phase 2: protect the wire
The port came from C++ concepts, but Rust needs its own safety rails. Keep adding tests around:
- exact byte fixtures for every encoder/decoder;
- short, overlong, reserved-bit, sentinel, and padding rejection;
- arbitrary-input decoder smokes for panic resistance;
- transport boundary conditions: TP, ETP, Fast Packet, BAM, RTS/CTS, aborts, sequence numbers, and timeouts;
- PGN/identifier normalization and rejection before invalid frames reach stack logic.
When a bug is fixed, preserve the input as a fixture or property seed unless it is too large. Large captures should be reduced first.
Phase 3: keep facades honest
The Rust API can be richer than the bindings. That is fine. What is not fine is a binding function that compiles but is not exercised.
For every new C/Python operation:
- add Rust behavior and Rust tests;
- add the C or Python facade;
- add one happy-path binding test;
- add at least one guardrail test for disabled subsystems, bad lengths, null pointers, out-of-range values, or precondition failures;
- run the binding make target and
make verify.
Avoid exposing borrowed Rust storage, raw layout, or internal state-machine types through C or Python.
Phase 4: make hardware evidence boring
The docs now have a capture playbook and an executable evidence manifest. The next step is not to write more prose; it is to run the captures and check in small, named evidence packets.
Each packet should contain:
- the requirement ID;
- the exact command, usually
candump -td -L; - the reduced trace;
- manifest provenance;
- a report with commit, interface, bitrate, peer device/tool, behavior proven, and caveats;
- a replay or test when practical.
Do not batch unrelated flows into one giant trace. Small evidence is easier to review and easier to replay.
Phase 5: improve interoperability confidence
After the local and hardware evidence paths are stable, improve independent peer coverage in this order:
- address claim and PGN Request flows;
- diagnostics request/response and clear flows;
- TP/BAM and large payload transfer;
- VT object-pool upload;
- TC DDOP upload and process data;
- File Server workflows;
- Section Control lifecycle;
- TIM authority/interlock behavior.
Each area should add tests first, then traces, then docs.
Phase 6: keep the docs as the evidence index
The book should be understandable without reading every test. For each new feature or hardening fix:
- put concepts and examples in the tutorial chapters;
- put exact evidence status in
protocol-coverage.md; - put capture instructions in
hardware-evidence.md; - put release implications in
release.mdorvalidation-history.md; - keep the CSV matrix machine-readable.
If the docs and tests disagree, treat that as a bug.
Original hardening plan, rewritten
This chapter preserves the intent of the original long audit plan without copying it as a wall of raw notes. It is useful when you want to understand why the hardening work was shaped the way it was.
Why the plan was needed
The project was translated from a C++ ISOBUS-oriented codebase. That made the initial Rust tree useful, but not automatically trustworthy. Translation can preserve names while losing invariants, tests, error handling, byte-exact behavior, or the distinction between “implemented” and “proven”.
The original plan therefore asked for hardening in every direction:
- protocol byte correctness;
- stack state-machine behavior;
- binding safety;
- examples that actually run;
- release gates;
- evidence wording;
- hardware and independent-peer capture path.
Original risk themes
| Risk | Why it mattered | Current response |
|---|---|---|
| Wire-format drift | ISOBUS/J1939/NMEA behavior depends on exact bytes, sentinels, bit fields, padding, and PGNs. | Fixture tests, property tests, malformed-input corpora, protocol matrix. |
| State-machine gaps | Address claim, TP, VT, TC, diagnostics, and TIM all have ordering and timeout behavior. | Virtual-bus tests and focused stack tests. |
| Binding unsafety | C and Python can accidentally expose invalid lifetimes or untested calls. | Opaque handles, ABI versioning, binding regression tests, C demos. |
| Example rot | Examples often compile only in the happy local environment. | Make targets for C, Python, SocketCAN examples, and trace replay. |
| Evidence overreach | Local tests are not the same as external certification or machine safety evidence. | Originally claim-boundary tests; those governance tests were removed (see validation-history.md), so the claim boundary is now maintained by hand as a convention together with this documentation set. |
| Release drift | Version numbers, generated headers, package contents, docs, and fixtures can diverge. | Release checklist plus generated C header drift check; the earlier package-contents gate was removed and package contents are now maintained by hand as a convention. |
What changed from raw audit notes
The original document mixed observations, to-do items, command transcripts, and spec reminders. That was useful for active triage, but poor documentation.
The book now separates those concerns:
- concepts and tutorials explain how to use the library;
protocol-coverage.mdsummarizes current protocol evidence;hardware-evidence.mdexplains how to add trace-backed evidence;validation-history.mdrecords the make gates and latest counts;release.mdexplains how to tag responsibly;audit/bindings.mdcaptures binding ownership and facade rules;audit/conformance.mdcaptures the public claim boundary.
What remains open
The important unfinished work is still the same:
- run real vcan captures and check them in as reduced traces with reports;
- run isolated physical-bus captures for the required flows;
- add independent-peer evidence for VT, TC, File Server, Section Control, TIM, diagnostics, and transport workflows;
- keep expanding fixture coverage for malformed and boundary inputs;
- keep C and Python bindings aligned with tested Rust behavior;
- keep docs, tests, package metadata, and release notes synchronized.
Use this page as history. Use the other reference pages as the current working instructions.
Troubleshooting overview
Debug from the bottom up:
- Build and feature flags.
- CAN interface.
- Address claim.
- Transport.
- Service-specific workflow.
- Bindings.
Keep a trace when the problem involves bus traffic.
Build problems
Try:
make build
make test
make verify
Common causes:
- Rust toolchain missing or too old.
- C compiler missing for C ABI examples.
- Python environment has a stale wheel.
- rustdoc/clippy warning promoted to error.
Prefer fixing the Makefile gate over bypassing it.
CAN interface problems
Check:
- interface exists
- interface is up
- bitrate is correct
- using extended IDs
- permissions allow access
- bus has proper termination
For local tests, start with vcan0 before touching physical machinery.
Address conflicts
Symptoms:
- node never reaches claimed state
- repeated address-claim events
- cannot-claim state
- peer appears to disappear/reappear
Check:
- preferred address
- NAME fields
- duplicate local names
- whether another device already owns the address
- whether commanded address traffic is present
VT upload problems
Common causes:
- object pool too large
- duplicate object ID
- missing child reference
- malformed child-list tail
- unknown object type
- EndOfObjectPool validation error
- TP transfer not finished before finalization
Remember: successful protocol upload does not mean a GUI window was painted.
VTServer records activated pool state and accepted render effects; hosted Rust
code can replay that through VtRenderRuntime, GTUI commands, or the software
framebuffer. If a screen stays blank, check both the upload/server state and the
separate hosted render/runtime path.
TC/DDOP problems
Common causes:
- duplicate DDOP object IDs
- invalid element references
- non-ASCII or overlong designators
- activation before upload
- malformed transfer
- server topology does not match the implement
machbus rejects malformed new transfers without corrupting an already active accepted pool.
Binding problems
C
- Regenerate/check
include/machbus.h. - Verify ownership: destroy only through matching machbus functions.
- Run
make c-demoandmake c-full-demo.
Python
- Use
make python-demo. - Avoid stale installed wheels.
- Check disabled-subsystem guards before expecting a handle.
- Drain events after ticking the session.