Migrating from Legacy Systems

Migrate OpenHTF Scripts to TofuPilot CLI

Run existing OpenHTF scripts on the TofuPilot CLI with no rewrite, and decide which ones are worth porting to the TofuPilot Framework.

JJulien Buteau
intermediate8 min readMay 9, 2026
Validate one test before a wider rollout.
Existing test
Connect results
Compare one run
Roll out

If you already have OpenHTF test scripts running in production, the first thing worth knowing is that you do not have to rewrite them. The TofuPilot CLI runs OpenHTF natively: phases, measurements, logs, attachments and operator prompts stream to the dashboard with no code changes. This guide covers that path, and then the separate question of when porting to the TofuPilot Framework is worth the effort.

Step 1: Run Your Existing Script on the CLI

Install the CLI:

install.sh
curl -fsSL https://www.tofupilot.app/install | sh

Point it at your existing OpenHTF entry point:

run.sh
tofupilot run ./main.py

That is the whole migration for most scripts. The CLI detects the OpenHTF test, executes it, and streams the run. You get real-time streaming, the operator UI, offline queueing and deploy-on-push without touching the test code.

Step 2: Decide How Results Reach the Dashboard

There are two supported paths, and they are not exclusive.

Through the CLI. Run with --upload on a linked procedure. Nothing in the script changes.

upload.sh
tofupilot logintofupilot linktofupilot run ./main.py --upload

Through the Python callback. If you manage your own distribution and do not want the CLI in the loop, the tofupilot package provides an OpenHTF output callback:

install_sdk.sh
pip install "tofupilot[openhtf]"
main.py
20 lines
import openhtf as htffrom tofupilot.openhtf import uploaddef phase_one(test):    return htf.PhaseResult.CONTINUEdef main():    test = htf.Test(        phase_one,        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",        part_number="PCB01",    )    test.add_output_callbacks(upload())    test.execute(lambda: "SN-0001")if __name__ == "__main__":    main()

upload() reads TOFUPILOT_API_KEY from the environment. add_output_callbacks is OpenHTF's own extension point, so the callback composes with any others you already use, such as json_factory.

For a self-hosted instance with a private CA:

main.py
test.add_output_callbacks(upload(    url="https://tofupilot.yourcompany.com",    verify="/path/to/ca-certificate.pem",))

Step 3: Migrate Off the Deprecated Context Manager

If your scripts use with TofuPilot(test):, that wrapper is deprecated. On tofupilot>=2.16.0 it still works as a thin wrapper around the same callback and emits a FutureWarning. Versions 2.15.0 to 2.15.2 do not include it at all and raise ImportError.

Replace it with the two-line callback form:

before.py
from tofupilot import TofuPilotwith TofuPilot(test):    test.execute(test_start=lambda: input("Serial: "))
after.py
from tofupilot.openhtf import uploadtest.add_output_callbacks(upload())test.execute(test_start=lambda: input("Serial: "))

Three details when you make that change:

OpenHTF is an extra since 2.15.0. Install tofupilot[openhtf], not plain tofupilot, so a station that only uses the API client does not pull a test framework it never imports.

MQTT streaming is gone. stream=True is accepted but ignored with a logged warning; pass stream=False to silence it. The live operator UI is provided by the CLI, so run with tofupilot run if you relied on it.

Ctrl-C behaves differently. The wrapper suppressed KeyboardInterrupt, so Ctrl-C exited quietly. The plain callback lets it raise. If your bench scripts depended on the quiet exit, wrap test.execute(...) in try/except KeyboardInterrupt.

Step 4: Deploy to Stations

Once the script runs under the CLI, deployment is a Git push. The station pulls the new version, with immutable build artifacts and instant rollback if it misbehaves. Runs queue locally when the network drops and sync when it returns.

Nothing about that requires the TofuPilot Framework. OpenHTF procedures deploy the same way.

When to Port to the TofuPilot Framework

Porting is optional. It is worth doing for specific reasons, not on principle.

Reason to portWhy OpenHTF cannot do it
The line is throughput-boundOpenHTF phases run sequentially; the Framework runs independent phases in parallel
Several boards on one fixtureMulti-slot execution, each slot its own run and outcome
Operators need rich guided workflowsDeclarative forms and displays, no frontend work
Validating waveform or sweep shapeAggregations and per-axis validators
You want limits reviewable as dataLimits live in YAML, diffable separately from the logic
Reason not to port
It works and the line is not throughput-boundThe migration cost buys nothing
One pure-Python paradigm is a team preferenceThe YAML layer is a real concept to learn
Apache 2.0 matters to your legal reviewOpenHTF's patent grant

The Port, Concretely

If you do port, the mapping is mechanical.

OpenHTFTofuPilot Framework
Phase function with @htf.measuresPlain function; measurements declared in YAML
.in_range(3.2, 3.4)validators with >= and <=
.equals("2.1.0")validators with operator: "=="
BasePlug subclassPlain Python class
setUp / tearDown__init__ / close
@htf.plug(dut=DutPlug)Plug injected by parameter name
test.measurements.x = vmeasurements.x = v
htf.Test(phase1, phase2)main: list in the procedure
@htf.PhaseOptions(timeout_s=10)timeout on the phase
Phase ordering in htf.Test(...)depends_on

Before:

openhtf_version.py
26 lines
import openhtf as htffrom openhtf.plugs import BasePlugfrom openhtf.util import unitsclass DutPlug(BasePlug):    def setUp(self):        self.connected = True    def read_voltage(self) -> float:        return 3.31    def tearDown(self):        self.connected = False@htf.measures(    htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)@htf.plug(dut=DutPlug)def test_power(test, dut):    test.measurements.rail_3v3 = dut.read_voltage()test = htf.Test(test_power, part_number="PCBA-100")test.execute(test_start=lambda: input("Serial: "))

After:

plugs/dut.py
class Dut:    def __init__(self):        self.connected = True    def read_voltage(self) -> float:        return 3.31    def close(self):        self.connected = False
phases/power.py
def power(measurements, dut):    measurements.rail_3v3 = dut.read_voltage()
procedure.yaml
24 lines
name: PCBA Functional Testversion: 1.0.0unit:  serial_number:    default_value: "PCBA000001"  part_number:    default_value: "PCBA-100"plugs:  - name: dut    python: plugs.dut:Dutmain:  - name: Power    python: phases.power    measurements:      - name: Rail 3V3        unit: V        validators:          - operator: ">="            expected_value: 3.2          - operator: "<="            expected_value: 3.4

Validate Before You Switch

Whichever path you take, run the old and new versions against the same units, including known-bad ones, and compare measurement by measurement. A limit transcribed wrong during a port is the failure mode that ships bad boards, and it is cheap to catch this way.

Keep the measurement names identical across the port. Renaming rail_3v3 splits the history and you lose the trend you were trying to preserve.

Mixing Both

You do not have to pick one for the whole plant. Keep OpenHTF where it already runs, and use the Framework on new stations that need parallel slots or richer operator workflows. Both run on the same CLI and upload to the same dashboard, so yield and traceability stay consistent across the mix.

See TofuPilot Framework vs OpenHTF for the full comparison.

Common Mistakes

MistakeConsequenceFix
Installing plain tofupilot for an OpenHTF scriptImportError on tofupilot.openhtfInstall tofupilot[openhtf]
Keeping with TofuPilot(test):FutureWarning, and ImportError on 2.15.xMove to add_output_callbacks(upload())
Expecting stream=True to do somethingSilently ignoredUse tofupilot run for the live UI
Renaming measurements during a portTrend history splits in twoKeep names byte-identical
Porting without parallel validationA transcription error reaches productionRun both on the same units first
Porting because it is newerCost with no benefitPort for parallelism, UI or waveform needs

More Guides

Put this guide into practice