Instrument Control

Getting Started with PyVISA

Control test instruments from Python with PyVISA: discovery, connection, SCPI basics, error handling, and wiring a DMM into a test procedure.

JJulien Buteau
beginner10 min readMarch 14, 2026
From test code to the instrument.
Python test
PyVISA / SCPI
Instrument
Measurements

PyVISA lets you control test instruments (multimeters, oscilloscopes, power supplies, signal generators) from Python using the same VISA protocol that LabVIEW and TestStand use. If your instrument has a USB, GPIB, Ethernet, or serial interface, you can talk to it with PyVISA. This guide covers setup, connection, SCPI commands, and turning instrument reads into recorded measurements.

Prerequisites

  • Python 3.8+
  • A test instrument with USB-TMC, GPIB, Ethernet (LXI), or serial interface
  • Optional: NI-VISA runtime (for GPIB and some USB instruments)

Installation

install.sh
pip install pyvisa pyvisa-py

Two backends are available:

BackendInstallWhen to Use
pyvisa-pypip install pyvisa-pyPure Python. Works on Linux/macOS/Windows. Supports USB-TMC, Ethernet (TCP/IP), serial. No NI software needed.
NI-VISAInstall from ni.comRequired for GPIB. Also supports USB-TMC and Ethernet. Windows and Linux only.

Start with pyvisa-py. Switch to NI-VISA only if you need GPIB or encounter compatibility issues.

Finding Your Instruments

Every VISA instrument has a resource string that identifies it. PyVISA can discover connected instruments automatically.

pyvisa_setup/discover.py
import pyvisarm = pyvisa.ResourceManager("@py")  # Use pyvisa-py backend# rm = pyvisa.ResourceManager()     # Use NI-VISA backend# List all connected instrumentsresources = rm.list_resources()print(f"Found {len(resources)} instrument(s):")for r in resources:    print(f"  {r}")

Common resource string formats:

InterfaceResource StringExample
USB-TMCUSB0::VID::PID::SERIAL::INSTRUSB0::0x2A8D::0x1301::MY59001234::INSTR
Ethernet (LXI)TCPIP::IP::INSTRTCPIP::192.168.1.100::INSTR
GPIBGPIB0::ADDR::INSTRGPIB0::22::INSTR
SerialASRL/dev/ttyUSB0::INSTRASRL/dev/ttyUSB0::INSTR

Connecting to an Instrument

Once you have the resource string, open a connection and verify identity with the standard *IDN? query.

pyvisa_setup/connect.py
import pyvisarm = pyvisa.ResourceManager("@py")dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")# Set timeout (milliseconds)dmm.timeout = 5000# Query instrument identityidn = dmm.query("*IDN?")print(f"Connected to: {idn.strip()}")# Output: Keysight Technologies,34461A,MY59001234,A.03.01

The query() method sends a command and reads the response. For commands that don't return data, use write().

SCPI Command Basics

SCPI (Standard Commands for Programmable Instruments) is the language most modern instruments speak. Commands follow a tree structure.

Reading a DC Voltage

pyvisa_setup/measure_voltage.py
import pyvisarm = pyvisa.ResourceManager("@py")dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")dmm.timeout = 5000# Configure for DC voltage measurement, auto-rangedmm.write(":CONF:VOLT:DC AUTO")# Trigger a measurement and read the resultdmm.write(":INIT")voltage = float(dmm.query(":FETCH?"))print(f"Voltage: {voltage:.4f} V")dmm.close()rm.close()

Common SCPI Commands

CommandPurposeExample
*IDN?Identify instrumentReturns manufacturer, model, serial, firmware
*RSTReset to factory defaultsGood practice at test start
*CLSClear error queueClear any previous errors
*OPC?Operation complete queryReturns "1" when last command finishes
:CONF:VOLT:DCConfigure DC voltage:CONF:VOLT:DC 10 for 10V range
:CONF:CURR:DCConfigure DC current:CONF:CURR:DC AUTO for auto-range
:CONF:RESConfigure resistance:CONF:RES AUTO
:MEAS:VOLT:DC?Measure DC voltage (configure + trigger + read)Returns float
:INITTrigger measurementUse with :FETCH? for separate trigger/read
:FETCH?Read last measurementReturns float
:SYST:ERR?Read error queueReturns error code and message

The :MEAS: shorthand configures, triggers, and reads in one command. Use :CONF: + :INIT + :FETCH? when you need more control over timing.

Controlling a Power Supply

Power supplies use similar SCPI commands but add output control.

pyvisa_setup/power_supply.py
30 lines
import pyvisaimport timerm = pyvisa.ResourceManager("@py")psu = rm.open_resource("TCPIP::192.168.1.101::INSTR")psu.timeout = 5000# Reset to known statepsu.write("*RST")psu.write("*CLS")# Configure channel 1: 5V, 500mA current limitpsu.write(":INST:SEL CH1")psu.write(":VOLT 5.0")psu.write(":CURR 0.5")# Enable outputpsu.write(":OUTP ON")time.sleep(0.5)  # Wait for output to stabilize# Read actual voltage and currentactual_voltage = float(psu.query(":MEAS:VOLT?"))actual_current = float(psu.query(":MEAS:CURR?"))print(f"Output: {actual_voltage:.3f}V, {actual_current:.4f}A")# Disable outputpsu.write(":OUTP OFF")psu.close()rm.close()

Error Handling

Instruments communicate errors through the SCPI error queue. Always check for errors after a sequence of commands.

