Instruments & drivers#

A Node is surrounded by real equipment (rotators, time taggers, polarimeters), and something has to turn “rotate this waveplate to 22.5°” into the actual bytes that make the motor move. That’s the job of this part of pqn-hardware: it gives every piece of hardware a uniform shape in software, so the rest of the system can drive it without knowing the messy details of any particular device.

Two ideas sit at the centre of that, and it helps to keep them apart from the start.

The first is the Instrument, the software abstraction for a piece of hardware. It’s a small contract that says what any instrument looks like: it has a name, it can be started and closed, and it exposes some values you can read or write and some actions you can call. The rest of the system only ever talks to this abstraction.

The second is the Driver, a concrete implementation of that contract for one specific piece of hardware, like a Thorlabs rotator or a Swabian time tagger. The same kind of instrument can have several Drivers: a rotator is still a rotator whether it speaks the Thorlabs APT protocol, a plain serial protocol, or the ELLx protocol, so all three are Drivers behind the one rotator Instrument.

So you write Drivers, and you program against Instruments. And when a piece of hardware lives on another machine, you still program against an Instrument, a ProxyInstrument that quietly forwards your calls across the Node. The rest of this page works through each of those in turn: the Instrument model first, then writing a Driver, then using one from afar through a ProxyInstrument.

The Instrument model#

Every instrument in pqn-hardware, real or simulated, is built on a single base type called Instrument, defined in src/pqn_hardware/instrument.py. It’s written as a typing.Protocol (Python’s way of describing a shape that other classes are expected to match), but it’s also a dataclass, so a Driver doesn’t just match it from a distance: it subclasses Instrument directly and fills in the pieces. In other words, Instrument does double duty. It’s the contract that describes what every instrument exposes to the rest of the system, and it’s the base class you start from when you write a new one.

The contract itself is small, so it’s worth building up a piece at a time.

At its core, an Instrument is an identity plus two groups of capabilities:

@runtime_checkable
@dataclass(slots=True)
class Instrument(Protocol):
    name: str
    desc: str
    hw_address: str
    parameters: set[str] = field(default_factory=set)
    operations: dict[str, Callable[..., Any]] = field(default_factory=dict)

Source: instrument.py:26-42

The identity fields are straightforward: a name the instrument is addressed by, a human-readable desc, and an hw_address (a serial port, a USB id, whatever the hardware is reached through).

These two groups are the heart of the model:

  • parameters is a set of attribute names: values you read and write. A rotator’s degrees, a dummy’s param_int: state you query or set.

  • operations is a dict mapping names to callables: actions you invoke. A rotator’s move_to, a time tagger’s count_singles: things the instrument does.

This split, values you get/set versus actions you call, is what makes an instrument addressable from a distance. When code drives a remote instrument, the proxy uses these two groups to decide whether an attribute access should read or write a value or call a method (see Using a ProxyInstrument below, and the wire format on Network transport: Router, Instrument Provider, Client).

Because both groups are keyed by name, there’s one rule on names:

Note

An instrument’s name may not contain a : character. The : separates the parts of a request when talking to a remote instrument, so a colon in the name would be ambiguous (instrument.py:33).

The @runtime_checkable decorator means an ordinary isinstance(x, Instrument) check works at runtime, so code can confirm something really is an instrument before driving it.

Anatomy of a Driver#

Lifecycle#

An Instrument has a start/stop lifecycle:

    def start(self) -> None: ...
    def close(self) -> None: ...

Source: instrument.py:47-48

These two look trivial, but they carry the whole relationship with the physical device, and it’s worth keeping them cleanly separated when you write a Driver.

The rule: constructing the Instrument object should do nothing to the hardware; the device should only be touched once start() is called. Building the object should only record where the device lives (its name, desc, and hw_address, plus any driver-specific settings) and set sensible defaults. It should not open a port, contact a controller, or move anything. Save all of that for start(): opening the serial or USB connection, handshaking with the controller, homing a motor to a known reference angle.

