Skip to main content
Looking to add a new vendor or model to a supported instrument type?Jump to that category’s driver-development section below: Oscilloscope (Scope), Power Supply (PSU), Digital Multimeter (DMM), Data Acquisition (DAQ), Arbitrary Waveform Generator (AWG), Electronic Load (ELoad), Flow Controller, or I2C.If your instrument doesn’t fit any supported type at all, see Generic Custom Instrumentation below instead — that’s for building an entirely new instrument type using Instrument.

Driver Development

Oscilloscope (Scope)

Overview

This section is for developers implementing InstroScope support for oscilloscopes that aren’t supported out of the box. A scope driver subclasses ScopeDriverBase and owns its transport. Its responsibilities:
  1. Expose a protocol-native constructor: accept a visa_resource string or VisaConfig.
  2. Own transport setup: create and store the transport internally. Do not require callers to pass a VisaDriver.
  3. Own lifecycle: implement open() and close().
  4. Map commands: translate each abstract method into vendor commands.
  5. Parse responses: convert responses to the expected types (float, enums, WaveformData).
  6. Drain the error queue: implement check_errors(). InstroScope calls it between setup commands and blocking queries, so a pending syntax error raises instead of hanging a data query.
Channels are 1-indexed analog input numbers throughout.

ScopeDriverBase Interface

All scope drivers subclass ScopeDriverBase and implement these abstract methods:
setup_measurement(measurement_type, channel) is an optional override (default no-op). Implement it for instruments that compute measurements during acquisition (e.g. Tektronix), where the measurement slot must exist at trigger time. Raise NotImplementedError for AcquisitionMode values the scope does not support (e.g. the Keysight 1200X has no ENVELOPE mode). See Exceptions for unsupported-feature handling. For VISA-backed drivers, create a VisaDriver internally and use self._visa.write(command) and self._visa.query(command) for all I/O. VisaDriver owns the resource lock and serializes concurrent calls. See the VisaDriver guide for the full transport reference.

Implementation Example: Keysight 1200 X-Series Driver

Power Supply (PSU)

This section is for developers implementing InstroPSU support for power supplies that aren’t supported out of the box.

Overview

Driver developers subclass PSUDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver, and that concrete driver exposes connection parameters that make sense for its protocol:
The driver is responsible for translating InstroPSU’s vendor-independent API (set_voltage, get_voltage, output_enable, …) into vendor-specific commands.

Driver Responsibilities

A PSU driver must:
  1. Expose a protocol-native constructor: accept inputs like visa_resource, host, port, unit_id, interface, or node_id, depending on the instrument.
  2. Own transport setup: create and store the transport internally. Do not require users to pass a VisaDriver, socket client, Modbus client, or other transport object.
  3. Own lifecycle: implement open() and close() by opening and closing the underlying transport.
  4. Map commands: translate each abstract method into vendor-specific commands.
  5. Parse responses: convert instrument responses to the expected Python types (float, bool, etc.).

PSUDriverBase Interface

All PSU drivers subclass PSUDriverBase and implement these abstract methods:
Required output-control and measurement methods on PSUDriverBase are declared with @abc.abstractmethod. Optional protection and sense methods default to NotImplementedError; override the ones the driver implements. See Exceptions for unsupported-feature handling.

Talking to the Instrument

Concrete drivers should hide transport details behind private attributes. For VISA-backed drivers, create a VisaDriver internally and use it for all I/O:
  • self._visa.write(command): Send a SCPI command (no response expected).
  • self._visa.query(command): Send a SCPI query and receive the response string.
VisaDriver owns the resource lock. Concurrent write / query calls against the same driver are serialized automatically. Use with self._visa.lock(): when a write and its error check need to execute atomically. See the VisaDriver guide for the full transport reference, covering configuration, terminators, timeouts, serial settings, and the raw-byte I/O path. For non-VISA instruments, follow the same shape with the protocol client your driver needs. The important part is that the public constructor describes the instrument connection, while the transport object remains an implementation detail.

Implementation Example: B&K Precision Single-Channel Driver

Here’s a representative driver implementation for the B&K Precision 9115-series single-channel supplies:
Vendor SCPI VariationsDifferent PSU vendors use different SCPI command sets:
  • Siglent: CH<n>:VOLT, MEAS:VOLT? CH<n>, SYST:ERR? (positive-zero prefix)
  • Keysight E36100: VOLT, MEAS:VOLT?, SYST:ERR? (single channel)
  • Rigol: :SOUR<n>:VOLT, :MEAS:VOLT? CH<n>, :SYST:ERR?
  • TDK Lambda Genesys / white-label Agilent/Keysight N5700: VOLT, MEAS:VOLT?, SYSTEM:ERROR? (single channel)
