OpenHTF on TofuPilot

Last updated on August 14, 2026

OpenHTF is a popular open-source Python hardware test framework maintained by Google, documented by TofuPilot at openhtf.com. It ships many hardware-test-specific features that general-purpose frameworks lack, making it an excellent choice for various test scenarios.

With TofuPilot, you can deploy OpenHTF scripts to your stations via Git push and stream live test data and the operator UI to both the kiosk and the dashboard, with zero configuration.

Getting started

To get started with OpenHTF on TofuPilot:

  • Clone our OpenHTF starter template to your favorite Git provider and deploy it on TofuPilot.
  • If you have an OpenHTF procedure, push it to your Git provider and import it from the New Procedure wizard.

Integration

OpenHTF is a code-first Python test framework that uses decorators to attach measurements and shared resources to phase functions, then runs them sequentially against the unit under test.

main.py
import openhtf as htf
from openhtf.util import units
from openhtf.plugs.user_input import UserInput

# Plug
class DMM(htf.plugs.BasePlug):
    def measure_voltage(self) -> float:
        return 5.03

# Phase
@htf.plug(dmm=DMM)  # inject plug
@htf.measures(htf.Measurement("voltage").in_range(4.8, 5.2).with_units(units.VOLT))  # validator
def measure_voltage(test, dmm):
    test.logger.info("Reading DMM")           # log
    test.measurements.voltage = dmm.measure_voltage()  # measurement
    test.attach_from_file("data/scope.png")   # attachment

# Operator prompt
@htf.plug(user_input=UserInput)
def prompt_serial(test, user_input):
    test.measurements.serial_number = user_input.prompt("Serial number?", text_input=True)  # operator input

htf.Test(prompt_serial, measure_voltage).execute()  # run phases on the unit under test

The TofuPilot CLI runs your OpenHTF scripts natively and streams phases, measurements, UI prompts, logs, and attachments to the dashboard.

Phases

In OpenHTF, a phase is a Python function that receives a test context, runs one step of the procedure, and returns a PhaseResult to control whether the next phase runs.

phases.py
import openhtf as htf

def power_on(test):
    return htf.PhaseResult.CONTINUE

htf.Test(power_on).execute()

TofuPilot maps OpenHTF phases to the phase resource as follows:

OpenHTFTofuPilotNotes
namenameCapped at 200 chars.
outcomeoutcomeSame enum: PASS, FAIL, SKIP, ERROR.
start_time_millisstartedAtEpoch ms → ISO 8601 timestamp.
end_time_millisendedAtEpoch ms → ISO 8601 timestamp.
codeinfo.docstringdocstringCapped at 50000 chars.
resultretryCountOnly PhaseResult.REPEAT is tracked, as the retry count. Other control-flow values are not stored.
measurementsmeasurementsInternally linked to the phase.
attachmentsattachmentsFlattened to the run level; phase association is not preserved.
marginalnot persistedMarginal validators on measurements are expanded into >= / <= validator entries on the measurement.
options, subtest_name, diagnosers, diagnosis_results, failure_diagnosis_resultsnot persisted

Measurements

In OpenHTF, a measurement is a typed value declared with @htf.measures on a phase, optionally constrained by validators and tagged with units, then assigned at runtime via test.measurements.<name>.

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

@htf.measures(
    htf.Measurement("voltage").in_range(4.8, 5.2).with_units(units.VOLT)
)
def measure_voltage(test):
    test.measurements.voltage = 5.03

TofuPilot maps OpenHTF measurements to the measurement resource as follows:

OpenHTFTofuPilotNotes
namenameCapped at 200 chars.
outcomeoutcomeOpenHTF PASS / FAIL map 1:1. UNSET, PARTIALLY_SET, SKIPPED are collapsed to UNSET.
unitsunitsOpenHTF unit object reduced to its suffix string. Capped at 60 chars.
docstringdocstringCapped at 50000 chars.
measured_valuevalueRouted to a typed table: numeric_measurement, string_measurement, boolean_measurement, or json_measurement.
dimensions + measured_valuemulti_dimensional_measurement + data_seriesMulti-dim measurements stored separately; per-axis validators not supported.
validatorsexpandedEach validator becomes a per-limit >= / <= entry on the measurement.
marginalexpandedMarginal validators are emitted as separate >= / <= entries flagged as marginal.
set_time_millis, allow_fail, conditional_validatorsnot persisted