The reason is in how Instruments come to life. An Instrument Provider builds each one straight from its config (class_(name=..., desc=..., hw_address=..., **settings), purely from the strings in a TOML file) and only then calls start() (instrument_provider.py:113). At construction time it has no idea whether the device is plugged in, powered, or even reachable. If your constructor tried to open the port, a missing or busy device would make the object impossible to create, and you would lose the clean line between an instrument that is configured and one that is connected. Deferring every side effect to start() keeps construction cheap and predictable, so an instrument can be created freely and brought online deliberately.

close() is the other half of that bargain. Most of these devices hold an exclusive resource (a serial port, a USB handle) that nothing else can open while it’s in use. If a process walks away without calling close(), the port can stay locked until the hardware is power-cycled, and the device may be left in an undefined state. So a Driver does its real connect-and-initialise in start() and is responsible for releasing everything cleanly in close().

Reporting state: info#

An Instrument should be able to report its complete current state on demand, and that’s what info is for. It hands back a single read-only snapshot that captures everything worth knowing about the instrument, its identity and every piece of live state, so a caller can inspect it without having to read parameters one by one:

    @property
    def info(self) -> InstrumentInfo: ...

Source: instrument.py:50-51

That snapshot is an InstrumentInfo. The base class only holds the fields every instrument has: its identity, plus a free-form hw_status for anything else worth reporting:

@dataclass(frozen=True, slots=True)
class InstrumentInfo:
    name: str = ""
    desc: str = ""
    hw_address: str = ""
    hw_status: dict[str, Any] = field(default_factory=dict)

Source: instrument.py:18-23

You won’t usually return a bare InstrumentInfo, though. When you write a Driver, you define your own info class that extends InstrumentInfo with the state specific to your device, and return that from info. The dummy driver’s DummyInfo adds its three parameter values; a rotator’s RotatorInfo adds the current angle and offset. A caller then gets back the full, typed state of whatever instrument it happens to be holding.

Instrument categories#

The base Instrument describes any instrument at all. On top of it, the library defines a more specific Instrument for each broad category of hardware:

  • TimeTaggerInstrument

  • RotatorInstrument

  • PolarimeterInstrument

Each of these builds on Instrument (it still is an Instrument) and pins down a standard interface for its category: the exact parameters and operations every rotator, time tagger, or polarimeter is expected to expose. That shared interface is the whole point. Because every rotator presents the same degrees, move_to, and move_by, the rest of the system can be written against “a rotator” without caring whether the physical device is a Thorlabs APT unit, a serial unit, or an ELLx unit. What each category adds, and how you build a Driver on top of one, is covered in Writing a Driver below.

Writing a Driver#

Writing a Driver means giving real behaviour to the lifecycle, parameters, operations, and info from the previous section, for one specific piece of hardware. The clearest way to see how is to build one up from scratch, and the repository already ships the perfect specimen for that: DummyInstrument, a fake instrument with no hardware behind it, used in tests and as a worked example. We’ll assemble it piece by piece, then look at how a real Driver differs.

Assembling a Driver, step by step#

A Driver is a dataclass that subclasses Instrument. Start with the class itself and the state it keeps:

@dataclass(slots=True)
class DummyInstrument(Instrument):
    _param_int: int = 2
    _param_str: str = "hello"
    _param_bool: bool = True
    connected: bool = False

Source: dummies.py:17-22

The fields prefixed with _ are the dummy’s private state, the values a real Driver would read off the hardware. (name, desc, and hw_address come from Instrument itself, so they don’t need repeating here.)

Next, declare what the instrument can do. Recall that an Instrument advertises its capabilities in two places, parameters and operations; a Driver fills those in inside __post_init__:

    def __post_init__(self) -> None:
        self.parameters = {"param_int", "param_str", "param_bool"}
        self.operations = {
            "double_int": self.double_int,
            "lowercase_str": self.lowercase_str,
            "uppercase_str": self.uppercase_str,
            "toggle_bool": self.toggle_bool,
            "set_half_input_int": self.set_half_input_int,
        }

