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:
curl -fsSL https://www.tofupilot.app/install | shPoint it at your existing OpenHTF entry point:
tofupilot run ./main.pyThat 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.
tofupilot logintofupilot linktofupilot run ./main.py --uploadThrough 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:
pip install "tofupilot[openhtf]"main.py20 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:
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:
from tofupilot import TofuPilotwith TofuPilot(test): test.execute(test_start=lambda: input("Serial: "))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 port | Why OpenHTF cannot do it |
|---|---|
| The line is throughput-bound | OpenHTF phases run sequentially; the Framework runs independent phases in parallel |
| Several boards on one fixture | Multi-slot execution, each slot its own run and outcome |
| Operators need rich guided workflows | Declarative forms and displays, no frontend work |
| Validating waveform or sweep shape | Aggregations and per-axis validators |
| You want limits reviewable as data | Limits live in YAML, diffable separately from the logic |
| Reason not to port | |
|---|---|
| It works and the line is not throughput-bound | The migration cost buys nothing |
| One pure-Python paradigm is a team preference | The YAML layer is a real concept to learn |
| Apache 2.0 matters to your legal review | OpenHTF's patent grant |
The Port, Concretely
If you do port, the mapping is mechanical.
| OpenHTF | TofuPilot Framework |
|---|---|
Phase function with @htf.measures | Plain function; measurements declared in YAML |
.in_range(3.2, 3.4) | validators with >= and <= |
.equals("2.1.0") | validators with operator: "==" |
BasePlug subclass | Plain Python class |
setUp / tearDown | __init__ / close |
@htf.plug(dut=DutPlug) | Plug injected by parameter name |
test.measurements.x = v | measurements.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.py26 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:
class Dut: def __init__(self): self.connected = True def read_voltage(self) -> float: return 3.31 def close(self): self.connected = Falsedef power(measurements, dut): measurements.rail_3v3 = dut.read_voltage()procedure.yaml24 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.4Validate 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
| Mistake | Consequence | Fix |
|---|---|---|
Installing plain tofupilot for an OpenHTF script | ImportError on tofupilot.openhtf | Install tofupilot[openhtf] |
Keeping with TofuPilot(test): | FutureWarning, and ImportError on 2.15.x | Move to add_output_callbacks(upload()) |
Expecting stream=True to do something | Silently ignored | Use tofupilot run for the live UI |
| Renaming measurements during a port | Trend history splits in two | Keep names byte-identical |
| Porting without parallel validation | A transcription error reaches production | Run both on the same units first |
| Porting because it is newer | Cost with no benefit | Port for parallelism, UI or waveform needs |