Logs

In OpenHTF, every phase receives a test.logger — a standard Python logger that captures records into the test for later inspection.

log.py
def measure_voltage(test):
    test.logger.info("Calibration started")

TofuPilot maps OpenHTF log records to the log resource as follows:

OpenHTFTofuPilotNotes
levellevelSame enum: DEBUG, INFO, WARNING, ERROR, CRITICAL.
timestamp_millistimestampEpoch ms → ISO 8601 timestamp.
messagemessageCapped at 50000 chars.
sourcesourceFileCapped at 200 chars.
linenolineNumber
logger_namenot persisted

Logs are stored at the run level; per-phase association is not preserved.

Attachments

In OpenHTF, an attachment is binary content tied to a phase, declared via test.attach(...) or test.attach_from_file(...).

attachment.py
def capture(test):
    test.attach_from_file("data/scope.png")

TofuPilot maps OpenHTF attachments to the upload resource as follows:

OpenHTFTofuPilotNotes
attachment keynameCapped at 200 chars.
mimetypecontentTypeCapped at 60 chars.
sizesizeBytes.
sha1etagStored as the upload's S3 ETag; the OpenHTF SHA-1 itself is dropped.

Attachments are linked to the run via run_attachments; per-phase association is not preserved.

Operator UI

OpenHTF ships an openhtf.plugs.user_input.UserInput plug to ask the operator for input mid-run. TofuPilot replaces it with a CLI-side implementation that streams the prompt to the kiosk and the dashboard.

prompt.py
import openhtf as htf
from openhtf.plugs.user_input import UserInput

@htf.plug(user_input=UserInput)
def prompt_serial(test, user_input):
    serial = user_input.prompt("Serial number?", text_input=True)
    test.measurements.serial_number = serial

TofuPilot supports the following UserInput.prompt arguments:

OpenHTFTofuPilotNotes
messagesupportedDisplayed verbatim on the kiosk and the dashboard.
text_inputsupportedRenders a text input when True, otherwise an acknowledge button.
timeout_ssupportedAuto-cancels the prompt after timeout_s seconds.
image_urlsupportedRendered inline next to the prompt.
cli_colorignoredTofuPilot has no CLI-color concept on the kiosk or dashboard.

Plugs

In OpenHTF, a plug is a class subclassing BasePlug, declared with @htf.plug(name=PlugClass) on a phase. The framework instantiates the plug, injects it as a phase argument, and tears it down when the test ends.

plug.py
import openhtf as htf

class DMM(htf.plugs.BasePlug):
    def measure_voltage(self) -> float:
        return 5.03

@htf.plug(dmm=DMM)
def measure(test, dmm):
    test.logger.info(f"voltage: {dmm.measure_voltage()}")

TofuPilot runs OpenHTF plugs as-is in Python. setUp, tearDown, and dependency injection work unchanged, but the dashboard does not surface plug instances, lifecycle events, or FrontendAwareBasePlug state. Only the data plugs emit (measurements, logs, attachments, prompts) is captured. UserInput is the one exception, replaced by a TofuPilot implementation that streams prompts.

Run outcome

In OpenHTF, test_record.outcome summarizes the whole test (PASS, FAIL, ERROR, TIMEOUT, ABORTED).

TofuPilot maps the test outcome to the run resource as follows:

OpenHTFTofuPilotNotes
outcome PASS / FAILoutcome1:1 mapping.
outcome ERRORoutcomeCollapsed to ERROR; unhandled exceptions land here.
outcome TIMEOUToutcomeCollapsed to ERROR.
outcome ABORTEDoutcomeCollapsed to ERROR.

DUT ID

In OpenHTF, the DUT ID is resolved by the callable passed to test.execute(...) and stored on test_record.dut_id.

dut.py
import openhtf as htf

def power_on(test):
    return htf.PhaseResult.CONTINUE

htf.Test(power_on).execute(lambda: "SN-0001")

TofuPilot uses the OpenHTF DUT ID as the unit serial number on the run. The UserInput plug can also drive it interactively when the operator types it into the kiosk prompt.

Test metadata

In OpenHTF, test_record.metadata is a free-form dict mutated by phases (e.g. test.metadata["serial_number"] = read_from_eeprom()).

