Skip to content
Test Types & Methods

HIL Testing for Embedded Systems

Set up hardware-in-the-loop testing for embedded systems using Python, GPIO control, and TofuPilot logging for automated regression analysis.

JJulien Buteau
intermediate12 min readMarch 14, 2026

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.

hil_test/gpio_plug.py
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.

hil_test/analog_plug.py
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.

hil_test/serial_plug.py
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.

hil_test/phases.py
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.

hil_test/main.py
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

SymptomLikely CauseFix
RuntimeError: RPi.GPIO not availableRunning on a non-Pi machineUse a GPIO simulator or deploy to the Pi
serial.SerialException: could not open portWrong port or DUT not poweredCheck PORT constant, verify DUT has power
No DAQ device foundDAQ not connected or driver missingRun lsusb to verify, install uldaq drivers
ADC reads 0V on all channelsWrong input mode or rangeConfirm SINGLE_ENDED vs DIFFERENTIAL, check wiring
DUT doesn't respond to serial commandsBaud rate mismatch or wrong line endingMatch DUT firmware settings, try `\r
vs
`
Measurements intermittently failSettling time too short after stimulusIncrease time.sleep() after state changes

More Guides

Put this guide into practice