Reports › Contract docs › expected-setup

IO diagnostics — the expected setup (a rig manifest)

2026-10-23. Code: io_diagnostics/expected.py (+ host.py, server.py, cli.py, the Expected setup card in web/app.js). Tests: tests/test_host_setup.py, tests/test_utilities.js ("expected setup"). API: docs/api-v1.md "Expected setup" and "Editing the expected setup".

Zach, 2026-10-23, after fdcanusb XXXXXXXX enumerated on USB but never put a frame on the wire: "it detects the canusb but something is wrong with it so you don't see any CAN things connected … in diagnostics we need to be able to like easily configure expected number of things". The expected setup is that: a small file that says what this PC should see, so the page can say "6/7 boards" and name the missing one, instead of only showing what happens to answer.

Where it lives

~/.config/io-diagnostics/expected.toml ($IO_DIAGNOSTICS_CONFIG_DIR overrides the directory, else $XDG_CONFIG_HOME/io-diagnostics). WHY outside the checkout: it is your data, like seen.json in ~/.local/state/io-diagnostics/. A live host reads it and re-reads it within one refresh (2 s) of a hand edit. A fixture or a fake machine reads one only when it is named: serve --fixture F1 --expected FILE.

A file that does not read (bad TOML, a device with no id) is named in the page's sources line ("expected setup skipped") and the panel refuses to rewrite it until it reads again: it never overwrites what you wrote.

The format

# IO diagnostics · expected setup: the rigs this PC should see, and the devices retired from service.
version = 1

# One [[rig]] per rig; its row is "1-DOF test rack · 2/2 adapters · 6/7 boards · 2/2 cameras".
[[rig]]
name = "1-DOF test rack"

# A device is matched by its hardware_id (the OS row's, usb:<vid>:<pid>:<serial>), else by a serial its row
# reports: the USB serial, or a driver's own (a RealSense's SDK serial). kind is the word the rig row counts it as.
[[rig.device]]
name = "fdcanusb XXXXXXXX"
kind = "adapter"
hardware_id = "usb:0483:5740:XXXXXXXX"
model_id = "mjbots-fdcanusb"
# The nodes the driver should find under it, relative to the device as the driver writes them. Each is a path, or
# a table with the path and, optionally, the model it should report (robotparts id) and its own repeatable id.
# CAN 15's moteus-c1 is being replaced (2026-10-23): until it answers, the row says "CAN 15 on fdcanusb XXXXXXXX".
nodes = [
  { path = "can/1", model_id = "mjbots-moteus-c1" },
  { path = "can/14", model_id = "mjbots-moteus-c1" },
  { path = "can/15", model_id = "mjbots-moteus-c1" },
]

# note: what the board drives (camera-confirmed 2026-10-23 by wiggling each board and tracking its ArUco tags);
# rotor_to_output_ratio: what its config should say, so a wrong one reads "config ratio 0.1667, expected 0.1 (10:1
# gearbox)" on that board. CAN 21 was CAN 1 until 2026-10-23 (renamed at Zach's request).
[[rig.device]]
name = "fdcanusb XXXXXXXX"
kind = "adapter"
hardware_id = "usb:0483:5740:XXXXXXXX"
model_id = "mjbots-fdcanusb"
nodes = [
  { path = "can/21", model_id = "mjbots-moteus-r4-11", note = "tag 5 · direct", rotor_to_output_ratio = 1.0 },
  { path = "can/11", model_id = "mjbots-moteus-r4-11", note = "link tags 8-12 · 10:1", rotor_to_output_ratio = 0.1 },
  { path = "can/12", model_id = "mjbots-moteus-r4-11", note = "tag 7 · 10:1", rotor_to_output_ratio = 0.1 },
  { path = "can/13", model_id = "mjbots-moteus-r4-11", note = "tag 6 · 10:1", rotor_to_output_ratio = 0.1 },
]

