# target_simulator/gui/sfp_debug_window.py """ Provides a Toplevel window for debugging the SFP transport layer. This version uses a sampling approach to handle high-frequency data streams without overwhelming the GUI thread. """ import tkinter as tk from tkinter import ttk, scrolledtext import logging import threading import collections import datetime import os import ctypes import time from typing import Dict, Callable, Optional, Any import socket # Third-party imports for image display try: from PIL import Image, ImageTk import numpy as np import cv2 _IMAGE_LIBS_AVAILABLE = True except ImportError: _IMAGE_LIBS_AVAILABLE = False # Imports from the project structure from target_simulator.core.sfp_transport import SfpTransport, PayloadHandler from target_simulator.core.sfp_structures import ( ImageLeaderData, SFPHeader, SfpRisStatusPayload, ) from target_simulator.gui.payload_router import DebugPayloadRouter class SfpDebugWindow(tk.Toplevel): """Top-level window for SFP debugging and payload inspection. This class was previously defining the DebugPayloadRouter inline; the router implementation has been moved to `target_simulator.gui.payload_router` to decouple routing logic from the Tk window and allow independent tests. """ GUI_POLL_INTERVAL_MS = 250 def __init__(self, master=None): super().__init__(master) self.master = master # Make the debug window slightly larger by default try: self.geometry("1100x700") except Exception: pass self.logger = logging.getLogger(__name__) # Router instance (buffers latest payloads per flow) self.payload_router = DebugPayloadRouter() # Transport reference (set when connecting) self.sfp_transport = None # Image display defaults self.image_area_size = 150 # Connection fields self.ip_var = tk.StringVar(value="127.0.0.1") self.port_var = tk.StringVar(value="60002") # --- Connection Frame (IP / Port / Connect controls) --- conn_frame = ttk.Frame(self) conn_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2)) ttk.Label(conn_frame, text="IP:").pack(side=tk.LEFT, padx=(4, 2)) ttk.Entry(conn_frame, textvariable=self.ip_var, width=18).pack( side=tk.LEFT, padx=(0, 6) ) ttk.Label(conn_frame, text="Port:").pack(side=tk.LEFT, padx=(0, 2)) ttk.Entry(conn_frame, textvariable=self.port_var, width=8).pack( side=tk.LEFT, padx=(0, 6) ) self.connect_btn = ttk.Button(conn_frame, text="Connect", command=self._on_connect) self.connect_btn.pack(side=tk.LEFT, padx=(0, 6)) self.disconnect_btn = ttk.Button(conn_frame, text="Disconnect", command=self._on_disconnect) self.disconnect_btn.pack(side=tk.LEFT, padx=(0, 6)) # Start with disconnect disabled until connected try: self.disconnect_btn.config(state=tk.DISABLED) except Exception: pass # Quick utility buttons self.send_probe_btn = ttk.Button(conn_frame, text="Send probe", command=self._on_send_probe) self.send_probe_btn.pack(side=tk.LEFT, padx=(6, 4)) self.send_ack_btn = ttk.Button(conn_frame, text="Send ACK", command=self._on_send_ack) self.send_ack_btn.pack(side=tk.LEFT) # Note: DebugPayloadRouter has been moved to `target_simulator.gui.payload_router`. # --- Script Sender Frame (below connection) --- script_frame = ttk.Frame(self) script_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(0, 5)) ttk.Label(script_frame, text="Script to send:").pack(side=tk.LEFT, padx=(5, 2)) self.script_var = tk.StringVar(value="print('hello from client')") ttk.Entry(script_frame, textvariable=self.script_var, width=60).pack( side=tk.LEFT, padx=(0, 5) ) self.send_script_btn = ttk.Button( script_frame, text="Send script", command=self._on_send_script ) self.send_script_btn.pack(side=tk.LEFT, padx=5) # --- Data Display Notebook (unchanged) --- self.notebook = ttk.Notebook(self) self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self.log_tab = scrolledtext.ScrolledText( self.notebook, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 9) ) self.notebook.add(self.log_tab, text="Raw Log") if _IMAGE_LIBS_AVAILABLE: self.mfd_tab = self._create_image_tab("MFD Image") self.notebook.add(self.mfd_tab["frame"], text="MFD Image") self.sar_tab = self._create_image_tab("SAR Image") self.notebook.add(self.sar_tab["frame"], text="SAR Image") # RIS status tab: two-column layout with scenario (left) and targets (right) ris_frame = ttk.Frame(self.notebook) paned = ttk.Panedwindow(ris_frame, orient=tk.HORIZONTAL) paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) # Left: scenario table (field, value) left = ttk.Frame(paned) self.scenario_tree = ttk.Treeview(left, columns=("field", "value"), show="headings", height=12) self.scenario_tree.heading("field", text="Field") self.scenario_tree.heading("value", text="Value") self.scenario_tree.column("field", width=140, anchor="w") self.scenario_tree.column("value", width=160, anchor="w") self.scenario_tree.pack(fill=tk.BOTH, expand=True) paned.add(left, weight=1) # Right: compact targets table right = ttk.Frame(paned) cols = ("idx", "flags", "heading", "x", "y", "z") self.ris_tree = ttk.Treeview(right, columns=cols, show="headings", height=12) for c, txt in zip(cols, ("#", "flags", "heading", "x", "y", "z")): self.ris_tree.heading(c, text=txt) self.ris_tree.column(c, width=70, anchor="center") # Apply smaller font to make table compact try: style = ttk.Style() small_font = ("Consolas", 8) style.configure("Small.Treeview", font=small_font) self.ris_tree.configure(style="Small.Treeview") self.scenario_tree.configure(style="Small.Treeview") except Exception: pass self.ris_tree.pack(fill=tk.BOTH, expand=True) paned.add(right, weight=2) # Save CSV button under the paned window btn_frame = ttk.Frame(ris_frame) btn_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) # View mode for scenario/targets: raw vs simplified self.scenario_view_mode = tk.StringVar(value="simplified") mode_frame = ttk.Frame(btn_frame) mode_frame.pack(side=tk.LEFT, padx=(4, 0)) ttk.Label(mode_frame, text="View:").pack(side=tk.LEFT, padx=(0, 6)) ttk.Radiobutton(mode_frame, text="Raw", value="raw", variable=self.scenario_view_mode).pack(side=tk.LEFT) ttk.Radiobutton(mode_frame, text="Simplified", value="simplified", variable=self.scenario_view_mode).pack(side=tk.LEFT) # Decimals control for simplified view (user-settable) self.simplified_decimals = tk.IntVar(value=4) ttk.Label(mode_frame, text=" Decimals:").pack(side=tk.LEFT, padx=(8, 2)) try: # Use tk.Spinbox; ttk.Spinbox may not be available in all tkinter versions sp = tk.Spinbox(mode_frame, from_=0, to=8, width=3, textvariable=self.simplified_decimals) sp.pack(side=tk.LEFT) except Exception: # fallback to an Entry if Spinbox is not supported e = ttk.Entry(mode_frame, textvariable=self.simplified_decimals, width=3) e.pack(side=tk.LEFT) self.ris_save_csv_btn = ttk.Button(btn_frame, text="Save CSV", command=lambda: self._on_save_ris_csv()) self.ris_save_csv_btn.pack(side=tk.RIGHT) self.notebook.add(ris_frame, text="RIS Status") # Raw SFP packet view with history on the left and details on the right raw_frame = ttk.Frame(self.notebook) # Left: history listbox history_frame = ttk.Frame(raw_frame, width=380) history_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(5, 2), pady=5) ttk.Label(history_frame, text="History (latest)").pack(anchor=tk.W, padx=4) # smaller font so more fits on one line try: history_font = ("Consolas", 8) except Exception: history_font = None # container for Treeview + scrollbar so buttons can sit under it list_container = ttk.Frame(history_frame) list_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=(2, 4)) # Create a Treeview with columns: Timestamp | Flow | TID | Size columns = ("ts", "flow", "tid", "size") self.history_tree = ttk.Treeview( list_container, columns=columns, show="headings", height=20 ) self.history_tree.heading("ts", text="Timestamp") self.history_tree.heading("flow", text="Flow") self.history_tree.heading("tid", text="TID") self.history_tree.heading("size", text="Size") # set column widths (ts wider) self.history_tree.column("ts", width=100, anchor="w") self.history_tree.column("flow", width=50, anchor="w") self.history_tree.column("tid", width=40, anchor="center") self.history_tree.column("size", width=50, anchor="e") # smaller font for tree rows try: style = ttk.Style() style.configure("Small.Treeview", font=history_font) self.history_tree.configure(style="Small.Treeview") except Exception: pass self.history_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # scrollbar self.history_vscroll = ttk.Scrollbar( list_container, orient=tk.VERTICAL, command=self.history_tree.yview ) self.history_vscroll.pack(side=tk.RIGHT, fill=tk.Y) self.history_tree.config(yscrollcommand=self.history_vscroll.set) hb_frame = ttk.Frame(history_frame) hb_frame.pack(fill=tk.X, padx=4, pady=(4, 4)) # Settings and clear buttons self.history_settings_btn = ttk.Button( hb_frame, text="Settings", command=lambda: self._open_history_settings_dialog(), ) self.history_settings_btn.pack(side=tk.LEFT) self.history_clear_btn = ttk.Button( hb_frame, text="Clear", command=lambda: self._on_clear_history() ) self.history_clear_btn.pack(side=tk.RIGHT) # Right: details view (previously raw_tab) self.raw_tab_text = scrolledtext.ScrolledText( raw_frame, state=tk.DISABLED, wrap=tk.NONE, font=("Consolas", 9) ) self.raw_tab_text.pack( side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(2, 5), pady=5 ) try: # insert as second tab (index 1) self.notebook.insert(1, raw_frame, text="SFP Raw") except Exception: # fallback self.notebook.add(raw_frame, text="SFP Raw") # Configure visual tags for flags (set/unset) on raw_tab_text try: self.raw_tab_text.tag_config( "flag_set", background="#d4ffd4", foreground="#006400" ) self.raw_tab_text.tag_config( "flag_unset", background="#f0f0f0", foreground="#808080" ) self.raw_tab_text.tag_config( "flag_error", background="#ffd4d4", foreground="#800000" ) self.raw_tab_text.tag_config("hdr_field", foreground="#000080") except Exception: pass self.bin_tab = scrolledtext.ScrolledText( self.notebook, state=tk.DISABLED, wrap=tk.NONE, font=("Consolas", 10) ) self.notebook.add(self.bin_tab, text="Binary (Hex)") self.json_tab = scrolledtext.ScrolledText( self.notebook, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 10) ) self.notebook.add(self.json_tab, text="JSON") # Start the periodic GUI poll loop to process latest payloads from the router try: self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) except Exception: # If the Tk mainloop isn't running in tests, this will be a no-op pass def _create_image_tab(self, title: str) -> Dict: frame = ttk.Frame(self.notebook) # Fixed-size container to keep UI tidy. Image area will be size x size px. image_container = ttk.Frame( frame, width=self.image_area_size, height=self.image_area_size, relief=tk.SUNKEN, ) image_container.pack(pady=5, padx=5) image_container.pack_propagate(False) image_label = ttk.Label( image_container, text=f"Waiting for {title}...", anchor=tk.CENTER ) image_label.pack(fill=tk.BOTH, expand=True) hex_view = scrolledtext.ScrolledText( frame, height=8, state=tk.DISABLED, wrap=tk.NONE, font=("Consolas", 9) ) hex_view.pack(fill=tk.BOTH, expand=True, pady=5, padx=5) return { "frame": frame, "image_label": image_label, "hex_view": hex_view, "image_container": image_container, } def _open_image_size_dialog(self): """Open a small dialog to change the image display size and persist it to settings.""" dlg = tk.Toplevel(self) dlg.title("Image Size") dlg.transient(self) dlg.grab_set() ttk.Label(dlg, text="Image area size (px):").pack(padx=10, pady=(10, 2)) size_var = tk.StringVar(value=str(self.image_area_size)) entry = ttk.Entry(dlg, textvariable=size_var, width=8) entry.pack(padx=10, pady=(0, 10)) btn_frame = ttk.Frame(dlg) btn_frame.pack(padx=10, pady=(0, 10)) def on_save(): try: v = int(size_var.get()) if v <= 0: raise ValueError() except Exception: message = "Please enter a positive integer for image size." try: tk.messagebox.showerror("Invalid value", message, parent=dlg) except Exception: pass return # Apply to current window self.image_area_size = v # Update existing containers if present for tab in (getattr(self, "mfd_tab", None), getattr(self, "sar_tab", None)): if tab and "image_container" in tab: tab["image_container"].config(width=v, height=v) # Persist to settings via ConfigManager on master (if available) gm = getattr(self.master, "config_manager", None) if gm: general = gm.get_general_settings() or {} image_display = general.get("image_display", {}) image_display["size"] = v general["image_display"] = image_display gm.save_general_settings(general) dlg.destroy() def on_cancel(): dlg.destroy() ttk.Button(btn_frame, text="Cancel", command=on_cancel).pack( side=tk.RIGHT, padx=(0, 5) ) ttk.Button(btn_frame, text="Save", command=on_save).pack(side=tk.RIGHT) def _on_connect(self): ip = self.ip_var.get() try: port = int(self.port_var.get()) except ValueError: self._log_to_widget("ERROR: Invalid port number.", "ERROR") return self._log_to_widget(f"Attempting to connect to {ip}:{port}...") ack_config = {ord("M"): 32, ord("S"): 16} self.sfp_transport = SfpTransport( host=ip, port=port, payload_handlers=self.payload_router.get_handlers(), ack_config=ack_config, raw_packet_callback=self.payload_router.update_raw_packet, ) if self.sfp_transport.start(): self._log_to_widget( "Connection successful. Listening for packets...", "INFO" ) self.connect_btn.config(state=tk.DISABLED) self.disconnect_btn.config(state=tk.NORMAL) else: self._log_to_widget("Connection failed. Check IP/Port and logs.", "ERROR") self.sfp_transport = None # Register raw packet callback regardless of start result (safe no-op if None) try: self.history_tree.bind( "<>", lambda e: self._on_history_select() ) except Exception: pass def _on_send_probe(self): """Sends a small UDP probe to the configured IP:port to "wake" the server. The server expects any message on its listening port to begin sending SFP messages, so we just send a short datagram. This function is intentionally lightweight and does not depend on self.sfp_transport; it uses a temporary UDP socket so it can be invoked even when not connected/listening. """ ip = self.ip_var.get() try: port = int(self.port_var.get()) except Exception: self._log_to_widget("ERROR: Invalid port number for probe.", "ERROR") return probe_payload = b"SFP_PROBE\n" # simple payload; server will accept any data try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(1.0) sock.sendto(probe_payload, (ip, port)) sock.close() self._log_to_widget(f"Sent probe to {ip}:{port}", "INFO") except Exception as e: self._log_to_widget(f"Failed to send probe to {ip}:{port}: {e}", "ERROR") def _on_send_ack(self): """Constructs a minimal SFP ACK header and sends it to the server. Uses the active transport socket when available so the packet originates from the same local port; otherwise uses a temporary UDP socket. """ ip = self.ip_var.get() try: port = int(self.port_var.get()) except Exception: self._log_to_widget("ERROR: Invalid port number for ACK.", "ERROR") return try: # Construct a minimal valid SFP data fragment (frag 0 of 1) with a small payload. payload = b"SFP_WAKE" # small payload so server sees valid metadata hdr = SFPHeader() # Direction: normal data (keep 0 for unspecified) or use '<' if needed by server hdr.SFP_DIRECTION = 0x3C hdr.SFP_FLOW = ord("M") if isinstance("M", str) else 0 hdr.SFP_TID = 1 # No special flags except zero; server expects total_frags > 0 hdr.SFP_FLAGS = 0x00 hdr.SFP_WIN = 32 # Fragment metadata: this is the only fragment hdr.SFP_TOTFRGAS = 1 hdr.SFP_FRAG = 0 hdr.SFP_PLSIZE = len(payload) hdr.SFP_PLOFFSET = 0 hdr.SFP_TOTSIZE = len(payload) pkt = bytes(hdr) + payload # Prefer to reuse the SfpTransport socket if available so source port matches sock = None used_temp_sock = False if self.sfp_transport and getattr(self.sfp_transport, '_socket', None): try: sock = self.sfp_transport._socket except Exception: sock = None if not sock: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) used_temp_sock = True sock.sendto(pkt, (ip, port)) if used_temp_sock: sock.close() self._log_to_widget(f"Sent SFP data-fragment to {ip}:{port} (flow=M,t id=1)", "INFO") except Exception as e: self._log_to_widget(f"Failed to send SFP fragment to {ip}:{port}: {e}", "ERROR") def _on_send_script(self): """Constructs a script_message_t-like payload and sends it to the server. The server expects a data tag with tag 'C','S' and type_validity set. We'll build the payload using ctypes to match layout and send it using the transport socket (if available) so the server treats us as the client. """ ip = self.ip_var.get() try: port = int(self.port_var.get()) except Exception: self._log_to_widget("ERROR: Invalid port number for script send.", "ERROR") return script_text = self.script_var.get() or "" # Limit script size to 1020 bytes to be conservative (server has ~1024) script_bytes = script_text.encode("utf-8") max_script = 1020 if len(script_bytes) > max_script: script_bytes = script_bytes[:max_script] # Local ctypes definitions that mirror what the C++ server expects class LocalDataTag(ctypes.Structure): _pack_ = 1 _fields_ = [ ("ID", ctypes.c_uint8 * 2), ("VALID", ctypes.c_uint8), ("VERSION", ctypes.c_uint8), ("SIZE", ctypes.c_uint32), ] class ScriptPayload(ctypes.Structure): _pack_ = 1 _fields_ = [ ("script_tag", LocalDataTag), ("script", ctypes.c_uint8 * 1024), ] try: payload = ScriptPayload() # set tag ID to 'C','S' payload.script_tag.ID[0] = ord("C") payload.script_tag.ID[1] = ord("S") payload.script_tag.VALID = 1 payload.script_tag.VERSION = 1 payload.script_tag.SIZE = len(script_bytes) # copy script bytes for i, b in enumerate(script_bytes): payload.script[i] = b # Build SFP header hdr = SFPHeader() hdr.SFP_DIRECTION = 0x3C hdr.SFP_FLOW = ord("R") # use 'R' for RIS script commands hdr.SFP_TID = 1 hdr.SFP_FLAGS = 0x00 hdr.SFP_WIN = 32 hdr.SFP_TOTFRGAS = 1 hdr.SFP_FRAG = 0 pl_bytes = bytes(payload) hdr.SFP_PLSIZE = len(pl_bytes) hdr.SFP_PLOFFSET = 0 hdr.SFP_TOTSIZE = len(pl_bytes) pkt = bytes(hdr) + pl_bytes sock = None used_temp = False if self.sfp_transport and getattr(self.sfp_transport, "_socket", None): try: sock = self.sfp_transport._socket except Exception: sock = None if not sock: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) used_temp = True sock.sendto(pkt, (ip, port)) if used_temp: sock.close() self._log_to_widget(f"Sent script ({len(script_bytes)} bytes) to {ip}:{port}", "INFO") except Exception as e: self._log_to_widget(f"Failed to send script to {ip}:{port}: {e}", "ERROR") def _on_disconnect(self): if self.sfp_transport: self._log_to_widget("Disconnecting...", "INFO") self.sfp_transport.shutdown() self.sfp_transport = None self.connect_btn.config(state=tk.NORMAL) self.disconnect_btn.config(state=tk.DISABLED) self._log_to_widget("Disconnected.", "INFO") def _on_close(self): self.logger.info("SFP Debug Window closing.") self._on_disconnect() self.destroy() def _process_latest_payloads(self): """GUI-thread loop to sample and display the latest payloads.""" # Get all new payloads that have arrived since the last check new_payloads = self.payload_router.get_and_clear_latest_payloads() # If there are new payloads, process them if new_payloads: self._log_to_widget( f"Processing {len(new_payloads)} new payload(s) for flows: {list(new_payloads.keys())}" ) for flow_id, payload in new_payloads.items(): if flow_id == "MFD" and _IMAGE_LIBS_AVAILABLE: self._display_image_data(payload, self.mfd_tab, "mfd_photo") # self.notebook.select(self.mfd_tab["frame"]) elif flow_id == "SAR" and _IMAGE_LIBS_AVAILABLE: self._display_image_data(payload, self.sar_tab, "sar_photo") # self.notebook.select(self.sar_tab["frame"]) elif flow_id == "BIN": self._display_hex_data(payload, self.bin_tab) # self.notebook.select(self.bin_tab) elif flow_id == "JSON": self._display_json_data(payload, self.json_tab) elif flow_id == "RIS_STATUS": # textual fallback: we intentionally do not write the # full RIS textual summary into the generic log to avoid # clutter; the structured JSON payload is used for UI. # Keep this branch present in case future handling is # needed. pass elif flow_id == "RIS_STATUS_JSON": # Populate the scenario tree and the RIS targets tree from structured JSON try: import json struct = json.loads(payload.decode("utf-8")) if isinstance(payload, (bytes, bytearray)) else payload # scenario table (field, value) for iid in self.scenario_tree.get_children(): self.scenario_tree.delete(iid) scenario = struct.get("scenario", {}) if isinstance(struct, dict) else {} if scenario: import math def to_deg(v): try: return float(v) * (180.0 / math.pi) except Exception: return v def m_s_to_ft_s(v): try: return float(v) * 3.280839895 except Exception: return v def m_to_ft(v): try: return float(v) * 3.280839895 except Exception: return v order = [ ("timetag", "timetag", ""), ("platform_azimuth", "platform_azimuth", "°"), ("ant_nav_az", "ant_nav_az", "°"), ("ant_nav_el", "ant_nav_el", "°"), ("flags", "flags", ""), ("mode", "mode", ""), ("vx", "vx", "ft/s"), ("vy", "vy", "ft/s"), ("vz", "vz", "ft/s"), ("baro_altitude", "baro_altitude", "ft"), ("latitude", "latitude", "°"), ("longitude", "longitude", "°"), ("true_heading", "true_heading", "°"), ] view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" dec_simp = int(self.simplified_decimals.get() if hasattr(self, "simplified_decimals") else 4) def fmt_raw_number(v, key_name=None): try: fv = float(v) if key_name in ("latitude", "longitude"): return f"{fv:.8f}" return f"{fv:.6f}" except Exception: return str(v) def fmt_simplified_number(v, unit_str, decimals=4): try: fv = float(v) return f"{fv:.{decimals}f} {unit_str}" if unit_str else f"{fv:.{decimals}f}" except Exception: return str(v) for label, key, unit in order: if key in scenario: val = scenario.get(key) if view_mode == "raw": if key in ("platform_azimuth", "true_heading", "ant_nav_az", "ant_nav_el"): display_val = fmt_raw_number(val, key) elif key in ("vx", "vy", "vz", "baro_altitude"): display_val = fmt_raw_number(val, key) elif key in ("latitude", "longitude"): display_val = fmt_raw_number(val, key) elif key == "flags": try: display_val = f"{int(val)} (0x{int(val):X})" except Exception: display_val = str(val) else: display_val = str(val) else: # simplified view: show converted value and unit adjacent to number if key in ("platform_azimuth", "true_heading"): if isinstance(val, (int, float)): conv = to_deg(val) display_val = fmt_simplified_number(conv, "°", dec_simp) else: display_val = str(val) elif key in ("ant_nav_az", "ant_nav_el"): if isinstance(val, (int, float)): conv = to_deg(val) display_val = fmt_simplified_number(conv, "°", dec_simp) else: display_val = str(val) elif key in ("vx", "vy", "vz"): if isinstance(val, (int, float)): conv = m_s_to_ft_s(val) display_val = fmt_simplified_number(conv, "ft/s", dec_simp) else: display_val = str(val) elif key == "baro_altitude": if isinstance(val, (int, float)): conv = m_to_ft(val) display_val = fmt_simplified_number(conv, "ft", dec_simp) else: display_val = str(val) elif key in ("latitude", "longitude"): if isinstance(val, (int, float)): # show decimal degrees with higher precision per request display_val = f"{float(val):.{8}f} °" else: display_val = str(val) elif key == "flags": try: display_val = f"{int(val)} (0x{int(val):X})" except Exception: display_val = str(val) elif key == "mode": display_val = str(int(val)) if isinstance(val, (int, float)) else str(val) else: display_val = str(val) # Show label without unit; unit is appended to the value in simplified view self.scenario_tree.insert("", tk.END, values=(f"{label}", display_val)) # targets for iid in self.ris_tree.get_children(): self.ris_tree.delete(iid) targets = struct.get("targets", []) if isinstance(struct, dict) else [] # Update target column headers to show units depending on view try: view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" # Column headers should be plain; units shown next to values self.ris_tree.heading("heading", text="heading") self.ris_tree.heading("x", text="x") self.ris_tree.heading("y", text="y") self.ris_tree.heading("z", text="z") except Exception: pass view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" dec_simp = int(self.simplified_decimals.get() if hasattr(self, "simplified_decimals") else 4) for t in targets: if view_mode == "raw": # format raw with reasonable precision try: heading_raw = float(t.get("heading")) heading_val = f"{heading_raw:.6f}" except Exception: heading_val = str(t.get("heading")) try: x_raw = float(t.get("x")) y_raw = float(t.get("y")) z_raw = float(t.get("z")) x_val = f"{x_raw:.6f}" y_val = f"{y_raw:.6f}" z_val = f"{z_raw:.6f}" except Exception: x_val = t.get("x") y_val = t.get("y") z_val = t.get("z") vals = ( t.get("index"), t.get("flags"), heading_val, x_val, y_val, z_val, ) else: # simplified: converted values with units next to number try: heading = float(t.get("heading")) heading_deg = heading * (180.0 / 3.141592653589793) heading_val = f"{heading_deg:.{dec_simp}f} °" except Exception: heading_val = str(t.get("heading")) try: x = float(t.get("x")) y = float(t.get("y")) z_m = float(t.get("z")) x_val = f"{x:.{dec_simp}f} m" y_val = f"{y:.{dec_simp}f} m" z_val = f"{(z_m * 3.280839895):.{dec_simp}f} ft" except Exception: x_val = str(t.get("x")) y_val = str(t.get("y")) z_val = str(t.get("z")) vals = ( t.get("index"), t.get("flags"), heading_val, x_val, y_val, z_val, ) self.ris_tree.insert("", tk.END, values=vals) except Exception: # ignore malformed JSON for now pass # self.notebook.select(self.json_tab) # Reschedule the next check self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) # Also check and display last raw packet raw_pkt = self.payload_router.get_and_clear_raw_packet() if raw_pkt: raw_bytes, addr = raw_pkt self._display_raw_packet(raw_bytes, addr) # Refresh history tree to show new entry try: self._refresh_history_tree() except Exception: pass def _refresh_history_tree(self): try: hist = self.payload_router.get_history() # clear current for iid in self.history_tree.get_children(): self.history_tree.delete(iid) # insert reversed (latest first) for i, entry in enumerate(reversed(hist)): ts = entry["ts"].strftime("%H:%M:%S.%f")[:-3] flow_name = entry.get("flow_name", "") tid = entry.get("tid", "") size = len(entry.get("raw", b"")) self.history_tree.insert( "", tk.END, values=(ts, flow_name, tid, f"{size}B") ) except Exception: pass def _on_history_select(self): try: sel = self.history_tree.selection() if not sel: return iid = sel[0] # find index of item among children (0-based latest-first) children = list(self.history_tree.get_children()) try: idx = children.index(iid) except ValueError: idx = None hist = list(reversed(self.payload_router.get_history())) if idx is None or idx < 0 or idx >= len(hist): return entry = hist[idx] self._display_raw_packet(entry["raw"], entry["addr"]) except Exception: pass def _on_clear_history(self): try: self.payload_router.clear_history() self._refresh_history_listbox() except Exception: pass def _open_history_settings_dialog(self): dlg = tk.Toplevel(self) dlg.title("History Settings") dlg.transient(self) dlg.grab_set() # Current values try: hist_size = self.payload_router._history_size persist = self.payload_router._persist except Exception: hist_size = 20 persist = False ttk.Label(dlg, text="History size (entries):").pack(padx=10, pady=(10, 2)) size_var = tk.StringVar(value=str(hist_size)) entry = ttk.Entry(dlg, textvariable=size_var, width=8) entry.pack(padx=10, pady=(0, 10)) persist_var = tk.BooleanVar(value=bool(persist)) ttk.Checkbutton( dlg, text="Persist raw packets to Temp/", variable=persist_var ).pack(padx=10, pady=(0, 10)) btn_frame = ttk.Frame(dlg) btn_frame.pack(padx=10, pady=(0, 10), fill=tk.X) def on_save(): try: v = int(size_var.get()) if v <= 0: raise ValueError() except Exception: try: tk.messagebox.showerror( "Invalid value", "Please enter a positive integer for history size.", parent=dlg, ) except Exception: pass return # Apply try: self.payload_router.set_history_size(v) self.payload_router.set_persist(bool(persist_var.get())) except Exception: pass # Persist into settings.json via ConfigManager (master.config_manager) try: gm = getattr(self.master, "config_manager", None) if gm: general = gm.get_general_settings() or {} sfp_debug = general.get("sfp_debug", {}) sfp_debug["history_size"] = v sfp_debug["persist_raw"] = bool(persist_var.get()) general["sfp_debug"] = sfp_debug gm.save_general_settings(general) except Exception: pass dlg.destroy() def on_cancel(): dlg.destroy() ttk.Button(btn_frame, text="Cancel", command=on_cancel).pack( side=tk.RIGHT, padx=(0, 5) ) ttk.Button(btn_frame, text="Save", command=on_save).pack(side=tk.RIGHT) def _display_raw_packet(self, raw_bytes: bytes, addr: tuple): """Show the raw SFP packet bytes and the parsed header (if possible).""" try: header_size = SFPHeader.size() if len(raw_bytes) < header_size: raise ValueError("Packet smaller than SFP header") header = SFPHeader.from_buffer_copy(raw_bytes) body = raw_bytes[header_size:] # Build a compact two-column header table to save horizontal space field_list = [ "SFP_MARKER", "SFP_DIRECTION", "SFP_PROT_VER", "SFP_PT_SPARE", "SFP_TAG", "SFP_SRC", "SFP_FLOW", "SFP_TID", "SFP_FLAGS", "SFP_WIN", "SFP_ERR", "SFP_ERR_INFO", "SFP_TOTFRGAS", "SFP_FRAG", "SFP_RECTYPE", "SFP_RECSPARE", "SFP_PLDAP", "SFP_PLEXT", "SFP_RECCOUNTER", "SFP_PLSIZE", "SFP_TOTSIZE", "SFP_PLOFFSET", ] # Collect (label, value) pairs, handle FLAGS specially pairs = [] flag_val = None for f in field_list: try: val = getattr(header, f) except Exception: val = "" if f == "SFP_FLAGS": flag_val = val # still include placeholder for alignment; actual flags printed later pairs.append( (f, f"{val} (0x{val:X})" if isinstance(val, int) else str(val)) ) continue if isinstance(val, int): pairs.append((f, f"{val} (0x{val:X})")) else: pairs.append((f, str(val))) # Render two columns: pair up items two-per-line # Build a full formatted text string so we can both log it # (helpful for external capture/tooling) and display it in the widget. out_lines = [] out_lines.append(f"From {addr}\n\nSFP Header:\n\n") col_width = 36 # width for each column block for i in range(0, len(pairs), 2): left = pairs[i] right = pairs[i + 1] if (i + 1) < len(pairs) else None left_text = f"{left[0]:12s}: {left[1]}" if right: right_text = f"{right[0]:12s}: {right[1]}" # Pad left_text to column width then append right_text line = f"{left_text:<{col_width}} {right_text}" else: line = left_text out_lines.append(line + "\n") # FLAG decoding based on provided enum frag_flags_t # bit0 = frag_flag_acq_required # bit1 = frag_flag_resent / please_resend # bit2 = frag_flag_please_trailer_ack # bit7 = frag_flag_error flag_defs = [ (0, "ACQ_REQ"), (1, "RESENT"), (2, "TRAILER_ACK"), (3, "RESV3"), (4, "RESV4"), (5, "RESV5"), (6, "RESV6"), (7, "ERROR"), ] out_lines.append(f"SFP_FLAGS : {flag_val} (0x{flag_val:X}) ") # Append colored flag labels; use 'flag_error' tag for ERROR for bit, name in flag_defs: is_set = False try: is_set = bool((flag_val >> bit) & 1) except Exception: is_set = False if name == "ERROR" and is_set: tag = "flag_error" else: tag = "flag_set" if is_set else "flag_unset" # Append textual flag indicator; tags are only for widget display out_lines.append(f" [{name}]") # Fixed legend text for flags (always visible) out_lines.append("\n\nFlags legend:\n") legend_map = { "ACQ_REQ": "Acquisition required/requested", "RESENT": "Fragment resent / please resend", "TRAILER_ACK": "Request trailer acknowledgement", "ERROR": "Packet-level error flag", "RESV3": "Reserved", "RESV4": "Reserved", "RESV5": "Reserved", "RESV6": "Reserved", } for _, name in flag_defs: desc = legend_map.get(name, "") out_lines.append(f" {name:12s}: {desc}\n") # Build hex dump but do not insert it into the Raw tab; # always show binary/body hex in the dedicated Binary tab. out_lines.append("\nBODY (hex):\n") hex_dump = self._format_hex_dump(body) out_lines.append(hex_dump) # Join into a single string and log it so external test-run captures # include the full packet instead of attaching it. full_text = "".join(out_lines) #try: # Use info level to match other logs produced by this window #self.logger.info(full_text) #except Exception: # Don't fail display on logging problems # pass # Display header/parsed fields in the Raw tab, but move the # full hex/body dump into the Binary tab to centralize binary data. self.raw_tab_text.config(state=tk.NORMAL) self.raw_tab_text.delete("1.0", tk.END) header_block, _, _ = full_text.partition("\nBODY (hex):\n") self.raw_tab_text.insert(tk.END, header_block + "\n", "hdr_field") self.raw_tab_text.config(state=tk.DISABLED) # Put the hex dump into the Binary tab try: self._display_hex_data(body, self.bin_tab) except Exception: # fallback: ensure binary tab contains something self.bin_tab.config(state=tk.NORMAL) self.bin_tab.delete("1.0", tk.END) self.bin_tab.insert("1.0", hex_dump) self.bin_tab.config(state=tk.DISABLED) return except Exception as e: text = f"Failed to format raw packet: {e}\n\nRaw dump:\n" text += self._format_hex_dump(raw_bytes) self.raw_tab_text.config(state=tk.NORMAL) self.raw_tab_text.delete("1.0", tk.END) self.raw_tab_text.insert("1.0", text) self.raw_tab_text.config(state=tk.DISABLED) def _display_image_data( self, payload: bytearray, tab_widgets: Dict[str, Any], photo_attr: str ): """Parses an image payload and displays it. Now handles simplified structure.""" try: if len(payload) < ctypes.sizeof(ImageLeaderData): raise ValueError("Payload smaller than ImageLeaderData header.") leader = ImageLeaderData.from_buffer(payload) h, w, bpp = ( leader.HEADER_DATA.DY, leader.HEADER_DATA.DX, leader.HEADER_DATA.BPP, ) stride = leader.HEADER_DATA.STRIDE offset = ctypes.sizeof(ImageLeaderData) if not (h > 0 and w > 0 and bpp in [1, 2] and stride >= w): raise ValueError( f"Invalid image dimensions in header: {w}x{h}, bpp={bpp}, stride={stride}" ) if bpp == 1: dtype = np.uint8 else: dtype = np.uint16 expected_size = stride * h * bpp if (offset + expected_size) > len(payload): # Fallback for old format where PIXEL_TAG was at the end of leader offset_fallback = ( ctypes.sizeof(SFPHeader) + ctypes.sizeof(ImageLeaderData) - ctypes.sizeof(leader.PIXEL_TAG) ) if (offset_fallback + expected_size) <= len(payload): offset = offset_fallback else: raise ValueError( f"Incomplete image data. Expected {expected_size} bytes, got {len(payload) - offset}" ) pixel_data_view = np.ndarray( shape=(h, stride), dtype=dtype, buffer=payload, offset=offset ) # Crop to actual width if stride is larger image_data = pixel_data_view[:, :w] display_img_8bit = cv2.normalize( image_data, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U ) img_pil = Image.fromarray( cv2.cvtColor(display_img_8bit, cv2.COLOR_GRAY2RGB) ) # Resize image to fit the label area while preserving aspect ratio try: # Use the fixed-size container (150x150) for resizing target resized = self._resize_pil_to_label( img_pil, tab_widgets.get("image_container", tab_widgets["image_label"]), ) except Exception: # Fallback to original if anything goes wrong resized = img_pil photo = ImageTk.PhotoImage(image=resized) tab_widgets["image_label"].config(image=photo, text="") setattr(self, photo_attr, photo) except Exception as e: self.logger.error(f"Error parsing image payload: {e}") tab_widgets["image_label"].config( image=None, text=f"Error parsing image:\n{e}" ) setattr(self, photo_attr, None) # Always show binary/body hex in the dedicated Binary tab instead of # attaching it to individual image tabs. try: self._display_hex_data(payload, self.bin_tab) except Exception: # best-effort fallback to the image tab hex_view if Binary tab isn't available try: self._display_hex_data(payload, tab_widgets["hex_view"]) except Exception: pass def _resize_pil_to_label( self, img: "Image.Image", label_widget: ttk.Label ) -> "Image.Image": """Resize a PIL Image to fit within the current label widget size. If the label widget has not been mapped yet (width/height == 1), this will fallback to the image's original size. """ try: # Get current allocated size for label (in pixels) width = label_widget.winfo_width() height = label_widget.winfo_height() # If the widget isn't yet laid out, width/height may be 1 -> use geometry if width <= 1 or height <= 1: geom = self.geometry() # format: WxH+X+Y if "x" in geom: parts = geom.split("+", 1)[0].split("x") win_w, win_h = int(parts[0]), int(parts[1]) # Use a fraction of window size for image area width = max(1, int(win_w * 0.9)) height = max(1, int(win_h * 0.6)) if width <= 1 or height <= 1: return img img_w, img_h = img.size # Compute scale preserving aspect ratio scale = min(width / img_w, height / img_h) if scale >= 1.0: return img new_w = max(1, int(img_w * scale)) new_h = max(1, int(img_h * scale)) return img.resize((new_w, new_h), Image.LANCZOS) except Exception: return img def _display_hex_data(self, payload: bytearray, widget: scrolledtext.ScrolledText): hex_dump = self._format_hex_dump(payload) widget.config(state=tk.NORMAL) widget.delete("1.0", tk.END) widget.insert("1.0", hex_dump) widget.config(state=tk.DISABLED) def _display_json_data(self, payload: bytearray, widget: scrolledtext.ScrolledText): try: import json text = json.dumps(json.loads(payload.decode("utf-8")), indent=2) except Exception as e: text = f"--- FAILED TO PARSE JSON ---\n{e}\n\n--- RAW HEX DUMP ---\n" text += self._format_hex_dump(payload) widget.config(state=tk.NORMAL) widget.delete("1.0", tk.END) widget.insert("1.0", text) widget.config(state=tk.DISABLED) def _log_to_widget(self, message: str, level: str = "DEBUG"): self.logger.info(message) self.log_tab.config(state=tk.NORMAL) self.log_tab.insert(tk.END, f"[{level}] {message}\n") self.log_tab.config(state=tk.DISABLED) self.log_tab.see(tk.END) def _on_save_ris_csv(self): try: import csv # collect rows from tree # collect scenario rows scenario_rows = [self.scenario_tree.item(iid, "values") for iid in self.scenario_tree.get_children()] # collect target rows rows = [self.ris_tree.item(iid, "values") for iid in self.ris_tree.get_children()] if not scenario_rows and not rows: self._log_to_widget("No RIS data to save.", "INFO") return # ensure Temp dir exists 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) ts = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%S") fname = f"ris_targets_{ts}.csv" path = os.path.join(temp_dir, fname) with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) # write scenario fields first if scenario_rows: writer.writerow(["Scenario Field", "Value"]) for s in scenario_rows: writer.writerow(s) writer.writerow([]) # write targets writer.writerow(["index", "flags", "heading", "x", "y", "z"]) for r in rows: writer.writerow(r) self._log_to_widget(f"Saved RIS targets CSV to {path}", "INFO") except Exception as e: self._log_to_widget(f"Failed to save RIS CSV: {e}", "ERROR") def _format_hex_dump(self, data: bytes, length=16) -> str: lines = [] for i in range(0, len(data), length): chunk = data[i : i + length] hex_part = " ".join(f"{b:02X}" for b in chunk) ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) lines.append(f"{i:08X} {hex_part:<{length*3}} |{ascii_part}|") return "\n".join(lines)