Hardware-in-the-loop (HIL) testing lets you verify embedded firmware against real hardware without manually poking at it with a multimeter. This guide walks through building a Python HIL test that controls GPIOs, reads analog signals, talks to the DUT over serial, and logs everything to TofuPilot.
Prerequisites
- Python 3.8+
- OpenHTF installed (
pip install openhtf) - TofuPilot Python client (
pip install tofupilot) - A DUT with GPIO, ADC, and UART interfaces
- A test fixture (Raspberry Pi, NI DAQ, or similar IO hardware)
- PySerial (
pip install pyserial)
What Is HIL Testing?
In a HIL setup, your DUT connects to a test fixture that simulates the real-world signals it would see in production. The fixture generates stimuli (voltage levels, digital pulses, serial commands) and measures the DUT's responses. Your test script orchestrates the whole sequence.
A typical HIL bench looks like this:
- Test host (PC or Raspberry Pi) runs the test script
- Digital IO (GPIO pins or a digital IO card) toggles inputs and reads outputs on the DUT
- Analog IO (DAQ or ADC/DAC) generates analog stimuli and measures DUT outputs
- Serial link (UART, SPI, I2C) sends commands and reads status from the DUT firmware
The test script drives all three channels, checks the DUT's behavior at each step, and records pass/fail results.
Step 1: Control Digital IO with GPIO
Most HIL setups need to toggle DUT inputs and read DUT outputs. If you're using a Raspberry Pi as your test host, RPi.GPIO works fine. For a PC-based setup, you'd swap this for your IO card's SDK.
import openhtf as htf
from openhtf.plugs import BasePlug
try:
import RPi.GPIO as GPIO
except ImportError:
GPIO = None # Allows development on non-Pi machines
class GpioPowerPlug(BasePlug):
"""Controls DUT power and reads digital status pins."""
POWER_PIN = 17
STATUS_PIN = 27
def setUp(self):
if GPIO is None:
raise RuntimeError("RPi.GPIO not available on this platform")
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.POWER_PIN, GPIO.OUT, initial=GPIO.LOW)
GPIO.setup(self.STATUS_PIN, GPIO.IN, pull_up_down=GPIO.PUD_DOWN)
def power_on(self):
GPIO.output(self.POWER_PIN, GPIO.HIGH)
def power_off(self):
GPIO.output(self.POWER_PIN, GPIO.LOW)
def read_status(self):
return GPIO.input(self.STATUS_PIN)
def tearDown(self):
GPIO.output(self.POWER_PIN, GPIO.LOW)
GPIO.cleanup([self.POWER_PIN, self.STATUS_PIN])The plug handles setup and teardown automatically. OpenHTF calls setUp() before the first phase that uses it and tearDown() after the test finishes, so your DUT always gets powered off cleanly.
Step 2: Read Analog Signals with a DAQ
For analog measurements, you'll need an ADC or DAQ. This example uses an MCC DAQ (via the uldaq library), but the pattern works the same with NI-DAQmx, Labjack, or an ADS1115 on I2C.
from openhtf.plugs import BasePlug
from uldaq import DaqDevice, InterfaceType, AiInputMode, Range
class AnalogInputPlug(BasePlug):
"""Reads analog voltages from a USB DAQ."""
def setUp(self):
devices = DaqDevice.get_inventory(InterfaceType.USB)
if not devices:
raise RuntimeError("No DAQ device found")
self.daq = DaqDevice(devices[0])
self.daq.connect()
self.ai = self.daq.get_ai_device()
def read_voltage(self, channel: int) -> float:
"""Read a single-ended voltage from the specified channel."""
return self.ai.a_in(channel, AiInputMode.SINGLE_ENDED, Range.BIP10VOLTS, 0)
def tearDown(self):
self.daq.disconnect()
self.daq.release()Step 3: Communicate with the DUT over Serial
Most embedded targets expose a UART debug console or command interface. Wrap it in a plug so OpenHTF manages the connection lifecycle.
import time
import serial
from openhtf.plugs import BasePlug
class SerialCommandPlug(BasePlug):
"""Sends commands to the DUT over UART and reads responses."""
PORT = "/dev/ttyUSB0"
BAUDRATE = 115200
TIMEOUT = 2.0
def setUp(self):
self.ser = serial.Serial(self.PORT, self.BAUDRATE, timeout=self.TIMEOUT)
time.sleep(0.5) # Wait for DUT bootloader
self.ser.reset_input_buffer()
def send_command(self, cmd: str) -> str:
"""Send a command and return the response line."""
self.ser.write(f"{cmd}\r
".encode())
return self.ser.readline().decode().strip()
def get_firmware_version(self) -> str:
return self.send_command("VERSION")
def tearDown(self):
self.ser.close()Step 4: Write OpenHTF Phases
Each test phase exercises one aspect of the DUT. Plug injection uses the @htf.plug decorator.
import time
import openhtf as htf
from openhtf.util import units
from hil_test.gpio_plug import GpioPowerPlug
from hil_test.analog_plug import AnalogInputPlug
from hil_test.serial_plug import SerialCommandPlug
@htf.plug(gpio=GpioPowerPlug)
@htf.measures(
htf.Measurement("boot_status").equals(1)
)
def power_on_and_check_boot(test, gpio):
"""Power on the DUT and verify it boots."""
gpio.power_on()
time.sleep(2.0) # Wait for boot
test.measurements.boot_status = gpio.read_status()
@htf.plug(serial=SerialCommandPlug)
@htf.measures(
htf.Measurement("firmware_version")
)
def read_firmware_version(test, serial):
"""Query the DUT firmware version over UART."""
version = serial.get_firmware_version()
test.measurements.firmware_version = version
@htf.plug(gpio=GpioPowerPlug, adc=AnalogInputPlug)
@htf.measures(
htf.Measurement("vout_3v3").in_range(minimum=3.1, maximum=3.5).with_units(units.VOLT),
htf.Measurement("vout_5v0").in_range(minimum=4.75, maximum=5.25).with_units(units.VOLT),
)
def measure_power_rails(test, gpio, adc):
"""Verify the DUT power rails are within spec."""
test.measurements.vout_3v3 = adc.read_voltage(channel=0)
test.measurements.vout_5v0 = adc.read_voltage(channel=1)
@htf.plug(serial=SerialCommandPlug, adc=AnalogInputPlug)
@htf.measures(
htf.Measurement("dac_output_1v0").in_range(minimum=0.95, maximum=1.05).with_units(units.VOLT),
)
def test_dac_output(test, serial, adc):
"""Command the DUT to output 1.0V on its DAC, then measure it."""
serial.send_command("DAC SET 1000") # 1000 mV
time.sleep(0.5)
test.measurements.dac_output_1v0 = adc.read_voltage(channel=2)
@htf.plug(gpio=GpioPowerPlug)
def power_off(test, gpio):
"""Shut down the DUT."""
gpio.power_off()Step 5: Integrate with TofuPilot
Wrap the OpenHTF test execution in TofuPilot to automatically log results, measurements, and DUT metadata.
import openhtf as htf
from tofupilot.openhtf import TofuPilot
from hil_test.phases import (
power_on_and_check_boot,
read_firmware_version,
measure_power_rails,
test_dac_output,
power_off,
)
test = htf.Test(
power_on_and_check_boot,
read_firmware_version,
measure_power_rails,
test_dac_output,
power_off,
)
with TofuPilot(test):
test.execute(test_start=lambda: input("Scan DUT serial number: "))Every test run uploads to TofuPilot with the DUT serial number, all measurements and limits, and pass/fail status per phase.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
RuntimeError: RPi.GPIO not available | Running on a non-Pi machine | Use a GPIO simulator or deploy to the Pi |
serial.SerialException: could not open port | Wrong port or DUT not powered | Check PORT constant, verify DUT has power |
No DAQ device found | DAQ not connected or driver missing | Run lsusb to verify, install uldaq drivers |
| ADC reads 0V on all channels | Wrong input mode or range | Confirm SINGLE_ENDED vs DIFFERENTIAL, check wiring |
| DUT doesn't respond to serial commands | Baud rate mismatch or wrong line ending | Match DUT firmware settings, try `\r |
vs | ||
| ` | ||
| Measurements intermittently fail | Settling time too short after stimulus | Increase time.sleep() after state changes |