Source: dummies.py:24-32

parameters is just the set of attribute names a caller may read and write; operations maps each callable name to the bound method that implements it. Everything a caller is allowed to do remotely has to be registered in one of these two.

Now the lifecycle. The dummy has nothing to connect to, so start and close only flip a flag, but this is exactly where a real Driver would open its serial port and release it again:

    def start(self) -> None:
        self.connected = True

    def close(self) -> None:
        self.connected = False

Source: dummies.py:45-49

A parameter is implemented as an ordinary property pair: a getter and a setter over the private field:

    @property
    @log_parameter
    def param_int(self) -> int:
        return self._param_int

    @param_int.setter
    @log_parameter
    def param_int(self, value: int) -> None:
        self._param_int = value

Source: dummies.py:51-59

The @log_parameter and @log_operation decorators that appear here and below are optional helpers from instrument.py: wrap a parameter accessor or an operation and every read, write, and call is logged with timing. They aren’t required for a working Driver, but they’re cheap observability, so the real drivers use them throughout.

An operation is just a method, registered in operations above:

    @log_operation
    def double_int(self) -> int:
        self._param_int *= 2
        return self._param_int

Source: dummies.py:81-84

Finally, info. As described earlier, a Driver returns its own info class carrying its complete state. For the dummy that’s DummyInfo, which extends InstrumentInfo with the three parameter values:

@dataclass(frozen=True, slots=True)
class DummyInfo(InstrumentInfo):
    param_int: int = 0
    param_str: str = ""
    param_bool: bool = False

Source: dummies.py:10-14

    @property
    def info(self) -> DummyInfo:
        return DummyInfo(
            name=self.name,
            desc=self.desc,
            hw_address=self.hw_address,
            param_int=self.param_int,
            param_str=self.param_str,
            param_bool=self.param_bool,
        )

Source: dummies.py:34-43

That’s a complete Driver: state, capabilities, lifecycle, accessors, and a snapshot. Swap the flag-flipping for real serial I/O and you have the shape of every real Driver in the package.

Building on an Instrument category#

Subclassing the base Instrument means wiring up everything by hand, as the dummy does. When your hardware fits one of the Instrument categories, you can subclass that instead and let it do some of the wiring for you. RotatorInstrument, for example, already registers the standard rotator operations and parameter in its own __post_init__:

    def __post_init__(self) -> None:
        self.operations["move_to"] = self.move_to
        self.operations["move_by"] = self.move_by
        self.parameters.add("degrees")

Source: instrument.py:180-184

It even implements move_to and move_by for you, in terms of degrees:

    def move_to(self, angle: float) -> None:
        self.degrees = angle

    def move_by(self, angle: float) -> None:
        self.degrees += angle

Source: instrument.py:194-200

So a rotator Driver built on RotatorInstrument only has to supply the parts that are genuinely hardware-specific: how to start and close the connection, how to read and write degrees, and its info. SerialRotator is about as small as a real Driver gets:

@dataclass(slots=True)
class SerialRotator(RotatorInstrument):
    _degrees: float = 0.0  # The hardware doesn't support position tracking
    _conn: serial.Serial = field(init=False, repr=False)

    def start(self) -> None:
        self._conn = serial.Serial(self.hw_address, baudrate=115200, timeout=1)
        self._conn.write(b"open_channel")
        self._conn.read(100)
        self._conn.write(b"motor_ready")
        self._conn.read(100)
        self.degrees = self.offset_degrees

    def close(self) -> None:
        self.degrees = 0
        self._conn.close()

    @property
    def degrees(self) -> float:
        return self._degrees

    @degrees.setter
    def degrees(self, degrees: float) -> None:
        self._conn.write(f"SRA {degrees}".encode())
        self._degrees = degrees
        _ = self._conn.readline().decode()

Source: rotator.py:95-132

