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 integrate them into production tests with OpenHTF and TofuPilot.
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 pyvisa
rm = pyvisa.ResourceManager("@py") # Pure Python backend
dmm = 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-range
voltage = float(dmm.query(":MEAS:VOLT:DC?"))
# DC current, auto-range
current = float(dmm.query(":MEAS:CURR:DC?"))
# Resistance, auto-range
resistance = float(dmm.query(":MEAS:RES?"))
# AC voltage
ac_voltage = float(dmm.query(":MEAS:VOLT:AC?"))Explicit (separate configure, trigger, read for more control):
# Configure for DC voltage, 10V range
dmm.write(":CONF:VOLT:DC 10")
# Trigger a measurement
dmm.write(":INIT")
# Wait for completion
dmm.query("*OPC?")
# Read the result
voltage = 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
import pyvisa
import time
rm = pyvisa.ResourceManager("@py")
psu = rm.open_resource("TCPIP::192.168.1.101::INSTR")
psu.timeout = 5000
psu.write("*RST")
psu.write("*CLS")
# Select channel and configure
psu.write(":INST:SEL CH1")
psu.write(":VOLT 5.0") # Set 5V output
psu.write(":CURR 0.5") # 500mA current limit
# Enable output
psu.write(":OUTP ON")
time.sleep(0.5) # Wait for stabilization
# Read actual values
actual_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 end
psu.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 setup
scope = rm.open_resource("TCPIP::192.168.1.102::INSTR")
scope.timeout = 10000
scope.write("*RST")
scope.write(":CHAN1:DISP ON") # Enable channel 1
scope.write(":CHAN1:SCAL 1.0") # 1V/div
scope.write(":TIM:SCAL 0.001") # 1ms/div
scope.write(":TRIG:EDGE:SOUR CHAN1") # Trigger on channel 1
scope.write(":TRIG:EDGE:LEV 1.5") # Trigger at 1.5V
# Measure frequency and amplitude
frequency = 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 commands
errors = 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 range
dmm.write(":TRIG:SOUR BUS") # Bus trigger mode
dmm.write(":INIT") # Arm the trigger system
# ... do other setup ...
dmm.write("*TRG") # Send the trigger
dmm.query("*OPC?") # Wait for completion
reading = 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.
Integration with OpenHTF and TofuPilot
Wrap SCPI commands in an OpenHTF Plug for production tests. The plug handles 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 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 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 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")
with TofuPilot(test):
test.execute(test_start=lambda: input("Serial: "))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 |