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 integrating instrument measurements into a TofuPilot test.
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
pip install pyvisa pyvisa-pyTwo backends are available:
| Backend | Install | When to Use |
|---|---|---|
| pyvisa-py | pip install pyvisa-py | Pure Python. Works on Linux/macOS/Windows. Supports USB-TMC, Ethernet (TCP/IP), serial. No NI software needed. |
| NI-VISA | Install from ni.com | Required 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.
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:
| Interface | Resource String | Example |
|---|---|---|
| USB-TMC | USB0::VID::PID::SERIAL::INSTR | USB0::0x2A8D::0x1301::MY59001234::INSTR |
| Ethernet (LXI) | TCPIP::IP::INSTR | TCPIP::192.168.1.100::INSTR |
| GPIB | GPIB0::ADDR::INSTR | GPIB0::22::INSTR |
| Serial | ASRL/dev/ttyUSB0::INSTR | ASRL/dev/ttyUSB0::INSTR |
Connecting to an Instrument
Once you have the resource string, open a connection and verify identity with the standard *IDN? query.
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.01The 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
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
| Command | Purpose | Example |
|---|---|---|
*IDN? | Identify instrument | Returns manufacturer, model, serial, firmware |
*RST | Reset to factory defaults | Good practice at test start |
*CLS | Clear error queue | Clear any previous errors |
*OPC? | Operation complete query | Returns "1" when last command finishes |
:CONF:VOLT:DC | Configure DC voltage | :CONF:VOLT:DC 10 for 10V range |
:CONF:CURR:DC | Configure DC current | :CONF:CURR:DC AUTO for auto-range |
:CONF:RES | Configure resistance | :CONF:RES AUTO |
:MEAS:VOLT:DC? | Measure DC voltage (configure + trigger + read) | Returns float |
:INIT | Trigger measurement | Use with :FETCH? for separate trigger/read |
:FETCH? | Read last measurement | Returns float |
:SYST:ERR? | Read error queue | Returns 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.
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.
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()Integrating with OpenHTF and TofuPilot
In a production test, instrument control lives inside an OpenHTF Plug. The plug manages the connection lifecycle, and measurements flow through OpenHTF into TofuPilot automatically.
import pyvisaimport openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import TofuPilotclass MultimeterPlug(BasePlug): """PyVISA multimeter plug with automatic lifecycle management.""" 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") self.logger.info(f"Connected: {self.instr.query('*IDN?').strip()}") def measure_dc_voltage(self, range_v: str = "AUTO") -> float: """Take a single DC voltage measurement.""" 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: """Take a single DC current measurement.""" 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: """Take a single resistance measurement.""" self.instr.write(f":CONF:RES {range_ohm}") return float(self.instr.query(":MEAS:RES?")) def tearDown(self): self.instr.close() self.logger.info("Multimeter disconnected")@htf.measures( htf.Measurement("supply_3v3") .in_range(3.2, 3.4) .with_units(units.VOLT) .doc("3.3V rail voltage"), htf.Measurement("supply_5v0") .in_range(4.8, 5.2) .with_units(units.VOLT) .doc("5.0V rail voltage"), htf.Measurement("idle_current") .in_range(0.01, 0.15) .with_units(units.AMPERE) .doc("Board idle current draw"),)@htf.plug(dmm=MultimeterPlug)def test_power_rails(test, dmm): """Measure all power rails and idle current.""" test.measurements.supply_3v3 = dmm.measure_dc_voltage() test.measurements.supply_5v0 = dmm.measure_dc_voltage() test.measurements.idle_current = dmm.measure_dc_current()@htf.measures( htf.Measurement("pullup_resistance") .in_range(4500, 5500) .with_units(units.OHM) .doc("I2C pullup resistance (expect 4.7k)"),)@htf.plug(dmm=MultimeterPlug)def test_pullup_resistors(test, dmm): """Verify I2C pullup resistor values.""" test.measurements.pullup_resistance = dmm.measure_resistance()def main(): test = htf.Test( test_power_rails, test_pullup_resistors, procedure_id="FCT-001", part_number="PCBA-100", ) with TofuPilot(test): test.execute(test_start=lambda: input("Scan serial number: "))if __name__ == "__main__": main()Every measurement (voltage, current, resistance) flows into TofuPilot with its name, value, limits, and units. You get FPY tracking, control charts, and Cpk analysis on each measurement without extra code.
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
VisaIOError: VI_ERROR_RSRC_NFOUND | Instrument not found | Check cable, verify IP/USB, run rm.list_resources() |
VisaIOError: VI_ERROR_TMO | Timeout | Increase instr.timeout, check if instrument is busy |
VisaIOError: VI_ERROR_CONN_LOST | Connection dropped | Check network, retry connection |
Empty response from query() | Instrument didn't respond | Add time.sleep() after write, check command syntax |
| USB instrument not detected | Missing driver | Install NI-VISA runtime or check USB-TMC kernel module (Linux) |
ValueError: could not convert string to float | Unexpected response format | Print raw response, check for error messages mixed in |