Always consult your PSU’s programming manual for the correct SCPI syntax.

Using a Custom Driver

For drivers that aren’t shipped in the library, construct InstroPSU with your own driver instance. The driver should accept connection settings directly and create its transport internally:

Summary

Driver development requires careful mapping of vendor-specific behavior to the unified InstroPSU interface. Focus on:
  • Subclassing PSUDriverBase
  • Designing a constructor around natural connection parameters for the instrument
  • Hiding transport construction inside the driver
  • Implementing all abstract methods on PSUDriverBase
  • Using the correct vendor protocol or command syntax

Digital Multimeter (DMM)

This section is for developers implementing InstroDMM support for DMMs that aren’t supported out of the box.

Overview

Driver developers subclass DMMDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver, and that concrete driver exposes connection parameters that make sense for its protocol:
The driver is responsible for translating InstroDMM’s vendor-independent API (set_measurement_function, measure_dc_voltage, …) into vendor-specific commands.

Driver Responsibilities

A DMM driver must:
  1. Expose a protocol-native constructor: accept inputs like visa_resource, host, port, depending on the instrument.
  2. Own transport setup: create and store the transport internally. Do not require users to pass a VisaDriver, socket client, or other transport object.
  3. Own lifecycle: implement open() and close() by opening and closing the underlying transport.
  4. Map commands: translate each abstract method into vendor-specific commands.
  5. Parse responses: convert instrument responses to the expected Python types (float).

DMMDriverBase Interface

All DMM drivers subclass DMMDriverBase and implement these abstract methods:
Optional overrides (raise NotImplementedError if not supported):
  • set_digits(n): Set resolution in digits.
  • set_aperture_seconds(seconds): Set integration time in seconds. (Not implemented by the bundled drivers.)
  • Per-function range setters: set_dc_voltage_range, set_ac_voltage_range, set_dc_current_range, set_ac_current_range, set_two_wire_resistance_range, set_four_wire_resistance_range. Each takes value: float | None (None = auto). InstroDMM dispatches to the right one based on the active measurement function, so the driver never needs to know which function is active.
  • Per-function NPLC setters: set_dc_voltage_nplc, set_ac_voltage_nplc, set_dc_current_nplc, set_ac_current_nplc, set_two_wire_resistance_nplc, set_four_wire_resistance_nplc. Same dispatch shape as the range setters.
  • measure_four_wire_resistance(): Measure 4-wire resistance.
The per-function range/NPLC split keeps InstroDMM’s _measurement_config.function as the single source of truth. The driver never receives or tracks the active function, so the wrong-function-passed-by-mistake class of bug doesn’t exist.

Talking to the Instrument

Concrete drivers should hide transport details behind private attributes. For VISA-backed drivers, create a VisaDriver internally and use it for all I/O:
  • self._visa.write(command): Send a SCPI command (no response expected).
  • self._visa.query(command): Send a SCPI query and receive the response string.
VisaDriver owns the resource lock. Concurrent write / query calls against the same driver are serialized automatically. See the VisaDriver guide for the full transport reference, covering configuration, terminators, timeouts, serial settings, and the raw-byte I/O path. For non-VISA instruments, follow the same shape with the protocol client your driver needs. The important part is that the public constructor describes the instrument connection, while the transport object remains an implementation detail.

Implementation Example: Agilent 34401A Driver

Here is the abridged shape of the Agilent 34401A driver:
Vendor SCPI VariationsDifferent DMM vendors use different SCPI command sets:
  • Agilent 34401A: MEAS:VOLT:DC?, MEAS:CURR:DC?
  • Keithley 2400: :SENS:FUNC 'VOLT', :READ?
Always consult your DMM’s programming manual for the correct SCPI syntax.

Using a Custom Driver

For drivers that aren’t shipped in the library, construct InstroDMM with your own driver instance. The driver should accept connection settings directly and create its transport internally:

Summary

Driver development requires careful mapping of vendor-specific behavior to the unified InstroDMM interface. Focus on:
  • Subclassing DMMDriverBase
  • Designing a constructor around natural connection parameters for the instrument
  • Hiding transport construction inside the driver
  • Implementing all abstract methods on DMMDriverBase (and optional overrides where supported)
  • Using the correct vendor protocol or command syntax

Data Acquisition (DAQ)

This section is for developers implementing InstroDAQ support for DAQ devices / vendors that are not supported out of the box.

