Writing a Python Driver

A Python hardware driver is a single .py file containing one class. Blackchirp instantiates the class, injects a small set of proxy objects onto the instance, and dispatches lifecycle and hardware-specific calls to its methods. This page covers the rules every driver follows; the per-type method list lives on Per-Type Capabilities.

Driver Class

Each Python hardware type expects a driver class with a specific default name (AwgDriver, IOBoardDriver, and so on). Keeping the default name means the Python Class dropdown in the Hardware Configuration dialog selects the correct entry without further input. If the class is renamed — for example, to host two drivers in one file — pick the desired one from the dropdown.

The class must have a no-argument constructor. Initialization that depends on the hardware connection or persistent settings belongs in initialize(), not in __init__: self.comm, self.settings, and self.log are not available until after construction.

Injected Proxies

Three proxy objects are attached to every driver instance before initialize() runs. They are the driver’s interface back to Blackchirp: hardware I/O, persistent settings, and the log panel are all relayed across the IPC pipe.

self.comm — communication

Routes through the C++ communication protocol that the user selected for this profile (RS-232, TCP, GPIB, or Virtual). The driver does not need to know which protocol is in use.

response: str = self.comm.query(cmd: str)
ok: bool      = self.comm.write(cmd: str)
data: bytes   = self.comm.read_bytes(n: int)
ok: bool      = self.comm.write_binary(data: bytes)

query sends a command and returns the response as a string. write sends a command without expecting a response. read_bytes returns n raw bytes from the device. write_binary sends a raw byte sequence (for AWG waveform uploads, digitizer block transfers, etc.). All four raise ConnectionError if the underlying transport fails.

The Custom protocol option signals that the driver bypasses self.comm entirely — typical when the script talks to its hardware through a vendor Python package, a USB-HID library, or a memory-mapped device. Connection parameters in this case live inside the .py script (as constants near the top of the file or as constructor arguments); the Communication Settings panel shows a note to that effect instead of input fields.

self.settings — persistent settings

Reads and writes settings from the profile’s persistent storage, the same mechanism the C++ side uses. Useful for configuration values that the user can adjust through the Hardware Settings dialog without restarting Blackchirp.

value = self.settings.get(key: str, default=None)
self.settings.set(key: str, value)
self.settings.key    # read-only: full hardware key, e.g. "PythonAwg.Default"
self.settings.model  # read-only: model name string

Values returned by get() are JSON-typed: integers, floats, booleans, strings, lists, and dicts pass through cleanly. Cast to the expected Python type if the stored value may have come from a Qt QVariant whose runtime type does not match.

self.log — logging

Sends messages to the Blackchirp log panel and the rolling log file. Each method maps to a log level recognized by the GUI:

self.log.log(msg)         # Normal
self.log.debug(msg)       # Debug (visible only when debug logging is on)
self.log.warning(msg)     # Warning (yellow)
self.log.error(msg)       # Error (red)
self.log.highlight(msg)   # Highlight (bold)

Log messages are buffered and delivered out-of-band, so they do not block other IPC traffic. They are safe to call from any thread, including a digitizer acquisition thread.

self.digi — push-style waveforms

The optional self.digi proxy is injected only for digitizer trampolines (FTMW Digitizer and LIF Digitizer). It exposes one method:

self.digi.emit_shot(raw_bytes: bytes, shots: int = 1)

emit_shot pushes a single record (or pre-accumulated block) of waveform bytes to the C++ side, which forwards them to the appropriate acquisition manager. The byte layout must match the digitizer configuration the driver applied in configure(): record length, bytes per point, byte order, and (for multi-record acquisitions) number of records. Pass shots=N when the bytes already represent N averaged shots; this lets the C++ shot counter advance correctly.

The push model lets the driver run its own acquisition loop on a background thread — typically a daemon thread started in begin_acquisition() and stopped in end_acquisition(). The main thread must remain free to process incoming IPC messages such as end_acquisition, sleep, and read_settings. emit_shot itself is thread-safe.

Lifecycle Methods

The methods below are invoked on every driver, regardless of hardware type. Each is called at a specific point in the experiment lifecycle. All methods other than initialize() are optional; missing methods return a safe default value, listed in Defaults for Unimplemented Methods below.

initialize(self)

Called once, immediately after the proxy objects are injected. Use it to set up internal state — channel dictionaries, calibration tables, default values — that does not require hardware communication. self.comm is available, but test_connection has not yet run, so do not assume the hardware responds. Returns nothing.

test_connection(self) -> bool

Verifies that the hardware responds. Blackchirp calls this when the user clicks Test in the Hardware menu, when an experiment starts, and after a profile change. Return True if communication succeeds, False otherwise. May be called multiple times during a session.

