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
| Feature | Built-in |
|---|---|
| Serial number prompt at test start | Yes, via test_start |
| Text prompts during a phase | Yes, via the UserInput plug |
| Web display of phase progress | Yes, the Station Server on localhost:12000 |
| Pass/fail display | Yes, basic |
| Rich input types (sliders, checklists, images) | No |
| Kiosk mode | No |
| Offline result queueing | No |
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.py20 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:
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.py56 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:
prompts.prompt( message="Close the fixture lid, then press OK.", timeout_s=120,)Running the Station Server
python main.pyWith 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:
tofupilot run ./main.pyThat 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.yaml35 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.55def torque_check(measurements): """The bound UI value populates screw_torque directly.""" passThe 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:
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
| Mistake | Consequence | Fix |
|---|---|---|
| Recording only "operator confirmed" | The typed value is lost forever | Make it a measurement with limits |
| Prompts with no timeout | A line blocks indefinitely | timeout_s on every prompt |
Console input() on a station | No visible state, easy to mis-key | Use a prompt plug or the CLI interface |
| Station Server exposed as the line UI | Operators navigate away mid-test | Kiosk mode via the CLI |
| Two entry points for attended and CI | They drift apart | One procedure plus external UI values |
| Free-text where a choice belongs | Unparseable data, typos | Use a constrained input type |
