PlatSim_Genova/TestEnvironment/scripts/GRIFO_M_PBIT_gui.py

1133 lines
46 KiB
Python

"""
GRIFO_M_PBIT_gui.py - Real-Time Test Monitor GUI
This module provides a tkinter-based GUI to monitor test execution in real-time.
Works with both real hardware and simulation mode.
Features:
- Live test progress visualization
- Current scenario information
- Real-time statistics (B6/B8/Serial)
- Color-coded status indicators
- Event log with filtering
- Thread-safe updates via queue
Requirements:
- Python 3.7+
- tkinter 8.6+ (included in embedded Python)
Usage:
# In test script:
from GRIFO_M_PBIT_gui import TestMonitorGUI
gui = TestMonitorGUI()
gui.start()
# Update from test code:
gui.update_status('run', current=5, total=10)
gui.update_scenario('processor_fail', 'Processor Failure', {...})
gui.log_event('info', 'BIT completed in 15.3s')
# Cleanup:
gui.stop()
Author: Test Automation Team
Date: 2026-01-30
"""
import tkinter as tk
from tkinter import ttk, scrolledtext
import threading
import queue
import time
import gc
import os
from datetime import datetime, timedelta
from typing import Dict, Any, Optional
class ToolTip:
"""
Creates a tooltip for a given widget.
Shows the tooltip when mouse enters the widget.
"""
def __init__(self, widget, text):
self.widget = widget
self.text = text
self.tooltip_window = None
self.widget.bind("<Enter>", self.show_tooltip)
self.widget.bind("<Leave>", self.hide_tooltip)
def show_tooltip(self, event=None):
"""Display the tooltip."""
if self.tooltip_window or not self.text:
return
# Get widget position
x = self.widget.winfo_rootx() + 20
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 5
# Create tooltip window
self.tooltip_window = tw = tk.Toplevel(self.widget)
tw.wm_overrideredirect(True) # Remove window decorations
tw.wm_geometry(f"+{x}+{y}")
# Create label with tooltip text
label = tk.Label(tw, text=self.text, justify=tk.LEFT,
background="#ffffe0", relief=tk.SOLID, borderwidth=1,
font=("Arial", 9))
label.pack(ipadx=5, ipady=3)
def hide_tooltip(self, event=None):
"""Hide the tooltip."""
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
class TestMonitorGUI:
"""
Real-time GUI monitor for GRIFO PBIT test execution.
Thread-safe GUI that displays:
- Test progress (runs, scenarios)
- Live statistics (B6, B8, Serial)
- Scenario information
- Event log
"""
def __init__(self):
"""Initialize GUI components (creates window but doesn't show it yet)."""
self.root = None
self.thread = None
self.running = False
self.update_queue = queue.Queue()
# Data storage
self.data = {
'run_current': 0,
'run_total': 0,
'scenario_name': 'Initializing...',
'scenario_description': '',
'pbit_time': 0.0,
'pbit_available': False,
'power_on': False,
'b6_total': 0,
'b6_pass': 0,
'b6_fail': 0,
'b6_known': 0,
'b8_checked': 0,
'b8_pass': 0,
'b8_fail': 0,
'serial_total': 0,
'serial_errors': 0,
'serial_fatal': 0,
'serial_recycles': 0,
'success_rate': 0.0,
'expected_failures': [],
'expected_passes': [],
'notes': '',
}
# GUI components (will be created in GUI thread)
self.widgets = {}
# Timing/ETA estimation
self.pbit_samples = [] # list of completed run durations (seconds)
self.run_start_timestamp = None
self.last_run_current = 0
self.default_pbit_sec = 182.0 # fallback per-run estimate
# Per-run detailed data storage (populated on GUI thread)
self.runs_data = {}
def start(self):
"""Start GUI in separate thread."""
if self.running:
return
self.running = True
# Start GUI thread as non-daemon so the process remains alive until user closes the GUI
self.thread = threading.Thread(target=self._run_gui, daemon=False)
self.thread.start()
# Wait for GUI to initialize
time.sleep(0.5)
def stop(self):
"""Stop GUI and close window."""
# Request shutdown from GUI thread to avoid calling tkinter methods
# from the main thread (which can trigger Tcl_AsyncDelete warnings).
if not self.running:
return
self.update_queue.put(('shutdown', {}))
# Wait for GUI thread to exit (safe join); allow longer timeout for slow systems
try:
if self.thread and self.thread.is_alive():
self.thread.join(timeout=15.0)
except Exception:
pass
self.running = False
def _run_gui(self):
"""GUI thread main loop."""
self.root = tk.Tk()
self.root.title("GRIFO M-PBIT Test Monitor")
# Increase default window size so log and runs table are visible without manual resize
self.root.geometry("1100x900")
self.root.minsize(900, 700)
self.root.protocol("WM_DELETE_WINDOW", self._on_closing)
# Configure styles
style = ttk.Style()
style.theme_use('clam')
# Create GUI layout
self._create_widgets()
# Start update polling
self._poll_updates()
# Run main loop
self.root.mainloop()
def _create_widgets(self):
"""Create all GUI widgets."""
# Main container
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
# Configure grid weights
main_frame.columnconfigure(0, weight=1)
main_frame.columnconfigure(1, weight=1)
# Give more vertical space to runs table (row 3) and some to log (row 4)
main_frame.rowconfigure(3, weight=3)
main_frame.rowconfigure(4, weight=1)
# ===== ROW 0: HEADER =====
header_frame = ttk.Frame(main_frame)
header_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
title_label = ttk.Label(header_frame, text="GRIFO M-PBIT Test Monitor",
font=('Arial', 16, 'bold'))
title_label.pack(side=tk.LEFT)
self.widgets['status_indicator'] = ttk.Label(header_frame, text="● IDLE",
font=('Arial', 12, 'bold'),
foreground='gray')
self.widgets['status_indicator'].pack(side=tk.RIGHT)
# ===== ROW 1: PROGRESS SECTION =====
progress_frame = ttk.LabelFrame(main_frame, text="Test Progress", padding="10")
progress_frame.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
progress_frame.columnconfigure(0, weight=0)
progress_frame.columnconfigure(1, weight=1)
progress_frame.columnconfigure(2, weight=0)
progress_frame.columnconfigure(3, weight=0)
# Run info
ttk.Label(progress_frame, text="Current Run:").grid(row=0, column=0, sticky=tk.W)
self.widgets['run_label'] = ttk.Label(progress_frame, text="0 / 0",
font=('Arial', 10, 'bold'))
self.widgets['run_label'].grid(row=0, column=1, sticky=tk.W, padx=(10, 0))
# Progress bar
self.widgets['progress_bar'] = ttk.Progressbar(progress_frame, mode='determinate')
self.widgets['progress_bar'].grid(row=1, column=0, columnspan=4, sticky=(tk.W, tk.E), pady=(5, 0))
# PBIT timer
ttk.Label(progress_frame, text="PBIT Time:").grid(row=2, column=0, sticky=tk.W, pady=(5, 0))
self.widgets['pbit_label'] = ttk.Label(progress_frame, text="0.0s",
font=('Arial', 10))
self.widgets['pbit_label'].grid(row=2, column=1, sticky=tk.W, padx=(10, 0), pady=(5, 0))
# Estimated remaining time (ETA) on the same row, right-justified
ttk.Label(progress_frame, text="Estimated Remaining:").grid(row=2, column=2, sticky=tk.W, pady=(5, 0))
self.widgets['eta_label'] = ttk.Label(progress_frame, text="--:--", font=('Arial', 10))
self.widgets['eta_label'].grid(row=2, column=3, sticky=tk.E, padx=(10, 0), pady=(5, 0))
# ===== ROW 2: SCENARIO INFO =====
scenario_frame = ttk.LabelFrame(main_frame, text="Current Scenario", padding="10")
scenario_frame.grid(row=2, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
scenario_frame.columnconfigure(1, weight=1)
# Scenario name
ttk.Label(scenario_frame, text="Scenario:").grid(row=0, column=0, sticky=tk.W)
self.widgets['scenario_label'] = ttk.Label(scenario_frame, text="Initializing...",
font=('Arial', 10, 'bold'))
self.widgets['scenario_label'].grid(row=0, column=1, sticky=tk.W, padx=(10, 0))
# Description
ttk.Label(scenario_frame, text="Description:").grid(row=1, column=0, sticky=tk.W, pady=(5, 0))
self.widgets['scenario_desc'] = ttk.Label(scenario_frame, text="",
font=('Arial', 9), wraplength=600)
self.widgets['scenario_desc'].grid(row=1, column=1, sticky=tk.W, padx=(10, 0), pady=(5, 0))
# Expected failures
ttk.Label(scenario_frame, text="Expected Failures:").grid(row=2, column=0, sticky=(tk.W, tk.N), pady=(5, 0))
self.widgets['expected_fail'] = tk.Text(scenario_frame, height=2, width=60,
font=('Courier', 8), wrap=tk.WORD,
background='#fff5f5')
self.widgets['expected_fail'].grid(row=2, column=1, sticky=(tk.W, tk.E), padx=(10, 0), pady=(5, 0))
# ===== ROW 3: PER-RUN TABLE (moved up for better visibility) =====
runs_frame = ttk.LabelFrame(main_frame, text="Runs Overview", padding="5")
runs_frame.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(0, 10))
runs_frame.columnconfigure(0, weight=1)
runs_frame.rowconfigure(1, weight=1) # Row 1 for table (row 0 for custom headers)
# Create custom header frame with tooltips
header_frame = ttk.Frame(runs_frame)
header_frame.grid(row=0, column=0, sticky=(tk.W, tk.E))
# Define headers with their tooltips
headers_with_tooltips = [
('Run', None, 60),
('Status', None, 80),
('PBIT Time', None, 80),
('B6 Status (P/F/K)', 'P = Pass\nF = Fail\nK = Known Failures', 120),
('B8 Checks (P/F/K)', 'P = Pass\nF = Fail\nK = Known Failures', 120),
('Serial Messages (E/F/R)', 'E = Errors\nF = Fatal\nR = Recycles', 120),
('Target', 'Target distance and cycle\nFormat: XXXXm @ cycle Y', 140),
('Target Test', None, 80)
]
# Create header labels with tooltips
for idx, (title, tooltip, width) in enumerate(headers_with_tooltips):
label = ttk.Label(header_frame, text=title, font=('Arial', 9, 'bold'),
anchor=tk.CENTER, relief=tk.RAISED, borderwidth=1)
label.grid(row=0, column=idx, sticky=(tk.W, tk.E), ipadx=2, ipady=3)
header_frame.columnconfigure(idx, weight=1 if idx > 0 else 0, minsize=width)
# Add tooltip if specified
if tooltip:
ToolTip(label, tooltip)
# Columns aligned with PDF report format for consistency
cols = ('run', 'status', 'pbit_time', 'b6_status', 'b8_checks', 'serial_msgs', 'target', 'target_test')
# Taller table for better visibility (show='tree' to hide built-in headers)
self.widgets['runs_table'] = ttk.Treeview(runs_frame, columns=cols, show='tree', height=12)
self.widgets['runs_table'].grid(row=1, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.widgets['runs_table'].column('#0', width=0, stretch=tk.NO) # Hide tree column
# Set column widths to match headers
column_widths = [60, 80, 80, 120, 120, 120, 140, 80]
for c, width in zip(cols, column_widths):
self.widgets['runs_table'].column(c, width=width, anchor=tk.CENTER)
# Add scrollbar
runs_scroll = ttk.Scrollbar(runs_frame, orient=tk.VERTICAL, command=self.widgets['runs_table'].yview)
runs_scroll.grid(row=1, column=1, sticky=(tk.N, tk.S))
self.widgets['runs_table'].configure(yscrollcommand=runs_scroll.set)
# Style tags for visual feedback
self.widgets['runs_table'].tag_configure('pass', background='#d4edda')
self.widgets['runs_table'].tag_configure('fail', background='#f8d7da')
# Bind double-click for details
self.widgets['runs_table'].bind('<Double-Button-1>', self._on_run_double_click)
# ===== ROW 4: EVENT LOG (moved down) =====
log_frame = ttk.LabelFrame(main_frame, text="Event Log", padding="10")
log_frame.grid(row=4, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S))
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
self.widgets['log_text'] = scrolledtext.ScrolledText(log_frame, height=15, width=80,
font=('Courier', 8), wrap=tk.WORD)
self.widgets['log_text'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configure text tags for color coding
self.widgets['log_text'].tag_config('info', foreground='black')
self.widgets['log_text'].tag_config('success', foreground='green')
self.widgets['log_text'].tag_config('warning', foreground='orange')
self.widgets['log_text'].tag_config('error', foreground='red')
self.widgets['log_text'].tag_config('debug', foreground='gray')
def _poll_updates(self):
"""Poll update queue and apply changes to GUI."""
if not self.running:
return
try:
# Process all queued updates
while not self.update_queue.empty():
update_type, data = self.update_queue.get_nowait()
if update_type == 'status':
self._update_status(data)
elif update_type == 'scenario':
self._update_scenario(data)
elif update_type == 'statistics':
self._update_statistics(data)
elif update_type == 'run_row':
self._update_run_row(data)
elif update_type == 'show_results':
self._show_results_dialog(data)
elif update_type == 'shutdown':
# Perform clean shutdown from the GUI thread
try:
self.running = False
if self.root:
try:
self.root.quit()
except Exception:
pass
try:
self.root.destroy()
except Exception:
pass
# CRITICAL: Clear all Tk references on GUI thread to avoid
# Tcl_AsyncDelete warnings when main thread cleans up
try:
self.root = None
self.widgets.clear()
except Exception:
pass
except Exception:
pass
return
elif update_type == 'gc_collect':
# Run garbage collection on the GUI thread so that any
# PhotoImage/ImageTk destructors that call into Tk run
# on the correct thread and avoid Tcl_AsyncDelete issues.
try:
gc.collect()
except Exception:
pass
elif update_type == 'log':
self._add_log_entry(data)
except queue.Empty:
pass
except Exception as e:
print(f"[GUI] Error processing update: {e}")
# Schedule next poll
if self.root:
self.root.after(100, self._poll_updates)
def _update_status(self, data: Dict[str, Any]):
"""Update test status display."""
if 'run_current' in data:
self.data['run_current'] = data['run_current']
if 'run_total' in data:
self.data['run_total'] = data['run_total']
if 'pbit_time' in data:
self.data['pbit_time'] = data['pbit_time']
if 'pbit_available' in data:
self.data['pbit_available'] = data['pbit_available']
if 'power_on' in data:
self.data['power_on'] = data['power_on']
# Update labels
self.widgets['run_label'].config(text=f"{self.data['run_current']} / {self.data['run_total']}")
self.widgets['pbit_label'].config(text=f"{self.data['pbit_time']:.1f}s")
# Update progress bar
if self.data['run_total'] > 0:
progress = (self.data['run_current'] / self.data['run_total']) * 100
self.widgets['progress_bar']['value'] = progress
# Update status indicator
if self.data['power_on']:
status_text = "● RUNNING"
status_color = 'green'
else:
status_text = "● IDLE"
status_color = 'gray'
self.widgets['status_indicator'].config(text=status_text, foreground=status_color)
# Initialize runs table when run_total is set
if self.data['run_total'] and 'runs_table_initialized' not in self.widgets:
self._init_runs_table(self.data['run_total'])
# ----- ETA / estimation logic -----
try:
now = time.time()
run_current = int(self.data.get('run_current', 0) or 0)
run_total = int(self.data.get('run_total', 0) or 0)
# Initialize run start timestamp when first run is seen
if run_current and self.run_start_timestamp is None:
self.run_start_timestamp = now
self.last_run_current = run_current
# Detect run increment -> previous run finished
if run_current and self.last_run_current and run_current > self.last_run_current:
# previous run finished at 'now'
try:
duration = now - (self.run_start_timestamp or now)
if duration > 0:
self.pbit_samples.append(duration)
except Exception:
pass
# start timestamp for the new run
self.run_start_timestamp = now
self.last_run_current = run_current
# Compute average per-run duration
if self.pbit_samples:
avg = sum(self.pbit_samples) / len(self.pbit_samples)
else:
avg = float(self.default_pbit_sec)
# Remaining runs (not counting current one)
remaining_runs = max(0, run_total - run_current)
current_pbit = float(self.data.get('pbit_time', 0.0) or 0.0)
# Estimate remaining seconds: remaining full runs + remaining of current
remaining_of_current = max(0.0, avg - current_pbit)
est_seconds = remaining_runs * avg + remaining_of_current
# If finished, show completed
if run_total and run_current >= run_total:
eta_text = "Completed"
else:
# Human-friendly duration with units
duration_str = self._format_seconds(est_seconds)
# Estimated finish clock time
try:
finish_dt = datetime.now() + timedelta(seconds=int(round(est_seconds)))
finish_str = finish_dt.strftime("%H:%M")
eta_text = f"{duration_str} ({finish_str})"
except Exception:
eta_text = duration_str
# Update ETA label
try:
self.widgets['eta_label'].config(text=eta_text)
except Exception:
pass
except Exception:
# Keep ETA unchanged on errors
pass
def _update_scenario(self, data: Dict[str, Any]):
"""Update scenario information display."""
if 'name' in data:
self.data['scenario_name'] = data['name']
if 'description' in data:
self.data['scenario_description'] = data['description']
if 'expected_failures' in data:
self.data['expected_failures'] = data['expected_failures']
if 'expected_passes' in data:
self.data['expected_passes'] = data['expected_passes']
if 'notes' in data:
self.data['notes'] = data['notes']
# Update labels
self.widgets['scenario_label'].config(text=self.data['scenario_name'])
self.widgets['scenario_desc'].config(text=self.data['scenario_description'])
# Update expected failures text box (enable for edits then disable)
txt = self.widgets['expected_fail']
try:
txt.config(state='normal')
except Exception:
pass
txt.delete('1.0', tk.END)
if self.data['expected_failures']:
fail_text = '\n'.join([f"{f}" for f in self.data['expected_failures']])
else:
fail_text = "(none expected)"
txt.insert('1.0', fail_text)
txt.config(state='disabled')
def _update_statistics(self, data: Dict[str, Any]):
"""Update statistics display.
Note: Statistics frame removed from GUI (data visible in runs table).
This method kept for backward compatibility but does nothing.
"""
# Update internal data only (no GUI update)
for key in ['b6_total', 'b6_pass', 'b6_fail', 'b6_known',
'b8_checked', 'b8_pass', 'b8_fail',
'serial_total', 'serial_errors', 'serial_fatal', 'serial_recycles']:
if key in data:
self.data[key] = data[key]
# GUI widgets removed - data now visible in runs table
def _add_log_entry(self, data: Dict[str, str]):
"""Add entry to event log."""
level = data.get('level', 'info')
message = data.get('message', '')
timestamp = time.strftime('%H:%M:%S')
# Format message with timestamp
log_line = f"[{timestamp}] {message}\n"
# Insert with appropriate tag
self.widgets['log_text'].insert(tk.END, log_line, level)
self.widgets['log_text'].see(tk.END)
# Limit log size (keep last 500 lines)
line_count = int(self.widgets['log_text'].index('end-1c').split('.')[0])
if line_count > 500:
self.widgets['log_text'].delete('1.0', '100.0')
def _format_seconds(self, seconds: float) -> str:
"""Format seconds into human-friendly string with units.
Examples:
- 14 -> "14s"
- 134 -> "2m 14s"
- 3670 -> "1h 01m 10s"
"""
try:
sec = int(round(seconds))
if sec <= 0:
return "0s"
hours, rem = divmod(sec, 3600)
minutes, secs = divmod(rem, 60)
parts = []
if hours:
parts.append(f"{hours}h")
if minutes:
parts.append(f"{minutes}m")
if secs or not parts:
parts.append(f"{secs}s")
return ' '.join(parts)
except Exception:
return "--:--"
def _on_closing(self):
"""Handle window close event."""
self.running = False
self.root.destroy()
# ===== PUBLIC API FOR TEST CODE =====
def update_status(self, run_current: Optional[int] = None, run_total: Optional[int] = None,
pbit_time: Optional[float] = None, pbit_available: Optional[bool] = None,
power_on: Optional[bool] = None):
"""Update test status (thread-safe)."""
data = {}
if run_current is not None:
data['run_current'] = run_current
if run_total is not None:
data['run_total'] = run_total
if pbit_time is not None:
data['pbit_time'] = pbit_time
if pbit_available is not None:
data['pbit_available'] = pbit_available
if power_on is not None:
data['power_on'] = power_on
if data:
self.update_queue.put(('status', data))
def update_scenario(self, name: str, description: str,
expected_failures: list, expected_passes: list, notes: str):
"""Update scenario information (thread-safe)."""
data = {
'name': name,
'description': description,
'expected_failures': expected_failures,
'expected_passes': expected_passes,
'notes': notes,
}
self.update_queue.put(('scenario', data))
def update_statistics(self, **kwargs):
"""Update statistics (thread-safe)."""
self.update_queue.put(('statistics', kwargs))
def _init_runs_table(self, total_runs: int):
"""Pre-populate runs table with empty rows for visual overview."""
table = self.widgets.get('runs_table')
if not table:
return
# Clear existing
for r in table.get_children():
table.delete(r)
for i in range(1, total_runs + 1):
table.insert('', 'end', iid=str(i), values=(f'{i}/{total_runs}', '', '', '', '', '', '', '', '', ''))
# initialize empty metadata for each run
try:
self.runs_data[str(i)] = {}
except Exception:
pass
self.widgets['runs_table_initialized'] = True
def update_run(self, run: int, **kwargs):
"""Thread-safe request to update one row in runs table.
Accepts keyword arguments matching PDF report format:
- result: PASS/FAIL status
- pbit: PBIT time (seconds)
- b6_pass, b6_fail, b6_known_fail: B6 test results (formatted as P/F/K)
- b8_pass, b8_fail, b8_checked: B8 test results (formatted as Pass/Fail or -)
- serial_errors, serial_fatal, serial_recycles: Serial message stats (formatted as E/F/R)
- target_simulated: Target simulation data (formatted as distance @ cycle)
- target_detected: Target test result (PASS/FAIL/-)
Additional metadata fields for detail dialog:
- start_time, end_time, run_duration
- failures, serial_details
- target_test_time, stby_verified, silence_verified, stby_silence_verify_details
- target_distance_m, target_distance_nm, target_found_iter
"""
data = {'run': run}
# Merge all kwargs into data to keep API flexible
try:
for k, v in kwargs.items():
data[k] = v
except Exception:
pass
self.update_queue.put(('run_row', data))
def _update_run_row(self, data: Dict[str, Any]):
"""Apply an update to the runs table row specified by 'run'."""
table = self.widgets.get('runs_table')
if not table:
return
run_num = str(data.get('run'))
if not table.exists(run_num):
# Insert if missing
total = self.data.get('run_total', 0)
table.insert('', 'end', iid=run_num, values=(f'{run_num}/{total}', '', '', '', '', '', '', ''))
# Read old values
old = table.item(run_num).get('values', [])
if not old:
old = [''] * 8 # Updated to 8 columns (aligned with PDF)
# Map columns to match PDF format
new = list(old)
for k, v in data.items():
if k == 'run':
new[0] = f"{v}/{self.data.get('run_total', '')}"
elif k == 'result':
# Column 1: Status (PASS/FAIL)
new[1] = v
elif k == 'pbit':
# Column 2: PBIT Time
new[2] = f"{v:.1f}s" if isinstance(v, (int, float)) else str(v)
elif k in ('b6_pass', 'b6_fail', 'b6_known_fail'):
# Column 3: B6 Status (P/F/K) - format like PDF
b6_pass = data.get('b6_pass', 0)
b6_fail = data.get('b6_fail', 0)
b6_known = data.get('b6_known_fail', 0)
new[3] = f"{b6_pass}/{b6_fail}/{b6_known}"
elif k in ('b8_pass', 'b8_fail', 'b8_checked', 'b8_known_fail'):
# Column 4: B8 Checks (Pass/Fail/Known) - format like PDF
b8_pass = data.get('b8_pass', 0)
b8_fail = data.get('b8_fail', 0)
b8_known = data.get('b8_known_fail', 0)
b8_checked = data.get('b8_checked', 0)
if b8_checked > 0:
new[4] = f"{b8_pass}/{b8_fail}/{b8_known}"
else:
new[4] = "-"
elif k in ('serial_errors', 'serial_fatal', 'serial_recycles'):
# Column 5: Serial Messages (E/F/R) - format like PDF
serial_e = data.get('serial_errors', 0)
serial_f = data.get('serial_fatal', 0)
serial_r = data.get('serial_recycles', 0)
new[5] = f"{serial_e}/{serial_f}/{serial_r}"
elif k == 'target_simulated':
# Column 6: Target (distance @ cycle) - format like PDF
if v and isinstance(v, dict):
try:
distance = v.get('distance', '?')
cycle = v.get('appeared_after_cycles', v.get('found_at_iter', '?'))
new[6] = f"{distance}m @ cycle {cycle}"
except Exception:
new[6] = "-"
else:
new[6] = "-"
elif k == 'target_detected':
# Column 7: Target Test (PASS/FAIL/-)
if v is None:
new[7] = "-"
elif v:
new[7] = "PASS"
# If target detected but no simulated data yet, keep existing target column
if new[6] == "-" or not new[6]:
new[6] = "Detected"
else:
new[7] = "FAIL"
# If target not detected, clear target column
new[6] = "-"
# Merge/remember detailed data for this run (kept on GUI thread)
try:
meta = self.runs_data.get(run_num, {})
for k, v in data.items():
meta[k] = v
# also store the displayed columns for convenience (aligned with PDF)
meta['display'] = {
'status': new[1], 'pbit_time': new[2], 'b6_status': new[3], 'b8_checks': new[4],
'serial_msgs': new[5], 'target': new[6], 'target_test': new[7]
}
self.runs_data[run_num] = meta
except Exception:
pass
table.item(run_num, values=new)
# Apply visual tag based on result for quick scanning
try:
res = new[1].strip() if len(new) > 1 and isinstance(new[1], str) else ''
if res.upper() == 'PASS':
table.item(run_num, tags=('pass',))
elif res.upper() == 'FAIL':
table.item(run_num, tags=('fail',))
else:
# clear tags
table.item(run_num, tags=())
except Exception:
pass
# Ensure the updated row is visible (scroll to it)
try:
table.see(run_num)
except Exception:
# Fallback: move view to bottom
try:
table.yview_moveto(1.0)
except Exception:
pass
def log_event(self, level: str, message: str):
"""Add log entry (thread-safe).
Args:
level: 'info', 'success', 'warning', 'error', 'debug'
message: Log message text
"""
data = {'level': level, 'message': message}
self.update_queue.put(('log', data))
def _on_run_double_click(self, event=None):
"""Handle double-click on runs table: open detailed run dialog."""
try:
table = self.widgets.get('runs_table')
if not table:
return
sel = table.focus()
if not sel:
# try selection
sel_items = table.selection()
if not sel_items:
return
sel = sel_items[0]
run_id = str(sel)
meta = self.runs_data.get(run_id, {})
# Build dialog
dlg = tk.Toplevel(self.root)
dlg.title(f"Run {run_id} Details")
dlg.transient(self.root)
frm = ttk.Frame(dlg, padding=12)
frm.pack(fill=tk.BOTH, expand=True)
# Header
hdr = ttk.Label(frm, text=f"Run {run_id}", font=('Arial', 12, 'bold'))
hdr.pack(anchor=tk.W)
# Key/value grid
grid = ttk.Frame(frm)
grid.pack(fill=tk.X, pady=(6, 6))
def _kv(row, key, val):
ttk.Label(grid, text=key+":", font=('Arial', 9, 'bold')).grid(row=row, column=0, sticky=tk.W, padx=(0,6))
ttk.Label(grid, text=str(val), font=('Arial', 9)).grid(row=row, column=1, sticky=tk.W)
display = meta.get('display', {})
_kv(0, 'Status', display.get('status', ''))
_kv(1, 'PBIT Time', display.get('pbit_time', ''))
_kv(2, 'B6 Status (P/F/K)', display.get('b6_status', ''))
_kv(3, 'B8 Checks', display.get('b8_checks', ''))
_kv(4, 'Serial Msgs (E/F/R)', display.get('serial_msgs', ''))
_kv(5, 'Target', display.get('target', ''))
_kv(6, 'Target Test', display.get('target_test', ''))
# Start / End times and duration (if provided)
start_iso = meta.get('start_time')
end_iso = meta.get('end_time')
run_duration = meta.get('run_duration')
try:
start_disp = start_iso
end_disp = end_iso
dur_disp = f"{run_duration:.2f}s" if isinstance(run_duration, (int, float)) else str(run_duration)
except Exception:
start_disp = start_iso
end_disp = end_iso
dur_disp = run_duration
_kv(7, 'Start Time', start_disp)
_kv(8, 'End Time', end_disp)
_kv(9, 'Run Duration', dur_disp)
# Target info
tgt_detected = meta.get('target_detected', None)
tgt_test_time = meta.get('target_test_time', None)
tgt_dist_m = meta.get('target_distance_m', None)
tgt_dist_nm = meta.get('target_distance_nm', None)
tgt_iter = meta.get('target_found_iter', None)
tgt_status = 'YES' if tgt_detected is True else ('NO' if tgt_detected is False else 'N/A')
_kv(10, 'Target Detected', tgt_status)
_kv(11, 'Target Test Time', f"{tgt_test_time:.2f}s" if isinstance(tgt_test_time, (int, float)) else str(tgt_test_time))
# Distance formatting
try:
dist_str = ''
if isinstance(tgt_dist_m, (int, float)):
dist_str = f"{tgt_dist_m:.1f} m"
if isinstance(tgt_dist_nm, (int, float)):
dist_str += f" ({tgt_dist_nm:.2f} NM)"
else:
dist_str = str(tgt_dist_m)
except Exception:
dist_str = str(tgt_dist_m)
_kv(12, 'Target Distance', dist_str)
_kv(13, 'Target Found At Iter', tgt_iter)
# Failures detail (multi-line)
ttk.Label(frm, text='Failures (detailed):', font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(6,0))
failures_text = tk.Text(frm, height=6, width=80, font=('Courier', 9), wrap=tk.WORD)
failures = meta.get('failures') or meta.get('fail_summary') or ''
# If failures stored as list, pretty-format
if isinstance(failures, (list, tuple)):
body = '\n'.join([str(x) for x in failures])
else:
body = str(failures)
failures_text.insert('1.0', body)
failures_text.config(state='disabled')
failures_text.pack(fill=tk.BOTH, expand=False, pady=(2,6))
# Serial details if present
serial = meta.get('serial_details') or meta.get('serial_events') or ''
ttk.Label(frm, text='Serial events/details:', font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(6,0))
serial_text = tk.Text(frm, height=4, width=80, font=('Courier', 9), wrap=tk.WORD)
serial_text.insert('1.0', str(serial))
serial_text.config(state='disabled')
serial_text.pack(fill=tk.BOTH, expand=False, pady=(2,6))
# Close button
btn_frame = ttk.Frame(frm)
btn_frame.pack(fill=tk.X, pady=(6,0))
close_btn = ttk.Button(btn_frame, text='Close', command=lambda: dlg.destroy())
close_btn.pack(side=tk.RIGHT)
# Center dialog
dlg.update_idletasks()
w = dlg.winfo_reqwidth()
h = dlg.winfo_reqheight()
try:
px = self.root.winfo_rootx()
py = self.root.winfo_rooty()
pw = self.root.winfo_width() or self.root.winfo_reqwidth()
ph = self.root.winfo_height() or self.root.winfo_reqheight()
x = px + max(0, (pw - w)//2)
y = py + max(0, (ph - h)//2)
dlg.geometry(f"{w}x{h}+{x}+{y}")
except Exception:
pass
dlg.transient(self.root)
dlg.grab_set()
except Exception as e:
print(f"Error opening run details: {e}")
def show_results(self, folder: str):
"""Request GUI to show results folder dialog (thread-safe).
This enqueues a request processed in the GUI thread.
"""
self.update_queue.put(('show_results', {'folder': folder}))
def _show_results_dialog(self, data: dict):
"""Create a professional dialog showing test completion and output locations."""
folder = data.get('folder') if isinstance(data, dict) else data
if not folder:
return
try:
dlg = tk.Toplevel(self.root)
dlg.title('GRIFO M-PBIT Test Completed')
dlg.transient(self.root)
# Main container with padding
main_frame = ttk.Frame(dlg, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)
# Header with icon and title
header_frame = ttk.Frame(main_frame)
header_frame.pack(fill=tk.X, pady=(0, 15))
title_label = ttk.Label(header_frame, text="✓ Test Execution Completed",
font=('Arial', 14, 'bold'), foreground='green')
title_label.pack(anchor=tk.W)
# Separator
sep1 = ttk.Separator(main_frame, orient=tk.HORIZONTAL)
sep1.pack(fill=tk.X, pady=(0, 15))
# Info section
info_frame = ttk.LabelFrame(main_frame, text="Test Results", padding="15")
info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 15))
msg_text = (
"The test execution has been completed successfully.\n\n"
"The following output files have been generated:"
)
msg_label = ttk.Label(info_frame, text=msg_text, justify=tk.LEFT)
msg_label.pack(anchor=tk.W, pady=(0, 10))
# File types list
files_frame = ttk.Frame(info_frame)
files_frame.pack(fill=tk.X, pady=(0, 10))
file_items = [
("PDF Report", "Complete test report with analysis"),
("CSV Export", "Detailed statistics and per-run data"),
("Log Files", "Execution logs and debug information")
]
for icon_text, description in file_items:
item_frame = ttk.Frame(files_frame)
item_frame.pack(fill=tk.X, pady=2)
ttk.Label(item_frame, text=f" {icon_text}:", font=('Arial', 9, 'bold')).pack(side=tk.LEFT)
ttk.Label(item_frame, text=f" {description}", font=('Arial', 9)).pack(side=tk.LEFT)
# Location section with folder path
location_frame = ttk.Frame(info_frame)
location_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Label(location_frame, text="Location:", font=('Arial', 9, 'bold')).pack(anchor=tk.W)
path_text = tk.Text(location_frame, height=2, wrap=tk.WORD,
font=('Courier', 8), relief=tk.FLAT,
background='#f0f0f0', padx=5, pady=5)
path_text.insert('1.0', folder)
path_text.config(state='disabled')
path_text.pack(fill=tk.X, pady=(5, 0))
# Separator
sep2 = ttk.Separator(main_frame, orient=tk.HORIZONTAL)
sep2.pack(fill=tk.X, pady=(0, 15))
# Button frame
btn_frame = ttk.Frame(main_frame)
btn_frame.pack(fill=tk.X)
def _open_folder():
try:
if os.name == 'nt':
os.startfile(folder)
else:
import subprocess
subprocess.Popen(['xdg-open', folder])
except Exception:
pass
def _close_all():
try:
dlg.destroy()
except Exception:
pass
try:
self._on_closing()
except Exception:
pass
# Buttons with better styling
open_btn = ttk.Button(btn_frame, text='Open folder', command=_open_folder)
open_btn.pack(side=tk.RIGHT, padx=(10, 0))
close_btn = ttk.Button(btn_frame, text='Close Test', command=_close_all)
close_btn.pack(side=tk.RIGHT)
# Update window to calculate final size, then center on parent and show
dlg.update_idletasks()
# Set minimum size based on content
dlg_width = max(600, dlg.winfo_reqwidth())
dlg_height = max(400, dlg.winfo_reqheight())
# Calculate position to center on parent window (use root's screen coordinates)
try:
parent_x = self.root.winfo_rootx()
parent_y = self.root.winfo_rooty()
parent_width = self.root.winfo_width() or self.root.winfo_reqwidth()
parent_height = self.root.winfo_height() or self.root.winfo_reqheight()
except Exception:
parent_x = 0
parent_y = 0
parent_width = self.root.winfo_screenwidth()
parent_height = self.root.winfo_screenheight()
x = parent_x + max(0, (parent_width - dlg_width) // 2)
y = parent_y + max(0, (parent_height - dlg_height) // 2)
# Clamp to screen bounds
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
x = max(0, min(x, screen_w - dlg_width))
y = max(0, min(y, screen_h - dlg_height))
# Apply geometry and make non-resizable
dlg.geometry(f'{dlg_width}x{dlg_height}+{x}+{y}')
dlg.resizable(False, False)
# Now grab focus
dlg.grab_set()
# Ensure dialog is on top
dlg.lift()
dlg.focus_force()
except Exception as e:
print(f"Error showing results dialog: {e}")
# ===== SIMPLE TEST/DEMO =====
if __name__ == '__main__':
print("Starting GUI test...")
gui = TestMonitorGUI()
gui.start()
# Simulate test execution
gui.update_status(run_current=0, run_total=10, power_on=True)
gui.log_event('info', 'Test started')
time.sleep(1)
for i in range(1, 11):
# Update run
gui.update_status(run_current=i, run_total=10)
gui.log_event('info', f'Starting run {i}/10')
# Update scenario
scenarios = ['normal', 'processor_fail', 'transmitter_fail']
scenario = scenarios[i % 3]
gui.update_scenario(
name=scenario.replace('_', ' ').title(),
description=f"Test scenario {scenario}",
expected_failures=['Field A', 'Field B'] if scenario != 'normal' else [],
expected_passes=['All other fields'],
notes='This is a test scenario'
)
# Simulate PBIT execution
for t in range(15):
gui.update_status(pbit_time=t + 1.0)
time.sleep(0.2)
# Update statistics
gui.update_statistics(
b6_total=12, b6_pass=10, b6_fail=1, b6_known=1,
b8_checked=0 if scenario == 'normal' else 50,
b8_pass=0 if scenario == 'normal' else 48,
b8_fail=0 if scenario == 'normal' else 2,
serial_total=i * 5, serial_errors=i, serial_fatal=0, serial_recycles=0
)
gui.log_event('success' if scenario == 'normal' else 'warning',
f'Run {i} completed')
time.sleep(1)
gui.log_event('success', 'All tests completed!')
# Keep GUI open
input("Press Enter to close GUI...")
gui.stop()