Test Station Setup

Operator Interface with OpenHTF

Build an operator interface for OpenHTF tests: the built-in Station Server, user input plugs, and the richer UI available through the TofuPilot CLI.

CCharlotte Evequoz
intermediate5 min readJune 17, 2026

OpenHTF ships with an operator interface, and most teams outgrow it. This guide covers what the built-in Station Server does, how to collect operator input in an OpenHTF test, and the options when you need more than prompts.

What OpenHTF Provides

FeatureBuilt-in
Serial number prompt at test startYes, via test_start
Text prompts during a phaseYes, via the UserInput plug
Web display of phase progressYes, the Station Server on localhost:12000
Pass/fail displayYes, basic
Rich input types (sliders, checklists, images)No
Kiosk modeNo
Offline result queueingNo

The Station Server is functional and honest about its scope. It was built to show a technician what is happening, not to be the product surface of a factory line.

Prompting for the Serial Number

The test_start callable runs before the first phase and returns the DUT identifier:

basic.py
20 lines
import openhtf as htffrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures(    htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)def test_power(test):    test.measurements.rail_3v3 = 3.31def main():    test = htf.Test(test_power, part_number="PCBA-100")    test.add_output_callbacks(upload())    test.execute(test_start=lambda: input("Scan serial number: "))if __name__ == "__main__":    main()

For a proper prompt rather than a console input(), use the built-in prompt helper:

prompt_start.py
from openhtf.plugs.user_input import prompt_for_test_starttest.execute(test_start=prompt_for_test_start("Scan the unit serial number"))

Prompting Inside a Phase

The UserInput plug blocks a phase until the operator responds:

operator_prompts.py
56 lines
import openhtf as htffrom openhtf.plugs.user_input import UserInputfrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.plug(prompts=UserInput)def phase_fixture_setup(test, prompts):    """Block until the operator confirms the fixture is ready."""    prompts.prompt(        message="Insert the board, close the lid, then press OK.",        text_input=False,    )@htf.measures(    htf.Measurement("visual_pass").equals(True),)@htf.plug(prompts=UserInput)def phase_visual_inspection(test, prompts):    """Collect a judgement and record it as a measurement."""    response = prompts.prompt(        message="Any visible defect? Type 'y' for yes, leave blank for none.",        text_input=True,    )    test.measurements.visual_pass = not response.strip()@htf.measures(    htf.Measurement("screw_torque")    .in_range(0.45, 0.55)    .with_units(units.NEWTON_METRE),)@htf.plug(prompts=UserInput)def phase_torque(test, prompts):    """A typed value is still a measurement."""    raw = prompts.prompt(        message="Enter the measured screw torque in Nm:",        text_input=True,    )    test.measurements.screw_torque = float(raw)def main():    test = htf.Test(        phase_fixture_setup,        phase_visual_inspection,        phase_torque,        part_number="PCBA-100",    )    test.add_output_callbacks(upload())    test.execute(test_start=lambda: input("Scan serial: "))if __name__ == "__main__":    main()

The torque phase is the pattern worth copying. A number an operator types is data, not flow control, so it becomes a measurement with limits and trends like any instrument reading. Discarding it, and recording only a boolean "operator confirmed", throws away the only record you had.

Add a timeout to any prompt that could block a line indefinitely:

timeout_prompt.py
prompts.prompt(    message="Close the fixture lid, then press OK.",    timeout_s=120,)

Running the Station Server

station_server.sh
python main.py

With the Station Server enabled, the interface is at http://localhost:12000. It shows phases as they execute, measurements as they are recorded, and the final verdict.

Its practical limits on a production line:

  • One page, no kiosk. An operator can navigate away or close the tab.
  • Text prompts only. No checklists, sliders, images or structured forms.
  • No offline handling. If the network drops, results are not queued.
  • No deployment story. Updating a station means copying files.

Getting a Richer Interface Without Rewriting

Existing OpenHTF suites run natively on the TofuPilot CLI:

run.sh
tofupilot run ./main.py

That gives the same test a full-screen kiosk interface, real-time streaming, offline queueing and deploy-on-push, with no change to the test code. For most teams that is the shortest path from "the Station Server is not enough" to a production-grade screen.

When to Move to Declarative UI

If you need input types OpenHTF does not have, the TofuPilot Framework declares the interface in the procedure rather than in phase code:

procedure.yaml
35 lines
main:  - name: Fixture Setup    python: phases.fixture_setup    ui:      components:        - key: instructions          type: text          label: "Insert the board and close the lid."        - key: preflight          type: checklist          label: "Confirm before starting"          required: true          options:            - label: "Board seated flat"              value: "seated"            - label: "Lid latched"              value: "latched"  - name: Torque Check    python: phases.torque_check    ui:      components:        - key: torque          type: number_input          label: "Screw torque (Nm)"          required: true          bind: measurements.screw_torque    measurements:      - name: Screw Torque        unit: Nm        validators:          - operator: ">="            expected_value: 0.45          - operator: "<="            expected_value: 0.55
phases/torque_check.py
def torque_check(measurements):    """The bound UI value populates screw_torque directly."""    pass

The bind field routes the operator's input straight into the measurement, so there is no parsing code and no chance of the value being collected and dropped.

Migration is optional and not free. See migrate OpenHTF scripts to the TofuPilot CLI and TofuPilot Framework vs OpenHTF.

Unattended Runs

An OpenHTF test with prompts cannot run in CI without an operator. The usual approaches are a separate entry point that omits the prompt phases, or a plug that auto-answers when a flag is set:

auto_prompts.py
import osimport openhtf as htffrom openhtf.plugs.user_input import UserInputclass AutoInput(UserInput):    """Answers prompts automatically when running unattended."""    def prompt(self, message: str, text_input: bool = False, **kwargs):        if os.environ.get("UNATTENDED") == "1":            return ""        return super().prompt(message, text_input=text_input, **kwargs)

Maintaining two entry points is the part that tends to drift. The declarative alternative keeps one procedure and supplies answers externally with --ui-values, which avoids that class of divergence.

Common Mistakes

MistakeConsequenceFix
Recording only "operator confirmed"The typed value is lost foreverMake it a measurement with limits
Prompts with no timeoutA line blocks indefinitelytimeout_s on every prompt
Console input() on a stationNo visible state, easy to mis-keyUse a prompt plug or the CLI interface
Station Server exposed as the line UIOperators navigate away mid-testKiosk mode via the CLI
Two entry points for attended and CIThey drift apartOne procedure plus external UI values
Free-text where a choice belongsUnparseable data, typosUse a constrained input type

More Guides

Put this guide into practice