Overview

Driver developers implement the DAQDriverBase abstract interface to add support for new DAQ vendors. The driver is responsible for translating InstroDAQ’s vendor-independent API calls into vendor-specific hardware operations, ensuring users get consistent behavior regardless of the underlying hardware (within its capabilities).

Driver Responsibilities

A DAQ driver shall:
  1. Hardware connection lifecycle: Implement open() and close() for establishing and terminating hardware connections.
  2. Channel and state tracking: The driver is the single source of truth for what’s configured. DAQDriverBase.__init__ initializes the private dicts (_ai_channels, _ao_channels, _di_channels, _do_channels, _relay_channels) and the private timing-config slots (_ai_hw_timing_config, plus AO/DI/DO slots); concrete drivers call super().__init__(), then populate those privates inside their configure_* methods and read from them in read_analog, fetch_analog, start, write_analog_value, etc. InstroDAQ exposes the same state to end users via read-only @property accessors that hand back frozen snapshots (daq.ai_channels, daq.ai_hw_timing_config, …) — no duplication. See Driver-owned state below.
  3. Channel configuration: Translate generic channel configurations into vendor-specific setup.
  4. Timing abstraction: Implement both software-timed reads and hardware-timed buffered acquisition. InstroDAQ manages the background daemon for both timing modes; software-timed pacing never reaches the driver.
  5. Data format conversion: Convert vendor-specific data formats to Measurement objects.
  6. Constraint validation: Handle vendor-specific hardware constraints and raise clear exceptions when the vendor library cannot.

DAQDriverBase Interface

All drivers must implement the DAQDriverBase abstract base class. Key methods:

Connection Management

  • open(): Establish connection to the hardware device
  • close(): Disconnect from the hardware device

Channel Configuration

Each of the methods below must (a) program the device and (b) record the resulting channel on the driver’s own private dict (self._ai_channels, self._di_channels, etc.) so later calls (read_analog, start, etc.) can find it. See Driver-owned state for the full rationale.
  • configure_di_line_channel(physical_channel, logic, ...): Parse, program, and register a digital input line. Record on self._di_channels[channel.alias].
  • configure_do_line_channel(physical_channel, logic, ...): Parse, program, and register a digital output line. Record on self._do_channels[channel.alias].
  • configure_di_port_channel(physical_channel, logic, port_width, ...): Parse, program, and register a digital input port. Record on self._di_channels[channel.alias]. Default raises NotImplementedError for line-only devices.
  • configure_do_port_channel(physical_channel, logic, port_width, ...): Parse, program, and register a digital output port. Record on self._do_channels[channel.alias]. Default raises NotImplementedError for line-only devices.
The analog methods below back InstroDAQ’s typed channel configuration; the support matrix there lists per-driver support. configure_ai_voltage_channel is required; the others default to NotImplementedError for hardware that lacks the capability.
  • configure_ai_voltage_channel(channel) / configure_ao_voltage_channel(channel): Configure a typed voltage input/output channel from an AnalogVoltageChannel; record on self._ai_channels / self._ao_channels.
  • configure_ai_current_channel(channel) / configure_ao_current_channel(channel): Configure a typed current input/output channel from an AnalogCurrentChannel; record on self._ai_channels / self._ao_channels.
  • configure_ai_thermocouple_channel(channel): Configure a thermocouple input channel from an AnalogThermocoupleChannel (thermocouple type plus cold-junction config); record on self._ai_channels.
  • configure_ai_channel(channel) / configure_ao_channel(channel): Deprecated. Implement configure_ai_voltage_channel / configure_ao_voltage_channel instead. Both default to NotImplementedError, so a new driver leaves them alone.

Timing Configuration

  • configure_ai_hw_timing(hw_timing_config): Program hardware-timed sampling on the device, then record on self._ai_hw_timing_config so start/fetch_analog can read it back.

Acquisition Control

  • start(): Begin hardware-timed data acquisition
    • Start hardware sampling and buffer filling
  • stop(): End hardware-timed acquisition
    • Stop sampling and flush buffers

Data Operations

  • read_analog() -> Any: Perform software-timed analog read
    • Trigger immediate conversion and return data
  • fetch_analog() -> Any: Fetch samples from hardware-timed acquisition buffer
    • Used during hardware-timed acquisition to retrieve buffered samples; reads timing from self.ai_hw_timing_config
  • read_digital_line(channel) -> int: Read a digital input line
  • write_digital_line(channel, data): Write to a digital output line
  • read_digital_port(channel) -> int: Read a digital input port
  • write_digital_port(channel, data): Write to a digital output port
    • Port read/write are required of every driver; LabJack T-Series raises NotImplementedError

