Skip to content
Instrument Control

Control Multiple Instruments in a Test

Learn how to orchestrate multiple instruments (PSU, DMM, serial port) in a single OpenHTF test using plugs, with automatic lifecycle management and logging.

JJulien Buteau
intermediate10 min readMarch 14, 2026

Functional testing rarely involves a single instrument. A real board test powers the DUT through a PSU, measures voltage and current with a DMM, and validates firmware communication over a serial port. OpenHTF handles this through the plug system: each instrument gets its own plug, phases declare which plugs they need, and OpenHTF manages lifecycle and injection.

Prerequisites

  • Python 3.8+
  • TofuPilot account and API key
  • OpenHTF and TofuPilot installed (pip install openhtf tofupilot)
  • Instrument drivers (pyvisa, pyserial)

Why Multi-Instrument Tests Benefit from Plugs

ApproachTeardownSharing Across PhasesTestability
Global variablesManual, error-proneImplicitHard to mock
Pass instruments as argsManualExplicit but verboseModerate
OpenHTF plugsAutomatic via tearDownDeclarative injectionEasy to mock

Each plug is a class that owns one instrument connection. OpenHTF instantiates it once per test run, injects it into every phase that requests it, and calls tearDown when the test completes.

Step 1: Define One Plug Per Instrument

plugs/psu_plug.py
import openhtf as htf
import pyvisa

class PSUPlug(htf.plugs.BasePlug):
    """Controls a SCPI-compliant bench power supply."""

    RESOURCE = "USB0::0x1AB1::0x0E11::DP8B213601234::INSTR"

    def setUp(self):
        rm = pyvisa.ResourceManager()
        self._instrument = rm.open_resource(self.RESOURCE)
        self._instrument.timeout = 5000

    def enable_output(self, channel: int, voltage: float, current_limit: float):
        self._instrument.write(f"INST CH{channel}")
        self._instrument.write(f"VOLT {voltage}")
        self._instrument.write(f"CURR {current_limit}")
        self._instrument.write("OUTP ON")

    def disable_output(self, channel: int):
        self._instrument.write(f"INST CH{channel}")
        self._instrument.write("OUTP OFF")

    def measure_voltage(self, channel: int) -> float:
        self._instrument.write(f"INST CH{channel}")
        return float(self._instrument.query("MEAS:VOLT?"))

    def measure_current(self, channel: int) -> float:
        self._instrument.write(f"INST CH{channel}")
        return float(self._instrument.query("MEAS:CURR?"))

    def tearDown(self):
        self._instrument.write("OUTP:ALL OFF")
        self._instrument.close()
plugs/dmm_plug.py
import openhtf as htf
import pyvisa

class DMMPlug(htf.plugs.BasePlug):
    """Controls a 6.5-digit DMM over GPIB."""

    RESOURCE = "GPIB0::22::INSTR"

    def setUp(self):
        rm = pyvisa.ResourceManager()
        self._instrument = rm.open_resource(self.RESOURCE)
        self._instrument.timeout = 10000
        self._instrument.write("*RST")

    def measure_dc_voltage(self) -> float:
        self._instrument.write("CONF:VOLT:DC")
        return float(self._instrument.query("READ?"))

    def measure_resistance(self) -> float:
        self._instrument.write("CONF:RES")
        return float(self._instrument.query("READ?"))

    def tearDown(self):
        self._instrument.write("*RST")
        self._instrument.close()
plugs/serial_plug.py
import openhtf as htf
import serial
import time