It never defines move_to, move_by, or __post_init__; all three come from RotatorInstrument. Notice the payoff of the lifecycle rule from earlier: opening the serial port lives entirely in start(), so constructing a SerialRotator from config touches no hardware.

Note

Because construction does no I/O, an operation could be called before start() has run. If that would misbehave on your hardware, guard against it: APTRotator raises DeviceNotStartedError if you try to move it before its device handle exists (rotator.py:67).

The drivers that ship today#

The package ships these Drivers, grouped by the Instrument category each one implements:

Category

Drivers

Hardware

TimeTaggerInstrument

SwabianTimeTagger

Swabian Instruments time tagger

RotatorInstrument

APTRotator, SerialRotator, EllxRotator

Thorlabs APT mounts; a plain serial rotator; Thorlabs ELLx mounts

PolarimeterInstrument

ArduinoPolarimeter

Arduino-based polarimeter

Instrument (base)

DummyInstrument

none; test and example fixture

The rotator row is the point made back in the orientation: one category, several Drivers. A RotatorInstrument is a rotator whatever protocol it speaks, and the rest of the system drives all three through the identical degrees / move_to / move_by interface.

Using a ProxyInstrument#

Everything so far has involved holding a Driver directly. In a running Node you rarely do that. The real instruments are created and owned by an Instrument Provider, often on a different machine, and your code reaches them over the Node’s internal network through the Router (how that fabric works is the subject of Network transport: Router, Instrument Provider, Client).

What your code holds instead is a ProxyInstrument: a local stand-in that forwards every call across the network to the real instrument. It satisfies the same Instrument contract as any Driver, so your code behaves exactly as it would a local instrument, with no idea the hardware is somewhere else.

        flowchart LR
    A[Your code] --> B[ProxyInstrument]
    B -. across the Node .-> C[Router]
    C --> D[Instrument Provider]
    D --> E[Instrument + hardware]
    

The Router and InstrumentProvider hops in the middle are exactly what Network transport: Router, Instrument Provider, Client covers; here we only care about the two ends: your code, and the instrument it drives.

Getting a ProxyInstrument#

You obtain a proxy through a Client. Connect one, ask a provider what it hosts, then request a device by name:

from pqn_hardware.network.client import Client

client = Client()                                      # connect to the Router
devices = client.get_available_devices("provider1")    # what does provider1 host?
instrument = client.get_device("provider1", "dummy1")  # a ProxyInstrument for "dummy1"

Source: tests/messaging/client.py:10-20

get_available_devices returns a dict that maps each instrument the provider hosts to its Driver class. Run against a provider hosting a single dummy, it gives:

{'dummy1': <class 'pqn_hardware.drivers.dummies.DummyInstrument'>}

get_device then hands back a ProxyInstrument for one of those names. Ask for a name the provider doesn’t host and it raises a PacketError carrying the provider’s reply (Instrument 'does_not_exist' not found.), so it’s worth checking get_available_devices first when a name might be wrong.

Using it#

From here you use instrument exactly as if it were local. Call its operations, with or without arguments:

instrument.double_int()                  # operation, no arguments
instrument.set_half_input_int(10)        # positional argument
instrument.set_half_input_int(value=36)  # keyword argument

Source: tests/messaging/client.py:25-39

Read and write its parameters as ordinary attributes:

current = instrument.param_int  # read a parameter
instrument.param_int = 42       # write a parameter

Source: tests/messaging/client.py:42-49

And ask for its state snapshot through instrument.info, just like a local Instrument. Each of these quietly becomes a round-trip to the provider and back, but your code never has to deal with that.

What you can’t do#

A proxy only exposes what the instrument actually registered as a parameter or an operation. Assigning anything else is rejected, rather than silently creating a local attribute that would never reach the hardware:

instrument.new_attr = 348  # raises AttributeError

Source: tests/messaging/client.py:53-56

Closing#

When you’re finished, close the proxy. For a ProxyInstrument, close() tears down the underlying network connection rather than touching any hardware; the real instrument keeps running under its Instrument Provider:

instrument.close()

Source: client.py:266-267