prepare_for_experiment(self, config: dict) -> bool

Called once at the start of every experiment, before begin_acquisition(). The config dictionary is the experiment configuration relevant to this hardware type — chirp and RF parameters for an AWG, clock-role assignments for a clock, digitizer settings for a scope, and so on. The exact shape is documented in the corresponding template’s docstring. Return True to proceed, False to abort the experiment with an error.

For some hardware types (IOBoard, FtmwDigitizer, LifDigitizer), the C++ trampoline calls a separate configure(config: dict) -> dict method instead, with a {"success": bool, "config": dict} return value that lets the driver report values clamped or substituted by the hardware. See Per-Type Capabilities for which method applies to each type.

begin_acquisition(self)

Called when acquisition starts (after prepare_for_experiment() succeeds and all hardware is ready). Use it to enable outputs, arm digitizers, or start a background acquisition thread. Returns nothing.

end_acquisition(self)

Called when acquisition stops (normally, or because the experiment was aborted). Use it to disable outputs, stop the acquisition thread, and return the hardware to an idle state. Returns nothing.

sleep(self, sleeping: bool)

Called when Blackchirp’s global sleep state changes. sleeping is True when entering sleep, False when waking. Use it to park the hardware in a safe low-power state and to restore it on wake. Returns nothing.

read_settings(self)

Called when the user accepts changes in the Hardware Settings dialog. The subprocess is not restarted, so any cached values in the driver remain in place; re-read whichever settings the driver depends on through self.settings.get. Returns nothing.

read_aux_data(self) -> dict[str, float]

Returns auxiliary data displayed in rolling-data plots and recorded to the experiment’s aux-data file. Each key becomes a plot trace (and a column in the aux-data CSV). Return an empty dict if the driver has no auxiliary readings at this time.

read_validation_data(self) -> dict[str, float]

Returns values that the experiment validation rules can compare against thresholds (for example, an interlock voltage that must stay above 4.5 V). The dict shape is identical to read_aux_data(); the difference is purely how Blackchirp uses the values.

In addition to these common methods, each hardware type defines its own granular methods (hw_read_flow, hw_set_frequency, read_analog_channels, configure, set_address, and so on). Per-Type Capabilities lists them for every trampoline.

Defaults for Unimplemented Methods

A driver that omits an optional method does not break the IPC protocol: the host script returns a safe default. The default depends on which method was called.

Method

Default return

Effect

test_connection

True

Connection assumed good (use this only for stub drivers).

read_aux_data
read_validation_data

{}

No aux or validation data is recorded.

prepare_for_experiment

True

Experiment proceeds without driver-specific configuration.

initialize
begin_acquisition
end_acquisition
sleep
read_settings

None

The call is a no-op.

Hardware-specific methods
(hw_*, configure,
read_analog_channels,
etc.)

None

Effectively unimplemented; the C++ trampoline treats the missing return as an error or empty data, depending on the method.

The required methods for each hardware type are the ones whose None default would leave the trampoline unable to function. Those are called out per type in Per-Type Capabilities.

Error Handling

When a method raises an exception, the host script catches it, forwards the exception type, message, and traceback to Blackchirp, and continues running. The traceback appears in the log panel (level Error), and the C++ trampoline treats the call as failed. The subprocess does not exit — subsequent calls dispatch normally.

This means a driver can let exceptions propagate from self.comm.query (for example, ConnectionError when the device times out) without special handling. For methods that must return a sentinel rather than raise — for example, hw_read_flow returning -1.0 on a transient read failure — catch the exception explicitly and return the documented sentinel value.

Method calls are dispatched serially on the subprocess’s main thread. A long-running method blocks subsequent calls. For acquisition loops, use a background thread (see self.digi — push-style waveforms).

Driver Skeleton

The following skeleton applies to most hardware types. Replace the class name and add hardware-specific methods from Per-Type Capabilities.

class MyDriver:
    def initialize(self):
        self.log.log("driver initialized")
        self._state = {}

    def test_connection(self):
        response = self.comm.query("*IDN?\n")
        ok = bool(response.strip())
        if not ok:
            self.log.error("no response to *IDN?")
        return ok

    def prepare_for_experiment(self, config):
        # configure hardware from config dict
        return True

    def begin_acquisition(self):
        pass

    def end_acquisition(self):
        pass

    def sleep(self, sleeping):
        pass

    def read_settings(self):
        pass

    def read_aux_data(self):
        return {}

The Overview page covers how Blackchirp finds, launches, and reloads the script. The Reloading and Editing a Script page covers the Reload Script and Open in Editor controls used while a driver is being developed.