Skip to content
Getting Started

How to Build a Test Sequencer with TofuPilot

Learn how to build a test sequencer with OpenHTF using phase ordering, skip logic, PhaseGroups, multi-SKU sequences, and TofuPilot result logging.

JJulien Buteau
intermediate10 min readMarch 14, 2026

A test sequencer runs ordered phases, decides which to skip, collects operator input, and records the outcome. This guide shows how to build one with OpenHTF and log structured results to TofuPilot.

Prerequisites

  • Python 3.8+
  • TofuPilot account and API key
  • OpenHTF installed: pip install openhtf tofupilot

What a Test Sequencer Does

A sequencer is more than a list of test steps. It controls:

ResponsibilityExample
Phase orderingPower-on before functional tests
Skip logicSkip RF calibration if hardware variant lacks antenna
Operator interactionPrompt for serial number, confirm visual inspection
Result aggregationSingle pass/fail outcome from all phases
TeardownPower off even when a phase fails

OpenHTF maps directly to these responsibilities. Each phase is a Python function. The test object sequences them, evaluates measurements, and streams results to TofuPilot.

Step 1: Define Phases

Each phase is a decorated function. Add measurements to capture numeric results with limits.

sequencer.py
import openhtf as htf
from openhtf.util import units

@htf.measures(
    htf.Measurement('supply_voltage')
    .in_range(minimum=4.75, maximum=5.25)
    .with_units(units.VOLT)
)
def power_on_test(test):
    voltage = read_supply_voltage()
    test.measurements.supply_voltage = voltage

@htf.measures(
    htf.Measurement('loop_back_result').equals(True)
)
def uart_loopback_test(test):
    result = send_uart_loopback()
    test.measurements.loop_back_result = result

@htf.measures(
    htf.Measurement('output_current')
    .in_range(minimum=0.9, maximum=1.1)
    .with_units(units.AMPERE)
)
def load_test(test):
    current = measure_output_current()
    test.measurements.output_current = current

Step 2: Use PhaseGroups for Setup and Teardown

PhaseGroup guarantees teardown runs even when a test phase fails. This is critical for hardware tests that hold relays, power supplies, or fixtures.

sequencer.py
from openhtf import PhaseGroup

def power_on(test):
    enable_power_supply()
    test.logger.info('Power supply enabled')

def power_off(test):
    # Runs even if functional_tests fails
    disable_power_supply()
    test.logger.info('Power supply disabled')

# Build the group: setup -> main -> teardown
test_group = PhaseGroup(
    setup=[power_on],
    main=[power_on_test, uart_loopback_test, load_test],
    teardown=[power_off],
)

teardown phases run regardless of outcome. setup failures skip main but still run teardown.

Step 3: Inject Plugs for Hardware Access

Plugs encapsulate instrument or fixture communication. Inject them with @htf.plug.

plugs/supply.py
import openhtf as htf

class PowerSupplyPlug(htf.plugs.BasePlug):
    def setUp(self):
        self._conn = open_supply_connection()

    def read_voltage(self):
        return self._conn.query('MEAS:VOLT?')

    def tearDown(self):
        self._conn.close()
sequencer.py
from openhtf.util import units
from plugs.supply import PowerSupplyPlug

@htf.plug(supply=PowerSupplyPlug)
@htf.measures(
    htf.Measurement('supply_voltage')
    .in_range(minimum=4.75, maximum=5.25)
    .with_units(units.VOLT)
)
def power_on_test(test, supply):
    test.measurements.supply_voltage = supply.read_voltage()

Note the @htf.plug(name=PlugClass) form. Don't use type hints for plug injection.

Step 4: Add Skip Logic for Conditional Execution

Return htf.PhaseResult.SKIP to bypass a phase based on DUT variant.

sequencer.py
import openhtf as htf

@htf.measures(
    htf.Measurement('rf_output_power')
    .in_range(minimum=18.0, maximum=22.0)
)
def rf_calibration_test(test):
    if not has_rf_module():
        test.logger.info('No RF module detected, skipping RF calibration')
        return htf.PhaseResult.SKIP
    power = measure_rf_output()
    test.measurements.rf_output_power = power

