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.
pip install pyvisa pyvisa-pypyvisa-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
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)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.13The 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
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:
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_id: EOL-A
dmm_resource: USB0::0x2A8D::0x0101::MY53220001::INSTRpython main.py --config-file station_a.yamlSimulation 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):
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
...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 raisespyvisa.VisaIOError, which ends the test asERROR— list it infailure_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_terminationset or every query hangs until timeout. - One
ResourceManagerper plug keeps teardown simple. Sharing one across plugs works too; close it once. - Several identical instruments: two
bind_init_argswith 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*RSTor a long setup command blocks until the instrument is ready.
Related
All the things
The upstream all_the_things.py example — configuration-bound plugs, a frontend-aware plug, a monitor, htf.Dimension, attachments, templated limits with with_args, test metadata, and three output callbacks (pickle, JSON, console) in one script.
pyserial plug
Talk to a DUT or instrument over a UART from an OpenHTF plug with pyserial — port discovery, command/response with timeouts, line framing, reading a serial number and firmware version into measurements, and capturing a boot log as an attachment.