plesty.lib.traffic.preflight

What a transport can say about an instrument before it opens it.

Discovery already knows why it failed and throws the answer away: the APT transport composes the ports present and the APT-capable candidates into a log line and returns False, so nothing above the transport can report or act on it. The server binds anyway, and a client holds a resource against an instrument that was never there.

A preflight returns those findings instead of logging them, and it is one type for all three layers: an experiment asks its rig, the rig asks each sub-device, and a sub-device’s server asks its own transport on the machine the instrument is attached to. Each answer hangs under the one that asked for it, in Preflight.parts, so “no answer from the stage” and “the stage’s server is up and its controller has no driver bound” are told apart by the experiment rather than by whoever is standing at the bench.

Every transport answers the same three questions, in Preflight:

  • Did the address resolve to something concrete, and to what?

  • What else is present that it could have resolved to?

  • Where the transport can tell, did the operating system bind a driver at all?

The third is the one that cannot be answered from the serial layer. On the bench that prompted this, the stage sat on the USB bus with a failed driver install: there was no port to enumerate, so every layer above could only report that no candidate existed, which reads as an absent instrument and sends the operator looking for the wrong thing. Driver is where a transport that can ask the device node says so.

Nothing here opens a port. Identification uses descriptor metadata and enumeration only, for the reason discover_instrument() gives: opening ports to find out what they are means opening one another device server holds, and on this platform that has hung processes uninterruptibly.

Serial numbers. A serial number identifies a specific instrument in a specific lab, and a hub repository is world-readable. It is carried in Candidate.serial for the operator, printed by Preflight.summary() where the operator is the only reader, and dropped by Preflight.to_dict(), which is the projection anything written to a file goes through — the rule plesty.lib.test.report_artifact applies to the published field-test report.

Exceptions

PreflightError

The instrument cannot be reached, with the reason in the message.

Classes

Reach

Whether the instrument can be reached, as far as the transport knows.

Driver

What the operating system has done with the device, where it can be asked.

Candidate

One port or resource the transport enumerated.

Preflight

What one transport found when it looked for an instrument.

Functions

usb_driver_state(→ Driver)

Return what the USB bus says about the device matching hardware_id.

Module Contents

class plesty.lib.traffic.preflight.Reach

Bases: enum.StrEnum

Whether the instrument can be reached, as far as the transport knows.

Initialize self. See help(type(self)) for accurate signature.

READY = 'ready'

The address resolved to something this transport can open.

UNREACHABLE = 'unreachable'

Nothing matched, and Preflight.reason says what to do about it.

UNKNOWN = 'unknown'

This transport cannot tell without opening the connection.

Not a failure. A transport with no enumeration to offer answers this, and a caller that refuses to serve on it would refuse every such transport.

class plesty.lib.traffic.preflight.Driver

Bases: enum.StrEnum

What the operating system has done with the device, where it can be asked.

Initialize self. See help(type(self)) for accurate signature.

BOUND = 'bound'

the device has the port or resource it should have.

Type:

A driver is bound

UNBOUND = 'unbound'

The device is on the bus and the OS bound no driver to it.

The one state the serial layer cannot see, and the one whose remedy is a driver install rather than a cable.

ABSENT = 'absent'

unplugged, powered off, or on another host.

Type:

No such device is on the bus

UNKNOWN = 'unknown'

The question could not be asked here, so nothing is claimed either way.

class plesty.lib.traffic.preflight.Candidate

One port or resource the transport enumerated.

Variables:
  • name – What the transport would open — "COM9", "/dev/ttyUSB0", "USB0::0x1313::0x8078::P0024017::INSTR".

  • description – What the OS or the backend calls it, when it says.

  • serial – The instrument serial number, where the descriptor carries one. Operator-facing only; Preflight.to_dict() drops it.

  • capable – Whether this could be the protocol’s instrument at all. An APT controller is behind an FTDI bridge, and so is an unrelated USB adapter, so a capable candidate is a shortlist entry and not a match.

name: str
description: str = ''
serial: str = ''
capable: bool = False
to_dict() → dict[str, Any]

Return the publishable mapping: everything but the serial number.

The serial is removed from name and description as well as dropped as a field. A VISA resource string carries the unit’s serial as one of its components — USB0::0x1313::0x8078::P0024017::INSTR — so a projection that only dropped the field would publish it anyway.

Return type:

dict[str, Any]

exception plesty.lib.traffic.preflight.PreflightError

Bases: RuntimeError

The instrument cannot be reached, with the reason in the message.

Initialize self. See help(type(self)) for accurate signature.

class plesty.lib.traffic.preflight.Preflight

What one transport found when it looked for an instrument.

