Skip to content
Test Types & Methods

HIL Testing with Python

Learn how to set up HIL testing for embedded systems with Python, OpenHTF, and TofuPilot, including GPIO, ADC, PWM, and communication interface testing.

JJulien Buteau
intermediate13 min readMarch 14, 2026

Hardware-in-the-loop (HIL) testing validates embedded firmware by stimulating real hardware inputs and measuring real outputs. Unlike pure simulation, HIL tests run on the actual target hardware with real peripherals. This catches bugs that simulation misses: timing issues, ADC noise, interrupt conflicts, and peripheral initialization problems. This guide shows you how to build a Python-based HIL test system with OpenHTF and TofuPilot.

HIL vs SIL vs MIL

MethodHardwareSoftwareFidelitySpeedCost
MIL (Model-in-the-Loop)NoneSimulated plant + controllerLowFast (seconds)Low
SIL (Software-in-the-Loop)NoneReal controller code on hostMediumFast (seconds)Low
HIL (Hardware-in-the-Loop)Real target boardReal firmwareHighReal-timeMedium
Physical testReal target + real plantReal firmwareHighestReal-timeHigh

HIL sits between SIL and physical testing. You test real firmware on real hardware, but simulate the environment (sensors, actuators, external systems).

When to Use HIL Testing

  • Firmware regression testing. Every firmware build gets the same physical tests. Catches regressions that unit tests miss.
  • Peripheral validation. GPIO, ADC, DAC, PWM, UART, SPI, I2C. Real silicon behaves differently than simulation models.
  • Safety-critical systems. Automotive, medical, aerospace require HIL testing for certification (ISO 26262, IEC 62304).
  • Pre-production validation. Validate firmware on real hardware before committing to production.

HIL Test Architecture

A typical Python HIL setup:

ComponentRoleExample
Target board (DUT)Runs firmware under testSTM32 dev board, ESP32, Raspberry Pi Pico
Test hostRuns Python test scriptsPC or Raspberry Pi
DAQ / GPIO adapterStimulate inputs, read outputsArduino, LabJack, NI DAQ
Power supplyControlled power to DUTBench PSU (programmable)
Serial/debugFlash firmware, send commandsUSB-to-serial, J-Link, ST-Link
InstrumentsPrecision measurementsDMM, oscilloscope

Step 1: Create Hardware Interface Plugs

Each piece of test hardware gets an OpenHTF Plug. This keeps the test phases clean and instrument-agnostic.

hil/plugs.py
import openhtf as htf
from openhtf.plugs import BasePlug


class GpioPlug(BasePlug):
    """GPIO interface for stimulating DUT inputs and reading outputs.

    Replace with real GPIO adapter (e.g., LabJack, Arduino, RPi.GPIO).
    """

    def setUp(self):
        self._pin_states = {}

    def set_pin(self, pin: int, state: bool):
        """Set a GPIO pin on the test fixture (DUT input)."""
        self._pin_states[pin] = state

    def read_pin(self, pin: int) -> bool:
        """Read a GPIO pin from the DUT (DUT output)."""
        return self._pin_states.get(pin, False)

    def tearDown(self):
        self._pin_states.clear()


class AdcPlug(BasePlug):
    """ADC interface for reading analog outputs from DUT.

    Replace with real DAQ (e.g., NI DAQmx, LabJack, ADS1115).
    """

    def setUp(self):
        self._channels = {0: 1.65, 1: 2.50, 2: 0.33}

    def read_channel(self, channel: int) -> float:
        """Read an ADC channel voltage."""
        return self._channels.get(channel, 0.0)

    def tearDown(self):
        pass


class PwmPlug(BasePlug):
    """PWM measurement interface.

    Replace with real frequency counter or oscilloscope.
    """

    def setUp(self):
        pass

    def measure_frequency(self) -> float:
        """Measure PWM output frequency in Hz."""
        return 1000.0

    def measure_duty_cycle(self) -> float:
        """Measure PWM duty cycle in percent."""
        return 50.0

    def tearDown(self):
        pass

Step 2: Write GPIO Tests

The simplest HIL test: set an input, check the firmware drives the correct output.

hil/test_gpio.py
import openhtf as htf


@htf.measures(
    htf.Measurement("gpio_loopback")
    .equals(True)
    .doc("GPIO pin loopback: set input, verify output follows"),
)
@htf.plug(gpio=GpioPlug)
def test_gpio_loopback(test, gpio):
    """Verify firmware routes GPIO input to output correctly."""
    gpio.set_pin(1, True)  # Stimulate DUT input
    readback = gpio.read_pin(1)  # Read DUT output
    test.measurements.gpio_loopback = readback