Data Conversion

  • _read_to_measurements(response, channel_list, daq_name, default_tags, **kwargs) -> list[Measurement]: Convert vendor data format to InstroDAQ Measurement objects
    • Maps vendor data to channel names
    • Creates Measurement objects for publishing

Properties

  • points_in_buffer (int): Number of samples currently in the hardware buffer

Implementation Considerations

Resource Handling

Each concrete driver owns its own transport and accepts the connection parameters it needs at construction:
  • Vendor SDK drivers (NI-DAQmx, LabJack LJM, MCC): Accept a device_id string (device name, serial number, or IP address) and use the vendor library directly.
  • SCPI/VISA drivers (e.g., Keysight 34980A): Accept a VISA resource string or a VisaConfig and compose a VisaDriver internally for all SCPI command/query operations.

Driver-owned state

The driver is the single source of truth for every channel and every timing config that has been configured. It holds that state in private dicts/slots that only the configure_* path mutates. InstroDAQ does not hold its own copies — daq.ai_channels, daq.ai_hw_timing_config, and friends are read-only @property accessors that delegate straight to the driver’s read-only accessors, which hand back frozen snapshots captured at call time. There is no back-channel into InstroDAQ, and the driver does not import InstroDAQ. To keep every driver consistent, DAQDriverBase.__init__ initializes the private dicts and slots; concrete drivers call super().__init__() once and then populate them inside their configure_* methods.
State on every driver
DAQDriverBase.__init__ initializes the private storage below — populate it inside the matching configure_* method, and read from it (via the private attribute) anywhere the driver needs to know what’s configured. Each private dict has a matching read-only @property (ai_channels, ao_channels, …) that returns a frozen snapshot for external consumers; drivers use the private form internally. Add vendor-specific state alongside these as needed (NI keeps an nidaqmx.Task per ChannelType; MCC caches a ULRange per channel; LabJack holds its LJM handle — all live on the driver instance, declared in the driver’s own __init__ after super().__init__()).
Pattern
The same self._<dict>[channel.alias] = channel pattern applies to the other typed analog methods and the four digital configure methods (configure_di_line_channel, configure_do_line_channel, configure_di_port_channel, configure_do_port_channel). DAQDriverBase.define_relay_channel’s default builds a RelayChannel and records it on self._relay_channels already — only override if your hardware needs different parsing, and ensure your override records too.
How InstroDAQ exposes this
End users read daq.ai_channels as a mapping, but it is a frozen snapshot: the returned MappingProxyType rejects writes, the channels inside are frozen dataclasses, and the snapshot does not change when later configure_* calls run — read the property again to see new state. The only sanctioned way to change configuration is the configure_* / define_* path, which programs the device and then records the channel. Reaching the driver via daq.driver exposes the same read-only accessors, not the private dicts.
Example: Vendor differences in timing
Different vendors configure timing differently, but InstroDAQ users must always configure a timing mode before start().
  • NI-DAQmx: Sample rate is configured on the device prior to starting an acquisition. The driver implements this in configure_ai_hw_timing() by calling DAQmx’s task.timing.cfg_samp_clk_timing() and then stores the config on self._ai_hw_timing_config.
  • LabJack LJM: Sample rate is configured when ljm.eStreamStart() is called. The driver records the HWTimingConfig in configure_ai_hw_timing() and reads it back from self._ai_hw_timing_config inside start().
Driver’s Job: Implement these differences so the user interface is consistent at the InstroDAQ level.

Summary

Driver development requires careful abstraction of vendor-specific behaviors to provide the unified InstroDAQ interface. Focus on:
  • Implementing all DAQDriverBase abstract methods
  • Abstracting timing configuration differences between vendors
  • Validating hardware constraints and providing clear error messages
  • Converting vendor data formats to Measurement objects correctly
  • Managing state in a thread-safe manner for background acquisition

Arbitrary Waveform Generator (AWG)

This section is for developers implementing InstroAWG support for waveform generators that aren’t supported out of the box.

Overview

Driver developers subclass AWGDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver, and that concrete driver exposes connection parameters that make sense for its protocol:
The driver is responsible for translating InstroAWG’s vendor-independent API (set_waveform, set_amplitude, output_enable, …) into vendor-specific commands.

Driver Responsibilities

