Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Claim boundary
  2. The standards, end to end — the story of how ISOBUS works, or ISOBUS in plain words for a quicker primer.
  3. Build and verify
  4. The session facade — the recommended API for new code.
  5. 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:

EvidencePurpose
Unit testsValidate small codecs and state transitions.
Golden fixturesPin exact bytes for known messages.
Property testsFeed broad input ranges and hostile bytes.
Session/role testsProve roles work together over a bus abstraction.
Binding testsCheck Rust, C, and Python surfaces.
Trace replayCheck captured or fixture CAN logs.
Hardware evidenceRequired 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.

LevelCatchesMisses
Static API shapeMissing public functions, feature driftRuntime behavior
Unit testsLocal codec/state mistakesCross-role interaction
Golden byte fixturesExact wire byte regressionsUntested byte variants
Property/fuzz smokePanic and bounds bugsSemantic conformance gaps
Session/role testsMulti-role workflowsReal hardware timing
Binding testsC/Python wrapper driftEvery host platform
Standards-text reviewRules never implemented rightAnything not written down
Trace replayCaptured log regressionsDevices not in captures
Hardware captureReal bus behaviorOfficial certification
AEF/PlugFest-style validationInteroperability evidenceFuture 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:

  1. Run make verify.
  2. Run examples over a virtual CAN interface.
  3. Capture and replay traffic with SocketCAN/candump.
  4. Test with a small hardware bench.
  5. Test with real VTs, TCs, service tools, tractors, and implements.
  6. Compare traces against expected behavior.
  7. 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:

  1. every communicating role is a control function;
  2. every control function has a stable NAME;
  3. the NAME is used to claim a temporary source address;
  4. normal traffic is grouped by PGN;
  5. short payloads fit in one CAN frame;
  6. long payloads use TP, ETP, or Fast Packet;
  7. 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:

LayerQuestion it answers
CANWhich frame won arbitration, and what bytes arrived?
J1939 identifierWhat priority, PGN, source, and destination are encoded in the identifier?
Address claimWhich NAME currently owns which source address?
TransportIs this one payload or a reassembled multi-frame payload?
Application protocolIs this diagnostics, VT, TC, FS, GNSS, TIM, or another service?
Your applicationWhat should the machine or UI do with that information?

From an application downward:

Application thoughtProtocol 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:

  1. The networking foundation — CAN, J1939, NAME and address claim, and transport (the spine everything else stands on).
  2. The Virtual Terminal — how an implement borrows the cab’s screen.
  3. The Task Controller — documented work and the shared DDI vocabulary.
  4. Application services — implement control, the tractor ECU, diagnostics, File Server, sequence control, and TIM.
  5. 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:

TokenExampleMeaning
timestamp(0.842301)When the frame was observed; present with -t options.
interfacecan0The SocketCAN device (can0, vcan0, …).
identifier18EF2280The CAN identifier in hex. Eight hex digits means an extended 29-bit ID.
##Separator between identifier and payload.
payload0102030405060708The 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:

FieldBitsValue
priority1106
EDP00
DP00
PF1110 11110xEF = 239
PS0010 00100x22
SA1000 00000x80

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:

PGNPF / PSKindWhat it is
0xEE00EE / dstPDU1Address claimed (announcing NAME ↔ address).
0xEA00EA / dstPDU1Request for a PGN.
0xE800E8 / dstPDU1Acknowledgement.
0xEC00EC / dstPDU1Transport connection management (RTS/CTS/BAM/abort).
0xEB00EB / dstPDU1Transport data transfer (the numbered packets).
0xFECAFE / CAPDU2A 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. 18EF2280 is the whole 29-bit word. The PGN (0xEF00 here) 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 00 means PGN 0x00EE00, not 0x00EE00 read 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

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:

Public lookup databases

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:

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.

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

StandardWhat it does, in one lineWhere in machbusRead more
ISO 11898 (CAN)The two-wire bus, bit timing, and non-destructive arbitration that everything rides on.validated by net CAN-config checksThe networking foundation
SAE J1939Turns CAN bits into named messages (PGNs) sent between addresses; the parent of ISOBUS.net (identifiers, PGNs), j1939The networking foundation
NMEA 2000CAN-based positioning/instrument standard; carries the GNSS fix on the bus.nmea, session::plugins::GnssPositioning
NMEA 0183Older serial GNSS sentences (GGA, RMC, …) for simpler receivers and benches.nmeaPositioning

ISO 11783 — the ISOBUS parts

PartWhat it does, in one lineWhere in machbusRead more
-1 General & device classesThe overall architecture and the device-class/role vocabulary.net (NAME, roles)Role boundaries
-2 Physical layerThe 250 kbit/s bus profile: cabling, termination, bit timing, sample point.net CAN-config validationThe networking foundation
-3 Data link layerFraming plus the multi-packet Transport Protocol (TP) and Extended TP (ETP).net (TP/ETP engines)The networking foundation
-4 Network layerJoining CAN segments: which PGNs forward across a router, and loop/clash guards.net::niu (interconnect)The networking foundation
-5 Network managementNAME-based address claiming — plug-and-play between strangers.net (address claimer)The networking foundation
-6 Virtual TerminalScreen sharing: an implement ships its UI to the cab terminal and drives it.isobus::vt, VtClient / VtServerThe Virtual Terminal
-7 Implement messagesHitch, PTO, aux valves, speed/distance, lighting — status and commands.isobus::implement, ImplementImplement & services
-8 Power train messagesEngine/transmission/powertrain status used across the machine.j1939 (engine/powertrain), PowertrainImplement & services
-9 Tractor ECUThe TECU and its classes: which facilities (speed, hitch, PTO, guidance) a tractor offers.isobus::implement (facilities), TECU personaImplement & services
-10 Task ControllerDocumented work: upload a device description, then trade process data for a job.isobus::tc, TcClient / TcServerThe Task Controller
-11 Data dictionary (DDI)The shared vocabulary so “application rate” means the same number to everyone.isobus::tc::ddi_databaseThe Task Controller
-12 Diagnostics servicesActive/previous faults, clears, freeze frames, memory access, identity strings.j1939::diagnostic, Diagnostics / DmMemoryImplement & services
-13 File ServerA shared filesystem on the bus: volumes, directories, open/read/write/close.isobus::fs, FsClient / FsServerImplement & services
-14 Sequence ControlRun saved sequences of steps — headland automation and the like.isobus::sc, ScMaster / ScClientImplement & services

AEF and certified capabilities

CapabilityWhat it does, in one lineWhere in machbusRead more
AEF functionalitiesVendor-interoperability functionalities + the certification process (machbus ships the mechanism, not certification).isobus::functionalities, ControlFunctionalitiesConformity first
TIM (Tractor Implement Management)Lets an implement command the tractor’s hitch/PTO under authority + safety interlocks.isobus::tim, TimTIM (AEF)

The supporting cast (ISOBUS/J1939 services)

These are smaller but real parts of a working node, each an machbus plugin:

ServiceOne linePlugin
HeartbeatPeriodic “I’m alive” for liveness detection.Heartbeat
Maintain PowerAsk the tractor to keep power after key-off to finish safely.MaintainPower
Shortcut Button / ISBThe cab “stop everything” safe-state signal.ShortcutButton
Language CommandBroadcast locale and unit preferences.LanguageCommand
Auxiliary (AUX-O / AUX-N)Joystick/switch-bank inputs assigned to implement functions.Auxiliary
Group Function / Request2 / NAME managementRequest/response and dynamic-NAME plumbing that keeps the network self-describing.GroupFunction, Request2, NameManagement

Suggested reading order

  1. The standards, end to end — the landscape and the wake-up timeline.
  2. The networking foundation — until this clicks, nothing above it makes sense.
  3. The service you are building: VT, TC, or implement & the rest.
  4. Positioning if guidance or TC-GEO is in scope.
  5. 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

The two ideas to carry away

  1. 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.
  2. 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 addressSession::start() + drive poll()Address claim
Sending any-size dataSession::send_raw / codec send (transport automatic)Transport Protocol
Routing across segmentsnet::niuNetwork routing
Seeing it on the wirecandump + replayReading 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

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:

  1. Globally unique — manufacturer code plus identity number guarantee no two CFs share a NAME.
  2. 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 NAMEnet::Name (with_function_code, with_identity_number, with_self_configurable, …)NAME management
Roles a node playsthe session-facade plugins you plugThe session facade
Where roles map to code—Role boundaries

See also

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 profilenet CAN-config checksCAN interface problems
Putting frames on real coppera Transport (e.g. EndpointTransport over SocketCAN)SocketCAN
Watching real framescandump + replayReading candump traces

See also

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 addressingNAME-based address claiming (plug-and-play)
BAM + RTS/CTS transportExtended TP for megabyte object pools and files
diagnostic DM familyISOBUS diagnostics wrinkles (a sixth ID field, functionalities)
generic vehicle messagesthe 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/PDU2net::Identifier, net::PgnPGN request
J1939 diagnosticsj1939::diagnosticISO 11783-12 — diagnostics
Engine/powertrain messagesj1939 engine codecs, Powertrain pluginISO 11783-9 — the tractor ECU

See also

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 payloadsSession::send_raw / codec send — transport is automaticTransport Protocol
Fast Packet (GNSS)Plugin::fast_packet_pgns registers reassemblyFast Packet
Watching a transferexamples/transport_demo.rsReading candump traces

See also

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 segmentsnet::niu forwarding rules + loop guardNetwork routing
Single-segment appsnothing — it just worksThe session facade

See also

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 addressSession::start() then drive poll(); watch for the Claimed eventAddress claim
Runtime “is it claimed?”controls.is_claimed() (or watch for the Claimed event)Address claim
Dynamic NAME adoptionsession::plugins::NameManagementNAME management
Address-conflict debugging—Address conflicts

See also

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:

CommandEffect on screen
change numeric valueupdate a number/bar/gauge
change string valueupdate text
hide / show, enable / disabletoggle visibility / interactivity
change active maskswitch the whole screen
change soft-key maskswitch the row of soft keys
change attribute / size / colour / positionrestyle or move an object
select input object, lock/unlock maskdrive focus and modality

VT → client (events). The terminal reports what the operator did and what it decided:

EventMeaning
soft-key / button activationoperator pressed a key
numeric / string value changedoperator edited an input field
input object selectedfocus moved / edit started
pool errorthe VT rejected something in the pool
language / units changedoperator changed locale; reload if needed
active working set changedanother 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 ActiveWorkingSet so 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::VtClientVirtual Terminal client
VT server (the terminal)session::plugins::VtServerVirtual Terminal server
Building an object poolisobus::vt (or an .iop export)VT object pools
Pushing UI updatesVtClient::set_value / set_string / …VT updates
Auxiliary discoveryVtClient aux capabilitiesVT 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

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:

PriorityCommandsWhat they are
33, A, E, Fvalue, set-value-and-ack, TC status, client task
4Dprocess data acknowledge (PDACK)
50, 1, 2, 4–9capabilities, 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::TcClientTask Controller client
TC server (controller side)session::plugins::TcServerTask Controller server
Building a device descriptionisobus::tc DDOP builderDDOP
The DDI vocabularyisobus::tc::ddi_databaseISO 11783-11
Position → setpointTC-GEO helpers + GnssTC-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

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 DDIDefinition does. 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 + conversionisobus::tc::ddi_database (ddi::* constants)DDOP
Using DDIs in a DDOPisobus::tc DDOP builderThe Task Controller
Geographic rate conversionTC-GEO helpersTC-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

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

The supporting cast

A few more services round out a real node, each an machbus plugin:

ServiceOne linePlugin
HeartbeatPeriodic “I’m alive” for liveness detection.Heartbeat
Maintain PowerKeep tractor power after key-off to finish safely.MaintainPower
Shortcut Button / ISBThe cab “stop everything” safe-state signal.ShortcutButton
Language CommandBroadcast locale and unit preferences.LanguageCommand
Auxiliary (AUX-O / AUX-N)Joystick / switch-bank inputs assigned to functions.Auxiliary
Functionalities / Group fn / Request2 / NAME mgmtAdvertisement 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 nodesession::presets::tractor()Tractor ECU
A curated implement nodesession::presets::implement(pool, ws, ddop)Implement ECU
The small respondersthe matching session::pluginsThe session facade

See also

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 / lightingsession::plugins::Implement (isobus::implement)Implement ECU
Engine/transmission alongsidesession::plugins::PowertrainPowertrain
Commanding the tractor under authoritysession::plugins::TimTIM (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

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:

AddendumClauseMeaning
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.9accepts “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 facilitiessession::presets::tractor() + ImplementTractor ECU
The status broadcastssession::plugins::ImplementImplement ECU
Keeping power after key-offsession::plugins::MaintainPowerThe session facade
A curated tractor nodesession::presets::tractor()The session facade

See also

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 DmDtcList keeps two encoders — encode() for the J1939 form and encode_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). You raise/clear faults through fine control; inbound peer faults arrive as Event::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, DM1session::plugins::DiagnosticsDiagnostics
Service-tool memory + identitysession::plugins::DmMemoryDiagnostics
The DM codecs directlyj1939::diagnosticDiagnostics

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

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::FsClientFile Server
Serving files (server)session::plugins::FsServerFile Server
The FS codecs directlyisobus::fsFile 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-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::ScMasterSequence Control
Executing steps (client)session::plugins::ScClientSequence 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 — 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 commandssession::plugins::TimTIM and automation
The hitch/PTO messages it guardssession::plugins::ImplementISO 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

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::guidance is the wire codecs, geo::guidance is pure path-to-curvature maths, and machbus drive is 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 = 20 1/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 AutoDrive plugin 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:

FieldWhat it means
Commanded curvatureHow hard to turn, in 1/km (0 = straight, sign = direction).
Curvature Command StatusThe 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:

FieldWhat it meansValue that means “good to steer”
Steering System Readiness StateThe headline “am I ready?” flag.On / active = ready and engaged. Off/passive = not ready.
Mechanical LockoutA physical safety cut-out (e.g. a lockout switch).Not active. If it is Active, you cannot engage at all.
Remote Engage Switch StatusThe 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 StatusWhether the operator’s steering wheel is being moved — the basis for override detection.(informational)
Guidance Limit StatusWhether your command is being clamped, the system is at a limit, or has a non-recoverable fault.Not limited.
Exit / reason codeWhy the system is refusing or last dropped out (a diagnostic — see below).No reason / all clear.
Estimated curvatureWhat 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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 curvatureAutoDrive::command(DriveCommand::steer(k))AutoDrive tutorial
Converting a radius or a (v, ω) twist to curvaturegeo::guidance::curvature_per_km_from_radius / curvature_per_km_from_twistAutoDrive tutorial
Commanding steering and speed togetherAutoDrive::command(DriveCommand { speed_mps, curvature_km_inv })AutoDrive tutorial
Reading the steering ECU’s feedbackAutoDrive::estimated_curvature, steering_readiness_state, machine_info, Event::GuidanceAutoDrive tutorial
The tractor advertising it can steerISO 11783-9 facilitiesISO 11783-9 — the tractor ECU
Steering as a granted, revocable authoritysession::plugins::TimTIM (AEF)

See also

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:

SignalWhy a machine cares
Rapid position (lat/lon)the basic fix, updated frequently for guidance
Detailed GNSS positionfull fix with quality/satellite info
COG / SOG (course & speed over ground)direction and speed for guidance and logging
Heading / track controlwhere the vehicle points (not always the same as COG)
Attitude (yaw/pitch/roll)terrain compensation for accurate ground position
Rate of turnsmoothing and prediction
DOPs (dilution of precision)how much to trust the fix
System time / date, local-time offsettimestamping 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 PGNssession::plugins::GnssNMEA 2000
Serial NMEA 0183 receiversnmea parserSerial GNSS
Steering on top of the fixguidance helpersGuidance
Position → setpointTC-GEO + DDOP geometryTC-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

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.
  • Pgn and 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:

  1. 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.
  2. Structured identifier. net::Identifier cracks the 29 bits into priority, PGN, source, and destination. This step is protocol-agnostic; it is pure bit arithmetic.
  3. 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).
  4. 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 j1939 decodes.
  • NMEA 2000 on the bus — how N2K reuses J1939, Fast Packet, the GNSS PGNs, and what nmea decodes.

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:

FormID widthCommon nameUsed by
CAN 2.0A11 bitsStandard / baseSimple automotive, appliances
CAN 2.0B29 bitsExtendedJ1939, 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