pyvisa_setup/error_handling.py
33 lines
import pyvisadef check_instrument_errors(instr):    """Read and report all errors from the instrument error queue."""    errors = []    while True:        err = instr.query(":SYST:ERR?").strip()        code = int(err.split(",")[0])        if code == 0:            break        errors.append(err)    return errorsrm = pyvisa.ResourceManager("@py")dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")dmm.timeout = 5000# Send commandsdmm.write("*RST")dmm.write(":CONF:VOLT:DC AUTO")dmm.write(":INIT")voltage = float(dmm.query(":FETCH?"))# Check for errorserrors = check_instrument_errors(dmm)if errors:    print(f"Instrument errors: {errors}")else:    print(f"Voltage: {voltage:.4f} V (no errors)")dmm.close()

From Script to Test

A script that prints a voltage is a good first step, but a production test needs the value recorded against limits, tied to a serial number, and kept. That means two changes: the instrument session moves into a plug, and the limits move into a procedure.

A plug is a plain Python class that owns the VISA session:

plugs/dmm.py
32 lines
import pyvisaclass Multimeter:    """PyVISA multimeter. Opens once, shared across phases."""    RESOURCE = "TCPIP::192.168.1.100::INSTR"    def __init__(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 5000        self.instr.write("*RST")        self.instr.write("*CLS")    def measure_dc_voltage(self, range_v: str = "AUTO") -> float:        self.instr.write(f":CONF:VOLT:DC {range_v}")        return float(self.instr.query(":MEAS:VOLT:DC?"))    def measure_dc_current(self, range_a: str = "AUTO") -> float:        self.instr.write(f":CONF:CURR:DC {range_a}")        return float(self.instr.query(":MEAS:CURR:DC?"))    def measure_resistance(self, range_ohm: str = "AUTO") -> float:        self.instr.write(f":CONF:RES {range_ohm}")        return float(self.instr.query(":MEAS:RES?"))    def identify(self) -> str:        return self.instr.query("*IDN?").strip()    def close(self):        self.instr.close()

The phase is a plain function that assigns readings. TofuPilot injects the plug by matching the parameter name:

phases/power_rails.py
def power_rails(measurements, dmm):    measurements.supply_3v3 = dmm.measure_dc_voltage()    measurements.supply_5v0 = dmm.measure_dc_voltage()    measurements.idle_current = dmm.measure_dc_current()
phases/pullups.py
def pullups(measurements, dmm):    measurements.pullup_resistance = dmm.measure_resistance()

The limits live in the procedure as data:

procedure.yaml
51 lines
name: PCBA Functional Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dmm    python: plugs.dmm:Multimetermain:  - name: Power Rails    python: phases.power_rails    measurements:      - name: Supply 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4      - name: Supply 5V0        unit: V        validators:          - operator: ">="            expected_value: 4.8          - operator: "<="            expected_value: 5.2      - name: Idle Current        unit: A        validators:          - operator: ">="            expected_value: 0.01          - operator: "<="            expected_value: 0.15  - name: Pullups    python: phases.pullups    depends_on: [power_rails]    measurements:      - name: Pullup Resistance        unit: "Ω"        description: I2C pullup, expect 4.7k        validators:          - operator: ">="            expected_value: 4500          - operator: "<="            expected_value: 5500

Run it with tofupilot run ./procedure.yaml. Every measurement is stored with its name, value, unit, limits and outcome, which is what makes first pass yield, Cpk and control charts work without extra code.

One caution specific to instruments: independent phases run in parallel, and a single VISA session is not safe to use from two phases at once. Either give each phase its own instrument, or serialise the sharing phases with depends_on, as above.

It is also worth recording which instrument produced a reading:

phases/record_equipment.py
def record_equipment(run, dmm):    run.metadata["dmm_id"] = dmm.identify()

If You Already Run OpenHTF

The same session belongs in a BasePlug with setUp and tearDown:

pyvisa_setup/openhtf_test.py
38 lines
import pyvisaimport openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass MultimeterPlug(BasePlug):    RESOURCE = "TCPIP::192.168.1.100::INSTR"    def setUp(self):        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource(self.RESOURCE)        self.instr.timeout = 5000        self.instr.write("*RST")        self.instr.write("*CLS")    def measure_dc_voltage(self, range_v: str = "AUTO") -> float:        self.instr.write(f":CONF:VOLT:DC {range_v}")        return float(self.instr.query(":MEAS:VOLT:DC?"))    def tearDown(self):        self.instr.close()@htf.measures(    htf.Measurement("supply_3v3")    .in_range(3.2, 3.4)    .with_units(units.VOLT),)@htf.plug(dmm=MultimeterPlug)def test_power_rails(test, dmm):    test.measurements.supply_3v3 = dmm.measure_dc_voltage()test = htf.Test(test_power_rails, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial number: "))

Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.

Troubleshooting

ProblemCauseFix
VisaIOError: VI_ERROR_RSRC_NFOUNDInstrument not foundCheck cable, verify IP/USB, run rm.list_resources()
VisaIOError: VI_ERROR_TMOTimeoutIncrease instr.timeout, check if instrument is busy
VisaIOError: VI_ERROR_CONN_LOSTConnection droppedCheck network, retry connection
Empty response from query()Instrument didn't respondAdd time.sleep() after write, check command syntax
USB instrument not detectedMissing driverInstall NI-VISA runtime or check USB-TMC kernel module (Linux)
ValueError: could not convert string to floatUnexpected response formatPrint raw response, check for error messages mixed in
Intermittent errors under parallel phasesOne session used concurrentlySerialise with depends_on, or one instrument per phase

More Guides

Put this guide into practice