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 pyvisa
rm = pyvisa.ResourceManager("@py") # Use pyvisa-py backend
# rm = pyvisa.ResourceManager() # Use NI-VISA backend
# List all connected instruments
resources = 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 pyvisa
rm = pyvisa.ResourceManager("@py")
dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")
# Set timeout (milliseconds)
dmm.timeout = 5000
# Query instrument identity
idn = 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 pyvisa
rm = pyvisa.ResourceManager("@py")
dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")
dmm.timeout = 5000
# Configure for DC voltage measurement, auto-range
dmm.write(":CONF:VOLT:DC AUTO")
# Trigger a measurement and read the result
dmm.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 pyvisa
import time
rm = pyvisa.ResourceManager("@py")
psu = rm.open_resource("TCPIP::192.168.1.101::INSTR")
psu.timeout = 5000
# Reset to known state
psu.write("*RST")
psu.write("*CLS")
# Configure channel 1: 5V, 500mA current limit
psu.write(":INST:SEL CH1")
psu.write(":VOLT 5.0")
psu.write(":CURR 0.5")
# Enable output
psu.write(":OUTP ON")
time.sleep(0.5) # Wait for output to stabilize
# Read actual voltage and current
actual_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 output
psu.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 pyvisa
def 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 errors
rm = pyvisa.ResourceManager("@py")
dmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")
dmm.timeout = 5000
# Send commands
dmm.write("*RST")
dmm.write(":CONF:VOLT:DC AUTO")
dmm.write(":INIT")
voltage = float(dmm.query(":FETCH?"))
# Check for errors
errors = 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 pyvisa
import openhtf as htf
from openhtf.plugs import BasePlug
from openhtf.util import units
from tofupilot.openhtf import TofuPilot
class 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 |