Building a custom operator panel lets your test technicians run tests without touching the command line. This guide shows how to create a tkinter-based GUI that controls instruments, collects serial numbers, and runs OpenHTF tests with TofuPilot logging.
Prerequisites
- Python 3.8+
- OpenHTF installed (
pip install openhtf) - TofuPilot Python SDK installed (
pip install tofupilot) - A TofuPilot API key configured
Step 1: Create the Main Panel Layout
Start with a basic tkinter window that has a serial number entry, instrument controls, and a test execution area.
import tkinter as tkfrom tkinter import ttkimport threadingclass TestPanel: def __init__(self, root): self.root = root self.root.title("Test Panel") self.root.geometry("480x520") self.root.resizable(False, False) self.instrument_connected = False self._build_instrument_frame() self._build_serial_frame() self._build_control_frame() self._build_status_frame() def _build_instrument_frame(self): frame = ttk.LabelFrame(self.root, text="Instrument", padding=10) frame.pack(fill="x", padx=10, pady=(10, 5)) self.connect_btn = ttk.Button( frame, text="Connect", command=self._connect_instrument ) self.connect_btn.pack(side="left", padx=(0, 5)) self.disconnect_btn = ttk.Button( frame, text="Disconnect", command=self._disconnect_instrument, state="disabled" ) self.disconnect_btn.pack(side="left") self.instr_status = ttk.Label(frame, text="Disconnected", foreground="gray") self.instr_status.pack(side="right") def _build_serial_frame(self): frame = ttk.LabelFrame(self.root, text="Unit Under Test", padding=10) frame.pack(fill="x", padx=10, pady=5) ttk.Label(frame, text="Serial Number:").pack(anchor="w") self.serial_var = tk.StringVar() self.serial_entry = ttk.Entry(frame, textvariable=self.serial_var, width=30) self.serial_entry.pack(fill="x", pady=(2, 0)) def _build_control_frame(self): frame = ttk.Frame(self.root, padding=10) frame.pack(fill="x", padx=10) self.start_btn = ttk.Button( frame, text="Start Test", command=self._start_test, state="disabled" ) self.start_btn.pack(fill="x") def _build_status_frame(self): frame = ttk.LabelFrame(self.root, text="Status", padding=10) frame.pack(fill="both", expand=True, padx=10, pady=(5, 10)) self.result_label = ttk.Label( frame, text="Idle", font=("Helvetica", 18, "bold"), anchor="center" ) self.result_label.pack(pady=(0, 10)) self.log_text = tk.Text(frame, height=10, state="disabled", font=("Courier", 10)) self.log_text.pack(fill="both", expand=True) def _log(self, message): self.log_text.configure(state="normal") self.log_text.insert("end", message + "") self.log_text.see("end") self.log_text.configure(state="disabled") def _set_result(self, text, color): self.result_label.configure(text=text, foreground=color) def _connect_instrument(self): self._log("Connecting to instrument...") self.instrument_connected = True self.instr_status.configure(text="Connected", foreground="green") self.connect_btn.configure(state="disabled") self.disconnect_btn.configure(state="normal") self.start_btn.configure(state="normal") self._log("Instrument connected.") def _disconnect_instrument(self): self._log("Disconnecting instrument...") self.instrument_connected = False self.instr_status.configure(text="Disconnected", foreground="gray") self.connect_btn.configure(state="normal") self.disconnect_btn.configure(state="disabled") self.start_btn.configure(state="disabled") self._log("Instrument disconnected.") def _start_test(self): serial = self.serial_var.get().strip() if not serial: self._log("Error: enter a serial number.") return self.start_btn.configure(state="disabled") self.serial_entry.configure(state="disabled") self._set_result("Running...", "orange") self._log(f"Starting test for {serial}...") thread = threading.Thread(target=self._run_test, args=(serial,), daemon=True) thread.start() def _run_test(self, serial): # Placeholder — see Step 4 for full implementation passif __name__ == "__main__": root = tk.Tk() app = TestPanel(root) root.mainloop()This gives you a clean panel with instrument connect/disconnect buttons, a serial number field, and a status area that shows logs and pass/fail results.
Step 2: Add an Instrument Plug
Create an OpenHTF plug that wraps your instrument communication. This example simulates a power supply, but you'd replace the internals with your actual SCPI or serial commands.
import timeimport openhtf as htfclass PowerSupplyPlug(htf.plugs.BasePlug): """Plug for controlling a bench power supply.""" ADDRESS = "GPIB0::5::INSTR" def setUp(self): self._connected = False self._voltage = 0.0 # Replace with real instrument connection (e.g., pyvisa) self._connected = True def set_voltage(self, voltage): if not self._connected: raise RuntimeError("Power supply not connected") self._voltage = voltage time.sleep(0.3) def measure_current(self): if not self._connected: raise RuntimeError("Power supply not connected") time.sleep(0.2) return 0.125 def tearDown(self): if self._connected: self.set_voltage(0.0) self._connected = FalseThe tearDown method runs automatically when the test finishes, so the instrument always returns to a safe state.
Step 3: Define OpenHTF Test Phases
Write test phases that use the instrument plug to take measurements. Note how plug injection uses the @htf.plug decorator.
import openhtf as htffrom openhtf.util import unitsfrom instrument_plug import PowerSupplyPlug@htf.plug(psu=PowerSupplyPlug)@htf.measures( htf.Measurement("supply_voltage_setpoint").with_units(units.VOLT), htf.Measurement("quiescent_current") .in_range(minimum=0.05, maximum=0.25) .with_units(units.AMPERE),)def phase_quiescent_current(test, psu): """Measure quiescent current at nominal voltage.""" psu.set_voltage(3.3) test.measurements.supply_voltage_setpoint = 3.3 current = psu.measure_current() test.measurements.quiescent_current = current@htf.plug(psu=PowerSupplyPlug)@htf.measures( htf.Measurement("overcurrent_detected").equals(True),)def phase_overcurrent_protection(test, psu): """Verify the DUT triggers overcurrent protection at high voltage.""" psu.set_voltage(5.5) test.measurements.overcurrent_detected = TrueEach phase focuses on one thing. The measurement validators (.in_range, .equals) tell OpenHTF what counts as a pass or fail.
Step 4: Wire the GUI to OpenHTF Execution
Now connect the panel's _run_test method to actually execute the OpenHTF test with TofuPilot logging.
import tkinter as tkfrom tkinter import ttkimport threadingimport openhtf as htffrom tofupilot.openhtf import TofuPilotfrom test_phases import phase_quiescent_current, phase_overcurrent_protectionclass TestPanel: def __init__(self, root): self.root = root self.root.title("Test Panel") self.root.geometry("480x520") self.root.resizable(False, False) self.instrument_connected = False self._build_instrument_frame() self._build_serial_frame() self._build_control_frame() self._build_status_frame() def _build_instrument_frame(self): frame = ttk.LabelFrame(self.root, text="Instrument", padding=10) frame.pack(fill="x", padx=10, pady=(10, 5)) self.connect_btn = ttk.Button( frame, text="Connect", command=self._connect_instrument ) self.connect_btn.pack(side="left", padx=(0, 5)) self.disconnect_btn = ttk.Button( frame, text="Disconnect", command=self._disconnect_instrument, state="disabled" ) self.disconnect_btn.pack(side="left") self.instr_status = ttk.Label(frame, text="Disconnected", foreground="gray") self.instr_status.pack(side="right") def _build_serial_frame(self): frame = ttk.LabelFrame(self.root, text="Unit Under Test", padding=10) frame.pack(fill="x", padx=10, pady=5) ttk.Label(frame, text="Serial Number:").pack(anchor="w") self.serial_var = tk.StringVar() self.serial_entry = ttk.Entry(frame, textvariable=self.serial_var, width=30) self.serial_entry.pack(fill="x", pady=(2, 0)) def _build_control_frame(self): frame = ttk.Frame(self.root, padding=10) frame.pack(fill="x", padx=10) self.start_btn = ttk.Button( frame, text="Start Test", command=self._start_test, state="disabled" ) self.start_btn.pack(fill="x") def _build_status_frame(self): frame = ttk.LabelFrame(self.root, text="Status", padding=10) frame.pack(fill="both", expand=True, padx=10, pady=(5, 10)) self.result_label = ttk.Label( frame, text="Idle", font=("Helvetica", 18, "bold"), anchor="center" ) self.result_label.pack(pady=(0, 10)) self.log_text = tk.Text(frame, height=10, state="disabled", font=("Courier", 10)) self.log_text.pack(fill="both", expand=True) def _log(self, message): self.log_text.configure(state="normal") self.log_text.insert("end", message + "") self.log_text.see("end") self.log_text.configure(state="disabled") def _set_result(self, text, color): self.result_label.configure(text=text, foreground=color) def _connect_instrument(self): self._log("Connecting to instrument...") self.instrument_connected = True self.instr_status.configure(text="Connected", foreground="green") self.connect_btn.configure(state="disabled") self.disconnect_btn.configure(state="normal") self.start_btn.configure(state="normal") self._log("Instrument connected.") def _disconnect_instrument(self): self._log("Disconnecting instrument...") self.instrument_connected = False self.instr_status.configure(text="Disconnected", foreground="gray") self.connect_btn.configure(state="normal") self.disconnect_btn.configure(state="disabled") self.start_btn.configure(state="disabled") self._log("Instrument disconnected.") def _start_test(self): serial = self.serial_var.get().strip() if not serial: self._log("Error: enter a serial number.") return self.start_btn.configure(state="disabled") self.serial_entry.configure(state="disabled") self._set_result("Running...", "orange") self._log(f"Starting test for {serial}...") thread = threading.Thread(target=self._run_test, args=(serial,), daemon=True) thread.start() def _run_test(self, serial): try: test = htf.Test( phase_quiescent_current, phase_overcurrent_protection, procedure_id="PCB-FUNC-001", part_number="PCB-2400", ) with TofuPilot(test): test_record = test.execute(test_start=lambda: serial) outcome = test_record.outcome.name if outcome == "PASS": self.root.after(0, self._set_result, "PASS", "green") self.root.after(0, self._log, f"Test PASSED for {serial}.") else: self.root.after(0, self._set_result, "FAIL", "red") self.root.after(0, self._log, f"Test FAILED for {serial}.") except Exception as e: self.root.after(0, self._set_result, "ERROR", "red") self.root.after(0, self._log, f"Error: {e}") finally: self.root.after(0, self._reset_controls) def _reset_controls(self): self.serial_entry.configure(state="normal") self.serial_var.set("") if self.instrument_connected: self.start_btn.configure(state="normal")if __name__ == "__main__": root = tk.Tk() app = TestPanel(root) root.mainloop()A few things to note here:
test.executeruns synchronously in the background thread, so the GUI stays responsive.root.after(0, ...)schedules GUI updates on the main thread. Tkinter isn't thread-safe, so you can't call widget methods directly from a worker thread.TofuPilot(test)wraps execution and automatically uploads the test record, including all measurements and outcomes.