ConditionPDU typePS byte meansPGN low byteDestination
PF < 240PDU1destination addressforced to 0the PS value
PF ≥ 240PDU2group extensionequals PSglobal (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:

  1. Split the 29 bits into priority, EDP/DP, PF, PS, source.
  2. Look at PF. Is it < 240 (PDU1, point-to-point) or ≥ 240 (PDU2, broadcast)?
  3. Compute the PGN accordingly, and the destination accordingly.
  4. 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.

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 0xFFFF is 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:

DMRole
DM1Active diagnostic trouble codes — faults happening now
DM2Previously active DTCs — the fault history
DM3Clear previously-active DTCs
DM11Clear active DTCs
DM13Stop/start broadcast — quiet the bus during service
DM14/15/16Memory 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 / moduleCovers (representative PGNs)What you get
engine — EEC1/2/30xF004 EEC1, 0xF003 EEC2, 0xFEC0 EEC3Engine speed, torque, demand, retarder, friction torque
engine — fuel/econ0xFEF2 fuel economy, 0xFEE9 fuel consumptionFuel rate, instantaneous & average economy
engine — tempsengine temperature 1 & 2Coolant, oil, fuel, intercooler temperatures
engine — hours0xFEE5 engine hoursTotal engine hours and revolutions
engine — otherambient conditions, fluid levels, dash display, aftertreatmentPressures, levels, ambient air, DEF/SCR data
speed_distancespeed & distance PGNsWheel/ground speed, trip & total distance
transmissionETC1 and transmission parametersSelected/current gear, output shaft speed
diagnostic0xFECA DM1, 0xFECB DM2, DM3–DM12, DM20–DM25DTC lists (SPN+FMI+count), lamps, freeze frames, IDs
dm_memory0xD900 DM14, 0xD800 DM15, 0xD700 DM16Memory-access request/response/transfer
diag_monitorDTC delta tracking over DM1Appeared/cleared fault deltas
heartbeatthe J1939 heartbeat PGNLiveness sequence, jump/loss detection
language0xFE0F language commandUnits, date/time/decimal format, unit system
maintain_power0xFE47 maintain powerKey-switch state, power-down hold requests
acknowledgment0xE800 ACK/NACKPositive/negative acknowledgement and reason
pgn_request0xEA00 RequestWhich PGN is being asked for
request20xC900 Request2 / transferRequest-2 query, reply, and transfer
proprietaryproprietary A / proprietary B rangesRaw 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:

  1. Receive a frame; build a net::Identifier.
  2. If it is a transport control/data frame, feed it to reassembly; continue until a full payload is ready.
  3. Take the PGN. Match it against the families above.
  4. Hand the payload to the matching j1939 codec.
  5. 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.

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-0xFF is 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.

PGNNameWhat it carries
129025GNSS Position, Rapid UpdateLatitude / longitude only, sent fast (the steering feed)
129026COG & SOG, Rapid UpdateCourse over ground + speed over ground, sent fast
129029GNSS Position DataThe full fix: lat/lon/alt, fix type, sats, ref station
129539GNSS DOPsDilution of precision (HDOP/VDOP/PDOP) — fix quality
129540GNSS Satellites in ViewPer-satellite detail (PRN, elevation, azimuth, SNR)
127250Vessel/Vehicle HeadingHeading (true or magnetic), deviation, variation
127251Rate of TurnAngular rate
127257AttitudeYaw, 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:

GroupRepresentative PGNsWhat you get
GNSS / nav129025, 129026, 129029, 129539, plus position-delta and XTEPosition, COG/SOG, detailed fix, DOPs, deviation
Orientation127250 heading, 127251 rate of turn, 127257 attitude, mag varHeading, turn rate, yaw/pitch/roll, variation
Time126992 system timeDate/time on the bus
Marine-ishrudder, speed through water, water depth(present for completeness; less used on land)
Engineengine parameters rapid, fluid level, battery statusQuick engine RPM/status, tank levels, voltage
Environmentwind 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:

PGNNameRole
126993HeartbeatPeriodic liveness — “I am still here”
126996Product InformationDevice model, software version, identity
126998Configuration InfoInstallation / 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.

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 plain u32 and wraps it. This is infallible: any u32 is 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 no Option and 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 small Priority newtype rather than a bare integer, which is why the example writes u8::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 calls destination() in that branch; it prints ALL instead.

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). Use is_pdu2() → false and read destination().

  • 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, and is_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:

  • 0x18EA26EE is a PGN request. Its PF is below 240, so it is PDU1. The low byte 0x26 is therefore a destination address, and the assertions confirm is_pdu2() is false and destination() is 0x26. The request is aimed at the ECU at address 0x26.

  • 0x0CF00400 is EEC1. Its PF is 0xF0 (240), which is ≥ 240, so it is PDU2. The assertions confirm is_pdu2() and is_broadcast() are both true. The 0x04 low 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

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;
}
  • Eec1 is the Electronic Engine Controller 1 message (PGN 61444): engine speed, driver demand, actual torque, and so on.
  • DmDtcList is the diagnostic-message DTC list — the structure behind DM1 (active faults) and its siblings.
  • Dtc is a single Diagnostic Trouble Code: an SPN plus a failure mode plus an occurrence count.
  • Fmi is the Failure Mode Identifier enum — the kind of fault (too high, too low, intermittent, and so on).
  • DiagnosticLamps is the lamp-status block that rides along with a DM1 (malfunction lamp, warning lamp, etc.).
  • Message (from machbus::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 an Option because the bytes might be too short or malformed; None means “this is not a valid payload for this message”. Always handle the None case 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 assembled Message. It reads the data out of the message and runs decode on it. Use this when you already have a Message in 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 Eec1 struct is built with physical, human-readable values: engine_speed_rpm: 1500.0 is RPM, driver_demand_percent: 40.0 is 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_address is 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 an Eec1. Note the .expect("valid EEC1 payload"): decode returns Option<Eec1>, and the example unwraps it because it just produced the bytes itself and knows they are valid. In real code you would match or if let on the Option instead, 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 address 0x00. In a real reader these three pieces come off the bus, not from a local encode.
  • Eec1::from_message(&msg) returns Option<Eec1>. The example uses if 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 the Message itself (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 matching from_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. Each Dtc has:
    • spn — the Suspect Parameter Number, identifying what is faulty. 110 is engine coolant temperature; 190 is engine speed.
    • fmi: Fmi::AboveNormalModerate / Fmi::BelowNormal — the Failure Mode Identifier, an enum describing the nature of the fault. AboveNormalModerate means a value is moderately too high (coolant running hot); BelowNormal means a value is too low. Because Fmi is 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 a Vec<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 an Option. The example loops over decoded.dtcs and 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 / Fmi types 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

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};
}
  • NMEAInterface is the pump decoder itself — it holds the event channels and any reassembly state.
  • NMEAConfig selects which PGN families the interface should decode. You enable the parts you need rather than paying for everything.
  • GNSSPosition is the decoded position type: a WGS coordinate plus fix metadata (satellites, fix type, and more).
  • Wgs (from machbus::geo) is the geographic-coordinate type: latitude, longitude, altitude.
  • Message (from machbus::net) is the assembled message you feed in — the same type the J1939 codecs use.
  • PGN_GNSS_POSITION_RAPID (129025) and PGN_GNSS_COG_SOG_RAPID (129026) are named PGN constants from machbus::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:

  1. You construct an NMEAInterface with a config that says which PGNs to decode.
  2. You subscribe handlers to the on_* events. Each event corresponds to a decoded quantity — on_position, on_cog, on_sog, and so on.
  3. You feed the interface raw Messages with handle_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 declared mut because 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 are FnMut(&T), so they receive the decoded value by reference and can hold mutable captured state across calls. Inside, the example reads pos.wgs.latitude, pos.wgs.longitude, pos.satellites_used, and pos.fix_type.
  • nmea.on_cog.subscribe(|cog_rad: &f64| { … }) and nmea.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() and sog_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 GNSSPosition is built with Wgs::new(52.379_189, 4.899_431, 0.0) — latitude, longitude, altitude for a point in Amsterdam — and satellites_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 a Message from source 0x80. The interface recognises PGN 129025, decodes it to a GNSSPosition, and fires on_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, and handle_message feeds it in. The interface fires both on_cog and on_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 0 is the rapid-PGN caveat from above — the rapid message does not carry a satellite count, so it decodes to the default.
  • fix GNSSFix is 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

Getting started

This section gets you from a checkout to a running node.

Read:

  1. Install Rust
  2. Build and verify
  3. no_std on microcontrollers, if you are building firmware or an embedded task
  4. First node
  5. 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;
  • embedded compiles the protocol/session surface as no_std + alloc;
  • embedded adds 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 surfaceWhy it is not in embedded
ffi / C ABIUses hosted ABI and std facilities.
Python bindingsUses pyo3 and requires std.
SocketCANLinux-specific host interface.
wirebitHost virtual-bus and simulation adapter.
concordRich hosted geo conversion stack.
file load/save helpersFirmware 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;
  • machbus is 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:

  1. get board monotonic time;
  2. drain received CAN frames from your driver or interrupt queue;
  3. feed frames into Session;
  4. tick protocol timers;
  5. transmit every queued frame through your CAN driver;
  6. 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:

DataEmbedded shape
NIU configparse/format text buffers; application persists them.
IOP/object-pool bytesparse bytes already supplied by the application.
VT stored poolsencode/decode storage blobs; application writes blobs to flash/SD/etc.
File Server datain-memory protocol model; real media integration is application-owned.
candump tracesfile 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

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:

  1. choose a NAME
  2. choose a preferred source address
  3. plug only the subsystems you need
  4. spawn over a transport — this gives you (Controls, Driver)
  5. start, then claim an address before normal traffic
  6. 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, and ctrl.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 returns None otherwise).
  • Events are drained regularly enough for your application queue policy.

Common mistakes

SymptomLikely causeFix
address claim times outno transport or peer traffic not being pumpeddrive the virtual bus and call driver.poll()?
with_mut::<Diagnostics> returns Nonediagnostics not pluggedadd .plug(Diagnostics::every(1000))
no GNSS eventsGNSS not plugged or no GNSS PGN/sentence was sentadd .plug(Gnss::listen()) and send input
normal traffic ignored by peersnode has not claimed an addresswait 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

StepTractor-like nodeImplement-like node
NAMEfunction/identity for tractor rolefunction/identity for implement role
preferred addressnormally tractor rangenormally implement range
enabled surfacesdiagnostics, GNSS, tractor/persona helpersdiagnostics, VT/TC/FS/implement helpers
startupstart address claimstart address claim
loopcall tick()call tick()
proofdrain tractor eventsdrain 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:

  • candump from 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. Session is 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 current embedded no_std + alloc build.
  • Plugin composition. Each subsystem is a Plugin you .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; the Controls is 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

TypeRole
SessionThe sans-IO core. feed / tick / poll_transmit / poll_event / drain::<E>.
SessionBuilderSession::builder(name, address), .plug(p), .plug_group(g), .build() or .spawn(transport).
PluginA composable subsystem (see session::plugins). One instance per type.
PluginCtxA plugin’s keyhole during a callback: send, emit, now, address, set_name.
TransportThe CAN boundary (recv/send). EndpointTransport adapts a wirebit::CanEndpoint.
Driver<T>Owns the transport + clock; poll / poll_at / pump run the loop.
ControlsCheap cloneable handle: start, address, is_claimed, with/with_mut, send_raw, drain.
SubscriptionRAII 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:

AreaPlugin
Diagnostics (DM1)Diagnostics
GNSS / NMEA 2000Gnss
Virtual TerminalVtClient, VtServer
Task ControllerTcClient, TcServer
File ServerFsClient, FsServer
Implement messagesImplement (hitch / PTO / aux / speed / lighting)
Sequence ControlScMaster, ScClient
TIMTim
Powertrain (J1939)Powertrain
HeartbeatHeartbeat
Maintain PowerMaintainPower
Shortcut ButtonShortcutButton
Language CommandLanguageCommand
Auxiliary (AUX-O/N)Auxiliary
DM14/15/16 + IDsDmMemory
CF FunctionalitiesControlFunctionalities
Group FunctionGroupFunction
Request2Request2
NAME ManagementNameManagement

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:

  1. Unified enum + poll — driver.poll()? / session.poll_event(). One match site for everything.

  2. 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.

  3. 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 modeSession shapeWhat owns time/CAN/storage
Hosted/defaultSession::builder(...).plug(...).spawn(transport)?, Driver::poll(), Controls, callbacks, presets, host adaptersDriver can read the host clock; adapters can use host transports and files.
EmbeddedSession::builder(...).build()?, Driver::new(session, transport), Driver::poll_at(now), feed/tick/poll_transmit/poll_eventYour firmware owns the monotonic timer, CAN HAL, allocator, panic behavior, and persistence.
Embedded fixed helpersSame 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

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:

#ChapterYou will buildLevel
1The ISOBUS Hello WorldA node that claims an address on a busBeginner
2The Hello World, line by lineThe same program, fully understoodBeginner
3Sending and receiving messagesBroadcast and receive a PGNBeginner
4Requests and acknowledgementsAsk another node for dataBeginner
5Moving big data with transportSend a payload too large for one frameIntermediate
6Talking diagnosticsPublish and read fault codesIntermediate
7Your first Virtual Terminal clientPut a screen on the terminalIntermediate
8Your first Task Controller clientReport a working value to a TCIntermediate
9Tractor and implement personasA tractor and an implement talkingAdvanced
10Onto real hardware with SocketCANThe same code on a vcan interfaceAdvanced
11Async event streamsAn async, await-driven event loopAdvanced
12Capstone: a complete implement ECUEverything, combinedAdvanced

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 vcan virtual 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:

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 with cargo 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:

  1. creates a simulated CAN bus with two seats on it,
  2. builds an machbus Session for our node,
  3. drives the address-claim handshake, and
  4. 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 call ctrl.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::net is the low-level protocol layer — raw frames, NAMEs, addresses, priorities. You reach into it when you want fine control.
  • machbus::session is the surface layer — the Session facade, its Controls / Driver pair, plugins, and the unified Event type. This is what most application code uses.
  • wirebit is the bus transport crate. Topology builds 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:

FieldWhat it saysIn the helper
Identity numberWhich specific unit this isthe identity argument
Function codeWhat the node does0x80
Self-configurableMay I move addresses if I lose?true
Manufacturer, device class, instances, …The rest of the identityleft 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 seat a’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 — the Controls: command and inspect the node (start(), is_claimed(), address(), with_mut::<Plugin, _>(...)).
  • drv_a — the Driver: 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, and drv.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() and ctrl_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 with cargo 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

  1. node_b builds and broadcasts a raw frame through its controls.
  2. We poll and pump until the message arrives.
  3. node_a reads 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:

ArgumentValue hereMeaning
PGN0xFECAwhich message this is
payload&[0xDE, 0xAD, 0xBE, 0xEF]the data bytes
destinationBROADCAST_ADDRESSwho it is for — everyone
priorityPriority::Defaulthow 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 what node_b put 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

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 real machbus API, not compiled includes. Everything still validates with make 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:

AckControlMeaningWhat the requester does
PositiveAckThe request was accepted (used where a request needs confirming, not answering with data).Treat as success; carry on.
NegativeAckUnderstood, but the node will not / does not supply that PGN.Give up on this PGN. Do not retry — the answer is “no.”
AccessDeniedThe 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.
CannotRespondThe 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_request returns None for 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 with cargo 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:

  1. 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”). The 0x10/0x20 are the sender and receiver addresses.
  2. rx.process_frame(&rts, 0) opens the matching receive session and returns a CTS (“Clear To Send”) granting a window of packets. The num=6 is how many DT frames it is ready for; next_seq=1 is where to start.
  3. 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).
  4. Feeding each DT frame back into rx.process_frame reassembles the bytes, and when the last one lands the receiver returns an EndOfMsgAck.
  5. tx.process_frame(&eoma) confirms delivery, which fires the on_complete event we subscribed to. The completed TransportSession carries the reassembled data, and got.data == payload proves 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 sizeMechanism
