Driver Development
Oscilloscope (Scope)
Overview
This section is for developers implementingInstroScope support for oscilloscopes that aren’t supported out of the box.
A scope driver subclasses ScopeDriverBase and owns its transport. Its responsibilities:
- Expose a protocol-native constructor: accept a
visa_resourcestring orVisaConfig. - Own transport setup: create and store the transport internally. Do not require callers to pass a
VisaDriver. - Own lifecycle: implement
open()andclose(). - Map commands: translate each abstract method into vendor commands.
- Parse responses: convert responses to the expected types (
float, enums,WaveformData). - Drain the error queue: implement
check_errors().InstroScopecalls it between setup commands and blocking queries, so a pending syntax error raises instead of hanging a data query.
ScopeDriverBase Interface
All scope drivers subclassScopeDriverBase 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 implementingInstroPSU support for power supplies that aren’t supported out of the box.
Overview
Driver developers subclassPSUDriverBase 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:
InstroPSU’s vendor-independent API (set_voltage, get_voltage, output_enable, …) into vendor-specific commands.
Driver Responsibilities
A PSU driver must:- Expose a protocol-native constructor: accept inputs like
visa_resource,host,port,unit_id,interface, ornode_id, depending on the instrument. - 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. - Own lifecycle: implement
open()andclose()by opening and closing the underlying transport. - Map commands: translate each abstract method into vendor-specific commands.
- Parse responses: convert instrument responses to the expected Python types (
float,bool, etc.).
PSUDriverBase Interface
All PSU drivers subclassPSUDriverBase and implement these abstract methods:
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 aVisaDriver 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:Using a Custom Driver
For drivers that aren’t shipped in the library, constructInstroPSU 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 unifiedInstroPSU 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 implementingInstroDMM support for DMMs that aren’t supported out of the box.
Overview
Driver developers subclassDMMDriverBase 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:
InstroDMM’s vendor-independent API (set_measurement_function, measure_dc_voltage, …) into vendor-specific commands.
Driver Responsibilities
A DMM driver must:- Expose a protocol-native constructor: accept inputs like
visa_resource,host,port, depending on the instrument. - Own transport setup: create and store the transport internally. Do not require users to pass a
VisaDriver, socket client, or other transport object. - Own lifecycle: implement
open()andclose()by opening and closing the underlying transport. - Map commands: translate each abstract method into vendor-specific commands.
- Parse responses: convert instrument responses to the expected Python types (
float).
DMMDriverBase Interface
All DMM drivers subclassDMMDriverBase and implement these abstract methods:
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 takesvalue: float | None(None = auto).InstroDMMdispatches 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.
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 aVisaDriver 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:Using a Custom Driver
For drivers that aren’t shipped in the library, constructInstroDMM 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 unifiedInstroDMM 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 theDAQDriverBase 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:- Hardware connection lifecycle: Implement
open()andclose()for establishing and terminating hardware connections. - 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 callsuper().__init__(), then populate those privates inside theirconfigure_*methods and read from them inread_analog,fetch_analog,start,write_analog_value, etc.InstroDAQexposes the same state to end users via read-only@propertyaccessors that hand back frozen snapshots (daq.ai_channels,daq.ai_hw_timing_config, …) — no duplication. See Driver-owned state below. - Channel configuration: Translate generic channel configurations into vendor-specific setup.
- Timing abstraction: Implement both software-timed reads and hardware-timed buffered acquisition.
InstroDAQmanages the background daemon for both timing modes; software-timed pacing never reaches the driver. - Data format conversion: Convert vendor-specific data formats to
Measurementobjects. - Constraint validation: Handle vendor-specific hardware constraints and raise clear exceptions when the vendor library cannot.
DAQDriverBase Interface
All drivers must implement theDAQDriverBase abstract base class. Key methods:
Connection Management
open(): Establish connection to the hardware deviceclose(): 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 onself._di_channels[channel.alias].configure_do_line_channel(physical_channel, logic, ...): Parse, program, and register a digital output line. Record onself._do_channels[channel.alias].configure_di_port_channel(physical_channel, logic, port_width, ...): Parse, program, and register a digital input port. Record onself._di_channels[channel.alias]. Default raisesNotImplementedErrorfor line-only devices.configure_do_port_channel(physical_channel, logic, port_width, ...): Parse, program, and register a digital output port. Record onself._do_channels[channel.alias]. Default raisesNotImplementedErrorfor line-only devices.
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 anAnalogVoltageChannel; record onself._ai_channels/self._ao_channels.configure_ai_current_channel(channel)/configure_ao_current_channel(channel): Configure a typed current input/output channel from anAnalogCurrentChannel; record onself._ai_channels/self._ao_channels.configure_ai_thermocouple_channel(channel): Configure a thermocouple input channel from anAnalogThermocoupleChannel(thermocouple type plus cold-junction config); record onself._ai_channels.configure_ai_channel(channel)/configure_ao_channel(channel): Deprecated. Implementconfigure_ai_voltage_channel/configure_ao_voltage_channelinstead. Both default toNotImplementedError, 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 onself._ai_hw_timing_configsostart/fetch_analogcan 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
- Used during hardware-timed acquisition to retrieve buffered samples; reads timing from
-
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
- Port read/write are required of every driver; LabJack T-Series raises
Data Conversion
_read_to_measurements(response, channel_list, daq_name, default_tags, **kwargs) -> list[Measurement]: Convert vendor data format to InstroDAQMeasurementobjects- Maps vendor data to channel names
- Creates
Measurementobjects 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_idstring (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
VisaConfigand compose aVisaDriverinternally 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 theconfigure_* 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
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
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 beforestart().
-
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’stask.timing.cfg_samp_clk_timing()and then stores the config onself._ai_hw_timing_config. -
LabJack LJM: Sample rate is configured when
ljm.eStreamStart()is called. The driver records theHWTimingConfiginconfigure_ai_hw_timing()and reads it back fromself._ai_hw_timing_configinsidestart().
InstroDAQ level.
Summary
Driver development requires careful abstraction of vendor-specific behaviors to provide the unified InstroDAQ interface. Focus on:- Implementing all
DAQDriverBaseabstract methods - Abstracting timing configuration differences between vendors
- Validating hardware constraints and providing clear error messages
- Converting vendor data formats to
Measurementobjects correctly - Managing state in a thread-safe manner for background acquisition
Arbitrary Waveform Generator (AWG)
This section is for developers implementingInstroAWG support for waveform generators that aren’t supported out of the box.
Overview
Driver developers subclassAWGDriverBase 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:
InstroAWG’s vendor-independent API (set_waveform, set_amplitude, output_enable, …) into vendor-specific commands.
Driver Responsibilities
An AWG driver must:- Expose a protocol-native constructor: accept inputs like
visa_resource,host,port, depending on the instrument. - Own transport setup: create and store the transport internally. Do not require users to pass a
VisaDriveror other transport object. - Own lifecycle: implement
open()andclose()by opening and closing the underlying transport. - Map commands: translate each abstract method into vendor-specific commands.
- Parse responses: convert instrument responses to the expected Python types (
Waveform,float,bool,ModulationType, etc.). - Validate hardware constraints: if the instrument only supports a subset of waveform shapes, modulation types, or carrier/modulator combinations, raise
ValueErrorfor unsupported combinations rather than silently misprogramming the instrument.
AWGDriverBase Interface
All AWG drivers subclassAWGDriverBase. The required methods are declared @abc.abstractmethod:
NotImplementedError if not supported):
set_output_load(channel, load)/get_output_load(channel): Set or read the output load impedance;Nonemeans high-Z.align_phase(): Sync the phase of all channels.set_modulation(channel, mod_type, shape, magnitude): Configure a channel’s modulation. Callmodulation_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). Callburst_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 toMANUAL; callset_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 toMANUAL; callset_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 aVisaDriver 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, constructInstroAWG 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 unifiedInstroAWG 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
ValueErrorrather 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 implementingInstroELoad support for electronic loads that aren’t supported out of the box.
Overview
Driver developers subclassELoadDriverBase 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:
InstroELoad’s vendor-independent API (set_mode, set_level, get_voltage, …) into vendor-specific commands.
Driver Responsibilities
An electronic load driver must:- Expose a protocol-native constructor: accept inputs like
visa_resource,host,port,unit_id,interface, ornode_id, depending on the instrument. - 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. - Own lifecycle: implement
open()andclose()by opening and closing the underlying transport. - Map commands: translate each abstract method into vendor-specific commands.
- Parse responses: convert instrument responses to the expected Python types (
float,bool, etc.).
ELoadDriverBase Interface
All electronic load drivers subclassELoadDriverBase and implement these abstract methods:
_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 aVisaDriver 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:
Implementation Example: B&K Precision Driver
Here’s the complete driver implementation for B&K Precision 85xx Series electronic loads:Using a Custom Driver
For drivers that aren’t shipped in the library, constructInstroELoad 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 unifiedInstroELoad 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 implementingInstroFlowController support for flow controllers that aren’t shipped in the library.
Overview
Driver developers subclassFlowControllerDriverBase and own whatever transport their instrument needs. The caller chooses a concrete driver and passes it to InstroFlowController:
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:- Expose a protocol-native constructor: accept inputs like
visa_resource,host,port, ordevice_id, depending on the instrument. - Own transport setup: create and store the transport internally. Do not require users to pass a
VisaDriveror other transport object. - Own lifecycle: implement
open()andclose()by opening and closing the underlying transport. - Map commands: translate each abstract method into vendor-specific commands.
- Parse responses: convert instrument responses into
FlowDataor the expected return type.
FlowControllerDriverBase Interface
All flow controller drivers subclassFlowControllerDriverBase and implement these abstract methods:
@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 aVisaDriver 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.
Using a Custom Driver
ConstructInstroFlowController with your own driver instance:
Summary
Driver development requires careful mapping of vendor-specific behavior to the unifiedInstroFlowController 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
FlowDatafrom 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_measurementmethod that emits data - a
@publish_commandmethod 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 theset_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.
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.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
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:
start():
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/floatto a channel that has ever carried astr(or vice versa) is a bug in the calling instrument, not something_package_measurementchecks for you. - Pass
.value, not the enum member, for a categorical read.@publish_measurementraisesTypeErrorif a returnedMeasurement’s single-value channel holds anything other thanint/float/str— soself._package_measurement(channel, some_enum_member.value, timestamp), notsome_enum_member. This catches a forgotten.valueat the call site. - Lifecycle.
open()/close()bracket the device connection.start()/stop()bracket the background daemon.close()callsstop()internally and also tears down attached publishers, so a singletry / finallyaroundclose()is enough to clean up everything.Instrumentis also a context manager.with SimpleTempController(name="controller") as controller:callsopen()on entry andclose()on exit, including when an exception escapes the block, which is the pattern the example above uses. Overrideopen()/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_commandraisesTypeErrorif the method returns anything other than aCommand.@publish_measurementdoes the same forMeasurement | 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 thatread_temperature()consumes. This is the most common shape for an instrument: a setpoint or mode-change command that drives the next round of measurements.