""" 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('', 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()