up to 8 bytesone ordinary CAN frame — no transport at all
9 to 1785 bytesTP (this chapter’s step 1)
1786 bytes and upETP (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 received is 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

6. Talking diagnostics

Anchor example: examples/diagnostic_demo.rs — run it any time with cargo 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:

  1. builds a DTC (a single fault) from its three parts,
  2. packs two of them into a DM1 (the “active faults” message) with a lamp lit,
  3. encodes that to bytes and decodes it straight back, and
  4. 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:

PartFieldMeans
SPN — Suspect Parameter Numberspn: u32What is faulty (a numeric handle for the parameter or component). 19 bits on the wire.
FMI — Failure Mode Indicatorfmi: FmiHow it is faulty (VoltageLow, MechanicalFail, AbnormalRateChange, …). 5 bits.
Occurrence countoccurrence_count: u8How 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 DmDtcList still 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 (use Dtc::matches, which compares by (spn, fmi) and ignores the count). The Diagnostics plugin’s raise is 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 with cargo 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:

  1. build a minimal object pool and a client,
  2. connect() and pump the connect state machine to Connected,
  3. react to the terminal’s replies along the way, and
  4. 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:

EventFires whenYou typically
on_state_changeThe FSM transitions.Log progress; gate UI commands on Connected.
on_soft_keyA soft key is activated.Map (ObjectID, ActivationCode) to an action.
on_buttonA button object is activated.Same, for on-screen buttons.
on_numeric_value_changeThe operator edits a number.Update your app model.
on_active_ws_statusYour 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 the Connected state and returns an error otherwise. Watch state() (or on_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_error and drops to Disconnected. 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 then on_active_ws_status stays 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 with cargo 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:

  1. build a tiny DDOP and a client,
  2. connect(), then hear the TC announce itself,
  3. announce a working set and negotiate the TC’s version,
  4. 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. Watch state() (or the state-change event) and gate your own logic on Connected — 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 Disconnected rather 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 with cargo 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() and presets::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:

StyleSourceWhen
Event-drivenImplementEvent::HitchCommand, PtoCommand, AuxValveCommandreact the moment a command arrives
Latest-valuethe 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 0xFE cannot 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. A None from 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 the socketcan feature on and a Linux CAN interface available, run it with cargo 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 a candump file, proving the wire path end-to-end. It does not build a Session — the session-on-SocketCAN snippets below are illustrative shape showing how you would swap the transport, grounded in the real spawn(...) 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 vcan0 shows 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 wirebit the SocketCAN code is not compiled in. If you see a usage hint instead of “opening SocketCAN interface”, rerun the example with cargo run --features wirebit --example socketcan_capture.
  • Interface down or missing. With create_if_missing: false, if vcan0 does not exist or is not UP, the example errors at startup. Re-run the step 2 commands and check ip link show vcan0.
  • Permissions. Creating and bringing up an interface needs sudo. Opening an existing vcan0 from the examples usually does not — but on a locked-down host, opening raw CAN sockets may still require elevated capabilities.
  • Nothing observed. If candump shows no frames, the session probably ran on a different interface. Use the same MACHBUS_SOCKETCAN_IFACE everywhere.
  • PDU2 vs PDU1 on the wire. What you see in candump is 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 !Send on purpose. Trying to move it onto a multi-threaded worker (a plain tokio::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 tokio LocalSet with spawn_local. A multi-threaded executor cannot hold a !Send future.
  • 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

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 with cargo 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 chapterCapabilityWhere it shows up here
1 / 2Build a NAME and a session; claim an addressAll three nodes claim before any traffic
3Broadcast and receive a PGNGNSS position out, DM1 in
4Ask a node for dataThe presets request/respond under the hood
5Move payloads bigger than one frameThe VT object pool and DM1 lists ride transport
6Publish and read fault codesThe low-fuel alarm becomes a DM1 every peer sees
7Stand up a VTA VT server emitting status
8Report working valuesThe implement connects its TC client, the same data a TC consumes
9Tractor and implement presetsTractor and implement talking on one wire
10The same code on real CANSwap the endpoint transport for a SocketCAN one, nothing else changes
11Drive the event loopThe 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 add Gnss::listen() to publish position.
  • the VT prefers 0x26 (the first VT address). There is no VT preset, so we plug a VtServer directly; VtServer::new validates the config and returns a Result.
  • the implement prefers 0x80 and 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() and presets::implement(...) bundle a role’s plugins, and a single plug such as VtServer::new(...) turns one subsystem on. At the net/isobus layer you would assemble each protocol’s state machine yourself on each node.
  • One Event enum instead of N inboxes. Diagnostics, GNSS, VT, and TC events all arrive on one stream you drain with one match. 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:

  1. Address claim
  2. PGN request and NAME management
  3. Transport Protocol
  4. 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.

FieldWidthWhat it expresses
Identity number21 bitsA per-unit serial-like value; keeps otherwise-identical products distinct.
Manufacturer code11 bitsWho built the ECU.
ECU instance3 bitsWhich of several identical ECUs for the same function this is.
Function instance5 bitsWhich occurrence of the function on this device.
Function8 bitsWhat the node does (for example, a terminal, a task controller, a tractor ECU).
Device class7 bitsThe broad equipment category the function belongs to.
Device class instance4 bitsWhich occurrence of that device class on the network.
Industry group3 bitsThe industry the node belongs to (agriculture, for ISOBUS).
Self-configurable address1 bitWhether 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:

  1. 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.
  2. 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:

StateMeaningWhat the node may send
Not started / idleNo claim attempted yet.Nothing application-level.
WaitingA claim was announced; the contention window is open.Only claim-related traffic.
ClaimedThe node owns the address.Full application traffic.
Cannot claimNo address could be secured.Only the special “cannot claim” announcement from the null address 0xFE.

The transitions:

  1. Start. The node announces its NAME at its preferred address and opens a short waiting window (the contention timeout is roughly a quarter second).
  2. No contest. If the window closes with no better claim seen, the node becomes Claimed and may begin normal traffic.
  3. Contest, you win. If another node claims the same address with a higher NAME, you keep the address. The other node must yield.
  4. 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.
  5. 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 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:

EventMeaningTypical action
ClaimedThe node owns a usable address.Enable normal application traffic.
LostA lower NAME took the address.Stop normal traffic; move or stop per policy.
Cannot claimNo address was available.Stay silent except the allowed claim traffic.
Request for claimA 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 / InternalCf pair 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

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:

SymbolRole
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:

FieldMeaning
requested_pgnThe PGN being asked for, same as the plain Request.
extended_idUp to three optional bytes that qualify the request.
use_transferWhen 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:

AckControlWire valueWhat it tells the requester
PositiveAck0The request was accepted (used where a request needs confirming rather than answering with data).
NegativeAck1The request was understood but the node will not or cannot supply that PGN.
AccessDenied2The node owns the PGN but the requester is not permitted to have it right now.
CannotRespond3The 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.

  1. 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.
  2. Positive acknowledgement. For requests that are commands rather than data pulls, the responder confirms acceptance with PositiveAck.
  3. 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 NegativeAck on 0xE800. A NACK is the correct answer to a destination-specific request for an unsupported PGN; it tells the requester to stop waiting.
  4. 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.
  5. 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

EventMeaningYour responsibility
Inbound Request for a PGN you ownA peer wants that data.Send the PGN, or an Ack explaining why not.
Inbound Request for a PGN you do not ownNot your concern, usually.NACK only a destination-specific request; stay silent on a global one.
Inbound Request2 with use_transferPeer wants the answer wrapped.Reply on PGN_TRANSFER with the original PGN prefixed (the responder does this for you).
Inbound AcknowledgementA 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_request and Request2Msg::decode return None for short, padded-wrong, or out-of-range payloads. A responder that decodes to None should 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. machbus does 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, and j1939::acknowledgment are pure encode/decode with no bus coupling — ideal for tests and embedded loops where you own every send. The Request2 plugin 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:

GuardRejected whenWhy
PGN matchmsg.pgn is not the commanded-address PGNWrong message entirely.
Source sanitysource is the null or broadcast addressA command must come from a real claimed node.
Lengthpayload is not exactly nine bytesMalformed; not a valid command.
Target matchthe carried NAME is not our_nameThe command is for some other node.
Address rangethe 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:

ModeDirectionMeaning
RequestCurrenttool → node“Tell me the NAME you are using right now.”
RequestCurrentResponsenode → toolThe current NAME, answering the above.
SetPendingtool → node“Stage this NAME; do not adopt it yet.”
RequestPendingtool → node“Tell me the NAME you have staged.”
RequestPendingResponsenode → toolThe staged NAME, answering the above.
AdoptPendingtool → node“Make the staged NAME your current NAME and re-claim.”
Acknowledgenode → toolA set or adopt succeeded.
NegativeAcknowledgenode → toolA request was refused; carries a reason.
RequestAddressClaimtool → 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 modemachbus reply
RequestCurrentRequestCurrentResponse carrying current_name.
SetPendingAcknowledge if accepted, else NegativeAcknowledge.
RequestPendingRequestPendingResponse if one is staged, else NACK PendingNotSet.
AdoptPendingAcknowledge if a pending NAME existed, else NACK PendingNotSet.
all response/ack modes, RequestAddressClaimno 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”:

ReasonWhat it means in practice
SecurityThe node will not accept this change from this source. The tool must authenticate or come from an allowed CF (a bridge or service tool).
InvalidItemsOne or more commanded fields are not allowed to change — in machbus, attempting to change the identity number.
ConflictThe node cannot take on what the change implies (it cannot perform the requested function, or cannot be self-configurable as asked).
ChecksumThe integrity check that guards against an addressee mismatch did not match.
PendingNotSetA RequestPending or AdoptPending arrived but nothing is staged. machbus returns this for both.
OtherA 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 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

EventFired byYour responsibility
on_commanded_addresshandle_commanded_addressApply the new address and re-claim. The session does this for you.
on_name_changedadopt_pendingThe current NAME is now the adopted one; re-claim under it and persist it.
on_name_managementevery received NM frameObserve/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_address returns None for any address above MAX_ADDRESS, so 0xFE/0xFF are silently ignored.
  • NACKed name change. A SetPending that touches the identity number, or that the node will not accept from this source, comes back as a NegativeAcknowledge. The pending state is left untouched; nothing was staged.
  • Adopt with nothing staged. AdoptPending when no pending NAME exists NACKs with PendingNotSet and changes nothing. adopt_pending consumes the pending NAME, so a second adopt also fails — adoption is one-shot.
  • Response/violation storms. Because machbus never 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 Checksum NACK 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 NameManager per 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 Security NACK 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. machbus does not own your non-volatile storage; persist the address (and an adopted NAME) yourself from the on_address_claimed and on_name_changed events so the network comes back up in the same shape it settled into.
  • Fine control vs the bare codecs. The NameManagement plugin is right for applications: it pumps, replies, applies adopted NAMEs, and re-claims for you. The bare NameManager is 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

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 lengthMechanismConstant
0..=8 bytesOne ordinary CAN frame, no transportCAN_DATA_LENGTH = 8
9..=1785 bytesTP (BAM or CMDT)TP_MAX_DATA_LENGTH = 1785
1786..=117_440_505 bytesETP (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):

ControlNameWho sends itCarries
RTS (0x10)Request To Sendsendertotal bytes, total packets, advertised packets-per-CTS, target PGN
CTS (0x11)Clear To Sendreceiverhow many packets to send now, which sequence to start at
EOMA (0x13)EndOfMsgAckreceiverechoed total bytes/packets, confirming completion
BAM (0x20)Broadcast Announcesendertotal bytes, total packets, target PGN
ABORT (0xFF)Connection Aborteither sidethe 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.

StateSideMeaning
None—Freshly constructed, not yet started.
WaitingForCTSsender (CMDT)RTS sent; waiting for the receiver to clear a window.
SendingDatasender (CMDT)A CTS granted a window; drain that many DT frames.
WaitingForEndOfMsgsender (CMDT)Last packet sent; waiting for EndOfMsgAck.
ReceivingDatareceiver (BAM)Mid-stream broadcast reassembly.
WaitingForDatareceiver (CMDT)CTS sent; waiting for the granted DT window.
CompleteeitherAll bytes transferred (and acknowledged, for CMDT).
AbortedeitherTorn 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 stateTimeout constantValue
WaitingForCTS / WaitingForEndOfMsg (sender)TP_TIMEOUT_T3_MS1250 ms
WaitingForData / ReceivingData (receiver)TP_TIMEOUT_T1_MS750 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:

ReasonNumericRaised when
None0Placeholder / unknown byte decoded back to no-reason.
AlreadyInSession1An RTS arrives for a transfer that already has a live receive session.
ResourcesUnavailable2No free session slot, or the advertised size exceeds the receive-allocation cap.
Timeout3A waiting window elapsed.
ConnectionModeError4A CTS arrived while the sender was already mid-window (a protocol-ordering error).
MaxRetransmitsExceeded5The receiver asked the sender to back up too many times.
UnexpectedPgn6Reserved; a frame targeted a PGN that does not fit the session.
BadSequence7A data frame arrived with the wrong sequence (zero, ahead, or — in ETP — before its DPO).
DuplicateSequence8A data frame repeated a sequence already received.
UnexpectedDataSize9The 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:

  1. tx.send(...) validates the payload, opens a Transmit session in WaitingForCTS, and returns the RTS frame.
  2. rx.process_frame(rts) opens a Receive session, allocates the reassembly buffer, and returns a CTS granting the first window.
  3. tx.process_frame(cts) moves the sender to SendingData; tx.get_pending_data_frames() drains that window as DT frames.
  4. Feeding the DT frames into rx.process_frame reassembles the bytes and, once the last packet lands, returns the EndOfMsgAck.
  5. tx.process_frame(eoma) confirms delivery and fires on_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:

EventFires whenYour job
on_completeA session reaches CompleteTake the reassembled TransportSession::data and hand it to the application layer.
on_abortA session tears down (either side)Log the TransportAbortEvent (PGN, peer, reason) and decide whether to retry.
on_session_timeoutA TpTimerSession window elapsesReact 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, which machbus treats 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 ResourcesUnavailable before 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 / ExtendedTransportProtocol engines give you frame-level control for unit tests and embedded loops, at the cost of driving every update and get_pending_data_frames yourself.
  • Concurrent sessions. Control frames identify the target PGN, but data frames do not. For that reason machbus rejects 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 counter 0; continuation frames count up 1, 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.

FrameByte 0Byte 1Bytes 2..8Payload bytes carried
First (frame counter 0)seq + countertotal payload lengthfirst slice of dataFIRST_FRAME_DATA = 6
Continuation (1, 2, …)seq + counterdatadataSUBSEQUENT_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:

  1. Reads byte 0 and splits it into sequence and frame counters.
  2. 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 counter 1.
  3. Otherwise it looks for an in-flight session matching (source, PGN, sequence). If none exists, the frame is an orphan and is dropped.
  4. 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 Message is 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 ≤ 8 or > 223 is 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 call update. 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

TransportUsed forHandshakeFlow controlMax payload
Single CAN frameUp to 8 bytes; the common caseNoNo8 bytes
Fast Packet (NMEA 2000)9–223 byte broadcast parameter groupsNoNoFAST_PACKET_MAX_DATA = 223
TP (ISOBUS / J1939)9–1785 bytes, point-to-point or BAMYes (RTS/CTS), BAM is open-loopYes (CTS windows)TP_MAX_DATA_LENGTH = 1785
ETPVery large transfersYesYesETP_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). Use with_max_rx_sessions(n) to bound memory more tightly; a cap of 0 refuses all new multi-frame sessions.
  • send(pgn, data, source) returns the Vec<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) returns Some(Message) only when the frame that completes a session arrives, and None for every frame before it (and for every frame it drops). The reassembled Message carries 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 past TP_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_frame for every received frame whose PGN you handle, and act on the Message when one completes.
  • Call update on 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_sessions increments. 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_sessions in-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_ADDRESS or BROADCAST_ADDRESS as 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 Message still has to be interpreted as the specific NMEA 2000 parameter group its PGN names. See NMEA 2000 for how machbus routes 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_sessions to the number of distinct broadcasters you expect keeps memory predictable without dropping legitimate traffic.
  • Surface vs low-level. FastPacketProtocol is 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.

PieceTypeWhat it holds
The two sidesSide::Tractor, Side::ImplementA two-valued label for “which segment a frame came from”. Side::other() flips it.
ConfigurationNiuConfigName, default-forwarding policy, filter mode, and loop-guard tuning.
Filter rulesVec<FilterRule>The per-PGN / per-NAME table of what to do.
Forward decisionForwardPolicyAllow, Block, or Monitor for a matched frame.
The forwarderNiuApplies the rules, rate-limits, and counts forwarded vs. blocked.
Translation tableAddressTranslationDbMaps a NAME’s address on one side to its address on the other.
The routerRouterA 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:

NiuFilterModeDefault for unmatched frames
PassAllForward unless a rule blocks it — open by default, block the noisy exceptions.
BlockAllDrop 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:

  1. Inactive NIU. If the NIU has not been started, everything is dropped.
  2. 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.
  3. Loop echo. If this exact frame was just forwarded toward this side inside the loop-guard window, it is dropped (see Loop prevention).
  4. 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.
  5. 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 raise on_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:

NiuFunctionWhat the NIU does
AddFilterEntryInstall a new allow rule for the given PGN.
DeleteFilterEntryRemove the rule matching that PGN.
DeleteAllEntriesClear the whole filter table, including persistent rules.
SetFilterModeSwitch between PassAll and BlockAll.
RequestPortStatsReply with forwarded / blocked counts.
RequestFilterDb / FilterDbResponseRead back the installed rules.
RequestPortConfig / PortConfigResponseReport port configuration.
OpenConnection / CloseConnectionManage 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:

EventFires whenTypical use
on_forwardedA frame crossed to the other side.Metrics, tracing.
on_blockedA frame was dropped (rule, rate limit, loop, bad source).Diagnose over-tight filters.
on_monitoredA Monitor-policy frame crossed.Tap a traffic class for inspection.
on_niu_messageA control message reconfigured the NIU.Audit remote reconfiguration.

Your responsibilities around the pump:

  • Drive both directions. Call process_frame for 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_ms drives 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 BlockAll NIU with no allow rules silently drops everything; a PassAll NIU with no block rules is a transparent repeater. If “nothing crosses”, check blocked() 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 Router blocks 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 BlockAll plus narrow allow rules, and rate-limit high-frequency PGNs, to keep each side within its capacity.
  • Stale clock. If now_ms never 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 name so 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 BlockAll default are your main levers; broadcast-heavy PGNs are the usual culprits.
  • Bridge vs. router. Use Niu when both segments share an address space and you only need to filter. Use Router when 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_snapshot and Router::policy_snapshot return 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

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:

  1. Find a VT partner on the bus and bind to it.
  2. Upload the object pool so the terminal has something to draw.
  3. 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

Piecemachbus typeRole
Connection statevt::VTStateWhere the client is in the connect/upload lifecycle.
Configurationvt::VTClientConfigPer-session timeout and preferred VT version.
The interfacevt::ObjectPool + vt::WorkingSetThe tree of objects you upload.
An outbound framevt::ClientOutboundA { pgn, data, dest } triple the caller ships.
Version preferencevt::VTVersionWhich VT generation (3–6) you target.
Languagevt::LanguageCodeThe 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.

StateWhat it meansWhat update does here
DisconnectedNo session.Nothing.
WaitForVTStatusListening for a VT to announce itself.Times out to Disconnected.
SendWorkingSetMasterA VT was found; announce our working set.Emits the Working Set Master frame, advances.
SendGetMemoryAsk the VT to reserve room for the pool.Emits Get Memory with the serialized size, advances.
WaitForMemoryWaiting for the VT’s memory verdict.Times out to Disconnected.
UploadPoolStream the serialized pool.Emits the object-pool transfer, advances.
WaitForPoolStoreLet the transfer drain before ending.After a settle delay, emits End Of Object Pool.
WaitForEndOfPoolWaiting for parse/activate result.Times out to Disconnected.
ReloadPoolLanguage changed; re-upload.Loops back to SendGetMemory.
ConnectedPool is live; UI commands allowed.Nothing time-based.

The transitions, end to end:

  1. Discover. connect serializes the pool once as a sanity check, clears any stale VT binding, and moves to WaitForVTStatus. 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 to SendWorkingSetMaster.
  2. Announce. The next update broadcasts the Working Set Master frame so the network knows this working set exists, then moves to SendGetMemory.
  3. Reserve memory. The following update serializes the pool, sends Get Memory carrying the byte size, and waits in WaitForMemory. 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, then 0xFF reserved tail bytes. On OK the client moves to UploadPool; otherwise it drops to Disconnected.
  4. Transfer. update ships the pool transfer command with the serialized bytes (this is what the transport-protocol layer fragments across many CAN frames) and moves to WaitForPoolStore.
  5. 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 WaitForPoolStore so the multi-frame transfer can finish on the wire, then emits End Of Object Pool and waits in WaitForEndOfPool.
  6. 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 fires on_pool_error and drops the client to Disconnected.

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.

OperationMethodEffect
List stored labelsget_versionsVT replies with its stored labels (on_versions_received).
Save current poolstore_version(label)Ask the VT to persist the active pool under label.
Reload a stored poolload_version(label)Skip the upload; the VT restores label.
Forget a stored pooldelete_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.

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:

EventFires whenYou typically
on_state_changeThe FSM transitions.Log progress; gate UI commands on Connected.
on_soft_keyA soft key is activated.Map (ObjectID, ActivationCode) to an action.
on_buttonA button object is activated.Same, for button objects.
on_numeric_value_changeThe operator edits a number.Update your app model from (ObjectID, u32).
on_string_value_changeThe operator edits a string.Update your app model from (ObjectID, String).
on_pool_errorThe VT rejects the pool.Inspect the error byte; fix the pool.
on_active_ws_statusThis working set becomes (in)active.Show/hide your interface accordingly.
on_language_changeThe VT’s language differs from yours.Reload a localized pool (see below).
on_unsupported_functionThe VT can’t do a function you used.Degrade gracefully; check unsupported_functions.
on_versions_receivedA version list arrives.Decide upload-fresh vs load_version.
on_store_version_response / on_load_version_responseA 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. connect serializes 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 stays Disconnected. The send-side steps also re-check this and bail to Disconnected rather than emit a malformed transfer.
  • Empty pool. connect rejects 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_error with 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 in Disconnected, 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_address reports which one). If you need to target a specific terminal, drive connect/disconnect so 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. If auto_reload_on_language_change is on (the default) and the VT’s language differs from yours, the client fires on_language_change and, while Connected, moves to ReloadPool to re-upload a pool built for the new language. Toggle this with set_auto_reload_on_language_change if your application manages localization itself.
  • Swapping pools at runtime. While Connected, swap_pool re-uploads a new pool (optionally storing the old one first), and quick_swap_to_version reloads a previously stored pool by label without a full transfer.
  • Reconnect. Because a drop to Disconnected clears the session binding, the reconnect story is simply: keep feeding inbound frames. The next VT status frame restarts the lifecycle from WaitForVTStatus with no special handling on your part.
  • Macros. Register reusable command sequences with register_macro and fire them by ID with execute_macro; the client emits on_macro_executed when 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 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.

