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.
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 testThe 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.
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:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
name | name | Capped at 200 chars. |
outcome | outcome | Same enum: PASS, FAIL, SKIP, ERROR. |
start_time_millis | startedAt | Epoch ms → ISO 8601 timestamp. |
end_time_millis | endedAt | Epoch ms → ISO 8601 timestamp. |
codeinfo.docstring | docstring | Capped at 50000 chars. |
result | retryCount | Only PhaseResult.REPEAT is tracked, as the retry count. Other control-flow values are not stored. |
measurements | measurements | Internally linked to the phase. |
attachments | attachments | Flattened to the run level; phase association is not preserved. |
marginal | not persisted | Marginal validators on measurements are expanded into >= / <= validator entries on the measurement. |
options, subtest_name, diagnosers, diagnosis_results, failure_diagnosis_results | not 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>.
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.03TofuPilot maps OpenHTF measurements to the measurement resource as follows:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
name | name | Capped at 200 chars. |
outcome | outcome | OpenHTF PASS / FAIL map 1:1. UNSET, PARTIALLY_SET, SKIPPED are collapsed to UNSET. |
units | units | OpenHTF unit object reduced to its suffix string. Capped at 60 chars. |
docstring | docstring | Capped at 50000 chars. |
measured_value | value | Routed to a typed table: numeric_measurement, string_measurement, boolean_measurement, or json_measurement. |
dimensions + measured_value | multi_dimensional_measurement + data_series | Multi-dim measurements stored separately; per-axis validators not supported. |
validators | expanded | Each validator becomes a per-limit >= / <= entry on the measurement. |
marginal | expanded | Marginal validators are emitted as separate >= / <= entries flagged as marginal. |
set_time_millis, allow_fail, conditional_validators | not persisted |
Logs
In OpenHTF, every phase receives a test.logger — a standard Python logger that captures records into the test for later inspection.
def measure_voltage(test):
test.logger.info("Calibration started")TofuPilot maps OpenHTF log records to the log resource as follows:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
level | level | Same enum: DEBUG, INFO, WARNING, ERROR, CRITICAL. |
timestamp_millis | timestamp | Epoch ms → ISO 8601 timestamp. |
message | message | Capped at 50000 chars. |
source | sourceFile | Capped at 200 chars. |
lineno | lineNumber | |
logger_name | not 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(...).
def capture(test):
test.attach_from_file("data/scope.png")TofuPilot maps OpenHTF attachments to the upload resource as follows:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
| attachment key | name | Capped at 200 chars. |
mimetype | contentType | Capped at 60 chars. |
size | size | Bytes. |
sha1 | etag | Stored 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.
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 = serialTofuPilot supports the following UserInput.prompt arguments:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
message | supported | Displayed verbatim on the kiosk and the dashboard. |
text_input | supported | Renders a text input when True, otherwise an acknowledge button. |
timeout_s | supported | Auto-cancels the prompt after timeout_s seconds. |
image_url | supported | Rendered inline next to the prompt. |
cli_color | ignored | TofuPilot 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.
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:
| OpenHTF | TofuPilot | Notes |
|---|---|---|
outcome PASS / FAIL | outcome | 1:1 mapping. |
outcome ERROR | outcome | Collapsed to ERROR; unhandled exceptions land here. |
outcome TIMEOUT | outcome | Collapsed to ERROR. |
outcome ABORTED | outcome | Collapsed 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.
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 key | TofuPilot | Notes |
|---|---|---|
procedure_id | procedureId | Procedure identifier on the run. |
part_number | partNumber | Part number on the run. |
revision | revision | Part revision. |
batch_number | batchNumber | Production batch. |
sub_units | subUnits | List of {"serial_number": "..."} for assembled units. |
| any other key | not persisted | Stays 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.
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):
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.pyThe 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:
tofupilot run ./procedure1.pyTo 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:
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_ifproduces no phase row on the run page: it looks as if the phase were not part of the test. Since thePhaseOptionsobject 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, returnPhaseResult.SKIPfrom 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.
pip install "tofupilot[openhtf]"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.
| Field | Required | Description |
|---|---|---|
procedure_id | Yes | Dashboard procedure UUID (find it on the procedure page). A legacy external identifier also works only if one was set on the procedure. |
part_number | Yes | Part number for the unit. |
revision | No | Part revision. Defaults to A. |
batch_number | No | Production batch. |
sub_units | No | List 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.
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.
| OpenHTF | Python | Notes |
|---|---|---|
| 1.6.1 | 3.9+ | Current release. |
| 1.5.2 | 3.9+ | |
| 1.4.4 | ≤ 3.9 | Uses 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.
| Feature | Status |
|---|---|
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 state | The 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_gui | Use the TofuPilot dashboard instead. |
How is this guide?
