Skip to content
Instrument Control

Getting Started with PyVISA

Learn how to connect to and control test instruments (DMMs, oscilloscopes, power supplies) from Python using PyVISA, with TofuPilot measurement logging.

JJulien Buteau
beginner10 min readMarch 14, 2026

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

pyvisa_setup/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 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:

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 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.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 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

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
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.

pyvisa_setup/error_handling.py
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.

pyvisa_setup/production_test.py
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

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

More Guides

Put this guide into practice