Guides

Building a PyVISA Plug for SCPI Instruments

Wrap a bench instrument in an OpenHTF plug with PyVISA — resource strings, *IDN? identification, SCPI query and write, timeouts, error checking, simulation mode and per-station configuration of the VISA address.

Last updated · Verified with OpenHTF 1.6.1

Most bench instruments — multimeters, power supplies, oscilloscopes, electronic loads, signal generators — speak SCPI over USB-TMC, GPIB, LAN (VXI-11 / HiSLIP) or serial. PyVISA gives them one Python API; an OpenHTF plug gives that API a managed lifecycle and a place in the test record.

Terminal
pip install pyvisa pyvisa-py

pyvisa-py is the pure-Python backend. On a bench with NI-VISA or Keysight IO Libraries installed you can skip it and PyVISA uses the vendor library.

Find the instrument

list_resources.py
import pyvisa

rm = pyvisa.ResourceManager()          # add '@py' to force the pyvisa-py backend
for name in rm.list_resources():
    try:
        inst = rm.open_resource(name)
        print(name, "->", inst.query("*IDN?").strip())
    except Exception as e:
        print(name, "->", e)
Terminal
USB0::0x2A8D::0x0101::MY53220001::INSTR -> Keysight Technologies,34461A,MY53220001,A.03.02
TCPIP0::192.168.1.50::inst0::INSTR      -> RIGOL TECHNOLOGIES,DP932E,DP9D264501253,00.01.13

The left column is the resource string. It includes the instrument's serial number for USB, or its IP for LAN, so it identifies one physical instrument.

The plug

dmm_plug.py
import pyvisa
from openhtf.core import base_plugs


class Dmm34461A(base_plugs.BasePlug):
    """Keysight 34461A digital multimeter over SCPI."""

    def __init__(self, resource: str, timeout_ms: int = 5000):
        self._rm = pyvisa.ResourceManager()
        self._inst = self._rm.open_resource(resource)
        self._inst.timeout = timeout_ms
        self._inst.read_termination = "\n"
        self._inst.write_termination = "\n"
        self._inst.write("*RST; *CLS")
        idn = self._inst.query("*IDN?").strip()
        self.logger.info("Connected: %s", idn)
        if "34461A" not in idn:
            raise RuntimeError(f"Unexpected instrument at {resource}: {idn}")

    def measure_vdc(self, range_v: float = 10, resolution_v: float = 1e-4) -> float:
        value = float(self._inst.query(f"MEAS:VOLT:DC? {range_v},{resolution_v}"))
        self._check_errors()
        return value

    def measure_idc(self, range_a: float = 1) -> float:
        value = float(self._inst.query(f"MEAS:CURR:DC? {range_a}"))
        self._check_errors()
        return value

    def _check_errors(self) -> None:
        # SCPI error queue: "+0,\"No error\"" when clean.
        err = self._inst.query("SYST:ERR?").strip()
        if not err.startswith("+0") and not err.startswith("0,"):
            raise RuntimeError(f"DMM error: {err}")

    def tearDown(self) -> None:
        # Always runs after the test, even on failure — leave the bench clean.
        try:
            self._inst.write("*RST")
        finally:
            self._inst.close()
            self._rm.close()

Configure the address per station

Hard-coding the resource string ties the script to one bench. Declare it as configuration and bind it to the constructor:

main.py
import openhtf as htf
from openhtf.util import configuration, units

from dmm_plug import Dmm34461A

CONF = configuration.CONF
DMM_RESOURCE = CONF.declare("dmm_resource", description="VISA resource string of the DMM")

DmmPlug = configuration.bind_init_args(Dmm34461A, DMM_RESOURCE)


@htf.plug(dmm=DmmPlug)
@htf.measures(
    htf.Measurement("vout").in_range(4.95, 5.05).with_units(units.VOLT),
    htf.Measurement("iq").in_range(maximum=0.020).with_units(units.AMPERE),
)
def measure_regulator(test, dmm):
    test.measurements.vout = dmm.measure_vdc(range_v=10)
    test.measurements.iq = dmm.measure_idc(range_a=0.1)


def main():
    test = htf.Test(measure_regulator)
    test.execute(lambda: "SN1234")


if __name__ == "__main__":
    main()
station_a.yaml
station_id: EOL-A
dmm_resource: USB0::0x2A8D::0x0101::MY53220001::INSTR
Terminal
python main.py --config-file station_a.yaml

Simulation mode

A simulate flag lets the script run on a laptop and in CI. Bind it the same way (the tutorial does this end to end):

dmm_plug.py
class Dmm34461A(base_plugs.BasePlug):
    def __init__(self, resource: str, simulate: bool = False):
        self.simulate = simulate
        if simulate:
            self.logger.warning("DMM in simulation mode")
            return
        ...

    def measure_vdc(self, range_v=10, resolution_v=1e-4):
        if self.simulate:
            return 5.0
        ...
main.py
SIMULATE = CONF.declare("simulate", default_value=False)
DmmPlug = configuration.bind_init_args(Dmm34461A, DMM_RESOURCE, SIMULATE)

Practical notes

  • Timeouts: set inst.timeout (ms) above the slowest measurement. A 10-PLC DMM reading at 50 Hz takes 200 ms; a long average can take seconds. A timeout raises pyvisa.VisaIOError, which ends the test as ERROR — list it in failure_exceptions (Test Options) only if it means the DUT is bad.
  • Terminations: USB-TMC handles message boundaries itself; serial and raw-socket connections need read_termination/write_termination set or every query hangs until timeout.
  • One ResourceManager per plug keeps teardown simple. Sharing one across plugs works too; close it once.
  • Several identical instruments: two bind_init_args with different config keys — see Multiple plug configuration.
  • Error queue: check SYST:ERR? after commands that can fail silently (out-of-range setpoints). Instruments accumulate errors otherwise and later readings become suspect.
  • *OPC? after *RST or a long setup command blocks until the instrument is ready.

On this page

First-pass yield
0%4.1
Track with TofuPilot