The operator interface is the screen a technician looks at all shift. It decides how fast the line runs, how many retests happen for no reason, and whether a real failure gets noticed or waved through. It is usually the last part of a test system to get attention and the first part to cause trouble.
What It Has to Do
| Requirement | Why |
|---|---|
| Identify the unit | Scan a barcode; typing serial numbers produces typos and untraceable results |
| Show progress | A technician needs to know whether a 90-second test is running or hung |
| Show the verdict unambiguously | Pass and fail must be distinguishable across a noisy room at a glance |
| Show what failed | "FAIL" alone sends the board to a diagnostic bench that did not need to be involved |
| Collect operator input | Visual checks, torque values, cable connections, lot codes |
| Prevent unsafe actions | No energising with the fixture open |
| Work offline | The line does not stop because the network did |
The fourth row is the one most often missed. A screen that shows only a red bar trains operators to retest rather than investigate, and a retest that passes hides a marginal board.
What Usually Gets Built Instead
Most teams start with a terminal running a Python script. It works for a prototype and fails on a line for predictable reasons: a scrolling console gives no glanceable state, operators learn to press Enter through prompts they stop reading, and there is no way to stop a run cleanly.
The next step is usually a small desktop app, and that is where the cost appears. A production-grade operator UI needs live state from the running test, safe interruption, input validation, and a build for every station. That is a frontend project with its own release cycle, competing for time with the tests themselves.
Declaring the Interface Instead
The alternative is to declare what the operator sees as part of the procedure, so there is no frontend to build or deploy. Each phase can carry a ui.components list, and each component has a key, a type and a label.
procedure.yaml55 lines
name: PCBA Functional Testversion: 1.0.0unit: serial_number: default_value: "PCBA000001" part_number: default_value: "PCBA-100"plugs: - name: fixture python: plugs.fixture:Fixture - name: dmm python: plugs.dmm:Multimetermain: - name: Fixture Setup python: phases.fixture_setup ui: components: - key: instructions type: text label: "Insert the board, close the lid, then continue." - key: preflight type: checklist label: "Confirm before starting" required: true options: - label: "Board seated flat" value: "seated" - label: "Lid latched" value: "latched" - label: "No debris on pogo pins" value: "clean" - name: Safety python: phases.safety depends_on: [fixture_setup] measurements: - name: Lid Closed validators: - operator: "==" expected_value: true - name: Power Rails python: phases.power_rails depends_on: [safety] measurements: - name: Rail 3V3 unit: V validators: - operator: ">=" expected_value: 3.2 - operator: "<=" expected_value: 3.4def fixture_setup(): """The checklist blocks until the operator completes it.""" passdef safety(measurements, fixture): measurements.lid_closed = fixture.lid_closed()Run it with tofupilot run ./procedure.yaml. The CLI renders the interface, and the same declaration works on a bench laptop and on a station in kiosk mode.
The Components Worth Knowing
| Component | Use |
|---|---|
text | Instructions, warnings, live values |
text_input / textarea | Notes, lot codes, free text |
number_input | Torque values, manual readings |
checklist | Multi-step confirmations before a risky action |
switch / radio / select / multiselect | Choices, variants, pass/fail judgements |
image | Show the operator what a good joint looks like |
slider | Bounded manual adjustment |
progress | Long-running phases |
An image next to a visual inspection prompt is worth more than any amount of written instruction, and it costs a few lines in the procedure.
Operator Input Is Data, Not Just Flow Control
The mistake that quietly destroys traceability is treating a prompt as a gate and discarding the answer. If an operator records a torque value, that value belongs in the record as a measurement with limits.
The bind field does exactly that: it routes the component's value straight into a measurement, with no phase code in between.
- 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; nothing to do here.""" passNow a torque that drifts across a shift is visible on a control chart, rather than being a number somebody typed and nobody kept. If it falls outside the limits, the phase fails like any other measurement.
bind also routes to unit fields and run attribution:
- name: Scan Unit python: phases.scan_unit ui: components: - key: serial_number type: text_input label: "Serial Number" bind: unit.serial_number - key: operator_badge type: text_input label: "Operator badge" bind: run.operated_byA badge scanner filling run.operated_by is how competence records get captured without anyone maintaining a separate log.
Running Without an Operator
The same procedure has to run unattended in CI or on a soak rack. Rather than maintaining a second copy, resolve the prompts externally with a JSON file keyed by phase and component:
tofupilot run ./procedure.yaml --json --ui-values ci/ui.json --ui-timeout 300{ "fixture_setup": { "preflight": ["seated", "latched", "clean"] }, "torque_check": { "torque": 0.5 }}--ui-timeout matters here. Without it, an unanswerable prompt blocks forever; with it, the phase fails and the job moves on.
Kiosk Mode on a Station
On a production station, the interface runs full-screen with no browser chrome, so operators cannot navigate away or close it. Deployments update it on a Git push, and runs queue locally when the network drops. See how to set up kiosk mode for test stations.
If You Already Run OpenHTF
OpenHTF has a built-in Station Server and user input plugs, and existing suites run natively on the TofuPilot CLI, which provides the operator UI:
import openhtf as htffrom openhtf.plugs.user_input import UserInputfrom openhtf.util import unitsfrom tofupilot.openhtf import upload@htf.measures( htf.Measurement("rail_3v3").in_range(3.2, 3.4).with_units(units.VOLT),)@htf.plug(prompts=UserInput)def test_power(test, prompts): prompts.prompt("Close the fixture lid, then press OK.") test.measurements.rail_3v3 = 3.31test = htf.Test(test_power, part_number="PCBA-100")test.add_output_callbacks(upload())test.execute(test_start=lambda: input("Scan serial: "))Install with pip install "tofupilot[openhtf]", or run it with tofupilot run for the richer interface. See operator interface with OpenHTF.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Showing FAIL with no detail | Boards go to diagnostics that did not need to | Show which measurement failed and its value |
| Typed serial numbers | Typos, untraceable results | bind: unit.serial_number with a scanner |
| Discarding operator input | Manual readings vanish from the record | bind: measurements.<key> with limits |
| Prompts an operator can click through blind | Confirmations stop meaning anything | Use checklists for genuinely risky steps |
| A separate unattended copy of the procedure | The two drift apart | One procedure, --ui-values for CI |
| No timeout on prompts | An unattended run hangs forever | --ui-timeout |
| Building a custom desktop app first | A frontend project competing with test work | Declare the UI in the procedure |