# The D405s go by their RealSense serial; the USB id (its USB serial differs) lets the host say what the kernel saw
# when one is missing, and match it before its driver has run.
[[rig.device]]
name = "RealSense D405 XXXXXXXXXXXX"
kind = "camera"
hardware_id = "usb:8086:0b5b:XXXXXXXXXXXX"
serial = "XXXXXXXXXXXX"
model_id = "realsense-d405"
nodes = []

[[rig.device]]
name = "RealSense D405 XXXXXXXXXXXX"
kind = "camera"
hardware_id = "usb:8086:0b5b:XXXXXXXXXXXX"
serial = "XXXXXXXXXXXX"
model_id = "realsense-d405"
nodes = []

# A retired device is expected nowhere; plugged in again, its row says "retired <date>: <reason>" (a warning).
[[retired]]
name = "fdcanusb XXXXXXXX"
hardware_id = "usb:0483:5740:XXXXXXXX"
date = "2026-10-23"
reason = "frames never reached the bus (2026-10-22); replaced by XXXXXXXX; suspected damage from the mains-ground event (Zach, 2026-10-23)"
Key Where Meaning
version top 1
name [[rig]] the rig's name, shown on its row (required)
name [[rig.device]] what the rig row calls it when it is missing
hardware_id device, retired the OS row's repeatable id; USB ids compare vid / pid in any case
serial device, retired a serial its row reports (the USB serial, or the driver's identity.serial_number)
kind device the word counted on the rig row: adapter, camera, ... (default: the row's icon type)
model_id device, node the robotparts id it should report; a node that reports another is a note, never a problem
nodes device relative node paths (strings or {path, model_id?, hardware_id?, note?, rotor_to_output_ratio?} tables)
note node your label for it ("tag 5 · direct"), shown in its Expected setup card; never judged
rotor_to_output_ratio node what its config should read (moteus motor_position.rotor_to_output_ratio); a fresh reading that differs is one warn on that node, "config ratio 0.1667, expected 0.1 (10:1 gearbox)"
date, reason retired shown on its row when it is plugged in again

At least one of hardware_id / serial per device. A device known only by a driver's serial is matched from its driver's first report on (so its plugin gets IO_DIAGNOSTICS_EXPECTED from its second run); when it is missing, its row under "Expected, not connected" says "no connected device reports serial …" instead of the kernel's evidence.

What the page shows

What each driver is told

On every diagnose and act, a device the file lists gets

IO_DIAGNOSTICS_EXPECTED={"nodes": ["can/1", "can/14", "can/15"], "rig": "1-DOF test rack"}

and any other device gets IO_DIAGNOSTICS_EXPECTED= (empty: no expected setup, the driver uses its own source). moteus_servo prefers it over its rack inventory: those ids are the boards it asks for by name and names when they do not answer ("expected by 1-DOF test rack"); without it, the inventory's last-known ids stay the fallback.

Editing it from the page

The selected row's Expected setup card (host-side file writes only; nothing touches a device, so a plain confirm and no token):

Row Buttons
a device (adapter, camera, ...) Expect what's here: add it (or update it) with the nodes answering below it now; takes a retired one back into service · Retire…: with a reason
a rig row Expect what's here: every present device's nodes as they answer now (the confirm lists what changes)
a row under Expected, not connected Forget: out of the file and out of seen.json, and kept out of a driver's own list until it is seen again · Retire…

The same from a terminal (a person or the orchestrator; --path needs the host to see the row, so expect --path runs every plugin once):

uv run --project ~/io-diagnostics io-diagnostics retire --hardware-id usb:0483:5740:XXXXXXXX --reason "frames never reached the bus"
uv run --project ~/io-diagnostics io-diagnostics expect --hardware-id usb:0483:5740:XXXXXXXX --name "fdcanusb XXXXXXXX" --kind adapter --nodes can/1,can/11,can/12,can/13 --rig "1-DOF test rack"

The panel and the CLI rewrite the file whole (atomically: a temporary file renamed over it), so comments other than the header are not kept.

Dead-adapter diagnosis (the generalisation of 2026-10-22)