TypeRole
VTServerConfigThe 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.
VTServerThe server engine. Holds the FSM, the list of connected working sets, the status cadence, and the input events.
VTServerStateWhere the server is in its lifecycle: Disconnected, WaitForClientStatus, SendWorkingSetMaster, WaitForPoolUpload, Connected.
ServerWorkingSetPer-client tracking: the client address, the uploaded pool, the upload flags, stored versions, and the object_state cache.
ServerObjectStateThe semantic cache for one activated pool — active mask, visibility, numeric/string values, attributes, and more.
OutboundFrameOne 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:

  1. Advertise. After start(), every call to update(elapsed_ms) advances a timer; once a second’s worth of time has accumulated it returns the eight-byte VT_STATUS payload 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.
  2. 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. From WaitForClientStatus this also moves the server to WaitForPoolUpload.
  3. 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.
  4. 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 emits on_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.
  5. 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.

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.

EventMeaningTypical action
on_state_changeThe server FSM moved.Track connection progress / UI state.
on_client_connectedA client’s pool activated.Add it to the rendered set; pick it if it should be foreground.
on_client_disconnectedA client dropped.Remove its surface; reassign the active working set.
on_active_ws_changedThe foreground client changed.Repaint with the new client’s pool.
on_soft_key_activation / on_button_activationThe operator pressed a key/button.Route the key number to the active client.
on_numeric_value_change / on_string_value_changeThe operator edited a value.Reflect and forward the new value.
on_input_object_selectedAn 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-0xFF reserved tail are rejected without mutating state, so a single bad frame cannot corrupt the cache.
  • Client disappears. A client that stops talking leaves its ServerWorkingSet in 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 with assign_aux_input, and folds incoming AUX status frames into the cache with handle_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, and cleanup_expired_versions persist 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 VtServer plugin is right for applications: it sequences the claim, the FSM, the inbound routing, and the status broadcast. The bare VTServer pump 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

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.

Typemachbus bodyRole
WorkingSetWorkingSetBodyThe implement’s root. Exactly one per pool. Children are masks.
DataMaskDataMaskBodyA normal full-screen page; names a Soft Key Mask.
AlarmMaskAlarmMaskBodyA page the terminal raises on an alarm; carries priority and an acoustic-signal hint.
SoftKeyMaskSoftKeyMaskBodyThe set of soft keys shown alongside a mask.
ContainerContainerBodyGroups child objects so they can be moved or hidden together.
WindowMaskWindowMaskBodyA reusable framed region (the version-6 “auxiliary” window family).
KeyGroupKeyGroupBodyGroups keys for the version-6 key arrangement.
KeyKeyBodyOne 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.

Typemachbus bodyEdits
InputBooleanInputBooleanBodyA toggle, backed by a Number Variable.
InputNumberInputNumberBodyA bounded number with scale/offset/decimals and a min/max range.
InputStringInputStringBodyFree text with an optional validation character set.
InputListInputListBodyA 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.

Typemachbus bodyDraws
OutputNumberOutputNumberBodyA formatted number from a Number Variable.
OutputStringOutputStringBodyText from a String Variable (or an inline value).
LineOutputLineBodyA straight line with a referenced Line Attributes.
RectangleOutputRectangleBodyA box with line + fill attributes and per-side line suppression.
EllipseOutputEllipseBodyA circle/arc; ellipse_type selects closed/segment/section.
PolygonOutputPolygonBodyA closed or open shape; needs at least three points.
MeterMeterBodyA round gauge with a needle, driven by a Number Variable.
LinearBarGraphLinearBarGraphBodyA straight fill bar with an optional target line.
ArchedBarGraphArchedBarGraphBodyA 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.

Typemachbus bodyProvides
PictureGraphicPictureGraphicBodyA bitmap (1/4/8-bit indexed, optionally RLE).
ObjectPointerObjectPointerBodyAn indirection — renders whatever object it points at.
FontAttributesFontAttributesBodyColour, size, type, and style for text.
LineAttributesLineAttributesBodyColour, width, and dash pattern for strokes.
FillAttributesFillAttributesBodyFill colour or pattern for closed shapes.
InputAttributesInputAttributesBodyA valid/invalid character set for input strings.
NumberVariableNumberVariableBodyA shared 32-bit value that output/input numbers read.
StringVariableStringVariableBodyA shared text value that output/input strings read.
ColourMap / ColourPaletteColourMapBody / ColourPaletteBodyColour 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

Typemachbus bodyRole
MacroMacroBodyA recorded list of VT commands the terminal runs on an event.
AuxFunction / AuxInputAuxFunctionBody / AuxInputBodyThe classic auxiliary-control objects.
AuxFunction2 / AuxInput2AuxFunction2Body / AuxInput2BodyThe version-2 auxiliary family.
AuxControlDesigAuxControlDesignatorBodyA designator that names an aux function or input.
AnimationAnimationBodyA timed sequence of picture/object frames.
GraphicContext / GraphicsContextGraphicContextBody / GraphicsContextBodyA 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:

  1. Uniqueness. No two objects in a pool may share an ID. ObjectPool::add enforces this and refuses a duplicate, so you cannot accidentally build an ambiguous pool.
  2. 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::NULL is 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:

  1. Start with the smallest useful pool: a Working Set, one Data Mask, one visible output object.
  2. Add a Soft Key Mask only when you need keys.
  3. Only then add inputs, macros, auxiliary objects, and graphics.
  4. 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. validate rejects 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 NULL reference is fine; a wrong one is not.
  • Body too short. Each decode checks the minimum length for its type and returns InvalidData. 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 encode and decode, 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. deserialize refuses 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, and net::hash_to_version derives 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 ObjectPointer renders whatever object its value names, 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

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:

Intentioncmd codeVTClient methodWhat it changes
Change numeric valueCHANGE_NUMERIC_VALUEchange_numeric_value(id, value)The numeric value held by an output number, meter, bar graph, or similar.
Change string valueCHANGE_STRING_VALUEchange_string_value(id, &str)The text of an output/input string object.
Hide / showHIDE_SHOWhide_show(id, visible)Whether a container (and its children) is drawn.
Enable / disableENABLE_DISABLEenable_disable(id, enabled)Whether an input object accepts operator interaction.
Change active maskCHANGE_ACTIVE_MASKchange_active_mask(ws_id, mask_id)Which Data or Alarm Mask is the active screen for a working set.
Change soft-key maskCHANGE_SOFT_KEY_MASKchange_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 / paletteSELECT_COLOUR_MAPselect_colour_map(id), select_colour_palette(id)Which Colour Map or Colour Palette remaps VT colour indexes.
Change attributeCHANGE_ATTRIBUTEchange_attribute(id, attribute_id, value)A single addressable attribute of any object that exposes one.
Change list itemCHANGE_LIST_ITEMchange_list_item(list_id, index, new_item_id)Which child object occupies a slot in a list.
Change child locationCHANGE_CHILD_LOCATIONchange_child_location(parent, child, dx, dy)A child’s position by a relative offset within its parent.
Change child positionCHANGE_CHILD_POSITIONchange_child_position(parent, child, x, y)A child’s absolute position within its parent.
Change sizeCHANGE_SIZEchange_size(id, width, height)An object’s width and height.
Change background colourCHANGE_BACKGROUND_COLOURchange_background_colour(id, colour)An object’s background colour index.
Select input objectSELECT_INPUT_OBJECT_COMMANDselect_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 maskLOCK_UNLOCK_MASKlock_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:

  1. Deduplicates against cached state. The helper borrows a VTClientStateTracker. When you call set_numeric_value(id, v) and the tracker already has v for that object, it returns None — no frame is produced. The same short-circuit applies to strings, visibility, enable state, and the active mask.
  2. Coalesces a batch to last-write-wins. Between begin_batch() and end_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 deduplicated Vec<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_mask so the operator does not see a half-updated screen.

Events and responsibilities

Your application owns both directions of this exchange:

EventSourceYour responsibility
Command response (success)VTTreat the change as applied; confirm the op so the tracker caches it.
Command response (error code)VTDecode the rejection; correct the value, ID, or timing and retry as appropriate.
Operator input (key, button, numeric/string change)VTUpdate your internal model; you may have raced a command you sent.
Active-mask change notificationVTTrust 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 Err if 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 raw VTClient builders 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

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.

ConceptProvided byWhat it representsmachbus object body
Auxiliary functionthe implementa thing the operator can command (lift, fold, rate)AuxFunction2Body (new style) / AuxFunctionBody (classic)
Auxiliary inputthe input devicea physical control the operator can moveAuxInput2Body (new style) / AuxInputBody (classic)
Auxiliary control designatoreither sideoptional operator-facing label/icon for an aux objectAuxControlDesignatorBody

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 on PGN_AUX_INPUT_TYPE2 and the setpoint range is the full 0..=65535.
  • Classic style. Function and input objects are AuxFunctionBody / AuxInputBody; live status rides on PGN_AUX_INPUT_STATUS with a setpoint range of 0..=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:

TypeAuxFunctionTypeBehavior
0Type0Boolean on/off (a latched or momentary switch).
1Type1Variable speed (analog, e.g. a proportional lever).
2Type2Variable 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:

  1. You call request_capabilities(). It returns the 8-byte Get Supported Objects request payload to send on PGN_ECU_TO_VT, naming the new-style auxiliary object types in the request, and marks a request as pending.
  2. The VT replies on PGN_VT_TO_ECU. You hand the inbound Message to handle_response().
  3. On a well-formed reply, the helper returns the populated AuxCapabilities and clears the pending flag. Each entry is an AuxChannelCapability carrying channel_id, aux_type (0 boolean / 1 analog / 2 bidirectional), resolution (step count for analog channels), and function_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:

  1. Advertise. Both pools are uploaded; the VT now holds the function and input objects with their types.
  2. 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.
  3. Validate. The VT checks the input type against the function type. If they are not compatible, the assignment is refused.
  4. Confirm. A valid assignment is stored and acknowledged to both the implement and the input device, so each knows the binding is live.
  5. Operate. Live input changes are forwarded as function status. For the new style this is PGN_AUX_INPUT_TYPE2; classic uses PGN_AUX_INPUT_STATUS.
  6. 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 (Type1 variable speed or Type2 variable position) needs an analog input that can deliver a value across its range.
  • A bidirectional input (aux_type == 2 in 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

EventWho actsResponsibility
Capability response arrivesimplement / input appDecode with handle_response; cache only for this session.
Assignment confirmedboth sidesTreat the binding as live; begin forwarding/acting.
Input value changeinput deviceSend a status frame for the bound input.
Function status receivedimplementActuate to the new state/setpoint, or hold safe-state.
Assignment cleared / pool unloadedboth sidesDrop 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. machbus refuses 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_response returns None for 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 a None as “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 == 2 advertises 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 / AuxOFunction path 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

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:

CallbackRegistered withFires whenYou return
Value requeston_value_requestthe TC asks for a current measured valuethe i32 value, or an Err to stay silent
Value commandon_value_commandthe TC sends a setpoint to your deviceOk(()) 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:

StateWhat is happening
DisconnectedIdle. Nothing attempted, or a fault returned here.
WaitForServerStatusconnect() succeeded; waiting to hear a TC announce itself.
SendWorkingSetMasterA TC was heard; about to announce this working set.
RequestVersion / WaitForVersionAsking the TC its version and capabilities.
RequestStructureLabel / WaitForStructureLabelAsking whether the TC already stores this DDOP’s structure.
RequestLocalizationLabel / WaitForLocalizationLabelSame check for the localization (language/units) label.
TransferDDOP / WaitForPoolResponseUploading the DDOP and waiting for accept/reject.
ActivatePool / WaitForActivationActivating the stored pool.
ConnectedThe pool is active; process data flows.
DeactivatePool / DeletePool and their waitsTearing down the old pool during a re-upload.

The driving rules:

  1. Discover. connect() first validates the DDOP, then moves to WaitForServerStatus. The TC broadcasts a periodic status; the first one received binds tc_address() and advances to SendWorkingSetMaster.
  2. Announce. update ships 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.
  3. Version handshake. The version reply carries the TC’s protocol version and its boom/section counts. The client records tc_version() and proceeds.
  4. 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-0xFF label 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.
  5. Upload, then activate. TransferDDOP serializes the DDOP and sends it as an object-pool transfer. A success response advances to activation; a failure returns to Disconnected. The activate command then lands the client in Connected, 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.

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

SituationWhat the client doesWhat you must do
TC asks for a valueCalls on_value_requestReturn the current reading, or Err to decline
TC sends a setpointCalls on_value_commandApply it; return Err if you cannot
TC requests “set and acknowledge”Builds the acknowledge for youMake the callback’s result truthful
FSM transitionsRaises on_state_change / TcEvent::StateChangedGate application logic on reaching Connected
TC stops announcingTimes out a WaitFor* state → DisconnectedStop 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 Disconnected instead 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 Err rather 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_message if 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 to Disconnected. Treat that as “stop work” and re-run connect() 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) from Connected validates 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 TcClient plugin is right for applications: it routes inbound frames and ships outbound ones on each poll. The raw TaskControllerClient is right for tests and embedded loops where you own the update/handle_tc_message cadence.

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 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.

