# target_simulator/gui/sync_tool_window.py import tkinter as tk from tkinter import ttk, messagebox import time import random import os import csv import threading from typing import Dict, Optional, List from collections import deque from datetime import datetime from target_simulator.core.sfp_communicator import SFPCommunicator from target_simulator.gui.payload_router import DebugPayloadRouter # Tentativo di importare matplotlib per il grafico try: import matplotlib matplotlib.use("TkAgg") from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure MATPLOTLIB_AVAILABLE = True except ImportError: MATPLOTLIB_AVAILABLE = False class SyncToolWindow(tk.Toplevel): """Finestra per il test di latenza tramite messaggi SYNC con grafico in tempo reale.""" def __init__( self, master, communicator: SFPCommunicator, router: DebugPayloadRouter ): super().__init__(master) self.communicator = communicator self.router = router self.title("Latency Sync Tool") self.geometry("900x700") self.transient(master) self.grab_set() self.pending_requests: Dict[int, float] = {} # {cookie: send_timestamp} # Stato per invio periodico self.periodic_active = False self.periodic_interval_ms = 500 # Default 500ms self.periodic_after_id = None # Storico delle latenze per il grafico (max 200 campioni) self.latency_history: deque = deque(maxlen=200) self.time_history: deque = deque(maxlen=200) # Statistiche self.latency_values: List[float] = [] # === Sistema di salvataggio CSV (asincrono e non invasivo) === self.csv_enabled = False self.csv_filepath: Optional[str] = None self.csv_buffer: deque = deque( maxlen=10000 ) # Buffer grande per evitare perdite self.csv_lock = threading.Lock() self.csv_writer_thread: Optional[threading.Thread] = None self.csv_stop_event = threading.Event() self.csv_session_start: Optional[float] = None self._create_widgets() self.protocol("WM_DELETE_WINDOW", self._on_close) self.after(100, self._process_sync_queue) def _create_widgets(self): main_frame = ttk.Frame(self, padding=10) main_frame.pack(fill=tk.BOTH, expand=True) # === CONTROLLI SUPERIORI === controls_frame = ttk.LabelFrame(main_frame, text="Controls", padding=5) controls_frame.pack(fill=tk.X, pady=(0, 10)) # Riga 1: Invio singolo row1 = ttk.Frame(controls_frame) row1.pack(fill=tk.X, pady=2) self.send_button = ttk.Button( row1, text="Send Single SYNC", command=self._send_sync, width=18 ) self.send_button.pack(side=tk.LEFT, padx=(0, 10)) self.status_var = tk.StringVar(value="Idle") ttk.Label(row1, textvariable=self.status_var, foreground="blue").pack( side=tk.LEFT ) # Riga 2: Invio periodico row2 = ttk.Frame(controls_frame) row2.pack(fill=tk.X, pady=2) self.start_stop_button = ttk.Button( row2, text="Start Periodic", command=self._toggle_periodic, width=18 ) self.start_stop_button.pack(side=tk.LEFT, padx=(0, 10)) ttk.Label(row2, text="Interval (ms):").pack(side=tk.LEFT, padx=(0, 5)) self.interval_var = tk.StringVar(value="500") interval_entry = ttk.Entry(row2, textvariable=self.interval_var, width=8) interval_entry.pack(side=tk.LEFT, padx=(0, 10)) ttk.Button(row2, text="Clear History", command=self._clear_history).pack( side=tk.LEFT ) # Riga 3: Salvataggio CSV row3 = ttk.Frame(controls_frame) row3.pack(fill=tk.X, pady=2) self.csv_check_var = tk.BooleanVar(value=False) self.csv_checkbox = ttk.Checkbutton( row3, text="Save to CSV", variable=self.csv_check_var, command=self._toggle_csv_save, ) self.csv_checkbox.pack(side=tk.LEFT, padx=(0, 10)) self.csv_status_var = tk.StringVar(value="Not saving") self.csv_status_label = tk.Label( row3, textvariable=self.csv_status_var, foreground="gray" ) self.csv_status_label.pack(side=tk.LEFT) # === STATISTICHE === stats_frame = ttk.LabelFrame(main_frame, text="Statistics", padding=5) stats_frame.pack(fill=tk.X, pady=(0, 10)) stats_grid = ttk.Frame(stats_frame) stats_grid.pack(fill=tk.X) ttk.Label(stats_grid, text="Samples:").grid( row=0, column=0, sticky=tk.W, padx=5 ) self.samples_var = tk.StringVar(value="0") ttk.Label( stats_grid, textvariable=self.samples_var, font=("TkDefaultFont", 10, "bold"), ).grid(row=0, column=1, sticky=tk.W, padx=5) ttk.Label(stats_grid, text="Last:").grid(row=0, column=2, sticky=tk.W, padx=5) self.last_var = tk.StringVar(value="-- ms") ttk.Label( stats_grid, textvariable=self.last_var, font=("TkDefaultFont", 10, "bold") ).grid(row=0, column=3, sticky=tk.W, padx=5) ttk.Label(stats_grid, text="Min:").grid(row=0, column=4, sticky=tk.W, padx=5) self.min_var = tk.StringVar(value="-- ms") ttk.Label( stats_grid, textvariable=self.min_var, font=("TkDefaultFont", 10, "bold") ).grid(row=0, column=5, sticky=tk.W, padx=5) ttk.Label(stats_grid, text="Max:").grid(row=0, column=6, sticky=tk.W, padx=5) self.max_var = tk.StringVar(value="-- ms") ttk.Label( stats_grid, textvariable=self.max_var, font=("TkDefaultFont", 10, "bold") ).grid(row=0, column=7, sticky=tk.W, padx=5) ttk.Label(stats_grid, text="Avg:").grid(row=0, column=8, sticky=tk.W, padx=5) self.avg_var = tk.StringVar(value="-- ms") ttk.Label( stats_grid, textvariable=self.avg_var, font=("TkDefaultFont", 10, "bold") ).grid(row=0, column=9, sticky=tk.W, padx=5) # === GRAFICO === if MATPLOTLIB_AVAILABLE: graph_frame = ttk.LabelFrame(main_frame, text="Latency Graph", padding=5) graph_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) self.figure = Figure(figsize=(8, 3), dpi=100) self.ax = self.figure.add_subplot(111) self.ax.set_xlabel("Time (s)") self.ax.set_ylabel("Latency (ms)") self.ax.set_title("One-Way Latency Over Time") self.ax.grid(True, alpha=0.3) self.canvas = FigureCanvasTkAgg(self.figure, graph_frame) self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) (self.line,) = self.ax.plot([], [], "b-", linewidth=1.5) else: graph_frame = ttk.LabelFrame(main_frame, text="Latency Graph", padding=5) graph_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) ttk.Label( graph_frame, text="Matplotlib not available. Install with: pip install matplotlib", foreground="red", ).pack(pady=20) # === TABELLA RISULTATI === tree_frame = ttk.LabelFrame(main_frame, text="Recent Results", padding=5) tree_frame.pack(fill=tk.BOTH, expand=True) columns = ("time", "rtt", "latency") self.tree = ttk.Treeview(tree_frame, columns=columns, show="headings", height=8) self.tree.heading("time", text="Timestamp") self.tree.heading("rtt", text="Round Trip (ms)") self.tree.heading("latency", text="One-Way Latency (ms)") self.tree.column("time", width=150) self.tree.column("rtt", width=150, anchor=tk.E) self.tree.column("latency", width=150, anchor=tk.E) scrollbar = ttk.Scrollbar( tree_frame, orient=tk.VERTICAL, command=self.tree.yview ) self.tree.configure(yscrollcommand=scrollbar.set) self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) def _send_sync(self): """Invia una singola richiesta SYNC.""" if not self.communicator or not self.communicator.is_open: messagebox.showerror( "Error", "SFP Communicator is not connected.", parent=self ) return cookie = random.randint(0, 2**32 - 1) send_time = time.monotonic() if self.communicator.send_sync_request(cookie): self.pending_requests[cookie] = send_time if not self.periodic_active: self.status_var.set( f"Sent request with cookie {cookie}. Waiting for reply..." ) else: self.status_var.set("Failed to send SYNC request.") def _toggle_periodic(self): """Attiva/disattiva l'invio periodico.""" if self.periodic_active: # Stop self.periodic_active = False if self.periodic_after_id: self.after_cancel(self.periodic_after_id) self.periodic_after_id = None self.start_stop_button.config(text="Start Periodic") self.status_var.set("Periodic sending stopped.") else: # Start try: interval = int(self.interval_var.get()) if interval < 50: messagebox.showwarning( "Warning", "Interval too short. Minimum is 50ms.", parent=self ) return self.periodic_interval_ms = interval except ValueError: messagebox.showerror("Error", "Invalid interval value.", parent=self) return self.periodic_active = True self.start_stop_button.config(text="Stop Periodic") self.status_var.set( f"Periodic sending active (every {self.periodic_interval_ms} ms)" ) self._periodic_send() def _periodic_send(self): """Invia periodicamente richieste SYNC.""" if not self.periodic_active: return self._send_sync() self.periodic_after_id = self.after( self.periodic_interval_ms, self._periodic_send ) def _clear_history(self): """Pulisce lo storico e le statistiche.""" self.latency_history.clear() self.time_history.clear() self.latency_values.clear() self._update_statistics() self._update_graph() # Pulisce anche la tabella for item in self.tree.get_children(): self.tree.delete(item) def _update_statistics(self): """Aggiorna le statistiche visualizzate.""" if not self.latency_values: self.samples_var.set("0") self.last_var.set("-- ms") self.min_var.set("-- ms") self.max_var.set("-- ms") self.avg_var.set("-- ms") else: self.samples_var.set(str(len(self.latency_values))) self.last_var.set(f"{self.latency_values[-1]:.2f} ms") self.min_var.set(f"{min(self.latency_values):.2f} ms") self.max_var.set(f"{max(self.latency_values):.2f} ms") self.avg_var.set( f"{sum(self.latency_values)/len(self.latency_values):.2f} ms" ) def _update_graph(self): """Aggiorna il grafico della latenza.""" if not MATPLOTLIB_AVAILABLE: return if len(self.time_history) > 0: # Converti i tempi assoluti in relativi (secondi dall'inizio) times = list(self.time_history) latencies = list(self.latency_history) start_time = times[0] relative_times = [(t - start_time) for t in times] self.line.set_data(relative_times, latencies) self.ax.relim() self.ax.autoscale_view() try: self.canvas.draw() except: pass # Ignora errori durante il disegno def _process_sync_queue(self): """Estrae i risultati dalla coda del router e aggiorna la UI.""" result = self.router.get_sync_result() if result: cookie = result.get("cookie") reception_time = result.get("reception_timestamp") if cookie in self.pending_requests: send_time = self.pending_requests.pop(cookie) rtt_s = reception_time - send_time rtt_ms = rtt_s * 1000 latency_ms = rtt_ms / 2.0 # Aggiorna storico self.latency_history.append(latency_ms) self.time_history.append(reception_time) self.latency_values.append(latency_ms) # Limita latency_values per non far crescere troppo la lista if len(self.latency_values) > 200: self.latency_values.pop(0) # Salva su CSV se abilitato (operazione velocissima, non blocca) if self.csv_enabled and self.csv_session_start is not None: elapsed_s = reception_time - self.csv_session_start server_timetag = result.get("server_timetag", 0) self._add_sample_to_csv_buffer( elapsed_s, cookie, rtt_ms, latency_ms, server_timetag ) # Aggiorna UI timestamp_str = time.strftime("%H:%M:%S") self.tree.insert( "", 0, values=(timestamp_str, f"{rtt_ms:.3f}", f"{latency_ms:.3f}") ) # Limita il numero di righe nella tabella items = self.tree.get_children() if len(items) > 50: self.tree.delete(items[-1]) if not self.periodic_active: self.status_var.set( f"Received reply for cookie {cookie}. Latency: {latency_ms:.2f} ms" ) self._update_statistics() self._update_graph() # Continua il polling self.after(100, self._process_sync_queue) def _toggle_csv_save(self): """Attiva/disattiva il salvataggio CSV asincrono.""" if self.csv_check_var.get(): # Avvia salvataggio self._start_csv_save() else: # Ferma salvataggio self._stop_csv_save() def _start_csv_save(self): """Avvia il salvataggio CSV in background.""" if self.csv_enabled: return # GiĆ  attivo # Crea nome file con timestamp project_root = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..") ) temp_dir = os.path.join(project_root, "Temp") os.makedirs(temp_dir, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") self.csv_filepath = os.path.join(temp_dir, f"sync_latency_{timestamp}.csv") self.csv_session_start = time.monotonic() # Inizializza il file CSV con header try: with open(self.csv_filepath, "w", newline="") as f: writer = csv.writer(f) writer.writerow( [ "elapsed_s", "timestamp", "cookie", "rtt_ms", "latency_ms", "server_timetag", ] ) self.csv_enabled = True self.csv_stop_event.clear() # Avvia thread di scrittura self.csv_writer_thread = threading.Thread( target=self._csv_writer_loop, daemon=True, name="CSVWriterThread" ) self.csv_writer_thread.start() filename = os.path.basename(self.csv_filepath) self.csv_status_var.set(f"Saving to {filename}") self.csv_status_label.config(foreground="green") except Exception as e: messagebox.showerror( "CSV Error", f"Failed to create CSV file: {e}", parent=self ) self.csv_check_var.set(False) self.csv_enabled = False def _stop_csv_save(self): """Ferma il salvataggio CSV.""" if not self.csv_enabled: return self.csv_enabled = False self.csv_stop_event.set() # Attendi che il thread finisca di scrivere i dati rimanenti if self.csv_writer_thread and self.csv_writer_thread.is_alive(): self.csv_writer_thread.join(timeout=2.0) self.csv_status_var.set("Not saving") self.csv_status_label.config(foreground="gray") if self.csv_filepath: filename = os.path.basename(self.csv_filepath) messagebox.showinfo( "CSV Saved", f"Latency data saved to:\n{filename}", parent=self, ) def _csv_writer_loop(self): """Thread worker che scrive i dati bufferizzati su disco periodicamente.""" while not self.csv_stop_event.is_set(): try: # Attendi un po' prima di scrivere (batch writing) time.sleep(1.0) # Estrai tutti i dati dal buffer rows_to_write = [] with self.csv_lock: while len(self.csv_buffer) > 0: rows_to_write.append(self.csv_buffer.popleft()) # Scrivi su file (fuori dal lock per non bloccare) if rows_to_write and self.csv_filepath: try: with open(self.csv_filepath, "a", newline="") as f: writer = csv.writer(f) writer.writerows(rows_to_write) except Exception: pass # Ignora errori di scrittura per non bloccare except Exception: pass # Flush finale dei dati rimanenti rows_to_write = [] with self.csv_lock: while len(self.csv_buffer) > 0: rows_to_write.append(self.csv_buffer.popleft()) if rows_to_write and self.csv_filepath: try: with open(self.csv_filepath, "a", newline="") as f: writer = csv.writer(f) writer.writerows(rows_to_write) except Exception: pass def _add_sample_to_csv_buffer( self, elapsed_s: float, cookie: int, rtt_ms: float, latency_ms: float, server_timetag: int, ): """Aggiunge un campione al buffer CSV (operazione velocissima, non blocca).""" if not self.csv_enabled: return timestamp_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] row = [ f"{elapsed_s:.3f}", timestamp_str, cookie, f"{rtt_ms:.3f}", f"{latency_ms:.3f}", server_timetag, ] with self.csv_lock: self.csv_buffer.append(row) def _on_close(self): """Gestisce la chiusura della finestra.""" if self.periodic_active: self.periodic_active = False if self.periodic_after_id: self.after_cancel(self.periodic_after_id) # Ferma il salvataggio CSV se attivo if self.csv_enabled: self.csv_check_var.set(False) self._stop_csv_save() self.destroy()