Skip to content
Test Station Setup

How to Build a Test Panel GUI with TofuPilot

Learn how to build a tkinter-based test panel GUI that controls instruments and runs OpenHTF tests with TofuPilot logging.

JJulien Buteau
intermediate8 min readMarch 14, 2026

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.

panel.py
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.

instrument_plug.py
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 = False

The 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.

test_phases.py
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 = True

Each 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.

panel_with_test.py
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.execute runs 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.

More Guides

Put this guide into practice