""" 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("", self.show_tooltip) self.widget.bind("", 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 (Pass/Fail)', 'Number of B8 checks\nPass/Fail format', 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('', 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'): # Column 4: B8 Checks (Pass/Fail) - format like PDF b8_pass = data.get('b8_pass', 0) b8_fail = data.get('b8_fail', 0) b8_checked = data.get('b8_checked', 0) if b8_checked > 0: new[4] = f"{b8_pass}/{b8_fail}" 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()