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
| Approach | Teardown | Sharing Across Phases | Testability |
|---|---|---|---|
| Global variables | Manual, error-prone | Implicit | Hard to mock |
| Pass instruments as args | Manual | Explicit but verbose | Moderate |
| OpenHTF plugs | Automatic via tearDown | Declarative injection | Easy 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
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()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()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.
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()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.
power_on_dut → verify_power_rails → verify_firmware → verify_adc_calibration
↑ PSU only ↑ PSU + DMM ↑ Serial only ↑ Serial onlyOpenHTF 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
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:
- Instantiates
PSUPlug,DMMPlug, andSerialPlugbefore the first phase - Runs phases in order, injecting the shared instances
- Calls
tearDownon all three plugs after the last phase, regardless of pass/fail - 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.
def tearDown(self):
try:
self._instrument.write("OUTP:ALL OFF")
self._instrument.close()
except Exception:
pass # instrument may already be disconnectedSharing 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.
# 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 PSUPlugThis works without any extra configuration.
Comparison: Single vs. Multi-Instrument Test Structure
| Concern | Single instrument | Multiple instruments |
|---|---|---|
| Plug count | 1 | 1 per instrument |
| Phase declarations | @htf.plug(inst=MyPlug) | Each phase declares only what it needs |
| Teardown | Automatic | Automatic, one tearDown per plug |
| Sequencing | N/A | Power on before firmware, disable in reverse |
| TofuPilot upload | All measurements from one plug | All measurements from all plugs, unified |