Keep skip decisions data-driven rather than hardcoded. Read hardware configuration in an earlier phase or from a config file.

Step 5: Build Multi-SKU Sequences

Different product variants need different phase sets. Compose sequences dynamically from a SKU map.

sequencer.py
from openhtf import PhaseGroup

# Base phases run on every SKU
BASE_PHASES = [power_on_test, uart_loopback_test]

# Extra phases per SKU
SKU_PHASES = {
    'MODEL_A': [load_test],
    'MODEL_B': [load_test, rf_calibration_test],
    'MODEL_C': [],  # base only
}

def build_sequence(sku: str):
    extra = SKU_PHASES.get(sku, [])
    return PhaseGroup(
        setup=[power_on],
        main=BASE_PHASES + extra,
        teardown=[power_off],
    )

Step 6: Log Results to TofuPilot

Wrap the test execution with TofuPilot. It captures every phase, measurement, and outcome.

sequencer.py
import openhtf as htf
from tofupilot.openhtf import TofuPilot

def main(sku: str = 'MODEL_A'):
    sequence = build_sequence(sku)

    test = htf.Test(
        sequence,
        test_name=f'Functional Test {sku}',
    )

    with TofuPilot(test):
        test.execute(test_start=lambda: input('Enter serial number: '))

if __name__ == '__main__':
    import sys
    sku = sys.argv[1] if len(sys.argv) > 1 else 'MODEL_A'
    main(sku)

Phase Outcome Reference

Return valueEffect
htf.PhaseResult.CONTINUEPhase passed, continue sequence
htf.PhaseResult.SKIPPhase skipped, continue sequence
htf.PhaseResult.STOPPhase failed, stop sequence (run teardown)
Measurement out of rangePhase fails automatically
Unhandled exceptionPhase fails, teardown runs

Full Example

sequencer.py
import sys
import openhtf as htf
from openhtf import PhaseGroup
from openhtf.util import units
from tofupilot.openhtf import TofuPilot

# -- Phases --

@htf.measures(
    htf.Measurement('supply_voltage')
    .in_range(minimum=4.75, maximum=5.25)
    .with_units(units.VOLT)
)
def power_on_test(test):
    test.measurements.supply_voltage = read_supply_voltage()

@htf.measures(
    htf.Measurement('uart_loopback').equals(True)
)
def uart_loopback_test(test):
    test.measurements.uart_loopback = send_uart_loopback()

@htf.measures(
    htf.Measurement('output_current')
    .in_range(minimum=0.9, maximum=1.1)
    .with_units(units.AMPERE)
)
def load_test(test):
    test.measurements.output_current = measure_output_current()

@htf.measures(
    htf.Measurement('rf_output_power')
    .in_range(minimum=18.0, maximum=22.0)
)
def rf_calibration_test(test):
    if not has_rf_module():
        return htf.PhaseResult.SKIP
    test.measurements.rf_output_power = measure_rf_output()

def power_on(test):
    enable_power_supply()

def power_off(test):
    disable_power_supply()

# -- Sequence builder --

BASE_PHASES = [power_on_test, uart_loopback_test]
SKU_PHASES = {
    'MODEL_A': [load_test],
    'MODEL_B': [load_test, rf_calibration_test],
}

def build_sequence(sku):
    return PhaseGroup(
        setup=[power_on],
        main=BASE_PHASES + SKU_PHASES.get(sku, []),
        teardown=[power_off],
    )

# -- Entry point --

def main():
    sku = sys.argv[1] if len(sys.argv) > 1 else 'MODEL_A'
    test = htf.Test(build_sequence(sku), test_name=f'Functional Test {sku}')
    with TofuPilot(test):
        test.execute(test_start=lambda: input('Enter serial number: '))

if __name__ == '__main__':
    main()

More Guides

Put this guide into practice