1059 lines
43 KiB
Python
1059 lines
43 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 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 log (row 4) and runs table (row 5)
|
|
main_frame.rowconfigure(4, weight=2)
|
|
main_frame.rowconfigure(5, 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: STATISTICS =====
|
|
stats_frame = ttk.Frame(main_frame)
|
|
stats_frame.grid(row=3, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=(0, 10))
|
|
stats_frame.columnconfigure(0, weight=1)
|
|
stats_frame.columnconfigure(1, weight=1)
|
|
stats_frame.columnconfigure(2, weight=1)
|
|
|
|
# B6 Stats
|
|
b6_frame = ttk.LabelFrame(stats_frame, text="B6 LRU Status", padding="10")
|
|
b6_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(0, 5))
|
|
self.widgets['b6_stats'] = ttk.Label(b6_frame, text="Total: 0\nPass: 0\nFail: 0\nKnown: 0",
|
|
font=('Courier', 9), justify=tk.LEFT)
|
|
self.widgets['b6_stats'].pack()
|
|
|
|
# B8 Stats
|
|
b8_frame = ttk.LabelFrame(stats_frame, text="B8 Diagnostics", padding="10")
|
|
b8_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S), padx=5)
|
|
self.widgets['b8_stats'] = ttk.Label(b8_frame, text="Checked: 0\nPass: 0\nFail: 0",
|
|
font=('Courier', 9), justify=tk.LEFT)
|
|
self.widgets['b8_stats'].pack()
|
|
|
|
# Serial Stats
|
|
serial_frame = ttk.LabelFrame(stats_frame, text="Serial Messages", padding="10")
|
|
serial_frame.grid(row=0, column=2, sticky=(tk.W, tk.E, tk.N, tk.S), padx=(5, 0))
|
|
self.widgets['serial_stats'] = ttk.Label(serial_frame, text="Total: 0\nErrors: 0\nFatal: 0\nRecycles: 0",
|
|
font=('Courier', 9), justify=tk.LEFT)
|
|
self.widgets['serial_stats'].pack()
|
|
|
|
# ===== ROW 4: EVENT LOG =====
|
|
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')
|
|
|
|
# ===== ROW 5: PER-RUN TABLE =====
|
|
runs_frame = ttk.LabelFrame(main_frame, text="Runs Overview", padding="5")
|
|
runs_frame.grid(row=5, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(10, 0))
|
|
runs_frame.columnconfigure(0, weight=1)
|
|
|
|
cols = ('run', 'result', 'pbit', 'b6_fail', 'b8_fail', 'known', 'fail_summary', 'serial_events', 'target_done', 'target_result')
|
|
# taller default table so more runs are visible
|
|
self.widgets['runs_table'] = ttk.Treeview(runs_frame, columns=cols, show='headings', height=10)
|
|
self.widgets['runs_table'].grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
# Column headers: Result = Overall PBIT result, Target Done and Target Result are new columns
|
|
for c, title in zip(cols, ['Run', 'Result', 'PBIT (s)', 'B6 Fail', 'B8 Fail', 'Known', 'Failures', 'Serial', 'Tgt Test', 'Tgt Result']):
|
|
self.widgets['runs_table'].heading(c, text=title)
|
|
self.widgets['runs_table'].column(c, width=100, anchor=tk.CENTER)
|
|
|
|
# Horizontal and vertical scrollbars for table
|
|
hscroll = ttk.Scrollbar(runs_frame, orient=tk.HORIZONTAL, command=self.widgets['runs_table'].xview)
|
|
vscroll = ttk.Scrollbar(runs_frame, orient=tk.VERTICAL, command=self.widgets['runs_table'].yview)
|
|
self.widgets['runs_table'].configure(xscrollcommand=hscroll.set, yscrollcommand=vscroll.set)
|
|
hscroll.grid(row=1, column=0, sticky=(tk.W, tk.E))
|
|
vscroll.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
# Configure row tags for quick visual pass/fail (background color)
|
|
try:
|
|
table = self.widgets['runs_table']
|
|
table.tag_configure('pass', background='#dff0d8') # light green
|
|
table.tag_configure('fail', background='#f2dede') # light red
|
|
except Exception:
|
|
pass
|
|
# Bind double-click on a row to open detailed run dialog
|
|
try:
|
|
self.widgets['runs_table'].bind('<Double-1>', self._on_run_double_click)
|
|
except Exception:
|
|
pass
|
|
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."""
|
|
# Update data
|
|
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]
|
|
|
|
# Update B6 stats
|
|
b6_text = f"Total: {self.data['b6_total']}\nPass: {self.data['b6_pass']}\nFail: {self.data['b6_fail']}\nKnown: {self.data['b6_known']}"
|
|
self.widgets['b6_stats'].config(text=b6_text)
|
|
|
|
# Update B8 stats
|
|
b8_text = f"Checked: {self.data['b8_checked']}\nPass: {self.data['b8_pass']}\nFail: {self.data['b8_fail']}"
|
|
self.widgets['b8_stats'].config(text=b8_text)
|
|
|
|
# Update Serial stats
|
|
serial_text = f"Total: {self.data['serial_total']}\nErrors: {self.data['serial_errors']}\nFatal: {self.data['serial_fatal']}\nRecycles: {self.data['serial_recycles']}"
|
|
self.widgets['serial_stats'].config(text=serial_text)
|
|
|
|
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 known keyword arguments (result, pbit, b6_fail, b8_fail, known,
|
|
fail_summary, serial_events, target_done, target_result) and any additional
|
|
metadata fields (e.g., start_time, end_time, run_duration, failures,
|
|
serial_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 = [''] * 10 # Updated to 10 columns
|
|
# Map columns
|
|
new = list(old)
|
|
for k, v in data.items():
|
|
if k == 'run':
|
|
new[0] = f"{v}/{self.data.get('run_total', '')}"
|
|
elif k == 'result':
|
|
new[1] = v
|
|
elif k == 'pbit':
|
|
new[2] = f"{v:.1f}" if isinstance(v, (int, float)) else str(v)
|
|
elif k == 'b6_fail':
|
|
new[3] = str(v)
|
|
elif k == 'b8_fail':
|
|
new[4] = str(v)
|
|
elif k == 'known':
|
|
new[5] = str(v)
|
|
elif k == 'fail_summary':
|
|
new[6] = str(v)
|
|
elif k == 'serial_events':
|
|
new[7] = str(v)
|
|
elif k == 'target_done':
|
|
new[8] = str(v)
|
|
elif k == 'target_result':
|
|
new[9] = str(v)
|
|
|
|
# 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
|
|
meta['display'] = {
|
|
'result': new[1], 'pbit': new[2], 'b6_fail': new[3], 'b8_fail': new[4],
|
|
'known': new[5], 'fail_summary': new[6], 'serial_events': new[7],
|
|
'target_done': new[8], 'target_result': new[9]
|
|
}
|
|
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, 'Result', display.get('result', ''))
|
|
_kv(1, 'PBIT Time (s)', display.get('pbit', ''))
|
|
_kv(2, 'B6 Fail', display.get('b6_fail', ''))
|
|
_kv(3, 'B8 Fail', display.get('b8_fail', ''))
|
|
_kv(4, 'Known', display.get('known', ''))
|
|
|
|
# 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(5, 'Start Time', start_disp)
|
|
_kv(6, 'End Time', end_disp)
|
|
_kv(7, '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(8, 'Target Test', tgt_status)
|
|
_kv(9, '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(10, 'Target Distance', dist_str)
|
|
_kv(11, '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()
|