Step 3: Write ADC/Sensor Tests

Simulate sensor inputs and verify the DUT reads them correctly.

hil/test_adc.py
import openhtf as htf
from openhtf.util import units


@htf.measures(
    htf.Measurement("adc_channel_0")
    .in_range(1.5, 1.8)
    .with_units(units.VOLT)
    .doc("ADC ch0: 1.65V reference voltage"),
    htf.Measurement("adc_channel_1")
    .in_range(2.3, 2.7)
    .with_units(units.VOLT)
    .doc("ADC ch1: 2.5V sensor output"),
)
@htf.plug(adc=AdcPlug)
def test_adc_readings(test, adc):
    """Verify ADC channels read expected voltages."""
    test.measurements.adc_channel_0 = adc.read_channel(0)
    test.measurements.adc_channel_1 = adc.read_channel(1)

Step 4: Write PWM Tests

Verify the firmware generates the correct PWM signal (frequency and duty cycle).

hil/test_pwm.py
import openhtf as htf
from openhtf.util import units


@htf.measures(
    htf.Measurement("pwm_frequency")
    .in_range(950, 1050)
    .with_units(units.HERTZ)
    .doc("PWM output frequency (target: 1kHz)"),
    htf.Measurement("pwm_duty_cycle")
    .in_range(48, 52)
    .doc("PWM duty cycle percentage (target: 50%)"),
)
@htf.plug(pwm=PwmPlug)
def test_pwm_output(test, pwm):
    """Verify PWM output frequency and duty cycle."""
    test.measurements.pwm_frequency = pwm.measure_frequency()
    test.measurements.pwm_duty_cycle = pwm.measure_duty_cycle()

Step 5: Assemble the HIL Test

Connect all phases with TofuPilot for tracking firmware version regressions over time.

hil/main.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot


def main():
    test = htf.Test(
        test_gpio_loopback,
        test_adc_readings,
        test_pwm_output,
        procedure_id="HIL-001",
        part_number="ECU-100",
    )
    with TofuPilot(test):
        test.execute(test_start=lambda: input("Firmware version: "))


if __name__ == "__main__":
    main()

Use the serial number prompt to enter the firmware version. This lets TofuPilot track which firmware builds pass or fail each test, building a regression history.

HIL in CI/CD

The real power of HIL testing comes from running it automatically on every firmware build.

CI/CD StepAction
1. Code pushDeveloper pushes firmware change
2. BuildCI compiles firmware binary
3. FlashCI flashes binary to target board (via J-Link/ST-Link)
4. HIL testCI runs OpenHTF test script
5. ResultsTofuPilot logs pass/fail per measurement
6. GateCI fails the build if any measurement is out of spec

This requires a dedicated test station connected to your CI runner. The test station has the target board, GPIO adapter, DAQ, and instruments. The CI runner (Jenkins, GitHub Actions self-hosted runner, etc.) triggers the test after each build.

Common HIL Test Categories

CategoryWhat to TestMeasurements
GPIOInput/output routing, interrupt responsePin state, response time
ADCSensor reading accuracy, noiseVoltage values, noise floor
DACOutput voltage accuracyVoltage values
PWMFrequency, duty cycle, resolutionFrequency, duty cycle
CommunicationUART, SPI, I2C, CAN data integrityBit error rate, response time
TimingInterrupt latency, task schedulingResponse time in microseconds
PowerSleep mode current, wake-up timeCurrent draw, wake-up latency
WatchdogRecovery from hangReset detection, recovery time

HIL vs Production FCT

HIL and FCT test different things at different stages:

HILFCT
WhenDevelopment, every firmware buildProduction, every manufactured board
WhatFirmware behavior on real hardwareBoard assembly and system function
DUTDev board or prototypeProduction board
Changes between testsFirmware (code changes)Hardware (manufacturing variation)
Failure meansFirmware bug (code fix)Assembly defect (rework or scrap)
Run frequencyEvery CI buildEvery unit manufactured
TofuPilot useTrack regressions across firmware versionsTrack FPY, Cpk, control charts

Many teams use the same OpenHTF test framework for both HIL and FCT. The plugs change (dev board GPIO vs. production fixture), but the test structure is the same.

More Guides

Put this guide into practice