OpenHTF on TofuPilot

Last updated on May 21, 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.

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 exposes a TofuPilot context manager that wraps htf.Test(...) directly. 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 TofuPilot

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

def main():
    test = htf.Test(
        phase_one,
        procedure_id="FVT1",
        part_number="PCB01",
    )
    with TofuPilot(test):
        test.execute(lambda: "SN-0001")

if __name__ == "__main__":
    main()
FieldRequiredDescription
procedure_idYesDashboard procedure UUID or external identifier.
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")

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