8. Your first Task Controller client
Anchor example:
examples/tc_client_demo.rs— run it any time withcargo run --example tc_client_demo.
In chapter 7 we gave the implement a screen to talk to the operator. Now we give it the quieter partner that needs no screen at all: a Task Controller. Where the Virtual Terminal is about showing things to a person, the Task Controller is about logging and controlling the actual field work — how much product to apply, which sections are on, and what was really done. A documentation TC mostly listens (it records measured values for the field log); a control TC also talks back (it sends setpoints down).
Here is the key idea, and it mirrors the VT one: before the implement can trade any work data, it must describe itself first. With the VT it uploaded an object pool of screens. With the TC it uploads a device description — a small tree called the DDOP (Device Descriptor Object Pool) that says “this is what I am and these are the values I can talk about.” Only after the TC has that description, and has activated it, do the two sides start exchanging process data.
By the end of this chapter you will have driven a TC client from Disconnected
all the way to Connected, watching its connect state machine advance one step
at a time as you feed it the frames a real Task Controller would send.
The deep reference for behavior is the Task Controller client tutorial and Task Controller concepts. This chapter is hands-on: add a bit, run it, see what happens.
What we are building
The example stands in for the whole bus in software. It plays the implement (the
TC client) and fakes the Task Controller at address 0x33 by feeding the
client the frames a real TC would send. That lets us watch every step of the
connect handshake without any hardware. The program will:
- build a tiny DDOP and a client,
connect(), then hear the TC announce itself,- announce a working set and negotiate the TC’s version,
- upload the DDOP and activate it, landing on
Connected.
Step 1 — build a DDOP and a client
A TC client always starts from a DDOP: the description of the machine the TC
will log and control. Ours is the smallest pool that means anything — one
device object (the implement as a whole, with a designator and software
version) that owns one device element (the Root of the device tree):
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:22:34}}
}
TaskControllerClient::new takes a TCClientConfig (the default is fine — a
six-second per-step timeout, a version-4 client). set_ddop hands it the device
description. connect() does not block and does not put anything on the
wire yet; it validates the DDOP, arms the state machine, and moves the client to
“listening for a TC”. Right after it returns, client.state() reports it is
waiting for the server’s status.
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:36:39}}
}
Why so small? A real DDOP describes every boom, section, bin, and reportable quantity, each with a DDI and units. That is a topic of its own — see the DDOP tutorial. Here we keep it to one device and one element so the handshake is the only thing on screen.
Step 2 — the TC announces itself
A real Task Controller periodically broadcasts a process-data status frame.
The client is waiting for exactly that: the first valid status binds the session
to that TC’s address and advances the FSM. We fake the broadcast (TC at 0x33)
and feed it in with handle_tc_message:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:41:51}}
}
This is the same tick-and-pump rhythm from
chapter 2, specialized for the TC: inbound frames
drive transitions (handle_tc_message); update performs the send-side steps
and hands you back the frames to ship. After this status, tc_address() is bound
to 0x33 — every reply from here on must come from that source, and frames from
any other address are refused.
Step 3 — announce the working set, then ask the TC’s version
With a TC in sight, the client announces which working set owns the upcoming pool
and then asks the TC what protocol version it speaks. Each update performs one
send-side step:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:53:60}}
}
The first update ships the Working Set Master announcement (member count
one). The second ships the version request. Version negotiation comes before
anything substantial on purpose: a connection only works at the level both ends
support, so “what version is this TC” is the first fact the client establishes.
Step 4 — the version reply
The TC answers with its protocol version and its technical capabilities (how many booms and sections it can handle). We feed that reply back and read the recorded version:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:63:72}}
}
tc_version() now reports what the TC told us. A malformed version reply is
ignored and the client keeps waiting until the timeout, rather than proceeding on
bad data.
Step 5 — upload the DDOP, then activate it
Now the description goes up. update serializes the DDOP and emits it as an
object-pool transfer (on a real bus, large pools are fragmented across many CAN
frames by the transport-protocol layer):
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:74:80}}
}
The TC replies with a verdict on the pool — accepted or rejected — and the client then sends the activate command and waits for its acknowledgement. A zero status means success:
#![allow(unused)]
fn main() {
{{#include ../../../examples/tc_client_demo.rs:82:95}}
}
When the activation reply comes back clean, the client reaches
TCState::Connected. The example asserts exactly that. Only now — pool uploaded
and activated — is it meaningful to emit process data.
Step 6 — the connect handshake, in one picture
What you just pumped through is a fixed sequence of states. It is worth seeing the whole path at once:
Disconnected
│ connect() (validates the DDOP)
▼
WaitForServerStatus ◄── TC status frame binds the server address
│ update()
▼
SendWorkingSetMaster ── emits Working Set Master
│ update()
▼
RequestVersion ── emits the version request
│
▼
WaitForVersion ◄── version + capabilities reply
│ update()
▼
TransferDDOP ── emits the device-description upload
│
▼
WaitForPoolResponse ◄── pool accepted
│ update()
▼
ActivatePool ── emits the activate command
│
▼
WaitForActivation ◄── activation reply, no error
│
▼
Connected ── process data now allowed
Each “waiting” state is bounded by the config timeout: if the TC goes silent, the
client falls back to Disconnected instead of hanging, and a later status frame
can start a fresh attempt. There is also a branch the demo skips for brevity — the
label check — described next.
Label-based caching: the upload you can skip
A real client does not always re-upload its DDOP. Between announcing its version and transferring the pool, it can ask the TC two questions: do you already hold my structure? and do you already hold my localization (language/units)? The device object carries a structure label and a localization label for exactly this. If the TC answers that both already match, the client skips the whole transfer and jumps straight to activation; if they do not match, it deletes the stale pool first and then uploads. Set stable labels on your device object so a TC that has met your implement before does not re-download the pool every session. The full label-driven decision is in the client tutorial.
Process data: the live conversation
Once Connected, the handshake is over and the running exchange begins. Every
process-data message names an element (which part of your device, from the
DDOP) and a DDI (which quantity, from the shared data dictionary), plus a
32-bit value where one applies. It flows both ways:
- Up — measured values. The implement reports what it is actually doing: real rates, flows, counts, section states. A documentation TC logs these. You build an ECU→TC value frame and ship it; the client also calls your value-request handler when the TC asks for a current reading.
- Down — setpoints and commands. A control TC sends what it wants done: target rates, section on/off. The client calls your value-command handler with the element, DDI, and value. A downward command is a request — your application validates it against the real machine state and decides whether acting on it is safe. The implement always owns its own behaviour.
A DDI is only meaningful with its owning element: the same “actual rate” DDI can appear once per section, so “DDI X changed” is ambiguous until you know which element reported it. The DDOP is the map that removes that ambiguity. The message-level detail is in DDOP and process data.
Step 7 — run it
cargo run --example tc_client_demo
You should see the handshake march through its steps, each line printed by the example as it advances:
=== TC Client Demo ===
[1] connect → WaitForServerStatus
[2] TC_STATUS → SendWorkingSetMaster, tc_addr=0x33
[3] sent WS Master + VERSION_REQUEST → WaitForVersion
[4] TC version=4, → TransferDDOP
[5] DDOP frame size=8 bytes → WaitForPoolResponse
[6] activated → Connected ✓
Read it top to bottom and you can see the whole lifecycle: connect arms the
machine, the TC status binds the server at 0x33, the working-set announcement
and version request go out, the version reply advances us, the DDOP uploads, and
the activation reply flips us to Connected. Only after that last line would real
process data start to flow.
Things that trip people up
- Process data before
Connected. Reporting values against a pool the TC has not activated is meaningless, and the client’s state guards exist to stop it. Watchstate()(or the state-change event) and gate your own logic onConnected— do not emit work data into the void. - Skipping the version handshake. Both ends cap themselves to the version and capabilities the other actually supports. Establish “what version is this TC” and “how many sections does it support” first, not as an afterthought.
- A DDOP the TC rejects. A non-zero pool response means the upload was
refused; the client returns to
Disconnectedrather than pretending the pool is live. Re-check the DDOP for duplicate object IDs or dangling references before retrying. - A value out of range. Element numbers are carried in a 12-bit wire field, so a number that does not fit is rejected when you build the payload — a coding error surfaces as an error, not a corrupted frame on the bus.
- A reply from the wrong TC. Once a TC is bound, frames from any other source address are refused. Make sure the TC you target is the one whose status you first heard.
Validate locally
cargo run --example tc_client_demo
make test
make test exercises the client’s label-driven upload/skip/delete decision, the
timeout-to-Disconnected path, the re-upload sequence, and the value-request and
setpoint callbacks — well beyond the happy path the example walks.
What this proves / does not prove
Proves: machbus can drive a TC client through its full connect handshake —
discover the TC, announce a working set, negotiate the version, upload the device
description, and activate it — landing on Connected, all from a few lines of
Rust against a simulated Task Controller.
Does not prove: interoperability with a specific third-party Task Controller, real-hardware timing or bandwidth behaviour, or any conformance/certification claim. machbus is not certified; real deployment still needs official standards, real hardware, and interoperability evidence.
Next
→ 9. Tractor and implement personas — step back from a single service and wire the whole machine together: a tractor ECU and an implement ECU talking across the bus.
See also
- Task Controller client — the connect FSM, the label-based upload decision, and the process-data callbacks in depth.
- DDOP — how to build and validate the device description you upload (more than our one-element demo).
- DDOP and process data — the message-level view of the device description and the live values.