An AWG driver must:
  1. Expose a protocol-native constructor: accept inputs like visa_resource, host, port, depending on the instrument.
  2. Own transport setup: create and store the transport internally. Do not require users to pass a VisaDriver or other transport object.
  3. Own lifecycle: implement open() and close() by opening and closing the underlying transport.
  4. Map commands: translate each abstract method into vendor-specific commands.
  5. Parse responses: convert instrument responses to the expected Python types (Waveform, float, bool, ModulationType, etc.).
  6. Validate hardware constraints: if the instrument only supports a subset of waveform shapes, modulation types, or carrier/modulator combinations, raise ValueError for unsupported combinations rather than silently misprogramming the instrument.

AWGDriverBase Interface

All AWG drivers subclass AWGDriverBase. The required methods are declared @abc.abstractmethod:
Optional overrides (raise NotImplementedError if not supported):
  • set_output_load(channel, load) / get_output_load(channel): Set or read the output load impedance; None means high-Z.
  • align_phase(): Sync the phase of all channels.
  • set_modulation(channel, mod_type, shape, magnitude): Configure a channel’s modulation. Call modulation_enable() to activate it.
  • modulation_enable(channel, enable): Enable or disable modulation on channel.
  • get_modulation_type(channel): Read back the active modulation type from the instrument.
  • get_modulation_state(channel): Read back whether modulation is enabled from the instrument.
  • set_burst(channel, burst_type) / get_burst_type(channel): Configure or read back a channel’s burst type (NCYCLE/GATED/INFINITE). Call burst_enable() to activate it.
  • burst_enable(channel, enable) / get_burst_state(channel): Enable/disable burst mode, or read back whether it’s enabled.
  • set_burst_trigger(channel, source) / get_burst_trigger(channel): Set or read back the burst trigger source (INTERNAL/EXTERNAL/MANUAL).
  • fire_burst_trigger(channel): Fire a burst trigger on channel immediately. Requires the trigger source already set to MANUAL; call set_burst_trigger() first otherwise.
  • set_burst_delay(channel, delay_s) / get_burst_delay(channel): Set or read back the burst trigger delay in seconds.
  • set_burst_gate_polarity(channel, gate_polarity) / get_burst_gate_polarity(channel): Set or read back the gate polarity (NORM/INV) for GATED bursts.
  • set_burst_ncycles(channel, n_cycles) / get_burst_ncycles(channel): Set or read back the number of cycles per trigger for NCYCLE bursts.
  • set_burst_period(channel, period) / get_burst_period(channel): Set or read back the internal burst period in seconds.
  • set_sweep(channel, sweep_type) / get_sweep_type(channel): Configure or read back a channel’s sweep type (LINEAR/LOG/STEP).
  • sweep_enable(channel, enable) / get_sweep_state(channel): Enable or disable sweep mode, or read back whether it’s enabled.
  • set_sweep_trigger(channel, source) / get_sweep_trigger(channel): Set or read back the sweep trigger source (INTERNAL/EXTERNAL/MANUAL).
  • set_sweep_start_freq(channel, frequency_hz) / get_sweep_start_freq(channel): Set or read the sweep start frequency.
  • set_sweep_end_freq(channel, frequency_hz) / get_sweep_end_freq(channel): Set or read the sweep end frequency.
  • set_sweep_time(channel, sweep_time) / get_sweep_time(channel): Set or read the sweep time.
  • set_sweep_start_hold_time(channel, hold_time) / get_sweep_start_hold_time(channel): Set or read the sweep start hold time.
  • set_sweep_stop_hold_time(channel, hold_time) / get_sweep_stop_hold_time(channel): Set or read the sweep stop hold time.
  • set_sweep_return_time(channel, return_time) / get_sweep_return_time(channel): Set or read the sweep return time.
  • fire_sweep_trigger(channel): Fire a sweep trigger on channel immediately. Requires the trigger source already set to MANUAL; call set_sweep_trigger() first otherwise.

Modulation

set_modulation() only configures modulation on a specific channel and does not enable modulation. Only exception is the Keysight 33521B driver: set_modulation will temporarily disable modulation, if previously enabled, to change modulation type. Enable state is restored once set_modulation finishes successfully. In error cases, set_modulation could leave modulation disabled. To enable or disable modulation, use modulation_enable(). Only one modulation type is enabled at a time. get_modulation_type returns the most previous modulation type set by the user.

Talking to the Instrument

Concrete drivers should hide transport details behind private attributes. For VISA-backed drivers, create a VisaDriver internally and use it for all I/O:
  • self._visa.write(command): Send a SCPI command (no response expected).
  • self._visa.query(command): Send a SCPI query and receive the response string.