class SerialPlug(htf.plugs.BasePlug):
    """Communicates with DUT firmware over UART."""

    PORT = "/dev/ttyUSB0"
    BAUDRATE = 115200

    def setUp(self):
        self._port = serial.Serial(
            port=self.PORT,
            baudrate=self.BAUDRATE,
            timeout=2.0
        )
        time.sleep(0.1)

    def send_command(self, command: str) -> str:
        self._port.write(f"{command}\r
".encode())
        return self._port.readline().decode().strip()

    def get_firmware_version(self) -> str:
        return self.send_command("VERSION?")

    def run_self_test(self) -> bool:
        return self.send_command("SELFTEST") == "PASS"

    def tearDown(self):
        self._port.close()

Step 2: Write Phases That Declare Their Plugs

Each phase receives the plugs it needs through the @htf.plug decorator. Phases only declare the instruments they actually use.

phases/power_phases.py
import time
import openhtf as htf
from openhtf.util import units
from plugs.psu_plug import PSUPlug
from plugs.dmm_plug import DMMPlug

@htf.plug(psu=PSUPlug)
@htf.measures(
    htf.Measurement("supply_voltage_v12")
    .in_range(minimum=11.8, maximum=12.2)
    .with_units(units.VOLT),
    htf.Measurement("supply_current")
    .in_range(minimum=0.05, maximum=2.0)
    .with_units(units.AMPERE),
)
def power_on_dut(test, psu):
    """Enable 12V rail and verify supply is within tolerance."""
    psu.enable_output(channel=1, voltage=12.0, current_limit=2.5)
    time.sleep(0.5)
    test.measurements.supply_voltage_v12 = psu.measure_voltage(channel=1)
    test.measurements.supply_current = psu.measure_current(channel=1)


@htf.plug(psu=PSUPlug, dmm=DMMPlug)
@htf.measures(
    htf.Measurement("output_rail_3v3")
    .in_range(minimum=3.267, maximum=3.333)
    .with_units(units.VOLT),
    htf.Measurement("load_resistance")
    .in_range(minimum=95.0, maximum=105.0)
    .with_units(units.OHM),
)
def verify_power_rails(test, psu, dmm):
    """Cross-check onboard 3.3V rail with external DMM measurement."""
    test.measurements.output_rail_3v3 = dmm.measure_dc_voltage()
    test.measurements.load_resistance = dmm.measure_resistance()
phases/firmware_phases.py
import openhtf as htf
from plugs.serial_plug import SerialPlug

EXPECTED_FW_VERSION = "2.4.1"

@htf.plug(serial=SerialPlug)
@htf.measures(
    htf.Measurement("firmware_version").equals(EXPECTED_FW_VERSION),
    htf.Measurement("self_test_passed").equals(True),
)
def verify_firmware(test, serial):
    """Check firmware version and run onboard self-test."""
    test.measurements.firmware_version = serial.get_firmware_version()
    test.measurements.self_test_passed = serial.run_self_test()


@htf.plug(serial=SerialPlug)
@htf.measures(
    htf.Measurement("adc_reading_mv")
    .in_range(minimum=1180, maximum=1220),
)
def verify_adc_calibration(test, serial):
    """Command DUT to report its ADC reading of the 1.2V reference."""
    response = serial.send_command("ADC:REF?")
    test.measurements.adc_reading_mv = float(response)

Step 3: Sequence Phases in the Correct Order

Instrument sequencing matters. Power the DUT before communicating with its firmware.

phase_order.txt
power_on_dut → verify_power_rails → verify_firmware → verify_adc_calibration
      ↑ PSU only      ↑ PSU + DMM         ↑ Serial only      ↑ Serial only

OpenHTF instantiates each plug once and reuses the same instance across all phases that request it. When power_on_dut and verify_power_rails both declare psu=PSUPlug, they receive the same PSUPlug instance.

Step 4: Assemble and Run the Full Test

test_board_functional.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot

from phases.power_phases import power_on_dut, verify_power_rails
from phases.firmware_phases import verify_firmware, verify_adc_calibration

def main():
    test = htf.Test(
        power_on_dut,
        verify_power_rails,
        verify_firmware,
        verify_adc_calibration,
        test_name="Board Functional Test",
    )

    with TofuPilot(test):
        test.execute(test_start=lambda: input("Enter DUT serial number: "))

if __name__ == "__main__":
    main()

When executed, OpenHTF:

  1. Instantiates PSUPlug, DMMPlug, and SerialPlug before the first phase
  2. Runs phases in order, injecting the shared instances
  3. Calls tearDown on all three plugs after the last phase, regardless of pass/fail
  4. TofuPilot uploads the full run with all measurements

Teardown Order and Safety

Plugs tear down in reverse instantiation order by default. Design your teardown to be safe regardless of order: always disable outputs in tearDown, never assume another plug is still active.

plugs/psu_plug.py
def tearDown(self):
    try:
        self._instrument.write("OUTP:ALL OFF")
        self._instrument.close()
    except Exception:
        pass  # instrument may already be disconnected

Sharing Instruments Across Phase Files

Because OpenHTF creates one plug instance per class per test run, you can split phases across multiple files and still share the same instrument connection.

test_board_functional.py
# Both files import the same PSUPlug class.
# OpenHTF injects the same instance into both phases.
from phases.power_phases import power_on_dut, verify_power_rails
from phases.stress_phases import run_load_step  # also uses PSUPlug

This works without any extra configuration.

Comparison: Single vs. Multi-Instrument Test Structure

ConcernSingle instrumentMultiple instruments
Plug count11 per instrument
Phase declarations@htf.plug(inst=MyPlug)Each phase declares only what it needs
TeardownAutomaticAutomatic, one tearDown per plug
SequencingN/APower on before firmware, disable in reverse
TofuPilot uploadAll measurements from one plugAll measurements from all plugs, unified

More Guides

Put this guide into practice