Instrument Control

Automate a Rigol Power Supply

Automate a Rigol power supply from Python: SCPI control, safe output sequencing, and recording load measurements in a test procedure.

JJulien Buteau
beginner10 min readMarch 14, 2026

Rigol DP800-series supplies (DP821, DP831, DP832) are common on test benches because they are programmable, multi-channel, and inexpensive. This guide covers controlling one from Python over SCPI, sequencing the output safely, and recording what you measure as structured test data.

Prerequisites

  • Python 3.10+
  • A Rigol DP800-series supply on USB or Ethernet
  • pip install pyvisa pyvisa-py

Connecting

rigol/connect.py
import pyvisarm = pyvisa.ResourceManager("@py")print(rm.list_resources())psu = rm.open_resource("TCPIP::192.168.1.101::INSTR")psu.timeout = 5000print(psu.query("*IDN?").strip())# RIGOL TECHNOLOGIES,DP832,DP8A1234567890,00.01.16

If the supply is on USB and is not discovered on Linux, the usual cause is udev permissions rather than PyVISA.

The Commands You Actually Need

CommandPurpose
:INST:NSEL 1Select channel by number (1, 2, 3)
:VOLT 5.0Set voltage on the selected channel
:CURR 0.5Set current limit on the selected channel
:OUTP CH1,ONEnable a channel's output
:OUTP CH1,OFFDisable it
:MEAS:VOLT? CH1Measure actual output voltage
:MEAS:CURR? CH1Measure actual output current
:MEAS:POWE? CH1Measure output power
:OUTP:OVP:VAL CH1,6.0Over-voltage protection threshold
:OUTP:OCP:VAL CH1,1.0Over-current protection threshold
:OUTP:OVP CH1,ONEnable OVP
:SYST:ERR?Read the error queue

Rigol accepts both :INST:NSEL n followed by bare :VOLT, and the channel-qualified :APPL CH1,5.0,0.5 form. The channel-qualified form is harder to get wrong in a script, because there is no hidden selected-channel state.

A Minimal Session

rigol/basic.py
26 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")# Configure channel 1 for 5 V with a 500 mA limitpsu.write(":APPL CH1,5.0,0.5")# Protection before the output is ever enabledpsu.write(":OUTP:OVP:VAL CH1,6.0")psu.write(":OUTP:OVP CH1,ON")psu.write(":OUTP CH1,ON")time.sleep(0.2)  # Let the rail settle before measuringv = float(psu.query(":MEAS:VOLT? CH1"))i = float(psu.query(":MEAS:CURR? CH1"))print(f"{v:.3f} V, {i:.4f} A")psu.write(":OUTP CH1,OFF")psu.close()

Two details that matter on a bench with a real board attached:

Set protection before enabling the output. OVP and OCP configured after the fact protect nothing during the inrush.

Measure after a settle delay. The supply will happily answer :MEAS:VOLT? mid-ramp and give you a number that is real but meaningless.

Wrapping It as a Plug

In a test, the supply belongs in a plug: opened once, shared by phases, and guaranteed to power down at the end even when a phase raises.

plugs/psu.py
54 lines
import timeimport pyvisaclass PowerSupply:    """Rigol DP800-series supply over SCPI."""    RESOURCE = "TCPIP::192.168.1.101::INSTR"    SETTLE_S = 0.2    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 configure(self, channel: int, volts: float, current_limit: float):        self.instr.write(f":APPL CH{channel},{volts},{current_limit}")        self.instr.write(f":OUTP:OVP:VAL CH{channel},{volts * 1.2:.3f}")        self.instr.write(f":OUTP:OVP CH{channel},ON")    def output(self, channel: int, on: bool):        self.instr.write(f":OUTP CH{channel},{'ON' if on else 'OFF'}")        if on:            time.sleep(self.SETTLE_S)    def measure_voltage(self, channel: int) -> float:        return float(self.instr.query(f":MEAS:VOLT? CH{channel}"))    def measure_current(self, channel: int) -> float:        return float(self.instr.query(f":MEAS:CURR? CH{channel}"))    def identify(self) -> str:        return self.instr.query("*IDN?").strip()    def errors(self) -> list[str]:        out = []        while True:            err = self.instr.query(":SYST:ERR?").strip()            if err.startswith("0,"):                break            out.append(err)        return out    def close(self):        """Always de-energise, even if a phase failed."""        for ch in (1, 2, 3):            try:                self.instr.write(f":OUTP CH{ch},OFF")            except Exception:                pass        self.instr.close()

close() is the important method. It runs at teardown whatever happened during the run, which is what stops a failed test leaving a board powered on the fixture overnight.

Using It in a Procedure

