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 tk
from tkinter import ttk
import threading
class 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
pass
if __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 time
import openhtf as htf
class 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 htf
from openhtf.util import units
from 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 tk
from tkinter import ttk
import threading
import openhtf as htf
from tofupilot.openhtf import TofuPilot
from test_phases import phase_quiescent_current, phase_overcurrent_protection
class 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.