TofuPilot reads a known set of keys from test_record.metadata and promotes them to the run:

OpenHTF metadata keyTofuPilotNotes
procedure_idprocedureIdProcedure identifier on the run.
part_numberpartNumberPart number on the run.
revisionrevisionPart revision.
batch_numberbatchNumberProduction batch.
sub_unitssubUnitsList of {"serial_number": "..."} for assembled units.
any other keynot persistedStays on the Python test.metadata dict, not promoted.

PhaseGroup

OpenHTF's PhaseGroup runs phases in three slots — setup, main, teardown — and guarantees teardown even if setup or main fails.

group.py
import openhtf as htf

def power_on(test): ...
def measure(test): ...
def power_off(test): ...

htf.Test(htf.PhaseGroup(setup=[power_on], main=[measure], teardown=[power_off])).execute()

TofuPilot honors the OpenHTF runtime contract — phases execute in the right order and teardown still fires — but flattens the group at capture time. Phase rows appear in execution order with no setup / main / teardown tag; the grouping itself is not persisted.

Multiple procedures, one codebase

By default the CLI looks for a main.py entry point. When several procedures share one codebase, for example procedure1.py, procedure2.py, and procedure3.py, keep a single directory and create one TofuPilot procedure per file, each pointing at its own entry point. No renaming to main.py and no separate checkouts.

Set each procedure's entry point once (the procedure ID comes from tofupilot procedures ls or the dashboard):

one procedure per file
tofupilot procedures update --id <procedure1-id> --entry-point procedure1.py
tofupilot procedures update --id <procedure2-id> --entry-point procedure2.py
tofupilot procedures update --id <procedure3-id> --entry-point procedure3.py

The entry point is baked into each deployment when it is built, so changing it takes effect on the next deploy. Stations need CLI 0.21.17 or newer; older versions silently fall back to main.py.

To test one of them locally, pass its file to tofupilot run directly:

run one procedure locally
tofupilot run ./procedure1.py

To deploy, link the directory to each procedure in turn and run tofupilot deploy, or link the repository once and let a push build every procedure with its own entry point, using Git deployments.

Running a subset of phases

To run only some phases of a test (a quick check on one bench, the full sequence on another), gate the optional phases with OpenHTF's run_if option instead of maintaining several copies of the test:

main.py
import os
import openhtf as htf

RUN_BURNIN = os.environ.get("RUN_BURNIN", "0") == "1"

@htf.PhaseOptions(run_if=lambda: RUN_BURNIN)
def thermal_burnin(test):
    ...

See the OpenHTF phases documentation for the other PhaseOptions. Two consequences on the TofuPilot side:

  • A phase skipped by run_if produces no phase row on the run page: it looks as if the phase were not part of the test. Since the PhaseOptions object itself is not persisted (see Unsupported features), nothing on the dashboard indicates why the phase is absent. If you want skipped phases visible on the run, return PhaseResult.SKIP from inside the phase instead: the phase row is recorded with its outcome.
  • Runs of the same procedure with different flags produce phase sets that differ run-to-run, which is expected, but consider one procedure per station configuration (with its own entry point) when the subsets are stable, so run analytics compare like with like.

Offline upload

When the bench can lose connectivity, the CLI queues runs locally and uploads them when the network comes back. Each queued run keeps its original timestamp, so analytics stay accurate even when uploads land hours later. See tofupilot queue.

Python connector (self-managed)

For users running their own deployment pipeline who want to skip the TofuPilot CLI, the tofupilot Python package provides an OpenHTF output callback. You handle distribution and execution; TofuPilot just receives the run.

install
pip install "tofupilot[openhtf]"
main.py
import openhtf as htf
from tofupilot.openhtf import upload

def phase_one(test):
    return htf.PhaseResult.CONTINUE

