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.commis available, buttest_connectionhas not yet run, so do not assume the hardware responds. Returns nothing.test_connection(self) -> boolVerifies 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
Trueif communication succeeds,Falseotherwise. May be called multiple times during a session.prepare_for_experiment(self, config: dict) -> boolCalled once at the start of every experiment, before
begin_acquisition(). Theconfigdictionary 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. ReturnTrueto proceed,Falseto abort the experiment with an error.For some hardware types (IOBoard, FtmwDigitizer, LifDigitizer), the C++ trampoline calls a separate
configure(config: dict) -> dictmethod 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.
sleepingisTruewhen entering sleep,Falsewhen 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 |
|---|---|---|
|
|
Connection assumed good (use this only for stub drivers). |
read_aux_dataread_validation_data |
|
No aux or validation data is recorded. |
|
|
Experiment proceeds without driver-specific configuration. |
initializebegin_acquisitionend_acquisitionsleepread_settings |
|
The call is a no-op. |
Hardware-specific methods
(
hw_*, configure,read_analog_channels,etc.)
|
|
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.