VisaDriver owns the resource lock. Concurrent write / query calls against the same driver are serialized automatically. Use with self._visa.lock(): when a sequence of writes and their error check need to execute atomically. See the VisaDriver guide for the full transport reference, covering configuration, terminators, timeouts, serial settings, and the raw-byte I/O path. For non-VISA instruments, follow the same shape with the protocol client your driver needs. The important part is that the public constructor describes the instrument connection, while the transport object remains an implementation detail.

Implementation Example: RigolDG1022Z Driver

Here’s an abridged shape of the Rigol DG1022Z driver:

Using a Custom Driver

For drivers that aren’t shipped in the library, construct InstroAWG with your own driver instance. The driver should accept connection settings directly and create its transport internally:

Summary

Driver development requires careful mapping of vendor-specific behavior to the unified InstroAWG interface. Focus on:
  • Subclassing AWGDriverBase
  • Designing a constructor around natural connection parameters for the instrument
  • Hiding transport construction inside the driver
  • Implementing all abstract methods on AWGDriverBase (and optional overrides where supported)
  • Using the correct vendor protocol or command syntax
  • Converting instrument responses to the expected Python types
  • Validating carrier/modulator/waveform compatibility your instrument actually supports, raising ValueError rather than misprogramming the instrument
  • Querying and reporting errors from the instrument’s error queue where one exists
  • Testing with actual hardware to ensure commands work as expected

Electronic Load (ELoad)

This section is for developers implementing InstroELoad support for electronic loads that aren’t supported out of the box.

Overview

Driver developers subclass ELoadDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver, and that concrete driver exposes connection parameters that make sense for its protocol:
The driver is responsible for translating InstroELoad’s vendor-independent API (set_mode, set_level, get_voltage, …) into vendor-specific commands.

Driver Responsibilities

An electronic load driver must:
  1. Expose a protocol-native constructor: accept inputs like visa_resource, host, port, unit_id, interface, or node_id, depending on the instrument.
  2. Own transport setup: create and store the transport internally. Do not require users to pass a VisaDriver, socket client, Modbus client, or other transport object.
  3. Own lifecycle: implement open() and close() by opening and closing the underlying transport.
  4. Map commands: translate each abstract method into vendor-specific commands.
  5. Parse responses: convert instrument responses to the expected Python types (float, bool, etc.).

ELoadDriverBase Interface

All electronic load drivers subclass ELoadDriverBase and implement these abstract methods:
Error checking is not part of the base contract: if your vendor exposes an error queue, add a private _check_errors() helper and call it from your write/query paths (see the representative driver below).

Talking to the Instrument

Concrete drivers should hide transport details behind private attributes. For VISA-backed drivers, create a VisaDriver internally and use it for all I/O:
  • self._visa.write(command): Send a SCPI command (no response expected).
  • self._visa.query(command): Send a SCPI query and receive the response string.
VisaDriver owns the resource lock. Concurrent write / query calls against the same driver are serialized automatically. See the VisaDriver guide for the full transport reference, covering configuration, terminators, timeouts, serial settings, and the raw-byte I/O path. For non-VISA instruments, follow the same shape with the protocol client your driver needs:
The important part is that the public constructor describes the instrument connection, while the transport object remains an implementation detail.

Implementation Example: B&K Precision Driver

Here’s the complete driver implementation for B&K Precision 85xx Series electronic loads:
Vendor SCPI VariationsDifferent electronic load vendors use different SCPI command sets. Always consult your electronic load’s programming manual for the correct SCPI syntax.

Using a Custom Driver

For drivers that aren’t shipped in the library, construct InstroELoad with your own driver instance. The driver should accept connection settings directly and create its transport internally:

Summary

Driver development requires careful mapping of vendor-specific behavior to the unified InstroELoad interface. Focus on:
  • Subclassing ELoadDriverBase
  • Designing a constructor around natural connection parameters for the instrument
  • Hiding transport construction inside the driver
  • Implementing all abstract methods on ELoadDriverBase
  • Using the correct vendor protocol or command syntax
  • Converting instrument responses to the expected Python types

Flow Controller

This section is for developers implementing InstroFlowController support for flow controllers that aren’t shipped in the library.

Overview

Driver developers subclass FlowControllerDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver and passes it to InstroFlowController:
The driver translates InstroFlowController’s vendor-independent API (get_flow_data, set_setpoint, select_gas, tare_flow) into vendor-specific commands.

Driver Responsibilities