def main():
    test = htf.Test(
        phase_one,
        procedure_id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",  # procedure UUID from the dashboard
        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, the same one json_factory uses, so the callback composes with any others you already have.

For a self-hosted instance, pass the server and — if it uses a private CA — the certificate:

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

The TofuPilot context manager is deprecated. On tofupilot>=2.16.0, with TofuPilot(test): still works: it is a thin wrapper around this same callback, and using it emits a FutureWarning. Versions 2.15.0 to 2.15.2 do not include it and raise ImportError on the import. It will be removed in a future release, so migrate to the two lines above. The MQTT streaming it used to provide is gone: stream=True is accepted but ignored with a logged warning (pass stream=False to silence it), and the live operator UI is provided by the CLI. If you relied on it, run the test with tofupilot run instead. OpenHTF is an extra since 2.15.0: install tofupilot[openhtf] rather than tofupilot, so a station that only uses the API client no longer pulls a test framework it never imports. One behavior difference when migrating off the wrapper: it suppresses KeyboardInterrupt, so Ctrl-C during a test exits without a traceback, while the plain callback lets Ctrl-C raise normally. Wrap test.execute(...) in try/except KeyboardInterrupt if your bench scripts depend on the quiet exit.

Uploading only needs the callback. To also get a live operator UI, deployment from Git, an offline queue and station management, use the CLI — it runs the same stock OpenHTF file with no TofuPilot code in it at all.

FieldRequiredDescription
procedure_idYesDashboard procedure UUID (find it on the procedure page). A legacy external identifier also works only if one was set on the procedure.
part_numberYesPart number for the unit.
revisionNoPart revision. Defaults to A.
batch_numberNoProduction batch.
sub_unitsNoList of {"serial_number": "..."} for assembled units.

For offline benches, write a JSON report during the run and upload it later from any machine with network access.

upload_later.py
from openhtf.output.callbacks import json_factory
from tofupilot import TofuPilotClient

test.add_output_callbacks(json_factory.OutputToJSON("./report.json", indent=2))
test.execute(lambda: "SN-0001")

client = TofuPilotClient()
client.create_run_from_openhtf_report("./report.json")

Supported OpenHTF versions

The CLI runs OpenHTF 1.3.0 (2018) through 1.6.1. Every release in that range produces the same measurements, validators, attachments, phase outcomes and dimensioned data on the dashboard.

OpenHTFPythonNotes
1.6.13.9+Current release.
1.5.23.9+
1.4.4≤ 3.9Uses collections.Iterable, moved in 3.10.
1.3.0≤ 3.10

Those Python ceilings come from OpenHTF itself, not from TofuPilot: 1.4.4 calls collections.Iterable, moved to collections.abc in Python 3.10, and releases before 1.5 call inspect.getargspec, removed in 3.11. Pin the interpreter in your procedure's pyproject.toml when you stay on one of them — otherwise the venv bootstrap picks a modern Python and the run fails inside OpenHTF's own imports before your test starts:

[project]
requires-python = ">=3.9,<3.11"
dependencies = ["openhtf==1.3.0", "setuptools<81"]

setuptools<81 is needed for the same reason: OpenHTF below 1.5 imports pkg_resources, which setuptools 81 removed.

Marginal limits (marginal_minimum / marginal_maximum) and PhaseGroup do not exist before 1.5 and 1.4.4 respectively, so nothing is reported for them on older releases. Everything else behaves identically.

Unsupported features

The following OpenHTF features run as-is in Python, but TofuPilot does not surface them on the dashboard.

FeatureStatus
Monitors (@openhtf.monitors(...))Sampling thread runs, but emitted values are not captured.
Diagnoses (@PhaseDiagnoser, @TestDiagnoser, DiagnosesStore, Diagnosis)Run as-is; diagnosis_results, failure_diagnosis_results, and diagnosers are dropped.
Conditional validators (_ConditionalValidator)Validator runs only when its diagnosis fires; not surfaced as a separate validator entry.
Subtests (Subtest, PhaseResult.FAIL_SUBTEST)Phases execute, but subtest grouping and per-subtest outcomes are not persisted.
Branches and checkpoints (BranchSequence, PhaseFailureCheckpoint, DiagnosisCheckpoint)Control flow is honored; the branch / checkpoint nodes themselves are not persisted.
with_plugs (plug-type substitution)Runs as plain Python; no extra metadata on the dashboard.
FrontendAwareBasePlug stateThe plug runs, but its frontend state stream is ignored.
PhaseOptions (timeout_s, run_if, repeat_limit, force_repeat, ...)Honored by OpenHTF at runtime; the options object itself is not persisted on the phase.
User-registered output.callbacks (json_factory, mfg-inspector, ...)Still fire; TofuPilot does not consume the resulting files.
OpenHTF output.servers.web_guiUse the TofuPilot dashboard instead.

How is this guide?

On this page