Test Types & Methods

HIL Testing with Python

Set up HIL testing for embedded systems with Python, using the TofuPilot Framework or OpenHTF, covering GPIO, ADC, PWM and CI integration.

JJulien Buteau
intermediate13 min readMarch 14, 2026
Hardware-in-the-loop setup connecting a simulator and signal interface to a real controller, with simulated sensor inputs and controller outputs.

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 the TofuPilot Framework and with OpenHTF.

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

Option A: The TofuPilot Framework

A procedure YAML declares the sequence and the limits. Each piece of test hardware is a plug, a plain Python class. Phases are plain functions that receive the plugs by parameter name.

Step 1: Write the Plugs

plugs/gpio.py
class Gpio:    """GPIO interface for stimulating DUT inputs and reading outputs.    Replace with a real GPIO adapter (LabJack, Arduino, RPi.GPIO).    """    def __init__(self):        self._pin_states = {}    def set_pin(self, pin: int, state: bool):        self._pin_states[pin] = state    def read_pin(self, pin: int) -> bool:        return self._pin_states.get(pin, False)
plugs/adc.py
class Adc:    """ADC interface for reading analog outputs from the DUT.    Replace with a real DAQ (NI DAQmx, LabJack, ADS1115).    """    def read_channel(self, channel: int) -> float:        return {0: 1.65, 1: 2.50, 2: 0.33}.get(channel, 0.0)
plugs/pwm.py
class Pwm:    """PWM measurement interface.    Replace with a real frequency counter or oscilloscope.    """    def measure_frequency(self) -> float:        return 1000.0    def measure_duty_cycle(self) -> float:        return 50.0

Step 2: Write the Phases

phases/gpio_loopback.py
def gpio_loopback(measurements, gpio):    """Verify firmware routes GPIO input to output correctly."""    gpio.set_pin(1, True)                      # Stimulate DUT input    measurements.gpio_loopback = gpio.read_pin(1)  # Read DUT output
phases/adc_readings.py
def adc_readings(measurements, adc):    """Verify ADC channels read expected voltages."""    measurements.adc_channel_0 = adc.read_channel(0)    measurements.adc_channel_1 = adc.read_channel(1)
phases/pwm_output.py
def pwm_output(measurements, pwm):    """Verify PWM output frequency and duty cycle."""    measurements.pwm_frequency = pwm.measure_frequency()    measurements.pwm_duty_cycle = pwm.measure_duty_cycle()

Step 3: Declare the Procedure

procedure.yaml
59 lines
name: ECU HIL Testversion: 1.0.0unit:  serial_number:    default_value: "ECU000001"  part_number:    default_value: "ECU-100"plugs:  - name: gpio    python: plugs.gpio:Gpio  - name: adc    python: plugs.adc:Adc  - name: pwm    python: plugs.pwm:Pwmmain:  - name: GPIO Loopback    python: phases.gpio_loopback    measurements:      - name: GPIO Loopback        validators:          - operator: "=="            expected_value: true  - name: ADC Readings    python: phases.adc_readings    measurements:      - name: ADC Channel 0        unit: V        validators:          - operator: ">="            expected_value: 1.5          - operator: "<="            expected_value: 1.8      - name: ADC Channel 1        unit: V        validators:          - operator: ">="            expected_value: 2.3          - operator: "<="            expected_value: 2.7  - name: PWM Output    python: phases.pwm_output    measurements:      - name: PWM Frequency        unit: Hz        validators:          - operator: ">="            expected_value: 950          - operator: "<="            expected_value: 1050      - name: PWM Duty Cycle        unit: "%"        validators:          - operator: ">="            expected_value: 48          - operator: "<="            expected_value: 52

Run it with tofupilot run ./procedure.yaml. The three phases are independent, so the engine runs them in parallel, which matters when a HIL suite grows to dozens of peripheral checks.

For firmware regression tracking, record the firmware version as run metadata so the dashboard can filter runs by build:

phases/record_build.py
def record_build(run):    run.metadata["firmware_version"] = "2.1.0"

Option B: OpenHTF

OpenHTF attaches measurements and plugs to phase functions with decorators, then runs them in sequence.

Step 1: Create Hardware Interface Plugs

hil/plugs.py
47 lines
import openhtf as htffrom openhtf.plugs import BasePlugclass GpioPlug(BasePlug):    """GPIO interface for stimulating DUT inputs and reading outputs."""    def setUp(self):        self._pin_states = {}    def set_pin(self, pin: int, state: bool):        self._pin_states[pin] = state    def read_pin(self, pin: int) -> bool:        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."""    def setUp(self):        self._channels = {0: 1.65, 1: 2.50, 2: 0.33}    def read_channel(self, channel: int) -> float:        return self._channels.get(channel, 0.0)    def tearDown(self):        passclass PwmPlug(BasePlug):    """PWM measurement interface."""    def setUp(self):        pass    def measure_frequency(self) -> float:        return 1000.0    def measure_duty_cycle(self) -> float:        return 50.0    def tearDown(self):        pass

Step 2: Write the Phases

hil/test_phases.py
42 lines
import openhtf as htffrom openhtf.util import units@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)    test.measurements.gpio_loopback = gpio.read_pin(1)@htf.measures(    htf.Measurement("adc_channel_0")    .in_range(1.5, 1.8)    .with_units(units.VOLT),    htf.Measurement("adc_channel_1")    .in_range(2.3, 2.7)    .with_units(units.VOLT),)@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)@htf.measures(    htf.Measurement("pwm_frequency")    .in_range(950, 1050)    .with_units(units.HERTZ),    htf.Measurement("pwm_duty_cycle").in_range(48, 52),)@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 3: Assemble the Test

hil/main.py
import openhtf as htffrom tofupilot.openhtf import uploaddef main():    test = htf.Test(        test_gpio_loopback,        test_adc_readings,        test_pwm_output,        procedure_id="HIL-001",        part_number="ECU-100",    )    test.add_output_callbacks(upload())    test.execute(test_start=lambda: input("Firmware version: "))if __name__ == "__main__":    main()

Install the extra with pip install "tofupilot[openhtf]", or skip the callback and run the suite with tofupilot run. Use the serial number prompt to enter the firmware version so TofuPilot tracks which builds pass or fail each test.

Which to Use for HIL

SituationChoice
New HIL bench, many independent peripheral checksTofuPilot Framework (parallel phases cut suite time)
Several target boards on one benchTofuPilot Framework (multi-slot)
Validating waveform or sweep shape, not just endpointsTofuPilot Framework (per-axis validators)
Existing OpenHTF suite already running in CIOpenHTF, it runs natively on the CLI
One pure-Python paradigm, no YAML layerOpenHTF

Both run natively on the TofuPilot CLI and upload to the same dashboard, so the choice does not lock your data in.

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 the procedure on the bench
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.

For unattended runs, resolve the identify prompt from the procedure and pre-bake any operator inputs so nothing blocks:

ci/run_hil.sh
tofupilot run ./procedure.yaml --json --ui-values ui.json --ui-timeout 300

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 framework for both HIL and FCT. The plugs change (dev board GPIO vs. production fixture), but the procedure structure is the same.

More Guides

Put this guide into practice