A flow controller driver must:
  1. Expose a protocol-native constructor: accept inputs like visa_resource, host, port, or device_id, depending on the instrument.
  2. Own transport setup: create and store the transport internally. Do not require users to pass a VisaDriver or other transport object.
  3. Own lifecycle: implement open() and close() by opening and closing the underlying transport.
  4. Map commands: translate each abstract method into vendor-specific commands.
  5. Parse responses: convert instrument responses into FlowData or the expected return type.

FlowControllerDriverBase Interface

All flow controller drivers subclass FlowControllerDriverBase and implement these abstract methods:
All six methods are @abc.abstractmethod. FlowData (from instro.flowcontroller.types) carries pressure, temperature, vol_flow, mass_flow, setpoint, gas, and status_flags.

Talking to the Instrument

Concrete drivers should hide transport details behind private attributes. For VISA-backed drivers (including RS-232/serial), create a VisaDriver internally and use it for all I/O:
  • self._visa.write(command): Send a command (no response expected).
  • self._visa.query(command): Send a command and receive the response string.
  • self._visa.read(): Read one line from the device buffer.
VisaDriver owns the resource lock. Use with self._visa.lock(): when a sequence of writes and reads must execute atomically. See the VisaDriver guide for the full transport reference, covering configuration, terminators, timeouts, and serial settings.

Implementation Example: Alicat MC-Series

AlicatMC is the reference driver. It communicates over RS-232 using Alicat’s proprietary ASCII polling protocol — not SCPI — so commands are terse single-character identifiers rather than IEEE 488.2-style mnemonics.
Alicat ASCII protocol, not SCPIAlicat MC-series devices use a proprietary ASCII polling protocol. Commands are single-character identifiers (e.g. {id} to poll, {id}s{setpt} to set a setpoint, {id}v to tare, {id}g{n} to select a gas) rather than SCPI mnemonics. An unrecognized command returns ?. Consult the Alicat Gas Flow Controller Manual for the full command reference.

Using a Custom Driver

Construct InstroFlowController with your own driver instance:

Summary

Driver development requires careful mapping of vendor-specific behavior to the unified InstroFlowController interface. Focus on:
  • Subclassing FlowControllerDriverBase
  • Designing a constructor around natural connection parameters for the instrument
  • Hiding transport construction inside the driver
  • Implementing all six abstract methods on FlowControllerDriverBase
  • Returning correctly typed FlowData from measurement methods
  • Parsing and raising instrument errors appropriately

Generic Custom Instrumentation

instro provides a framework for developing instruments that do not ship with the library. This lets an engineer with domain knowledge of an instrument type develop new functionality within a paradigm that grants the features of other instruments within instro. This is different than custom driver development within an existing instrument type — see Driver Development above for that. For example, if there is no built-in RF Power Meter instrument type but you want the instro experience, creating a custom instrument type using Instrument is the path forward.

Walkthrough: building a SimpleTempController instrument

This section builds a custom instrument end to end. It’s a simulated temperature controller (like a benchtop PID controller or thermal chamber) whose reading lags the commanded setpoint as it warms up or cools down. Standard library only, no hardware, no extra dependencies. The full file lives at examples/custom/simple_temp_controller.py. The example covers everything a custom instrument needs:
  • a @publish_measurement method that emits data
  • a @publish_command method that changes instrument state and visibly affects the next several reads
  • a background daemon that polls the measurement on an interval

About the “device”

A real temperature controller has two pieces of state: the current temperature (what its sensor measures right now) and the setpoint (the temperature you’ve told it to hold). When you change the setpoint, the controller’s heater or cooler kicks on, and the current temperature lags behind, closing in on the new target over the next several reads rather than snapping to it. The simulation fakes that lag in one line. Each tick, the current temperature moves 20% of the way toward the setpoint, plus a small amount of noise. That’s enough for the set_target_temperature command to have a dramatic, observable effect. You’ll watch the temperature climb from room temperature toward 50°C, then toward 75°C, in real time.

1. Subclass Instrument

Every custom instrument is a subclass of Instrument. The base class owns the publisher list, the background-daemon thread, default tags, and the channel buffer. Your subclass gets all of them for free.
The base constructor takes a name (used as the channel-name prefix), and any extra **kwargs become default tags. self.background_interval sets the daemon’s polling cadence in seconds. Two methods register what the daemon runs each tick: add_background_daemon_function(method, *args, **kwargs) appends one call to the daemon’s list (call it once per method you want polled), and define_background_daemon(method, *args, **kwargs) is the replace-all variant that clears the list and registers a single method. Override open() and close() to manage the connection to your device. The simulated controller has nothing to open, so both overrides just call super(). This is the seam where a real instrument would call socket.create_connection, open a VISA session, claim a USB interface, etc. The base close() already stops the background daemon and closes attached publishers, so user-side overrides only need to add their own teardown.