Piecemachbus type / fieldWhat it holds
IdentityTCServerConfig::tc_number, tc_versionThe TC number that distinguishes this controller, and the version it speaks.
Capacitynum_booms, num_sections, num_channelsThe boom/section/channel dimensions advertised to clients.
Feature flagsserver_optionsA bitfield of supported features (documentation, section control, peer control, geo).
Per-client recordTCClientInfoAddress, stored DDOP, whether the pool is activated, last transfer, tracked client version.
Stored labelsstructure_label, localization_labelSeven-byte labels that let a returning implement skip re-uploading.
Trigger runtimeMeasurementTriggerRuntimePer-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:

FlagMeaning
SupportsDocumentationThe controller can log values for record-keeping (the documentation TC).
SupportsTCGEOWithoutPositionBasedControlGeo features without position-based section/rate control.
SupportsTCGEOWithPositionBasedControlGeo features with position-based control (prescription maps).
SupportsPeerControlAssignmentOne client may be wired to control another’s value.
SupportsImplementSectionControlThe 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:

StateMeaningWhat the server does
DisconnectedNot running.Nothing. update returns no status.
WaitForClientsStarted, advertising, no client yet.Emits periodic TC status; waits for a first client.
ActiveAt least one client registered.Full process-data exchange; still broadcasts status.

The transitions:

  1. Start. start() moves the server from Disconnected to WaitForClients. From here update(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.
  2. 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 a TCClientInfo and raises on_client_connected.
  3. 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 (raising on_client_version_received).
  4. 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).
  5. Activation. The client requests activation; activate_pool flips the stored pool to active if it holds at least one device, otherwise it answers with an activation error (tc::ObjectPoolActivationError) and raises on_pool_activation_error.
  6. Process data. With an active pool the two sides exchange values: the server answers RequestValue, receives Value / SetValueAndAcknowledge, and may originate its own requests and setpoints.
  7. 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 to Disconnected and clears all client records.

Doing it with machbus

There are two ways to run a TC server, and they suit different needs.

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 inbound PGN_ECU_TO_TC frame and get back a Vec<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, otherwise None.

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:

CallbackFires onYour job
on_value_request(cb)Client RequestValueReturn the current i32 value for (element, DDI).
on_value_received(cb)Client Value / SetValueAndAcknowledgeAccept the value; return a ProcessDataAcknowledgeErrorCodes.
on_peer_control_assignment(cb)Peer-control assignmentAccept 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_request must 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 due RequestValue frames — 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 ThereAreErrorsInTheDDOP and raises on_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_options honest.
  • Client disconnects mid-task. If a client falls off the bus, its TCClientInfo still 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_message returns an error for traffic that is not PGN_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_options flags you set decide which one your node presents as. Many controllers do both.
  • Peer control. With SupportsPeerControlAssignment advertised, one client’s value can be wired to drive another’s. The server validates the assignment and hands it to your on_peer_control_assignment callback, 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 TcServer plugin is right for applications: it claims, routes, and broadcasts for you. The bare TaskControllerServer is 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

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.

Objectmachbus typeWire tagWhat it carries
DeviceDeviceObjectDVCThe machine itself: designator, software version, serial, structure + localization labels.
Device ElementDeviceElementDETA node in the tree: a type, an element number, a parent, and a child list.
Process DataDeviceProcessDataDPDA live measurable: a DDI, trigger methods, an optional presentation.
PropertyDevicePropertyDPTA fixed value baked into the description: a DDI and a constant i32.
Value PresentationDeviceValuePresentationDVPScale, 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:

  1. Create the root DeviceObject with a designator and version.
  2. Add a root DeviceElement (type Device) and, beneath it, Function elements for booms and Section elements for each controllable segment.
  3. For each section, add DeviceProperty objects for fixed geometry (offsets, width) using the offset/width DDIs, and DeviceProcessData objects for the live rates and counts it reports or accepts.
  4. Add DeviceValuePresentation objects for any value that needs scaling, and point the relevant process-data/property objects at them.
  5. Wire the tree: set each element’s parent_id and child_objects so the IDs form one connected hierarchy.
  6. 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_id must 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 0xFFFF means “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_id returns the null ID and add_* 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 Function element and each sub-boom as a Function element whose parent is the main boom, with Section elements beneath each sub-boom. extract_geometry understands this nesting and fills the sub_booms list with per-sub-boom sections and rates.
  • Connector / navigation reference. Use a Connector element with an X-offset property to anchor the implement to the hitch, and a NavigationReference element where guidance needs a defined reference point. The geometry helper reads the connector offset into connector_x_mm.
  • Stable IDs vs auto-allocation. Auto-allocation is convenient for generated pools; explicit with_id is 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

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.

TypeWhat it holds
WgsA WGS84 latitude/longitude/altitude triple (re-exported from concord). Positions and zone vertices are both Wgs.
GeoPointA Wgs position plus a timestamp_us. This is a timestamped fix, so you can reason about how fresh it is.
PrescriptionZoneA boundary (a Vec<Wgs> polygon), optional holes (a Vec<Vec<Wgs>> of exclusion polygons), and an application_rate (an i32 in DDI-dependent units).
PrescriptionMapA structure_label (a String name) and its zones (a Vec<PrescriptionZone>).
TCGEOInterfaceHolds 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), returning Ok(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-byte PGN_ECU_TO_TC Process Data Value payload ready to ship, again Some/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:

EventFires whenTypical action
on_position_updateA new position is recorded (via set_position or a decoded GNSS frame).Note freshness; trigger a re-evaluation.
on_application_rate_changedupdate runs and the looked-up rate differs from the last one.Send the new setpoint to the implement.
on_prescription_map_receivedA 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_position returns None. 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 (None from 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_us and 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. TCGEOInterface is 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

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:

ClassWhat it implies
Class1Basic state: speed, hitch position, PTO, power management.
Class2Full measurements: distance, direction, draft, lighting, aux-valve flow.
Class3Accepts 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:

StateMeaning
PowerOffThe boot/default state; nothing powered.
IgnitionOnNormal operation, key on.
ShutdownInitiatedKey off; a bounded window of power remains.
FinalShutdownPower-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:

EventTypical TECU responsibility
Power-upClaim an address, then broadcast the facilities response.
Required-facilities receivedNarrow what you broadcast to what implements asked for.
Periodic tickRe-send speed/distance/hitch/PTO status at the right cadence.
Command receivedValidate against interlocks; act or negative-acknowledge.
Key-offEnter the shutdown window; honour maintain-power requests until they expire.
Sensor unavailableSend 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 — 0xFF bytes, the NotAvailable enum variants — so an unconfigured PtoStatus or HitchStatus already 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 decode is defensive: wrong length, bad reserved bits, or corrupt padding yield None. A peer’s garbage frame returns None and must not be allowed to mutate your cached state.
  • Speed-source switching under control. When the tractor is steering by MachineSelectedSpeedFull and 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. SafeModeTrigger names 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. machbus carries these as the *_limit_status and *_exit_code facility bits and the LimitStatus / ExitReasonCode fields on the status codecs; with_class3_v2_all and with_front_v2_all set 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::instance records which you are.
  • Repetition and update timing. Status messages are periodic; the speed message in particular runs on a tight cadence. TecuConfig carries facilities_broadcast_interval_ms and status_broadcast_interval_ms as 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

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:

ConversationDirectionPurpose
Implement messages (this page)mostly tractor → implementRead ground speed, distance, hitch, PTO, lighting; optionally command them.
Virtual terminalimplement ↔ VTDraw the operator UI and receive button/soft-key input.
Task controllerimplement ↔ TCReport 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.

TypeWhat it carries
WheelBasedSpeedDistSpeed from the driveline (speed_mps), accumulated distance_m, travel direction, key-switch and start/stop state. Basic, available from most tractors.
GroundBasedSpeedDistSpeed/distance from a ground sensor (radar/GNSS), independent of wheel slip. Richer tractors only.
MachineSelectedSpeedFullThe 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.

TypeCommand set
HitchCommandNoAction, Lower, Raise, Position (with a target).
HitchCommandMsgThe wire message: command, target_position (0.0025 % per bit), rate.
PtoCommandNoAction, Engage, Disengage, SetSpeed.
PtoCommandMsgcommand, target_speed_rpm, ramp_rate.
ValveCommandNoAction, Extend, Retract, Float, Block.
AuxValveCommandMsgA 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:

  1. Speed in. Decode WheelBasedSpeedDist / MachineSelectedSpeedFull each tick; cache the latest.
  2. Operator in. The VT client delivers button/soft-key events — sections on/off, target rate changes.
  3. Compute. Convert ground speed plus target rate into a metering setpoint per section.
  4. Act on the machine. Drive the implement’s own actuators; optionally request hitch/PTO action via the command messages if the operation needs it.
  5. 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:

EventFired whenTypical 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 / GroundSpeedSpeed/distance broadcast.Update the metering setpoint.
MachineSelectedSpeedSelected-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 / TecuClass first; 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 / PtoStatus feedback, 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 enum NotAvailable / Error variants). Treat these as “no data”, never as zero. A direction of NotAvailable is 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_valve validates valve_index against MAX_AUX_VALVES and 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 Implement plugin 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

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

TypeCarries
Eec1Engine speed (rpm), actual/demanded/driver-demand torque as a percent, starter mode, and the source address that owns the speed signal.
Eec2Accelerator pedal position, engine load percent, low-idle and kickdown switches, road-speed limit.
Eec3Nominal friction percent, desired operating speed, operating-speed asymmetry.
Tsc1A 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

TypeCarries
EngineTemp1Coolant, fuel, oil, turbo-oil, and intercooler temperatures in °C.
EngineTemp2A second set of oil/turbo/intercooler temperatures at finer resolution.
EngineFluidLpOil and coolant pressure (kPa), oil and coolant level (percent), fuel-delivery and crankcase pressure.
DashDisplayFuel level and washer-fluid level (percent), fuel- and oil-filter differential pressure, cargo/ambient temperature.
AmbientConditionsBarometric pressure, ambient/intake/road-surface temperature.
Vep1Battery, charging-system, and key-switch voltage; alternator current.

Engine hours and totals

TypeCarries
EngineHoursTotal engine hours and total engine revolutions — lifetime counters.
FuelEconomyInstantaneous and average fuel rate (L/h) and throttle position.
FuelConsumptionTrip and total fuel used (litres).
Aftertreatment1 / Aftertreatment2DEF 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

TypeCarries
Etc1Current gear and selected gear, output-shaft speed (rpm), shift-in-progress flag, torque-converter lockup flag.
TransmissionOilTempTransmission oil temperature (°C).
CruiseControlWheel-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 12000 decodes to 1500.0 rpm. 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 0 is −125 % and raw 250 is +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, or 0xFFFF for two bytes) means no reading. machbus encodes the not-available pattern into unused bytes and, on decode, returns None for frames whose mandatory fields are not-available rather than handing you a fake number.

Two consequences worth internalizing:

  1. encode clamps 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.
  2. decode is strict. Wrong length, reserved bits set where they must be zero, or an unrepresentable not-available value all yield None. The Default for several of these structs deliberately is the not-available state (for example Etc1::default() reports gears at −125 and both flags as 0x03, 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 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 a PowertrainSnapshot whose fields (eec1, etc1, engine_hours, …) are Option, populated only once a valid frame has arrived. There are shortcuts like latest_eec1() and latest_etc1() / latest_cruise_control().
  • An event drain. Newly decoded frames arrive as PowertrainEvent on the event stream, one entry per frame, each tagged with the source address and the decoded data. 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 familyMeaningTypical action
Eec1 / Eec2 / Eec3New engine speed/torque/load snapshot.Update displays; gate work on load/rpm.
EngineTemp1 / EngineTemp2 / EngineFluidLpNew thermal/fluid reading.Warn or derate on over-temperature; flag low fluid.
EngineHours / FuelEconomy / FuelConsumptionNew counters.Log duty and consumption.
Etc1 / TransmissionOilTempNew transmission state.Coordinate with gear/shift; watch oil temp.
CruiseControlVehicle 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 per source, not globally.
  • Honor not-available. A None from decode, or an Option field 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 for decode returning None on perfectly valid “sensor absent” frames.
  • Reserved bits. Eec1 rejects a frame whose starter-mode nibble has the upper bits set; Etc1 rejects 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 Etc1 combined with a known final-drive ratio approximates ground speed; EngineHours sampled 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 CruiseControl wheel 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 Powertrain plugin 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 speaksAEF TIM — src/isobus/tim/
SteeringPGN 0xAD00 Guidance System CommandPGN 0x2400/0x2300, function ExternalGuidance (0x46)
SpeedPGN 0xFD43 Machine Selected Speed Commandsame PGN pair, function VehicleSpeed (0x44)
Gate before you may commandnone — broadcastassignment table + authentication + heartbeat counter
Also coversnothing elsePTO, 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.

NoAuthority is still unused. AutodriveRefusal::NoAuthority sounds 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. (FacilityNotAdvertised is 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:

AddendumClauseWhat it means
G§4.4.2.7The 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.8The tractor is “capable of accepting speed and/or drive strategy commands from an implement controller”.
M§4.4.2.9The 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() and engage() refuse with facility_not_advertised, and a tractor that revokes it mid-drive trips a safe stop.
  • Response says no speed command → a DriveCommand carrying 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 every COMMAND_STALE_MS (300 ms) while engaged, even with an unchanged setpoint. Miss it and AutoDrive trips SafeStopTrigger::CommandStale, falls to DriveCommand::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

ConstantDefaultMeaning
MIN_TX_INTERVAL_MS100 msConformance minimum; with_cadence clamps to it
MAX_TX_INTERVAL_MS2000 msIdle re-broadcast when not active
COMMAND_STALE_MS300 msUnrefreshed setpoint → CommandStale
LINK_TIMEOUT_MS300 msThree missed 100 ms Machine Info → GuidanceLinkTimeout
DEFAULT_MIN_SPEED_MPS0.05 m/sBelow 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 }.

TriggerCause
GuidanceLinkTimeout300 ms without Machine Info
IsbStopOperator holds the shortcut button, a seen ISB source goes silent, or the machine reports a mechanical lockout
OperatorOverrideEngage switch dropped, or the ECU reports OperatorLimitedControlled / NonRecoverableFault
CommandStaleYou stopped refreshing the setpoint
SendFailed(pgn)The network layer refused one of the two command PGNs
PositionStale, FixDegradedGNSS events, via GnssHazards
BusOff, AddressClaimLost, HeartbeatError, ClockWentBackwards, KeySwitchOffSession 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

PluginWhy
AutoDriverequired
Gnssstrongly recommended — without it GnssHazards never fires, so clear_stop() can re-arm autonomy against a receiver that stopped reporting
ShortcutButtonthe ISO stop-all path
DiagnosticsDTCs 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

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

RoleWhat it ownsWhat 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:

  1. Load and start. You add steps while the master is Idle (add_step refuses once you leave Idle). start requires at least one step and moves the master to Ready, clearing its ready/active timers and its ready/ack client sets.
  2. Ready → Active. The master sits in Ready advertising the sequence as ready. As clients report Ready, the master collects their addresses; once it has required_client_count unique clients it transitions to Active and emits the first step. A client moves itself to Ready the moment it sees an active master advertising a ready sequence.
  3. 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 calls step_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 to Complete after the last one.
  4. Pause / resume. The master may pause only from Active (→ Paused) and resume only from Paused (→ 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.
  5. Abort and errors. abort from any live state forces Error and 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 in Error, 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 reserved Initialization);
  • byte 2 — the current sequence/step number, or the 0xFF not-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 1 and ignored on receive;
  • bytes 5–7 — reserved, held at 0xFF on 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, and 0xFF is the ready / not-applicable sentinel. add_step rejects a larger id, and rejects duplicate ids in the same sequence.
  • A Ready sequence state must carry the 0xFF sentinel; 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:

EventSideMeaning
MasterStateChanged { from, to }masterThe master’s lifecycle state moved.
MasterStepStarted { step_id }masterA new step was dispatched.
MasterStepCompleted { step_id }masterA step was recorded complete.
MasterSequenceCompletemasterThe last step finished.
MasterTimeout { reason }masterA ready or active timeout struck.
MasterClientStatus { source, state }masterA valid client status arrived.
ClientStateChanged { from, to }clientThe client’s state moved after a master status or timeout.
ClientSequenceStartclientThe client observed a new sequence start.
ClientStepRequest { step_id }clientThe client was asked to execute a step.
ClientPause / ClientResumeclientThe client inferred pause or resume.
ClientAbortclientThe 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 abort from Ready, Active, or Paused forces Error and emits a visible Abort status immediately. Calling it from Idle, Complete, or Error is 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_count clients do), the master never leaves Ready. The ready timeout eventually fires and drops it to Error.
  • Client stuck busy. A client that stays busy past its busy_pause_timeout_ms while Active or Paused drives itself to Error, 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 to Error. 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-0xFF tail is rejected without moving the state machine. Use the try_* 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 Idle and emits an abort event, so a vanished master is not mistaken for a paused one.

