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
| Method | Hardware | Software | Fidelity | Speed | Cost |
|---|---|---|---|---|---|
| MIL (Model-in-the-Loop) | None | Simulated plant + controller | Low | Fast (seconds) | Low |
| SIL (Software-in-the-Loop) | None | Real controller code on host | Medium | Fast (seconds) | Low |
| HIL (Hardware-in-the-Loop) | Real target board | Real firmware | High | Real-time | Medium |
| Physical test | Real target + real plant | Real firmware | Highest | Real-time | High |
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:
| Component | Role | Example |
|---|---|---|
| Target board (DUT) | Runs firmware under test | STM32 dev board, ESP32, Raspberry Pi Pico |
| Test host | Runs Python test scripts | PC or Raspberry Pi |
| DAQ / GPIO adapter | Stimulate inputs, read outputs | Arduino, LabJack, NI DAQ |
| Power supply | Controlled power to DUT | Bench PSU (programmable) |
| Serial/debug | Flash firmware, send commands | USB-to-serial, J-Link, ST-Link |
| Instruments | Precision measurements | DMM, 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
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)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)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.0Step 2: Write the Phases
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 outputdef 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)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.yaml59 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: 52Run 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:
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.py47 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): passStep 2: Write the Phases
hil/test_phases.py42 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
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
| Situation | Choice |
|---|---|
| New HIL bench, many independent peripheral checks | TofuPilot Framework (parallel phases cut suite time) |
| Several target boards on one bench | TofuPilot Framework (multi-slot) |
| Validating waveform or sweep shape, not just endpoints | TofuPilot Framework (per-axis validators) |
| Existing OpenHTF suite already running in CI | OpenHTF, it runs natively on the CLI |
| One pure-Python paradigm, no YAML layer | OpenHTF |
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 Step | Action |
|---|---|
| 1. Code push | Developer pushes firmware change |
| 2. Build | CI compiles firmware binary |
| 3. Flash | CI flashes binary to target board (via J-Link/ST-Link) |
| 4. HIL test | CI runs the procedure on the bench |
| 5. Results | TofuPilot logs pass/fail per measurement |
| 6. Gate | CI 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:
tofupilot run ./procedure.yaml --json --ui-values ui.json --ui-timeout 300Common HIL Test Categories
| Category | What to Test | Measurements |
|---|---|---|
| GPIO | Input/output routing, interrupt response | Pin state, response time |
| ADC | Sensor reading accuracy, noise | Voltage values, noise floor |
| DAC | Output voltage accuracy | Voltage values |
| PWM | Frequency, duty cycle, resolution | Frequency, duty cycle |
| Communication | UART, SPI, I2C, CAN data integrity | Bit error rate, response time |
| Timing | Interrupt latency, task scheduling | Response time in microseconds |
| Power | Sleep mode current, wake-up time | Current draw, wake-up latency |
| Watchdog | Recovery from hang | Reset detection, recovery time |
HIL vs Production FCT
HIL and FCT test different things at different stages:
| HIL | FCT | |
|---|---|---|
| When | Development, every firmware build | Production, every manufactured board |
| What | Firmware behavior on real hardware | Board assembly and system function |
| DUT | Dev board or prototype | Production board |
| Changes between tests | Firmware (code changes) | Hardware (manufacturing variation) |
| Failure means | Firmware bug (code fix) | Assembly defect (rework or scrap) |
| Run frequency | Every CI build | Every unit manufactured |
| TofuPilot use | Track regressions across firmware versions | Track 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.

