Until now, a PLESTY device module would install, configure and start its server happily with no instrument behind it. The transport logged "nothing found", the server bound its port anyway, and the first visible symptom was a field test that measured nothing twenty minutes later. This month every layer learned to answer one question up front — can the instrument be reached? — and to say why not in a form the operator can act on.

For users
Start an experiment or a device server and get a clear, nested diagnosis — which device, on which machine, and whether it is a wrong address, a missing instrument, or a USB driver that never installed.
For contributors
New device modules are scaffolded with a preflight.py; you fill in one function. New transports can report what they see by overriding one method — or inherit a safe default.

A few terms first

TermMeaning here
Transport / traffic managerThe plesty-lib class that talks to the hardware over one protocol: serial, VISA, APT (the Thorlabs motion-controller protocol) or TCP/IP. It lives in plesty.lib.traffic.
Device serverThe process a device module starts with python -m plesty.<module>. It runs on the machine the instrument is plugged into and serves the device's API over the network.
Rig / composite deviceThe set of devices an experiment drives, built as a CompositeDevice whose sub-devices are clients of those servers — often on other machines.
PreflightA check that runs before any connection is opened. It only enumerates and inspects; it never opens a port.

One question, asked at three layers

A preflight is not three separate checks. The experiment asks its rig, the rig asks each device server, and each server asks its own transport — on the machine where the hardware actually is. Each answer is hung under the one that asked, so a single result comes back up.

CONTROL MACHINE BENCH MACHINE (instrument attached) Experiment connect_rig() may this run start? Rig CompositeDevice.preflight() is each device there and itself? Device server device.preflight() exposed on its API Transport TrafficManager.preflight() on the bus / port / network? asks ▸ one nested Preflight comes back crosses machines as to_dict() — no serial numbers rig: unreachable └ stage: on the USB bus, NO driver bound

That split matters because the transport is the only layer that can see hardware, and it usually runs on a different machine from the experiment. Seen from the control machine, "the stage does not answer" and "the stage's server is up but its controller has no USB driver bound" used to look identical. They need different people doing different things — and now the experiment tells them apart.

Three answers, and why unknown is not a failure

ReachMeansA caller should
readyThe address resolved to something that can be opened.proceed
unreachableNothing matched; reason says in one sentence what to fix.refuse to start
unknownThis transport cannot tell without opening the connection.proceed, and log the reason

Only unreachable stops anything (raise_for_reach() raises on it alone). A transport with nothing to enumerate — plain TCP to a vendor SDK, say — answers unknown, and refusing on that would refuse every such module.

Where the operating system can be asked, the result also carries a driver state: bound, unbound (on the USB bus but no driver installed) or absent (not on the bus at all). Each has a different remedy — reinstall a driver versus check a cable — which the serial layer on its own could never distinguish.

Using it — for operators and experiment authors

Starting a device server

Every device module scaffolded with plesty-sdk 0.5.0.dev1 or later checks before binding its port:

# refuses with the reason if the transport looked and did not find the instrument
python -m plesty.<module>

# serve anyway — for protocol work with no hardware attached
python -m plesty.<module> --skip-preflight

The module's check answers four questions in one report: is the instrument enumerated and a driver bound; what did discovery resolve and what else did it see; which .env file is in effect and does its server port agree with the port the run was given; and which version of the module is installed, from PyPI or from a source checkout.

Starting an experiment

connect_rig(), the standard start-up for an experiment's __main__, runs the rig preflight and on a failure prints the nested diagnosis, then offers [r]etry, [a]bort, or [s]kip for sub-devices declared optional. In your own code:

report = rig.preflight()

report.ready             # True only if the whole tree resolved
report.overall           # worst state wins: unreachable > unknown > ready
report.problems()        # {"stage": "on the USB bus, no driver bound"}
print(report.summary())  # the tree, indented, for the person at the bench

A summary() reads as one page — the failing sub-device, and indented under it what its own transport saw:

unreachable
protocol   : rig
address    : (any)
  stage: unreachable
  protocol   : apt
  address    : /dev/ttyUSB0
  seen       : nothing
  driver     : NOT BOUND — the device is on the USB bus without one
  reason     : …one sentence naming the fix…
Upgrading from plesty-lib 0.4.x: CompositeDevice.preflight() now returns a Preflight object instead of a dict[str, str]. Replace if rig.preflight(): checks with if not rig.preflight().ready, or call .problems() to get the old mapping. A device server on an older library simply contributes no nested part — mixed-version benches keep working.

Building on it — for contributors

A new device module

  1. Scaffold with plesty init device. You get plesty/<module>/preflight.py and tests/test_preflight.py; both __main__ and tests/field_test.py already call it.
  2. Fill in the single author hook, transport(address), beside Device.connect(): build the same traffic manager connect() opens and return it unopened. Return None if the module talks through a vendor SDK with no transport of its own — the instrument is then reported unknown, which never blocks.
def transport(address: str) -> Any | None:
    import plesty.lib.traffic.serial as serial   # import the module, not the class
    return serial.SerialTrafficManager(address)  # returned unopened

Import the transport's module as shown rather than the class by name: the base-class quality gate reads from plesty.lib… import SomeManager as a class this file must derive from. The generated test_preflight.py mocks the transport, so the logic runs in CI with nothing attached and the module stays above the coverage gate. Everything else in preflight.py stays as generated.

Existing modules: plesty init field-test checks whether preflight.py exists. If not, the generated field test gets a preflight() that says what is missing and lets the run go on — copy preflight.py from the device template, fill in transport(), and regenerate to get the real check.

A new transport

Overriding TrafficManager.preflight(address) is optional — the base answers unknown with no code. A transport that can enumerate returns a Preflight with resolved (what it would open, best first), seen (everything present, matching or not), and reason (one sentence naming the fix). The rule is strict: enumerate, never open — opening a port another server holds has hung processes on this platform. The APT transport in plesty/lib/traffic/apt.py is the worked example, including the device-node query for the driver state. The shipped transports report:

TransportReports
APTports present and the APT-capable ones among them, plus driver state
VISAthe backend that answered and the resources it enumerated
Serialthe ports present
TCP/IPwhether the host resolves and the port accepts
Serial numbers stay at the bench. A serial number identifies one instrument in one lab, and hub repositories are world-readable. summary() — read only by the operator — prints them, because knowing which serials are present is most of the diagnosis. to_dict() drops them everywhere, including inside VISA resource strings like USB0::0x1313::0x8078::<serial>::INSTR. It is also the wire format between a server and the rig that asked, so no serial crosses a machine boundary. Anything you write to a file or report goes through to_dict().

Where to find it

Both ship in the 0.5.0 development pre-releases; the final 0.5.0 carries them to every module on the hub.