Advanced

  • Multi-client coordination. Set required_client_count above 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 into Error as 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 next update if a send would come too soon. The master emits on its status_interval_ms. The standard expects a faster cadence in active states than in ready; the timeout and spacing constants in sc::types encode those expectations, and SCMasterConfig / SCClientConfig let you tune them.
  • Session facade vs the bare codecs. Use the ScMaster / ScClient plugins for applications — they route frames, run the update loop, and fan out events. Drop to SCMaster / SCClient directly 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

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.
  • machbus is 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 use machbus to 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).

TypeRole
TimOptionOne named controllable function (PTO, hitch, speed, guidance).
TimOptionSetA 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 position 0..=MAX_HITCH_POSITION (10_000, i.e. 0.00%–100.00% at 0.01%/bit); out-of-range positions are rejected by validate() / try_encode() with TimValidationError::HitchPositionOutOfRange.
  • AuxValveCommand — a valve index 0..MAX_AUX_VALVES (32), state, and flow; an out-of-range index yields TimValidationError::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:

StateMeaning
IdleNo request outstanding. Nothing may be commanded.
RequestedThe client asked for a set of options; awaiting a grant.
GrantedAuthority is active. Covered commands may be issued.
DeniedThe request was refused.
RevokedA 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 ◄────┘
  1. request. TimAuthority::request(set) moves Idle → Requested. It fails with UnsupportedOptions if the set is not a subset of what is available, or ReservedOptionBits if either set has undefined bits set. You cannot request what you do not support.
  2. grant. grant() moves Requested → Granted — but only if no interlock is blocking. If a stop, road mode, or missing-operator condition is active, the grant is refused with InterlockActive and the machine stays safe. A grant() from any state other than Requested fails with AuthorityNotRequested.
  3. deny. deny() records Denied. The client must not command anything.
  4. command. While Granted, ensure_command(cmd) (or ensure_option) checks four things in order: the option is supported, it was part of the request, no interlock is blocking, and the state is still Granted. Only if all four hold does the command proceed.
  5. revoke / forced safe state. revoke() moves to Revoked. Crucially, set_interlocks(...) also forces Granted → Revoked automatically 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 fresh request + grant cycle 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:

ConditionTimInterlockWhy it blocks
Operator absentOperatorNotPresentTIM requires a supervising operator.
Road transport modeRoadTransportModeImplement control on the road is unsafe.
External stop activeExternalStopA stop request (e.g. a safe-state input) overrides everything.
Implement not readyImplementNotReadyThe 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:

ResponsibilityWhat you must do
Request only what you supportBuild the requested TimOptionSet as a subset of available options.
Honor revocation immediatelyStop commanding the instant authority leaves Granted.
Go to a safe state on lossOn timeout, revocation, or interlock trip, command the machine to a defined safe state, not “last value”.
Keep messages flowingTIM 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/RevokedRe-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()), or grant() 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_POSITION or a valve index past MAX_AUX_VALVES is 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_present or asserts an external stop revokes the grant through set_interlocks, and the machine returns to a safe state.

Advanced

  • Combining with guidance and speed. TimOption::GuidanceCurvatureIsSupported and 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 — machbus carries 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 TimAuthority directly in unit tests and embedded loops where you own timing and wiring. Use the Tim plugin 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:

PartTypeWidthMeaning
SPN — Suspect Parameter Numberu3219 bitsWhat 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 IndicatorFmi5 bitsHow it is faulty: above normal, below normal, voltage high/low, mechanical failure, root cause unknown, condition exists, and so on.
Occurrence countu87 bitsHow 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.

Messagemachbus typeRole
DM1DmDtcListActive DTCs + lamp panel. Broadcast periodically.
DM2DmDtcListPreviously active DTCs + lamps. Sent on request.
DM3DmClearAllRequest (Dm3ClearPreviouslyActiveRequest)Clear previously active codes.
DM11DmClearAllRequest (Dm11ClearActiveRequest)Clear active codes.
DM4Dm4MessageDriver information (lamps + DTCs, alternate layout).
DM6 / DM12 / DM23Dm6Message / Dm12Message / Dm23Message (aliases of DmDtcList)Pending, emissions-related, and previously-MIL-off DTC lists.
DM7 / DM8Dm7Command / Dm8TestResultCommand a non-continuous monitor test and report its result.
DM13Dm13SignalsSuspend / resume broadcasts network-wide.
DM20Dm20ResponseMonitor performance ratios.
DM21Dm21ReadinessDiagnostic readiness counters.
DM22Dm22MessageClear/reset a single DTC, with Ack/Nack.
DM25FreezeFrame / Dm25RequestFreeze-frame / expanded snapshot of a DTC.
DM5DiagnosticProtocolIdWhich diagnostic protocols the ECU speaks.
DM9 / DM10Dm9VehicleIdentificationRequest / Dm10VehicleIdentificationRequest / return the VIN.
DM14 / DM15 / DM16Dm14Request / Dm15Response / Dm16TransferMemory access: request, response, data transfer.
ECU / Software / Product IDEcuIdentification, 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 a Dm14Command (Read, Write, StatusRequest, Erase, BootLoad, EdcpGeneration), a Dm14PointerType, a 24-bit address, a length, and a security key. The encoder rejects an address that will not fit the 24-bit wire field.
  • Dm15Response (ECU → tool) returns a Dm15Status (Proceed, Busy, Completed, Error, EdcpFault), echoes the address and length, and carries a seed byte for the security handshake.
  • Dm16Transfer carries 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 with encode_j1939 for the five-field form, encode_iso11783 for six, or encode to 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 a Dm9VehicleIdentificationRequest.

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.

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 with raise(spn, fmi), clear(spn, fmi), set_lamps(..), broadcast_dm1(), active(), previous(), and senders for DM7/DM8/DM13/DM22. raise is 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.
  • DmMemory handles DM14/DM15/DM16 plus ECU/Software/Product identification, with send_dm14, send_dm15, send_dm16, request_ecu_identification, and send_ecu_identification. When you supply an EcuIdentification, the session answers PGN Requests for it automatically.
  • ControlFunctionalities installs the PGN 0xFC8E responder; 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())MeaningTypical 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 / Dm8ResultA peer ran a non-continuous monitor test.Run the test / record the outcome.
Dm13SignalsA peer asked to suspend or resume broadcasts.The plugin already gates DM1; observe if needed.
Dm22MessageAn 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 DmDtcList still 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 Dm14Request does not guarantee a Proceed. A responder may answer Busy, Error, or refuse via the seed/key handshake. Treat the Dm15Status as 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() (via ctrl.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 an Err) 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::matches to recognize a recurring (spn, fmi) and bump the count instead of pushing a duplicate. The plugin’s raise is idempotent by (spn, fmi) and does not auto-increment, so counting policy stays explicit.
  • Persistence. The Diagnostics plugin 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 Diagnostics plugin 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:

OperationFSFunctionWhat it does
Get file-server propertiesGetFileServerPropertiesRead 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 statusFileServerStatusRead the busy flag and the count of currently open files. Also broadcast periodically.
Get current directoryGetCurrentDirectoryAsk which directory this client’s session is currently in.
Change current directoryChangeDirectoryMove the client’s session to another directory, including . (stay), .. (up), and \ (root).
Open fileOpenFileOpen 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.
SeekSeekFileSet the absolute byte position of an open handle.
ReadReadFileRead 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.
WriteWriteFileWrite a payload to an open handle at its current position; the file grows if needed and the position advances.
CloseCloseFileRelease a handle.
MoveMoveFileRename/relocate a file within the namespace.
DeleteDeleteFileRemove a file.
Get attributesGetFileAttributesRead a file’s attribute byte (read-only, hidden, system, directory, archive, volume).
Set attributesSetFileAttributesSet a file’s attribute byte.
Get date/timeGetFileDateTimeRead the packed filesystem date and time for a path.
Initialize volumeInitializeVolumeReset the volume to an empty namespace (a service-tool operation).
Volume statusVolumeStatusServer-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:

FlagBitsMeaning
Readmode 0x00Open for reading.
Writemode 0x01Open for writing.
ReadWritemode 0x02Open for both.
OpenDirmode 0x03Open a directory for listing (read its entries with ReadFile).
Create0x04Create the file if it does not exist.
Append0x08Start the position at end-of-file. Invalid for read-only or directory opens.
Exclusive0x10With 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:

FSErrorCategoryClient reaction
SuccessOKProceed; use the returned data.
NotFoundPathThe file or directory is not there. Create it (if you meant to) or correct the path.
WrongTypePathYou asked for a file but the path is a directory, or vice versa. Pick the right operation.
InvalidSourceName / InvalidDestNamePathThe name is illegal (bad characters, traversal, host-absolute). Fix the path before retrying.
AccessDeniedPermissionThe file is read-only, is open elsewhere, or the target already exists for an exclusive create. Do not retry blindly.
InvalidAccessPermissionThe requested access mode or flag combination is not allowed. Fix the flags.
TooManyOpenResource (per client)You hit your own open-file cap. Close something and retry. Retryable.
MaxHandlesResource (server-wide)The server is out of handle slots. Back off and retry later. Retryable.
InvalidHandleHandleThe handle is unknown, stale, or not yours. Re-open the file.
NoSpaceStorageThe volume is full. Free space or stop.
WriteFailStorage / I/OA write failed at the media. Retryable.
MediaNotPresentVolumeRemovable media is gone. Fatal for the in-flight transfer; wait for the volume to return.
NotInitializedVolumeThe file system did not mount. Fatal.
NotSupportedCapabilityThe server does not implement this operation (check the properties first).
InvalidLengthFramingA length field in the request or response is wrong.
OutOfMemoryResourceThe server could not allocate. Fatal.
EndOfFileReadThe read started at or past end-of-file. machbus surfaces this on the client as Ok(empty) so a read loop ends cleanly.
TANErrorProtocolThe transaction number was the reserved sentinel or otherwise invalid.
MalformedRequestFramingThe request could not be parsed. Fix the payload shape.
OtherErrorCatch-allUnspecified 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 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 returns MaxHandles. Both are retryable after closing files or waiting.
  • Path not found. A non-existent path returns NotFound unless the open carries Create. 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/tick and 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 with add_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 / FsServer plugins for applications — they route frames, ship keepalives, and fan out events for you. Use the raw FileClient / FileServer for 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

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. machbus decodes it into GNSSPosition.wgs (a concord::Wgs with latitude, 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). machbus fills in altitude_m, fix_type, satellites_used, hdop, and pdop from 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 is NoFix.
  • is_rtk() — true for RTKFixed or RTKFloat, 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:

WhatPGN groupWhere it lands
Course over ground + speed over groundCOG/SOG rapid (129026)cog_rad, speed_mps
Heading / track controlheading-track (127250)heading_rad
Attitude (yaw, pitch, roll)attitude (127257)heading_rad, pitch_rad, roll_rad
Rate of turnrate-of-turn (127251)rate_of_turn_rps
Magnetic variationmagnetic 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:

PriorityGroups
2129025 position rapid, 129026 COG/SOG, 129027 position delta, 127250 heading, 127251 rate of turn, engine/transmission rapid
3129029 GNSS position data, 126992 system time, 127257 attitude, switch state
5127497 engine trip
6129539 DOPs, 129540 satellites in view, 127258 magnetic variation
7126993 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 GNSSPosition carries timestamp_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 require is_rtk(), and optionally bound hdop or satellites_used. A NoFix or 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 cached GNSSPosition, or None if no fix yet.
  • broadcast_position, broadcast_cog_sog — broadcast your own fix on the bus when your node is the source.
  • interface_mut() — the underlying NMEAInterface for 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

EventMeaningTypical action
GnssEvent::PositionThe cached fix changed.Re-evaluate freshness and quality; act if good.
GnssEvent::Cog / SogCourse / speed over ground updated.Update motion model.
GnssEvent::HeadingHeading updated.Feed steering / display.
GnssEvent::AttitudeYaw/pitch/roll updated.Terrain compensation, tilt.
GnssEvent::DopsDOP report arrived.Update the quality gate.
GnssEvent::SystemTimeBus 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() returns None until the first decodable position arrives. Handle the None case 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 None and required ones leave the cache unchanged. Treat None as “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 NMEAConfig toggle 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 GNSSPosition is the input to guidance and to geo-referenced prescription. Convert the WGS84 fix to a local frame with to_enu, to_ned, or to_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 GNSSBatch and convert them all at once with to_enu_batch, to_ned_batch, or to_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 the Gnss plugin (and the build_* builders on NMEAInterface) encode the same PGNs back onto the bus.
  • Network management. Use N2KManagement to 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

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:

PartShapeMeaning
Start$Marks the beginning of a sentence. Lines that do not start with it are dropped.
Talker IDtwo 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 typethree charactersThe structural identity: position fix, course/speed, satellite list, etc.
Fieldscomma-separated textThe payload. Empty fields are allowed and common; their commas still appear.
Checksum* then two hex digitsXOR 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:

TypeCarriesWhat machbus extracts
GGAPrimary position fixLatitude, longitude, altitude, fix quality, satellites used, HDOP, geoidal separation, UTC time.
RMCRecommended minimumLatitude, longitude, speed over ground, course over ground, UTC time; only when the line reports a valid fix.
VTGTrack and ground speedCourse over ground and speed over ground.
GSAActive satellites / DOPsFix dimensionality (no-fix vs fixed) plus PDOP, HDOP, VDOP.
GLLGeographic lat/lonLatitude, longitude, UTC time; only when the line is marked valid.
GSVSatellites in viewThe satellites-in-view count (kept separate from the satellites used in the fix).
ZDADate and timeA 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:

FieldUnitSource sentences
wgs (concord::Wgs: latitude, longitude, altitude)degrees / metresGGA, RMC, GLL
fix_type (GNSSFixType)enumGGA, RMC, GSA, GLL
satellites_usedcountGGA
speed_mpsmetres per secondRMC, VTG
cog_radradiansRMC, VTG
hdop / pdop / vdopdimensionlessGGA, GSA
geoidal_separation_mmetresGGA
timestamp_usmicroseconds since midnight UTCGGA, 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 in wgs.
  • Speed from RMC arrives in knots and from VTG in km/h; both are converted to metres per second in speed_mps.
  • Course arrives in degrees and is converted to radians in cog_rad.
  • Time arrives as hhmmss.sss and becomes microseconds-since-midnight in timestamp_us; ZDA additionally 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 / accessorFires / returns whenTypical action
on_positionA position-bearing sentence updated the fix.Forward the fix to guidance, logging, or a position PGN.
on_sogRMC or VTG produced a ground speed.Update a speed display or feed wheel/ground-speed logic.
on_cogRMC 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 GGA with quality 0, or an RMC/GLL flagged invalid, marks the fix as NoFix and does not emit a position. latest_position() then returns None.
  • 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 GNSSPosition carries a concord::Wgs and 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_type and DOP first.
  • Rate. The fix rate is set by the receiver, not the parser; feed_bytes imposes no rate of its own. Read the serial port often enough that the OS buffer never overflows, and let feed_bytes absorb 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, the on_* 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:

ClauseRequirementWhere
§4.7.1fail-safe on loss of power or communicationlink timeout, bus-off → safe stop
§4.7.2“The implement shall not start unexpectedly.”arm latch, latching stop, deliberate clear
§4.7.3not “prevented from stopping once the command has been given”disengage is infallible and idempotent
§4.7.7stop automatically when a failure prevents remote controldead-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.

