SCPI (Standard Commands for Programmable Instruments) is the command language that most modern test instruments speak. If your multimeter, power supply, or oscilloscope was made after 1990, it almost certainly supports SCPI. This guide covers the commands you'll use most in manufacturing test, how to send them from Python with PyVISA, and how to wire them into a production test procedure.
What Is SCPI
SCPI defines a standard set of text commands for controlling instruments. Instead of learning a different protocol for every vendor, you use the same command structure across Keysight, Rigol, Rohde & Schwarz, Tektronix, and others.
Commands follow a tree structure:
:MEASure
:VOLTage
:DC? → Measure DC voltage
:AC? → Measure AC voltage
:CURRent
:DC? → Measure DC current
:RESistance? → Measure resistance
The colon separates levels. A question mark means "query" (read a value). No question mark means "command" (set something).
Setup
pip install pyvisa pyvisa-pyConnect to an instrument:
import pyvisarm = pyvisa.ResourceManager("@py") # Pure Python backenddmm = rm.open_resource("TCPIP::192.168.1.100::INSTR")dmm.timeout = 5000 # 5 seconds# Every SCPI instrument responds to *IDN?idn = dmm.query("*IDN?")print(f"Connected: {idn.strip()}")Common SCPI Commands
IEEE 488.2 Mandatory Commands
Every SCPI instrument supports these:
| Command | Purpose | Returns |
|---|---|---|
*IDN? | Identify instrument | Manufacturer, model, serial, firmware |
*RST | Reset to factory defaults | Nothing |
*CLS | Clear status and error queue | Nothing |
*OPC? | Operation complete query | "1" when done |
*TST? | Self-test | 0 = pass, nonzero = fail |
*WAI | Wait for pending operations | Nothing (blocks until done) |
Always start a test with *RST and *CLS. This puts the instrument in a known state.
dmm.write("*RST")dmm.write("*CLS")Measurement Commands
Two patterns: shorthand and explicit.
Shorthand (configure + trigger + read in one command):
# DC voltage, auto-rangevoltage = float(dmm.query(":MEAS:VOLT:DC?"))# DC current, auto-rangecurrent = float(dmm.query(":MEAS:CURR:DC?"))# Resistance, auto-rangeresistance = float(dmm.query(":MEAS:RES?"))# AC voltageac_voltage = float(dmm.query(":MEAS:VOLT:AC?"))Explicit (separate configure, trigger, read for more control):
# Configure for DC voltage, 10V rangedmm.write(":CONF:VOLT:DC 10")# Trigger a measurementdmm.write(":INIT")# Wait for completiondmm.query("*OPC?")# Read the resultvoltage = float(dmm.query(":FETCH?"))Use the explicit pattern when you need precise timing control or when measuring multiple channels in sequence.
| Pattern | When to Use | Commands |
|---|---|---|
:MEAS:...? | Quick single reading | 1 command |
:CONF: + :INIT + :FETCH? | Precise timing, multi-channel | 3+ commands |
:CONF: + :READ? | Configure once, read repeatedly | 2 commands |
Power Supply Commands
scpi/power_supply.py27 lines
import pyvisaimport timerm = pyvisa.ResourceManager("@py")psu = rm.open_resource("TCPIP::192.168.1.101::INSTR")psu.timeout = 5000psu.write("*RST")psu.write("*CLS")# Select channel and configurepsu.write(":INST:SEL CH1")psu.write(":VOLT 5.0") # Set 5V outputpsu.write(":CURR 0.5") # 500mA current limit# Enable outputpsu.write(":OUTP ON")time.sleep(0.5) # Wait for stabilization# Read actual valuesactual_v = float(psu.query(":MEAS:VOLT?"))actual_i = float(psu.query(":MEAS:CURR?"))print(f"Output: {actual_v:.3f}V, {actual_i:.4f}A")# Always disable output at the endpsu.write(":OUTP OFF")psu.close()Common power supply commands:
| Command | Purpose |
|---|---|
:INST:SEL CH1 | Select output channel |
:VOLT 5.0 | Set voltage |
:CURR 0.5 | Set current limit |
:OUTP ON / :OUTP OFF | Enable/disable output |
:MEAS:VOLT? | Read actual output voltage |
:MEAS:CURR? | Read actual output current |
:VOLT:PROT 6.0 | Set over-voltage protection |
:CURR:PROT 1.0 | Set over-current protection |
Oscilloscope Commands
# Basic oscilloscope setupscope = rm.open_resource("TCPIP::192.168.1.102::INSTR")scope.timeout = 10000scope.write("*RST")scope.write(":CHAN1:DISP ON") # Enable channel 1scope.write(":CHAN1:SCAL 1.0") # 1V/divscope.write(":TIM:SCAL 0.001") # 1ms/divscope.write(":TRIG:EDGE:SOUR CHAN1") # Trigger on channel 1scope.write(":TRIG:EDGE:LEV 1.5") # Trigger at 1.5V# Measure frequency and amplitudefrequency = float(scope.query(":MEAS:FREQ? CHAN1"))amplitude = float(scope.query(":MEAS:VAMP? CHAN1"))Error Handling
Instruments report errors through a queue. Always drain the queue after a command sequence.
def check_errors(instr) -> list[str]: """Read all errors from the SCPI 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# After a sequence of commandserrors = check_errors(dmm)if errors: for e in errors: print(f" Error: {e}") raise RuntimeError(f"Instrument errors: {errors}")Common SCPI error codes:
| Code | Meaning | Typical Cause |
|---|---|---|
| -100 | Command error | Typo in command string |
| -200 | Execution error | Invalid parameter value |
| -300 | Device-specific error | Hardware issue |
| -400 | Query error | Query without reading response |
| 0 | No error | Queue empty |
Triggering Models
SCPI defines several trigger sources. The right choice depends on your timing requirements.
| Trigger Source | Command | Use Case |
|---|---|---|
| Immediate | :TRIG:SOUR IMM | Default. Measures as soon as configured. |
| Bus | :TRIG:SOUR BUS | Software trigger with *TRG. Precise timing. |
| External | :TRIG:SOUR EXT | Hardware trigger line. Synchronized with other instruments. |
| Timer | :TRIG:SOUR TIM | Periodic measurements at fixed intervals. |
Bus trigger example (precise timing control):
dmm.write(":CONF:VOLT:DC 10") # Configure rangedmm.write(":TRIG:SOUR BUS") # Bus trigger modedmm.write(":INIT") # Arm the trigger system# ... do other setup ...dmm.write("*TRG") # Send the triggerdmm.query("*OPC?") # Wait for completionreading = float(dmm.query(":FETCH?"))Vendor-Specific Differences
SCPI is a standard, but vendors add their own extensions. Common differences:
| Feature | Keysight | Rigol | Rohde & Schwarz |
|---|---|---|---|
| Channel select | :INST:SEL CH1 | :INST CH1 | :INST:SEL 1 |
| Screenshot | :DISP:DATA? PNG | :DISP:DATA? | :HCOP:DATA? |
| Error query | :SYST:ERR? | :SYST:ERR? | :SYST:ERR:ALL? |
| Beeper | :SYST:BEEP | :SYST:BEEP:STAT ON | :SYST:BEEP:IMM |
Always check your instrument's programming manual for exact syntax. The core measurement commands (:MEAS:, :CONF:, :INIT, :FETCH?) are consistent across vendors.
Using SCPI in a Production Test
In a production test, the SCPI session belongs in a plug so the connection opens once, is shared across phases, and closes even when a phase fails.
With the TofuPilot Framework
A plug is a plain Python class. TofuPilot injects it into any phase that names it as a parameter.
plugs/dmm.py29 lines
import pyvisaclass Multimeter: """SCPI multimeter plug. Opens on first use, closes at teardown.""" RESOURCE = "TCPIP::192.168.1.100::INSTR" def __init__(self): rm = pyvisa.ResourceManager("@py") self.instr = rm.open_resource(self.RESOURCE) self.instr.timeout = 5000 self.instr.write("*RST") self.instr.write("*CLS") def measure_dc_voltage(self, range_v: str = "AUTO") -> float: 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: 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: self.instr.write(f":CONF:RES {range_ohm}") return float(self.instr.query(":MEAS:RES?")) def close(self): self.instr.close()def power_rails(measurements, dmm): """Measure the 3.3V rail with the SCPI multimeter.""" measurements.rail_3v3 = dmm.measure_dc_voltage()procedure.yaml24 lines
name: PCBA Functional Testversion: 1.0.0unit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: dmm python: plugs.dmm:Multimetermain: - name: Power Rails python: phases.power_rails measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4Run it with tofupilot run ./procedure.yaml. The limits live in the YAML as data, so the phase stays a plain function that assigns a reading.
With OpenHTF
OpenHTF wraps the same SCPI session in a BasePlug with setUp and tearDown.
scpi/openhtf_integration.py41 lines
import pyvisaimport openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass ScpiDmm(BasePlug): """SCPI multimeter plug with automatic lifecycle.""" 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") def measure_dc_voltage(self, range_v: str = "AUTO") -> float: self.instr.write(f":CONF:VOLT:DC {range_v}") return float(self.instr.query(":MEAS:VOLT:DC?")) def tearDown(self): self.instr.close()@htf.measures( htf.Measurement("voltage_3v3") .in_range(3.2, 3.4) .with_units(units.VOLT),)@htf.plug(dmm=ScpiDmm)def test_rail(test, dmm): test.measurements.voltage_3v3 = dmm.measure_dc_voltage()def main(): test = htf.Test(test_rail, procedure_id="FCT-001", part_number="PCBA-100") test.add_output_callbacks(upload()) test.execute(test_start=lambda: input("Serial: "))Install the extra with pip install "tofupilot[openhtf]", or skip the callback and run the suite with tofupilot run.
One practical note for either framework: a single instrument shared by phases that run in parallel needs care. The TofuPilot Framework can run independent phases concurrently, so either give each phase its own instrument, or serialize the phases that share one with depends_on.
Quick Reference
| Task | SCPI Command |
|---|---|
| Identify | *IDN? |
| Reset | *RST |
| Clear errors | *CLS |
| Measure DC voltage | :MEAS:VOLT:DC? |
| Measure DC current | :MEAS:CURR:DC? |
| Measure resistance | :MEAS:RES? |
| Set voltage (PSU) | :VOLT 5.0 |
| Set current limit (PSU) | :CURR 0.5 |
| Output on/off (PSU) | :OUTP ON / :OUTP OFF |
| Read error | :SYST:ERR? |
| Wait for complete | *OPC? |
| Software trigger | *TRG |