phases/power_up.py
def power_up(measurements, psu):    """Energise the board and confirm the rail came up under load."""    psu.configure(channel=1, volts=5.0, current_limit=0.5)    psu.output(1, True)    measurements.rail_5v = psu.measure_voltage(1)    measurements.inrush_current = psu.measure_current(1)
phases/idle_current.py
def idle_current(measurements, psu):    """Board is booted; measure the quiescent draw."""    measurements.idle_current = psu.measure_current(1)
phases/supply_health.py
def supply_health(measurements, psu):    measurements.supply_errors = len(psu.errors())
procedure.yaml
51 lines
name: Board Power-Up Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: psu    python: plugs.psu:PowerSupplymain:  - name: Power Up    python: phases.power_up    measurements:      - name: Rail 5V        unit: V        validators:          - operator: ">="            expected_value: 4.9          - operator: "<="            expected_value: 5.1      - name: Inrush Current        unit: A        validators:          - operator: "<="            expected_value: 0.45  - name: Idle Current    python: phases.idle_current    depends_on: [power_up]    measurements:      - name: Idle Current        unit: A        validators:          - operator: ">="            expected_value: 0.01          - operator: "<="            expected_value: 0.12  - name: Supply Health    python: phases.supply_health    depends_on: [idle_current]    measurements:      - name: Supply Errors        description: SCPI error queue depth after the sequence        validators:          - operator: "=="            expected_value: 0

Run it with tofupilot run ./procedure.yaml.

The phases are chained with depends_on deliberately. They share one VISA session, and independent phases run concurrently, so a shared instrument must be serialised or given one session per phase.

The current-limit setting is worth a comment: at 0.5 A limit with an inrush limit of 0.45 A, a board that trips into constant-current mode will read a voltage well below 4.9 V and fail the rail measurement rather than silently passing at a collapsed rail.

Recording the Supply Identity

phases/record_equipment.py
def record_equipment(run, psu):    run.metadata["psu_id"] = psu.identify()

Useful the first time two benches disagree and you need to know which supply produced which numbers.

Multi-Channel Boards

For a board with several rails, configure each channel and measure them together:

phases/all_rails.py
RAILS = {1: (5.0, 0.5), 2: (3.3, 0.5), 3: (1.8, 0.3)}def all_rails(measurements, psu):    for ch, (volts, limit) in RAILS.items():        psu.configure(ch, volts, limit)        psu.output(ch, True)    measurements.rail_5v = psu.measure_voltage(1)    measurements.rail_3v3 = psu.measure_voltage(2)    measurements.rail_1v8 = psu.measure_voltage(3)

Power-up order matters on many designs. If your board requires a sequence, enable the channels in that order with delays between them rather than all at once.

If You Already Run OpenHTF

The same supply wraps into a BasePlug:

rigol/openhtf_test.py
39 lines
import openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsfrom tofupilot.openhtf import uploadclass RigolPsu(BasePlug):    def setUp(self):        import pyvisa        rm = pyvisa.ResourceManager("@py")        self.instr = rm.open_resource("TCPIP::192.168.1.101::INSTR")        self.instr.timeout = 5000        self.instr.write("*RST")    def apply(self, channel: int, volts: float, limit: float):        self.instr.write(f":APPL CH{channel},{volts},{limit}")        self.instr.write(f":OUTP CH{channel},ON")    def measure_voltage(self, channel: int) -> float:        return float(self.instr.query(f":MEAS:VOLT? CH{channel}"))    def tearDown(self):        for ch in (1, 2, 3):            self.instr.write(f":OUTP CH{ch},OFF")        self.instr.close()@htf.measures(    htf.Measurement("rail_5v").in_range(4.9, 5.1).with_units(units.VOLT),)@htf.plug(psu=RigolPsu)def test_power_up(test, psu):    psu.apply(1, 5.0, 0.5)    test.measurements.rail_5v = psu.measure_voltage(1)test = htf.Test(test_power_up, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))

Install with pip install "tofupilot[openhtf]", or skip the callback and run it with tofupilot run.

Troubleshooting

ProblemCauseFix
Voltage reads far below setpointBoard is drawing the current limitRaise the limit, or treat it as a genuine failure
Reading taken mid-rampNo settle delay after :OUTP ONSleep before measuring
-113, "Undefined header"Command typo, or unsupported on this modelCheck the model's programming guide
Output stays on after a failed runTeardown did not executeDisable outputs in the plug's close()
Intermittent timeouts under parallel phasesOne VISA session used concurrentlySerialise with depends_on
USB device not found on Linuxudev permissionsAdd a udev rule for the Rigol vendor ID

More Guides

Put this guide into practice