JoystickKeyboard
Dead-manR2 held (analog, > 0.3)SPACE held (auto-repeat)
Armhold R2 for ARM_HOLD_SECS (1.5 s)hold SPACE for 1.5 s
Releaselet go of R2stop pressing SPACE
Emergency stopA / Cross — zero motion, disarmENTER — zero motion, disarm
Clear a latched stopcomplete a fresh 1.5 s arm holdC
Re-arm after a disarmmust release R2 fully firstmust 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’s CommandStale watchdog 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

KeyAction
SPACEdead-man — hold to drive
W / Saccelerate / brake
A / Dsteer left / right
ENTERemergency stop + disarm
Cclear a latched safe stop
I / Kspeed limit ±
H / Jhitch raise / lower
P / OPTO on / off
Xcycle counter multiplier
Q, Ctrl+Cquit

Joystick

ControlAction
R2dead-man — hold to drive (hold 1.5 s to arm)
Left stickthrottle (Y) and steer (X)
A / Crossemergency stop + disarm
B / Circlehitch raise
X / Squarehitch lower
Y / TrianglePTO engage
D-pad ↑ / ↓speed limit ±
Startcycle 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

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

  1. Reduce the failing input to the smallest useful fixture.
  2. Add a unit, property, or replay test that fails without the fix.
  3. Document the expected behavior in the relevant tutorial/reference chapter.
  4. 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

FamilyExample stems
session facadesession_minimal
basicsaddress_claim, heartbeat_demo, virtual_can_demo, transport_demo
diagnostics/powertraindiagnostic_demo, engine_powertrain_demo, tractor_ecu_demo
VT/TC/FSvt_client_demo, vt_server_demo, tc_client_demo, tc_server_demo, file_server_demo
GNSS/NMEAgnss_monitor, gnss_batch, serial_gnss, speed_monitor
bindingsexamples/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.rs
  • examples/session_minimal.rs plus presets::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.rs plus presets::implement(pool, ws, ddop) (see The session facade)

Expected learning:

  • implement-side setup with the Implement plugin (or presets::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:

  1. link_down — arming before any steering ECU answers is refused, and the refusal names the precondition. AutoDrive returns refusals rather than silently ignoring, because a client that asks to steer and is ignored cannot tell “commanded” from “declined”.
  2. 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.
  3. Fault + operator_override — releasing the dead-man disengages and falls back to DriveCommand::halt(). Losing the dead-man must land in the same place; see the drive tool safety model.
  4. stop_latched then cleared — the stop latches, so re-engaging is refused until an explicit, separate clear_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

  • AutoDrive gates 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

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 VtServer plugin 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 TcServer plugin 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 FsServer plugin 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.rs extended 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.Session in 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, drain poll_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:

  1. feed received CAN frames with machbus_session_feed,
  2. advance the virtual clock with machbus_session_tick,
  3. drain outbound frames with machbus_session_poll_transmit and write them to your bus,
  4. 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 with machbus_session_new, release exactly once with machbus_session_free. machbus_session_free(NULL) is a no-op; double-free is outside the contract. Set your pointer to NULL after freeing.
  • Errors: fallible calls return bool (or a sentinel). On failure the reason is in the thread-local machbus_session_last_error(), valid until the next ABI call on the same thread. A false from poll_transmit/poll_event means “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

FunctionPurpose
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

FunctionPurpose
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

FunctionPurpose
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

FunctionPurpose
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.

FunctionPurpose
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.

FunctionPurpose
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.

FunctionPurpose
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.

FunctionPurpose
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.

FunctionPurpose
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

MethodPurpose
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

MethodPurpose
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".

MethodPurpose
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.

MethodPurpose
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

FunctionPurpose
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 with MachbusConfig::enable_guidance and the MACHBUS_EVENT_KIND_GUIDANCE_STOP_REQUESTED event kind. Guidance and AutoDrive were mutually exclusive authors of PGN 0xAD00 with ~80 % duplicated safety logic; every audit round had to fix both, identically. Port to the machbus_session_autodrive_* family:

    removedreplacement
    guidance_engageautodrive_arm then autodrive_engage
    guidance_disengageautodrive_disengage
    guidance_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_engaged
    guidance_stop_reasonautodrive_stop_reason
    guidance_clear_stopautodrive_clear_stop
    guidance_estimated_curvaturepoll the GuidanceMachineInfo event

    MachbusConfig is unchanged in size (48 bytes) and no surviving field moved, so the layout assertions still hold — but a caller that set enable_guidance will no longer compile.

  • MACHBUS_EVENT_KIND_GUIDANCE_MACHINE_INFO, _LINK_LOST and _LINK_RESTORED are unchanged and still fire — AutoDrive now emits them, so an event-driven C caller reading the steering ECU’s feedback needs no change. _GUIDANCE_STOP_REQUESTED is removed because MACHBUS_EVENT_KIND_AUTODRIVE_SAFE_STOP supersedes 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_stop and machbus_session_guidance_clear_stop can now return false. 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 returned true unconditionally and cleared the last error. A caller that showed the fault as cleared on a true return re-enabled Engage with the latch still set. They now return false and 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.

  • MachbusLanguageData unit fields now carry 0xFF for 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 with 0xFF in the fields it could not interpret. Treat 0xFF as “not specified” rather than as a unit value.

  • machbus_session_autodrive_stop_reason and machbus_session_guidance_stop_reason return MachbusSafeStopTrigger instead of a bare uint32_t. The values are unchanged — the enum mirrors SafeStopTrigger::as_code, with MACHBUS_SAFE_STOP_TRIGGER_NONE = 0 for “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 were TimStatusTimeout and FunctionRequestTimeout, which had no producer); they must never be reused, or every value above them shifts for callers built against an older header.

  • MachbusEventKind gained TcServerClientDisconnected (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); source is 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_seek gained an argument. It was (handle, position: uint32_t, out_tan); it is now (handle, mode: uint8_t, offset: int32_t, out_tan). mode is 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: InvalidHandle is now 5 (was 7), MediaNotPresent 10 (was 12), NotSupported 12 (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.h against src/ffi.rs),
  • exported function compile surface,
  • C POD layout assertions,
  • Rust-side FFI contract tests,
  • C demo workflows.

When changing ABI

  1. Update the Rust FFI code in src/ffi.rs.
  2. Bump MACHBUS_C_ABI_VERSION if the change is caller-visible.
  3. Regenerate and check the header (make bind-c, make bind-c-check).
  4. Update examples.
  5. Run make verify.
  6. 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

AreaPathPurpose
Low-level CAN/J1939src/net/identifiers, address claim, TP/ETP, sessions
CAN transport seamsrc/net/can_transport.rs, src/net/can_adapter.rscrate-owned CanTransport boundary plus hosted adapter isolation
J1939 diagnosticssrc/j1939/DM messages and diagnostic helpers
ISOBUS servicessrc/isobus/VT, TC, FS, SC, AUX, guidance, implement data
NMEAsrc/nmea/NMEA 2000 and NMEA 0183 GNSS/navigation helpers
Hosted session facadesrc/session/sans-IO Session core, Plugins, Driver/Controls, presets, typed events in hosted builds
Embedded session facadesrc/embedded_session.rsno_std + alloc Session, Driver::poll_at, caller-owned transport loop
Fixed-capacity helperssrc/fixed.rsembedded queues, slots, byte buffers, and bounded message helpers
Timesrc/time.rsInstant — the monotonic timestamp injected into the sans-IO core
Lightweight geosrc/geo.rsprotocol-facing WGS/ECEF/local-frame types, with optional hosted concord conversions
VT storage blobssrc/vt_storage.rsstorage-agnostic VT stored-pool encode/decode for embedded-owned persistence
C ABIsrc/ffi.rsexported C surface
Pythonsrc/python/Python extension bindings
Examplesexamples/runnable API demonstrations

How to choose the right layer

NeedStart hereWhy
Decode or encode a single frame/payloadsrc/net/, src/j1939/, src/isobus/, src/nmea/lowest surface with byte-level types
Build an ECU-like applicationsrc/session/plugin-composed, sans-IO core + driver/handle split — see The session facade
Build MCU firmwaresrc/embedded_session.rs, src/net/can_transport.rs, src/fixed.rsboard-owned clock/CAN/storage with no_std + alloc; see no_std on microcontrollers
Build a common tractor/implement/VT/TC rolesrc/session/presets.rscurated plugin groups for a role
Expose to Csrc/ffi.rs and include/machbus.hstable opaque-handle API
Expose to Pythonsrc/python/Pythonic wrappers and event dictionaries
Prove behavior with executable samplesexamples/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>(...):

SubsystemPlugin (session::plugins)
Diagnostics (DM1)Diagnostics
GNSS / NMEA 2000Gnss
Virtual TerminalVtClient, VtServer
Task ControllerTcClient, TcServer
File ServerFsClient, FsServer
Implement messagesImplement
Sequence ControlScMaster, ScClient
TIMTim
PowertrainPowertrain
Heartbeat / Maintain PowerHeartbeat, MaintainPower
Shortcut Button / LanguageShortcutButton, LanguageCommand
Auxiliary / DM memoryAuxiliary, DmMemory
Functionalities / Group fn / Request2 / NAME mgmtControlFunctionalities, 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

TermMeaning in machbusMain code/docs
Control FunctionOne 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
ECUThe 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)
NAMEThe 64-bit identity used for arbitration and partner tracking.src/net/name.rs, Glossary
Source addressThe claimed 8-bit address used as the sender field in CAN identifiers.src/net/identifier.rs, src/net/address_claimer.rs
Working SetA functional group used by Virtual Terminal clients and object pools.src/isobus/vt/, Working sets and object pools
Virtual TerminalThe display/input role and the implement client role around object pools and runtime commands.src/isobus/vt/, Virtual Terminal concepts
Task ControllerThe role that manages DDOP upload, process data, peer control, and TC-GEO helpers.src/isobus/tc/, Task Controller concepts
Tractor ECUTractor facilities, maintain-power, hitch/PTO, speed, lighting, and related messages.src/isobus/implement/, src/session/presets.rs
Implement ECUImplement-side control/status surfaces, including sections, guidance helpers, File Server, VT client, TC client, and diagnostics.src/isobus/, src/session/presets.rs
File ServerThe ISO file-access client/server role.src/isobus/fs/, File Server and large data
Sequence ControlMaster/client workflow for ordered implement actions.src/isobus/sc/, Sequence Control and TIM
TIMAutomation authority and interlock helpers.src/isobus/tim.rs, TIM and automation
NIUNetwork interconnect/routing helper.src/net/niu.rs, Network routing

Boundary rules

These rules keep examples, tests, and docs aligned:

  1. A node must claim an address before it sends normal application traffic.
  2. Address arbitration belongs to the network-management layer, not to VT, TC, FS, SC, or diagnostics code.
  3. Protocol roles stay separate from machine-safety decisions. TIM and shortcut button helpers expose protocol state; applications still own the real safety policy.
  4. A binding is a facade decision, not a new protocol definition. Rust, C, and Python should all point back to the same role behavior.
  5. 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

FeatureWhat it enablesWhat it pulls inWhen to use it
defaultFull hosted stack: std, C ABI, Python bindings, rich geo conversions, and the wirebit host CAN backendwirebit, pyo3, concord, tracing/stdDesktop/Linux development, bindings, simulator workflows
embeddedno_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
wirebitHost CAN backend: virtual bus / simulation adapter and Linux SocketCANwirebit, wirebit/socketcanReal or virtual Linux CAN interfaces and host-adapter examples
asyncRuntime-agnostic async event stream piecesfutures-coreLocal-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::Instant values 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 for core::result::Result<T, Error>. Most signatures in the codebase use the alias, so Result<()> means “fallible, no payload” and Result<Frame> means “fallible, returns a frame”.
  • Error — a struct of code: ErrorCode and message: String. Build it with Error::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). ErrorCode also impl From<ErrorCode> for Error, so code.into() yields a message-less error.
  • Display — an Error prints as just the code description when the message is empty, or code: message when it is set. The code’s own as_str gives 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

CodePlain meaningTypical cause
AddressClaimFailedA node could not secure a source address.Claim arbitration did not resolve in this node’s favour.
AddressConflictA requested source address already belongs to another NAME.Two nodes target the same address; the lower-priority NAME loses.
InvalidAddressA supplied address is out of range or reserved.Passing the null or global address where a real one is required.

Transport sessions

CodePlain meaningTypical cause
TimeoutA timed operation did not complete in its window.No expected response arrived before the deadline elapsed.
TransportTimeoutA multi-frame transfer stalled.A TP/ETP peer stopped sending or acknowledging mid-transfer.
TransportAbortedA multi-frame transfer ended early by abort.A connection-abort condition was raised by either side.
SessionExistsA transport session is already active for that key.A second transfer is started for a PGN/direction/port already in flight.
NoResourcesNo session slot or buffer was available.The session table is full and cannot admit another transfer.

Protocol identity and parse

CodePlain meaningTypical cause
InvalidPgnA PGN value is malformed or not handled here.A frame’s parameter group does not match what the decoder expects.
InvalidDataA payload failed validation.Wrong length, an out-of-range field, or a structurally invalid message body.

Capacity and buffers

CodePlain meaningTypical cause
BufferOverflowData exceeded the space a codec can hold.Encoding more bytes than the target frame or assembly buffer allows.

Object pools

CodePlain meaningTypical cause
PoolErrorA generic object-pool failure.A pool operation failed in a way that is not a specific validation issue.
PoolValidationA pool or descriptor object failed validation.A DDOP or object-pool field is malformed (bad text encoding, bad reference).

State and lifecycle

CodePlain meaningTypical cause
NotConnectedAn operation needs an established link that is not there.Calling a client method before its server connection completed.
InvalidStateAn operation was requested in the wrong state.Driving a state machine through a transition it does not allow yet.

Driver and interface