Variables:
  • protocol – The transport that produced this — "apt", "visa", "serial", "tcp".

  • address – The address that was asked for; empty means “any instrument this transport can drive”.

  • reach – Whether the address resolved. See Reach.

  • driver – What the OS did with the device, where it could be asked.

  • resolved – The concrete addresses to open, best match first. Empty unless reach is Reach.READY.

  • seen – Everything the transport enumerated, matching or not. This is the half of a failure that says what to try instead.

  • backend – The library doing the enumerating, where there is a choice of one — the VISA backend, for instance. Empty where there is not.

  • reason – One sentence saying what is wrong and what to do about it. Empty when reach is Reach.READY. Carries no serial number, so it is safe wherever to_dict() is.

  • stalled – Whether the probe itself never finished. A bus with a wedged device on it stalls enumeration, and the instrument asked for may be perfectly healthy behind it, so this failure means “power-cycle the bus” where every other one means “the address is wrong”.

  • name – What this result is of, when it is one of several: the sub-device name in a rig, empty for a result standing alone.

  • parts – The preflights this one is composed of. A rig’s parts are its sub-devices; a sub-device’s part is what its own transport reported from the machine it runs on. This is what makes the three layers one answer instead of three: an experiment asking its rig gets back the bus the stage is missing from, not merely “no answer”.

protocol: str
address: str
reach: Reach
driver: Driver
resolved: tuple[str, Ellipsis] = ()
seen: tuple[Candidate, Ellipsis] = ()
backend: str = ''
reason: str = ''
stalled: bool = False
name: str = ''
parts: tuple[Preflight, Ellipsis] = ()
property overall: Reach

The reach of this result and everything under it, worst first.

One unreachable part makes the whole unreachable: a rig missing a stage is not a rig. An unknown part leaves the whole unknown, because a layer that could not look has not established that anything is there — and saying “ready” on its behalf is exactly the claim that let a server bind against an instrument that was never there.

Return type:

Reach

property ready: bool

Whether this and every part of it resolved.

Return type:

bool

property matched: str | None

The address that will be opened, or None when nothing resolved.

Return type:

str | None

problems() → dict[str, str]

Return the named parts that are unreachable, and why.

The shape the device layer answered in before it spoke this one — sub-device name to a one-line problem — kept because it is what an operator prompt and a log line actually want, and because a caller that only needs “what is broken” should not have to walk a tree.

Return type:

dict[str, str]

summary() → str

Return the operator-facing lines, most useful first.

This is the one place a serial number is printed: the reader is the person at the bench, and telling them which serials are present is most of the diagnosis when the one they asked for is not.

Parts are indented under the result they belong to, so a rig prints as one page: the sub-device that failed, and under it what its own transport saw on the machine it runs on.

Return type:

str

to_dict() → dict[str, Any]

Return the publishable mapping of this result.

Every serial number is dropped, here and in each candidate: this is the projection for anything written to a file in a repository that is world-readable. Port names, resource strings and host names remain — the module’s own configuration already carries them — so a caller publishing an artifact still passes it through that artifact’s own projection, as plesty.lib.test.report_artifact does.

Return type:

dict[str, Any]

classmethod from_dict(document: dict[str, Any]) → Preflight

Rebuild a result from to_dict().

This is the wire format: a device server answers its own transport preflight as a mapping, and the composite that asked rebuilds it and hangs it under the sub-device it belongs to. What comes back has been through to_dict(), so it carries no serial numbers and the candidates it rebuilds have none either.

Parameters:

document (dict[str, Any]) – A mapping as to_dict() produces. Missing keys take their defaults, so a server running an older version — which answers fewer of them — still produces a usable result.

Return type:

Preflight

raise_for_reach() → Preflight

Return self, raising when this or any part of it is unreachable.

Reach.UNKNOWN does not raise. A layer that could not look has not found a problem; it has found nothing, and refusing on that would refuse every transport that cannot enumerate.

Raises:

PreflightError – overall is Reach.UNREACHABLE; the message is summary(), parts included.

Return type:

Preflight

static _scrub(text: str, serials: list[str]) → str

Return text with every serial this report saw taken out of it.

A resolved VISA address is a resource string with the unit’s serial in it, and the address asked for may be the same string. Both are removed by the serials the enumeration reported, which is the only way to know what in an opaque address string is the identifying part.

Parameters:
  • text (str) – The address or resolved name to clean.

  • serials (list[str]) – Every serial number seen carries.

Return type:

str

_describe() → str

Return the candidates as one line, serial numbers included.

Return type:

str

plesty.lib.traffic.preflight.usb_driver_state(hardware_id: str) → Driver

Return what the USB bus says about the device matching hardware_id.

The serial layer cannot distinguish the three states that need three different fixes, because all of them look like “no port”: a device with no driver bound, a device whose driver is bound but exposes no port, and a device that is not plugged in at all. The device node knows which, so it is asked directly — on Windows, which is where the question has an answer that can be had without root.

Parameters:

hardware_id (str) – A PnP hardware id prefix, such as "USB\\VID_0403".

Returns:

Driver.BOUND when the device is present and healthy, Driver.UNBOUND when it is present in a fault state, Driver.ABSENT when no such device is on the bus, and Driver.UNKNOWN where the platform cannot be asked — on which the caller claims nothing about the driver either way.

Return type:

Driver