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

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.