CodePlain meaningTypical cause
DriverErrorA lower CAN driver or setup step failed.Bus construction or a driver call reported a failure.
SocketErrorA socket-backed transport call failed.A read/write on the underlying socket returned an error.
InterfaceDownThe 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) return Result directly. A decode that sees a wrong length returns Err(Error) with InvalidData rather than panicking; a session that is full returns NoResources. 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 returns Result when the caller can recover immediately (for example Timeout from 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 recoverable Error; check setup at construction instead.

Patterns for handling them

  • Match on the code, not the message. The message is for humans and may change; error.code is the stable contract.
  • Retry vs fail-fast. Timeout, TransportTimeout, and NoResources are often transient — a bounded retry or back-off is reasonable. InvalidPgn, InvalidData, and PoolValidation describe 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_message lets 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 Error becomes 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 ErrorCode meaning carries across. See the Python page.

Neither binding invents new error categories; they re-present the same codes.

Common confusions

  • Ok is not a success return. It is the zero variant of the enum for numeric compatibility. Success in Rust is Ok(value) from Result.
  • Timeout vs TransportTimeout. The first is any timed wait; the second is specifically a multi-frame transport transfer that stalled.
  • TransportTimeout vs TransportAborted. A timeout means silence past the deadline; an abort means an explicit end-of-transfer condition.
  • PoolError vs PoolValidation. Validation means a pool object’s contents failed a check; PoolError is the broader catch-all for other pool failures.
  • InvalidData vs InvalidPgn. InvalidPgn is about the message identity being wrong or unhandled; InvalidData is about the body of a message that was otherwise addressed correctly.
  • NotConnected vs InvalidState. NotConnected means a required link is absent; InvalidState means 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

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.

TargetPurpose
make buildBuild the crate.
make testRun default tests.
make verifyFull local validation gate.
make no-std-checkCheck the transitional embedded no_std + alloc surface.
make no-std-target-checkCheck the embedded surface on NO_STD_TARGET (default thumbv7em-none-eabihf).
make no-std-surface-checkCheck embedded public imports and loop shape through a dedicated no-std surface test.
make embedded-examples-checkCompile embedded-shaped examples without requiring host IO.
make bind-c-checkCheck generated C header.
make c-demoBuild/run C demo.
make c-full-demoBuild/run full C demo.
make python-demoBuild/install/run Python smoke.
make trace-replay-demoRun trace replay examples.
make fuzz-smokeRun arbitrary-input decoder smoke tests.
make wirebit-examples-checkCompile SocketCAN examples without requiring live CAN.
make standard-suite-checkRun the standard-derived ISO 11783, AEF TIM, and NMEA 2000 test suite.
make bookBuild this mdBook.
make whitespace-checkRun 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-tests are locally complete for their documented scope.
  • Rows marked implemented-needs-external-oracle have 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

LevelMeaning in this repository
FixtureA test checks exact bytes, decoded fields, rejected malformed bytes, or a named regression case.
Virtual busTwo or more machbus stacks exchange frames through the in-memory bus.
Binding smokeC or Python calls exercise the same Rust behavior through the public facade.
Trace replayA checked-in candump-style file is parsed and summarized by the replay tooling.
Hardware evidenceA 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.

AreaWhat is coveredMain files
CAN identifiers and PGNs29-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 claimNAME 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 ETPBAM/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 PacketNMEA-style fast-packet transmit/receive, sequence handling, malformed stream handling, and generated receive streams.src/net/fast_packet.rs, tests/protocol_fixtures.rs
DiagnosticsDM1/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 PGNsHeartbeat, 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.

FamilyCurrent shapeEvidence style
Virtual Terminal client/server/render runtimeObject-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/serverDDOP/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 ServerConnect, properties/status, directory, open/read/write/close-style workflows.Virtual-bus tests and C/Python smoke coverage.
Section ControlMaster/client lifecycle, section routing, ready/playback/ack/completion events.Virtual-bus tests and C/Python smoke coverage.
TIMAuthority and command/status logic with interlock-oriented tests.Local session tests; independent-peer captures are still required.
Tractor/implement/GNSSTECU, 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, and make c-full-demo (the examples/c_abi/ demos against the generated include/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.rs parses compact and bracketed candump text;
  • tests/fixtures/traces/manifest.txt records provenance for checked-in traces;
  • make trace-replay-demo replays the current trace fixtures;
  • the hardware-evidence fixtures under tests/fixtures/hardware/ and tests/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:

IDEvidence class
vcan_address_claimvcan development capture
vcan_dm1vcan development capture
physical_address_claimisolated physical-bus capture
peer_request_address_claimindependent-peer capture
tp_bam_transferindependent TP observer capture
etp_connection_transferindependent ETP observer capture
network_interconnect_routermulti-segment router capture
diagnostic_request_responseservice-tool style peer capture
vt_object_pool_uploadindependent VT upload capture
vt_object_pool_upload_failureindependent VT failure-path capture
implement_message_broadcastindependent implement decoder capture
powertrain_engine_traceindependent powertrain decoder capture
tractor_ecu_facility_traceTECU peer capture
tc_ddop_uploadindependent TC capture
file_server_read_writeindependent File Server peer capture
section_control_lifecycleindependent Section Control peer capture
tim_authority_interlockindependent TIM peer capture
nmea2000_gnss_environment_traceindependent 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:

  1. find the protocol family above;
  2. check whether evidence is fixture-only, virtual-bus, binding, trace, or hardware evidence;
  3. open the named source/test files;
  4. 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

StatusMeaning
drawableThe object becomes a scene node and draw command.
interactiveThe object is drawable and also participates in input/focus handling.
soft-keyThe object is resolved into the soft-key area rather than the data-mask node list.
reference-resolvedThe object is consumed as value, style, palette, label, or pointer metadata.
parsed-but-not-renderedThe object model exists, but faithful visual output is still missing or placeholder-only.
missing-object-modelThe object family is in the render inventory but has no ObjectType model yet.
out-of-scopeThe 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:

  • OutputList is 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 selected Key objects 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 selected OutputString/OutputNumber item values. Selected ObjectPointer entries may also target an ExternalObjectPointer; 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.
  • OutputNumber and InputNumber fixed-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 standard 0..=7 range, rejects the reserved non-standard hexadecimal format selector, and observes the standard fixed/exponential, leading-zero, zero-as-blank, and truncate option bits.
  • InputAttributes and ExtendedInputAttributes are parsed as reference metadata and enforced for hosted InputString edits. Change String Value against an InputAttributes object 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.
  • WorkingSetSpecialControls applies 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 on Scene so 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_language gives 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.
  • ColourPalette uses the ISO 11783-6:2018 Table B.73 body shape: reserved Options, 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, and AuxControlDesignator) are deliberately out-of-scope for 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.
  • WorkingSet now 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-only SceneLanguage entries unless WorkingSetSpecialControls supplies one or more language/country pairs, in which case the special-controls list supersedes the Working Set list.
  • ObjectLabelRef is 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, and ExternalObjectPointer now 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 an ObjectPointer chain 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 local ObjectPointer NULL 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 ObjectType has 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

ColumnMeaning
partThe standard area or evidence family.
areaA short repo-owned behavior name.
repo_moduleThe current code, docs, or evidence surface.
statusWhether the area is planned, implemented but still needs standard-suite tests, or later complete.
test_statusWhether existing local tests exist and whether the new standard suite has caught up.
external_trace_statusWhether independent traces or hardware reports exist.
next_actionThe 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

  1. Keep this matrix and the older protocol matrix in sync by hand.
  2. Add standard-derived code tests under tests/standard/.
  3. Promote a row only when implementation, tests, documentation, and evidence all support the claim.
  4. Do not use a green local test as a replacement for external interoperability evidence.
  5. 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:

  • planned means the repo has a roadmap item but not enough checked evidence.
  • implemented-needs-standard-tests means 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-suite in test_status means at least one repo-owned standard-derived test file now covers the part; it is not by itself a conformance claim.
  • Future complete rows 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.txt
  • tests/fixtures/hardware/capture_playbook.txt
  • tests/fixtures/hardware/can_adapter_matrix.txt
  • tests/fixtures/evidence/gap_external_evidence_map.txt
  • tests/fixtures/evidence/public_data_requirements.txt
  • tests/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:

  • vcan is useful development and replay evidence, but it is not physical timing proof.
  • physical adapter rows must be configured at 250000 bit/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:

  • candump shows an extended Address Claimed frame;
  • the monitor prints an Address Claimed summary;
  • the example later emits a DM1 frame for SPN 100 / FMI 1.

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.

IDWhat the capture should prove
vcan_address_claimmachbus emits Address Claimed on a Linux vcan SocketCAN interface.
vcan_dm1machbus emits the DM1 smoke frame after the example raises SPN 100 / FMI 1.
physical_address_claimmachbus emits Address Claimed on an isolated physical 250 kbit/s CAN bus.
peer_request_address_claiman independent peer sends Request Address Claimed and machbus responds.
tp_bam_transferan external observer sees at least one multi-packet TP/BAM transfer.
etp_connection_transferRTS/CTS/DPO/DT/EOMA traffic is captured for an ETP-sized transfer.
network_interconnect_routertranslated NIU/router traffic is captured across two observed bus segments.
diagnostic_request_responsean external peer participates in a diagnostic request/response or clear workflow.
vt_object_pool_uploadobject-pool upload traffic is captured against an independent VT or reference tool.
vt_object_pool_upload_failurea failed VT replacement upload is captured without accepting a stale active pool as the new upload.
implement_message_broadcastselected implement/tractor message PGNs are decoded by an independent observer.
powertrain_engine_traceselected EEC/TSC/transmission PGNs are decoded by an independent observer.
tractor_ecu_facility_tracetractor facility and maintain-power traffic is captured with source-scoped peer observations.
tc_ddop_uploadDDOP upload or activation traffic is captured against an independent TC or reference tool.
file_server_read_writeconnect/open/read/write/close style File Server traffic is captured with an independent peer.
section_control_lifecycleReady/PlayBack/pause/resume/abort style Section Control traffic is captured with an independent peer.
tim_authority_interlockTIM authority grant/revoke and blocked-command behavior is captured with an independent peer.
nmea2000_gnss_environment_traceselected 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:

  1. capture with candump -td -L;
  2. trim the file to the smallest reproducible sequence;
  3. store the reduced trace under tests/fixtures/traces/;
  4. add a tests/fixtures/traces/manifest.txt row with provenance reduced-hardware;
  5. add or update a replay/test target that states what the trace proves;
  6. write a report under tests/fixtures/hardware/capture_reports/;
  7. update tests/fixtures/hardware/capture_requirements.txt from missing to complete and point it at the trace ID and report path;
  8. update book/src/reference/protocol-coverage.md if the capture changes the human evidence story;
  9. 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

  1. Read the current CHANGELOG.md.
  2. Check whether the release is Rust-only, Python-only, C-header relevant, or all three.
  3. Inspect Cargo.toml, pyproject.toml, PROJECT, and generated C header metadata for version drift.
  4. Check book/src/reference/audit/conformance.md before 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:

  1. make check
  2. make test
  3. make check-all
  4. make test-all
  5. make clippy
  6. make rustdoc
  7. make bind-c-check
  8. make c-demo
  9. make c-full-demo
  10. make python-demo
  11. make trace-replay-demo
  12. make fuzz-smoke
  13. make wirebit-examples-check
  14. make standard-suite-check
  15. make 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 .rs files 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.rs run through make fuzz-smoke as an arbitrary-input decoder smoke;
  • cargo check --features wirebit --examples run through make wirebit-examples-check, which keeps SocketCAN examples buildable and does not require a live vcan;
  • tests/standard.rs run through make standard-suite-check, which runs the standard-derived ISO 11783, AEF TIM, and NMEA 2000 tests;
  • git diff --check run through make 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

GateWhy it exists
make whitespace-checkRuns git diff --check so whitespace damage is caught by make, not only by manual review.
make fuzz-smokeRuns tests/fuzz_targets.rs so arbitrary-input decoder coverage is visible in logs.
make wirebit-examples-checkRuns cargo check --features wirebit --examples; this keeps SocketCAN helpers compiling and does not require a live vcan.
make standard-suite-checkRuns 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; ObjectPool now 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 .iop files and third-party VT-client uploads.
  • Made InputAttributes, Macro, and StringVariable self-delimiting per the standard (length/num_bytes fields), so the prefix-free format is unambiguous.
  • net::iop_parser now delegates to the conformant codec (one source of truth); legacy naive per-type lengths removed.
  • Evidence: full cargo test suite 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_length to ISO 11783-6 (Virtual Terminal) data lengths (fixed commands are 8-byte frames; the legacy table had impossible values such as 0xA7 = 9).
  • Evidence: full cargo test green; 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 .gitignore policy;
  • 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_mut returning Option);
  • 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 (was i32).
  • Child positions: signed i16 X/Y (was u16); each child record is the standard 6 bytes [oid:u16][x:i16][y:i16] (was 2 bytes, OID only).
  • Child counts: u8 per the standard (was u16).
  • 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 options byte), InputBoolean (background, width, foreground as a Font Attributes ref, variable_ref, value, enabled — no height/options), InputString (Length is u8; justification precedes length), InputNumber (gained value/justification/standard Options 2; dropped non-standard input_attributes), OutputNumber (gained value/justification), OutputLine/OutputRectangle/OutputEllipse (line_attributes first), Meter/LinearBarGraph/ArchedBarGraph (u16 min/max + value; ArchedBarGraph bar_width not number_of_ticks), PictureGraphic (actual_width/actual_height/transparency/u32 raw-data length), StringVariable (gained Length field).
  • 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 same ObjectPool::deserialize path consumes machbus-produced pools, real .iop files, and pools uploaded by third-party VT clients. net::iop_parser delegates to this codec — one source of truth.
  • Self-delimiting bodies: to support the prefix-free format, InputAttributes gained its standard length:u8 field ([type][len][string]), Macro gained its standard num_bytes:u16 prefix ([num_bytes][commands…]), and StringVariable emits 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 GraphicContext surfaces, PictureGraphic-backed ScaledGraphic, Animation, and the bounded PNG GraphicData subset 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 FileServerProperties lives in src/isobus/fs/types.rs;
  • v2 FS properties use FileServerPropertiesV2 in src/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.

DocumentSubstantive for this audit
ISO 11783-1:2017§6.13 safe mode (defers to part 9), §7 electronic database
ISO 11783-2:2019bit rate and sample point, bus power minima, §9.6 fail-safe
ISO 11783-3:2018transport timeouts, size limits, Table 8 abort reasons
ISO 11783-4:2011Table 2 NIU function codes
ISO 11783-5:2019NAME self-configurable bit and claim behaviour
ISO 11783-6:2018VT 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:20063 pages — §4.2/§4.3 precedence over J1939-71 only
ISO 11783-9:2012tractor classes, facilities handshake, §4.7 safe mode
ISO 11783-10:2015process data commands, TC/client status, DDOP, TimeLog
ISO 11783-11:20113 pages — §4.2 DDI entry shape only
ISO 11783-12:2019DM1/DM2, diagnostic protocol, B.9 functionalities
ISO 11783-13:2022error codes, flags, volume and file operations
ISO 11783-14:2013F.3 SCClientStatus and its timeouts
AEF 023 RIG 2TIM function messages, SLOTs and facility blocks
NMEA 2000 App. A/Bper-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.

AreaDefectClause
AutoDrivenever sent Required Tractor Facilities, so a conforming TECU may never broadcast the Machine Info it refuses to engage without11783-9 §4.4.2
AutoDrivefacility request set reserved bits to 1, asking for every undefined facility11783-7 §5.4, 11783-9 §4.4.2
File Serverrejected volume requests whose reserved bits were set11783-13 B.29/B.30, §4.9
Diagnosticsone unknown functionality code discarded the whole message11783-12 B.9
Task Controllerevery Process Data message sent at priority 611783-10 B.2
Task ControllerTimeLog encoder omitted five declared position columns11783-10 Table 3
PowertrainPython exposed only the strict speed decoder, which rejects real frames11783-8 §4.2
NMEA 2000every PGN transmitted at priority 6NMEA 2000 App. B.1
NMEA 2000a reserved Sequence ID discarded the whole parameter groupNMEA 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”.
  • Dm5Message was 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

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

SurfaceRoleMain filesMain gate
RustCanonical 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
COpaque-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
PythonErgonomic pyo3 facade for examples and scripting.src/python/mod.rs, examples/python_binding/, pyproject.tomlmake 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.

HandleConstructorFree functionLifetime note
MachbusSession*machbus_session_newmachbus_session_freeOne 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 NULL immediately 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:

  1. validate pointers, lengths, enum ranges, and subsystem state before mutating the session;
  2. report a clear error through the surface’s normal error channel;
  3. add a negative test for the bad input;
  4. 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 classCurrent statusWhere to look
Local build/test gatePresentMakefile, book/src/reference/validation-history.md
Standards-text reviewPresent for the areas named in the audit pagebook/src/reference/audit/standards-text-audit.md
Public claim boundaryPresentthis page and book/src/conformity/
Protocol fixturesPresent for selected flowstests/protocol_fixtures.rs, tests/fixtures/, book/src/reference/assets/protocol_matrix.csv
AgIsoStack/reference-style bytesPresent for selected rows onlytests/agisostack_compat.rs, tests/fixtures/oracle/agisostack_manifest.txt
C ABI behaviorPresent for exposed facade callssrc/ffi.rs, include/machbus.h, examples/c_abi/
Python behaviorPresent for exposed facade callssrc/python/mod.rs, examples/python_binding/regression.py
SocketCAN/vcan toolingPresentexamples/socketcan_capture.rs, examples/candump_replay.rs
Physical-bus reportsEvidence contract exists; no completed reports/traces yettests/fixtures/hardware/capture_requirements.txt, tests/fixtures/hardware/capture_reports/
AEF-style external validationNot present in this checkoutno 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:

  1. add Rust behavior and Rust tests;
  2. add the C or Python facade;
  3. add one happy-path binding test;
  4. add at least one guardrail test for disabled subsystems, bad lengths, null pointers, out-of-range values, or precondition failures;
  5. 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:

  1. address claim and PGN Request flows;
  2. diagnostics request/response and clear flows;
  3. TP/BAM and large payload transfer;
  4. VT object-pool upload;
  5. TC DDOP upload and process data;
  6. File Server workflows;
  7. Section Control lifecycle;
  8. 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.md or validation-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

RiskWhy it matteredCurrent response
Wire-format driftISOBUS/J1939/NMEA behavior depends on exact bytes, sentinels, bit fields, padding, and PGNs.Fixture tests, property tests, malformed-input corpora, protocol matrix.
State-machine gapsAddress claim, TP, VT, TC, diagnostics, and TIM all have ordering and timeout behavior.Virtual-bus tests and focused stack tests.
Binding unsafetyC and Python can accidentally expose invalid lifetimes or untested calls.Opaque handles, ABI versioning, binding regression tests, C demos.
Example rotExamples often compile only in the happy local environment.Make targets for C, Python, SocketCAN examples, and trace replay.
Evidence overreachLocal 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 driftVersion 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.md summarizes current protocol evidence;
  • hardware-evidence.md explains how to add trace-backed evidence;
  • validation-history.md records the make gates and latest counts;
  • release.md explains how to tag responsibly;
  • audit/bindings.md captures binding ownership and facade rules;
  • audit/conformance.md captures the public claim boundary.

What remains open

The important unfinished work is still the same:

  1. run real vcan captures and check them in as reduced traces with reports;
  2. run isolated physical-bus captures for the required flows;
  3. add independent-peer evidence for VT, TC, File Server, Section Control, TIM, diagnostics, and transport workflows;
  4. keep expanding fixture coverage for malformed and boundary inputs;
  5. keep C and Python bindings aligned with tested Rust behavior;
  6. 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:

  1. Build and feature flags.
  2. CAN interface.
  3. Address claim.
  4. Transport.
  5. Service-specific workflow.
  6. 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-demo and make 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.