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.
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
| Term | Meaning here |
|---|---|
| Transport / traffic manager | The 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 server | The 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 device | The set of devices an experiment drives, built as a CompositeDevice whose sub-devices are clients of those servers — often on other machines. |
| Preflight | A 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.
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
| Reach | Means | A caller should |
|---|---|---|
| ready | The address resolved to something that can be opened. | proceed |
| unreachable | Nothing matched; reason says in one sentence what to fix. | refuse to start |
| unknown | This 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…
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
- Scaffold with
plesty init device. You getplesty/<module>/preflight.pyandtests/test_preflight.py; both__main__andtests/field_test.pyalready call it. - Fill in the single author hook,
transport(address), besideDevice.connect(): build the same traffic managerconnect()opens and return it unopened. ReturnNoneif 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:
| Transport | Reports |
|---|---|
| APT | ports present and the APT-capable ones among them, plus driver state |
| VISA | the backend that answered and the resources it enumerated |
| Serial | the ports present |
| TCP/IP | whether the host resolves and the port accepts |
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
plesty-lib≥0.5.0.dev7—plesty.lib.traffic.preflight(Preflight,Reach,Driver), everyTrafficManager.preflight(), and the new documentation page Concepts → Preflight.plesty-sdk≥0.5.0.dev1— the device template'spreflight.py,--skip-preflight, and the field-test integration.
Both ship in the 0.5.0 development pre-releases; the final 0.5.0 carries them to every module on the hub.