2. Publish a Measurement with @publish_measurement

Any method decorated with @publish_measurement must return a Measurement (or list[Measurement], or None). The decorator publishes the returned object to every attached publisher automatically. Your method just has to build it. The _package_measurement(channel, value, timestamp, **tags) helper on Instrument does the build for you: it prefixes the channel with {self.name}., passes a str value through untouched and coerces anything else to float, merges in self.default_tags, and returns a single-channel Measurement.
_package_measurement and _package_command are stable, supported extension points for Instrument subclass authors. The leading underscore marks them as protected (for subclasses, not external callers), which is standard Python. Both are documented on the Instrument reference page.
Every call to read_temperature() advances the simulated temperature one step and publishes the new value under {name}.temperature_c. With the constructor above, the background daemon calls this on its own every 500 ms. That’s where the visible “warming up” comes from. Any publisher you attach (e.g. NominalCorePublisher) receives every sample.

3. Publish a Command with @publish_command

Commands are for changing instrument state, like setpoints, mode switches, and configuration changes. A @publish_command-decorated method returns a Command and the decorator publishes it. Use the parallel helper _package_command(channel, value, timestamp, **tags) to build one. Pass the descriptor with a trailing .cmd so the published channel name has it.
controller.set_target_temperature(50.0) does two things. It changes the target the simulated controller will close in on, and it publishes a controller.setpoint_c.cmd record with the new value. The next several read_temperature() calls will show the temperature climbing. The command literally drives the measurement. If set_target_temperature raised before reaching _package_command (say, a bounds check that rejected value=999), the decorator wouldn’t fire. No record would land on your dashboard, and the previous setpoint would still be in effect. The decorator only publishes on a clean return.

4. Use the instrument

For each target, the loop sends the command and lets the background daemon collect samples for five seconds (about ten ticks at the 500 ms cadence). The base Instrument keeps an in-memory channel buffer of every published sample, so controller.get_channel("controller.temperature_c").latest returns the most recent reading if you want to inspect it from your script:
To stream the same data to Nominal Core instead of (or in addition to) the in-memory buffer, attach a publisher before start():
Both controller.temperature_c (telemetry) and controller.setpoint_c.cmd (command) now stream to Nominal automatically. No changes to the instrument class itself.

Patterns to copy

  • Channel naming. Writes get a trailing .cmd (e.g. "setpoint_c.cmd"); reads do not, whatever the value’s type (e.g. "temperature_c" for a numeric reading, "operating_mode" for a categorical one). The _package_* helpers prepend {self.name}. so multiple instances stay namespaced. See Backwards-compatible channel naming for the full convention.
  • One type per channel. A given channel’s value is always numeric or always a string across its lifetime, never both — publishing a bool/float to a channel that has ever carried a str (or vice versa) is a bug in the calling instrument, not something _package_measurement checks for you.
  • Pass .value, not the enum member, for a categorical read. @publish_measurement raises TypeError if a returned Measurement’s single-value channel holds anything other than int/float/str — so self._package_measurement(channel, some_enum_member.value, timestamp), not some_enum_member. This catches a forgotten .value at the call site.
  • Lifecycle. open() / close() bracket the device connection. start() / stop() bracket the background daemon. close() calls stop() internally and also tears down attached publishers, so a single try / finally around close() is enough to clean up everything. Instrument is also a context manager. with SimpleTempController(name="controller") as controller: calls open() on entry and close() on exit, including when an exception escapes the block, which is the pattern the example above uses. Override open() / close() and you get context-manager support for free, with no need to implement __enter__ / __exit__ yourself. For instruments that share a transport between threads (the daemon polling while user code sends commands), also serialize I/O behind a lock. instro’s built-in categories (PSU, ELoad, DMM, Modbus, …) all do this.
  • Decorator guarantees. @publish_command raises TypeError if the method returns anything other than a Command. @publish_measurement does the same for Measurement | list[Measurement] | None. Wrong return type fails loudly at the call site instead of silently producing the wrong kind of record.
  • Errors don’t get published. If your @publish_* method raises before returning, the decorator never fires: the instrument’s prior state is left untouched and no spurious record lands on your dashboard.
  • Coupled commands and measurements. set_target_temperature() mutates state that read_temperature() consumes. This is the most common shape for an instrument: a setpoint or mode-change command that drives the next round of measurements.