Skip to content
Instrument Control

SCPI Commands in Python for Test Engineers

A reference for SCPI commands in Python using PyVISA, covering measurement types, triggering, error handling, and OpenHTF integration with TofuPilot.

JJulien Buteau
beginner13 min readMarch 14, 2026

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

install.sh
pip install pyvisa pyvisa-py

Connect to an instrument:

scpi/connect.py
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:

CommandPurposeReturns
*IDN?Identify instrumentManufacturer, model, serial, firmware
*RSTReset to factory defaultsNothing
*CLSClear status and error queueNothing
*OPC?Operation complete query"1" when done
*TST?Self-test0 = pass, nonzero = fail
*WAIWait for pending operationsNothing (blocks until done)

Always start a test with *RST and *CLS. This puts the instrument in a known state.

scpi/reset.py
dmm.write("*RST")
dmm.write("*CLS")

Measurement Commands

Two patterns: shorthand and explicit.

Shorthand (configure + trigger + read in one command):

scpi/measure_shorthand.py
# 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):

scpi/measure_explicit.py
# 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.

PatternWhen to UseCommands
:MEAS:...?Quick single reading1 command
:CONF: + :INIT + :FETCH?Precise timing, multi-channel3+ commands
:CONF: + :READ?Configure once, read repeatedly2 commands

Power Supply Commands

scpi/power_supply.py
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:

CommandPurpose
:INST:SEL CH1Select output channel
:VOLT 5.0Set voltage
:CURR 0.5Set current limit
:OUTP ON / :OUTP OFFEnable/disable output
:MEAS:VOLT?Read actual output voltage
:MEAS:CURR?Read actual output current
:VOLT:PROT 6.0Set over-voltage protection
:CURR:PROT 1.0Set over-current protection

Oscilloscope Commands

scpi/oscilloscope.py
# 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.

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

CodeMeaningTypical Cause
-100Command errorTypo in command string
-200Execution errorInvalid parameter value
-300Device-specific errorHardware issue
-400Query errorQuery without reading response
0No errorQueue empty

Triggering Models

SCPI defines several trigger sources. The right choice depends on your timing requirements.

Trigger SourceCommandUse Case
Immediate:TRIG:SOUR IMMDefault. Measures as soon as configured.
Bus:TRIG:SOUR BUSSoftware trigger with *TRG. Precise timing.
External:TRIG:SOUR EXTHardware trigger line. Synchronized with other instruments.
Timer:TRIG:SOUR TIMPeriodic measurements at fixed intervals.

Bus trigger example (precise timing control):

scpi/bus_trigger.py
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:

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

scpi/openhtf_integration.py
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

TaskSCPI 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

More Guides

Put this guide into practice