From 92b7a124926523578666f5275f477460751f15a4 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 22 Oct 2025 15:34:51 +0200 Subject: [PATCH] refactoring della gestione della comunicazione con il server e condivisione tra finestre --- settings.json | 2 +- target_simulator/core/sfp_communicator.py | 99 +- target_simulator/core/sfp_transport.py | 95 +- .../core/simulation_payload_handler.py | 2 +- target_simulator/gui/main_view.py | 262 +++- target_simulator/gui/payload_router.py | 22 +- target_simulator/gui/ppi_display.py | 59 + target_simulator/gui/sfp_debug_window.py | 1244 ++++------------- target_simulator/utils/config_manager.py | 12 + target_simulator/utils/network.py | 3 + 10 files changed, 687 insertions(+), 1113 deletions(-) diff --git a/settings.json b/settings.json index cf70896..00c89cf 100644 --- a/settings.json +++ b/settings.json @@ -3,7 +3,7 @@ "scan_limit": 60, "max_range": 100, "geometry": "1599x1024+626+57", - "last_selected_scenario": null, + "last_selected_scenario": "scenario_dritto", "connection": { "target": { "type": "sfp", diff --git a/target_simulator/core/sfp_communicator.py b/target_simulator/core/sfp_communicator.py index e39b72b..81d69a4 100644 --- a/target_simulator/core/sfp_communicator.py +++ b/target_simulator/core/sfp_communicator.py @@ -6,12 +6,12 @@ Handles SFP (Simple Fragmentation Protocol) communication with the target device import socket import time -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, Callable from queue import Queue from target_simulator.core.communicator_interface import CommunicatorInterface from target_simulator.core.models import Scenario -from target_simulator.core.sfp_transport import SfpTransport +from target_simulator.core.sfp_transport import SfpTransport, PayloadHandler from target_simulator.core import command_builder from target_simulator.utils.logger import get_logger from target_simulator.core.simulation_payload_handler import SimulationPayloadHandler @@ -21,7 +21,7 @@ from target_simulator.analysis.simulation_state_hub import SimulationStateHub class SFPCommunicator(CommunicatorInterface): """ A communicator that uses the SFP transport layer to send commands - and (eventually) receive status updates. + and receive status updates. Manages a single shared transport instance. """ def __init__(self, simulation_hub: Optional[SimulationStateHub] = None, update_queue: Optional["Queue"] = None): @@ -29,20 +29,33 @@ class SFPCommunicator(CommunicatorInterface): self.transport: Optional[SfpTransport] = None self.config: Optional[Dict[str, Any]] = None self._destination: Optional[tuple] = None - self.simulation_hub = simulation_hub # Store the hub instance + self.simulation_hub = simulation_hub self.update_queue = update_queue + self._connection_state_callbacks: List[Callable[[bool], None]] = [] + self._extra_payload_handlers: Dict[int, PayloadHandler] = {} + + def add_connection_state_callback(self, callback: Callable[[bool], None]): + if callback not in self._connection_state_callbacks: + self._connection_state_callbacks.append(callback) + + def remove_connection_state_callback(self, callback: Callable[[bool], None]): + try: + self._connection_state_callbacks.remove(callback) + except ValueError: + pass + + def _notify_connection_state_changed(self): + is_open = self.is_open + for callback in self._connection_state_callbacks: + try: + callback(is_open) + except Exception: + self.logger.exception("Error in connection state callback") def connect(self, config: Dict[str, Any]) -> bool: - """ - Initializes the SFP transport. - Config must contain: - - 'ip': Remote IP address (server) - - 'port': Remote port (server's listening port) - - 'local_port': Local port to bind for receiving data - """ if self.is_open: - self.logger.warning("Already connected. Disconnecting first.") - self.disconnect() + self.logger.warning("Already connected. Returning True.") + return True remote_ip = config.get("ip") remote_port = config.get("port") @@ -54,6 +67,7 @@ class SFPCommunicator(CommunicatorInterface): ) return False + result = False try: self._destination = (remote_ip, int(remote_port)) local_port_int = int(local_port) @@ -62,17 +76,14 @@ class SFPCommunicator(CommunicatorInterface): f"Initializing SFP Transport: Bind {local_port_int} -> Remote {self._destination}" ) - # --- MODIFICATION START --- - # Create payload handlers if a simulation hub is provided payload_handlers = {} if self.simulation_hub: self.logger.info("Simulation hub provided. Setting up simulation payload handlers.") sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue) - payload_handlers = sim_handler.get_handlers() - else: - self.logger.warning("No simulation hub provided. SFP communicator will only send data.") - # --- MODIFICATION END --- + payload_handlers.update(sim_handler.get_handlers()) + payload_handlers.update(self._extra_payload_handlers) + self.transport = SfpTransport( host="0.0.0.0", port=local_port_int, @@ -82,16 +93,36 @@ class SFPCommunicator(CommunicatorInterface): if self.transport.start(): self.config = config self.logger.info("SFP Transport started successfully.") - return True + result = True else: self.logger.error("Failed to start SFP Transport.") self.transport = None - return False - + result = False except Exception as e: self.logger.error(f"Exception during SFP connect: {e}", exc_info=True) self.transport = None - return False + result = False + + self._notify_connection_state_changed() + return result + + def add_payload_handlers(self, handlers: Dict[int, PayloadHandler]): + if not handlers: + return + self._extra_payload_handlers.update(handlers) + if self.transport: + self.transport.add_payload_handlers(handlers) + self.logger.info("Attached extra payload handlers to running transport.") + + def remove_payload_handlers(self, handlers: Dict[int, PayloadHandler]): + if not handlers: + return + for flow in handlers.keys(): + self._extra_payload_handlers.pop(flow, None) + + if self.transport: + self.transport.remove_payload_handlers(handlers) + self.logger.info("Detached extra payload handlers from running transport.") def disconnect(self) -> None: if self.transport: @@ -100,35 +131,29 @@ class SFPCommunicator(CommunicatorInterface): self.transport = None self.config = None self._destination = None + self._notify_connection_state_changed() @property def is_open(self) -> bool: - return self.transport is not None + return self.transport is not None and self.transport._socket is not None def send_scenario(self, scenario: Scenario) -> bool: - """ - Sends the initial scenario state using a sequence of 'tgtinit' commands. - """ if not self.is_open or not self._destination: self.logger.error("Cannot send scenario: SFP not connected.") return False self.logger.info(f"Sending scenario '{scenario.name}' via SFP...") - # 1. Pause simulation on server if not self._send_single_command(command_builder.build_pause()): return False - # 2. Send init for all targets for target in scenario.get_all_targets(): cmd = command_builder.build_tgtinit(target) if not self._send_single_command(cmd): self.logger.error(f"Failed to send init for target {target.target_id}") return False - # Small delay to avoid overwhelming the server's command buffer if it has one time.sleep(0.01) - # 3. Resume simulation if not self._send_single_command(command_builder.build_continue()): return False @@ -136,9 +161,6 @@ class SFPCommunicator(CommunicatorInterface): return True def send_commands(self, commands: List[str]) -> bool: - """ - Sends a batch of commands (typically 'tgtset' for live updates). - """ if not self.is_open: return False @@ -146,27 +168,19 @@ class SFPCommunicator(CommunicatorInterface): for cmd in commands: if not self._send_single_command(cmd): all_success = False - # We continue trying to send the rest of the batch even if one fails - return all_success def _send_single_command(self, command: str) -> bool: - """Internal helper to send a single command via the transport.""" if not self.transport or not self._destination: return False return self.transport.send_script_command(command, self._destination) @staticmethod def test_connection(config: Dict[str, Any]) -> bool: - """ - Tests if we can bind to the specified local port. - Does NOT strictly test reachability of the remote server, as SFP is UDPless. - """ local_port = config.get("local_port") if not local_port: return False try: - # Try to bind a UDP socket to the local port to see if it's free sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("0.0.0.0", int(local_port))) sock.close() @@ -176,5 +190,4 @@ class SFPCommunicator(CommunicatorInterface): @staticmethod def list_available_ports() -> List[str]: - """SFP (UDP) doesn't have enumerable ports in the same way as Serial.""" return [] \ No newline at end of file diff --git a/target_simulator/core/sfp_transport.py b/target_simulator/core/sfp_transport.py index 6d543ca..b0168fe 100644 --- a/target_simulator/core/sfp_transport.py +++ b/target_simulator/core/sfp_transport.py @@ -14,7 +14,7 @@ import logging import threading import time import ctypes -from typing import Dict, Callable, Optional +from typing import Dict, Callable, Optional, List from target_simulator.utils.network import create_udp_socket, close_udp_socket from target_simulator.core.sfp_structures import SFPHeader @@ -27,9 +27,7 @@ logger = logging.getLogger(__name__) class SfpTransport: """Manages SFP communication, payload reassembly, and sending.""" - # Max size for a script payload, conservative to fit in server buffers. MAX_SCRIPT_PAYLOAD_SIZE = 1020 - # Size of the fixed part of the script message (the DataTag). SCRIPT_TAG_SIZE = 8 def __init__( @@ -45,26 +43,49 @@ class SfpTransport: self._host = host self._port = port - self._payload_handlers = payload_handlers + self._payload_handlers: Dict[int, List[PayloadHandler]] = {} + self.add_payload_handlers(payload_handlers) + self._ack_config = ack_config if ack_config is not None else {} self._raw_packet_callback = raw_packet_callback self._socket: Optional[socket.socket] = None self._receiver_thread: Optional[threading.Thread] = None self._stop_event = threading.Event() - self._tid_counter = 0 # Simple transaction ID counter for sending - self._send_lock = threading.Lock() # Protects TID counter and socket sending + self._tid_counter = 0 + self._send_lock = threading.Lock() - # transaction state: key=(flow, tid) -> {frag_index: total_frags} self._fragments: Dict[tuple, Dict[int, int]] = {} - # buffers for reassembly: key=(flow, tid) -> bytearray(total_size) self._buffers: Dict[tuple, bytearray] = {} + logger.debug(f"{self._log_prefix} ACK window config: {self._ack_config}") + + def add_payload_handlers(self, handlers: Dict[int, PayloadHandler]): + """Adds payload handlers to the transport.""" + if not handlers: + return + for flow, handler in handlers.items(): + if flow not in self._payload_handlers: + self._payload_handlers[flow] = [] + if handler not in self._payload_handlers[flow]: + self._payload_handlers[flow].append(handler) logger.debug( f"{self._log_prefix} Registered handlers for flows: " f"{[chr(k) if 32 <= k <= 126 else k for k in self._payload_handlers.keys()]}" ) - logger.debug(f"{self._log_prefix} ACK window config: {self._ack_config}") + + def remove_payload_handlers(self, handlers: Dict[int, PayloadHandler]): + """Removes specified payload handlers from the transport.""" + if not handlers: + return + for flow, handler in handlers.items(): + if flow in self._payload_handlers: + try: + self._payload_handlers[flow].remove(handler) + if not self._payload_handlers[flow]: + del self._payload_handlers[flow] + except ValueError: + logger.warning(f"{self._log_prefix} Handler for flow {flow} not found for removal.") def start(self) -> bool: """Starts the receiving thread.""" @@ -107,30 +128,19 @@ class SfpTransport: logger.info(f"{self._log_prefix} Shutdown complete.") def send_script_command( - self, command_string: str, destination: tuple, flow_id: int = ord("R") + self, + command_string: str, + destination: tuple, + flow_id: int = ord("R") ) -> bool: """ Encapsulates and sends a text command as a single-fragment SFP packet. - - This method constructs a payload similar to the server's `script_message_t`, - wraps it in an SFP header, and sends it to the specified destination. - - Args: - command_string: The text command to send (e.g., "tgtinit ..."). - destination: A tuple (ip, port) for the destination. - flow_id: The SFP flow ID to use for this message. - - Returns: - True if the packet was sent successfully, False otherwise. """ log_prefix = f"{self._log_prefix} Send" if not self._socket: logger.error(f"{log_prefix} Cannot send: socket is not open.") return False - # Define the expected payload structures using ctypes - # The server expects script_payload_t which contains a ctrl_tag and ctrl[32] - # before the script_tag, so replicate that layout here. class LocalDataTag(ctypes.Structure): _pack_ = 1 _fields_ = [ @@ -150,15 +160,11 @@ class SfpTransport: ] try: - # Normalize command: ensure it starts with '$' (no blank after $) and ends with newline cs = command_string or "" cs = cs.strip() - # if cs and not cs.startswith("$"): - # cs = "$" + cs.lstrip() if cs and not cs.endswith("\n"): cs = cs + "\n" - # (no transformation) send cs as-is; server-side now accepts spaces command_bytes = cs.encode("utf-8") if len(command_bytes) > self.MAX_SCRIPT_PAYLOAD_SIZE: logger.error( @@ -167,7 +173,6 @@ class SfpTransport: ) return False - # Create and populate the script payload structure payload_struct = ScriptPayload() payload_struct.script_tag.ID[0] = ord("C") payload_struct.script_tag.ID[1] = ord("S") @@ -177,17 +182,14 @@ class SfpTransport: ctypes.memmove(payload_struct.script, command_bytes, len(command_bytes)) payload_bytes = bytes(payload_struct) - # Compute the offset of the script buffer within the payload structure try: script_offset = ScriptPayload.script.offset except Exception: - # Fallback: assume script tag only (legacy) script_offset = self.SCRIPT_TAG_SIZE actual_payload_size = script_offset + len(command_bytes) payload_bytes = payload_bytes[:actual_payload_size] - # Create and populate the SFP header header = SFPHeader() with self._send_lock: self._tid_counter = (self._tid_counter + 1) % 256 @@ -195,7 +197,7 @@ class SfpTransport: header.SFP_DIRECTION = ord(">") header.SFP_FLOW = flow_id - header.SFP_TOTFRGAS = 1 # Single fragment message + header.SFP_TOTFRGAS = 1 header.SFP_FRAG = 0 header.SFP_PLSIZE = len(payload_bytes) header.SFP_TOTSIZE = len(payload_bytes) @@ -203,9 +205,7 @@ class SfpTransport: full_packet = bytes(header) + payload_bytes - # Send the packet self._socket.sendto(full_packet, destination) - # Log the actual normalized command that was placed into the payload try: sent_preview = ( cs if isinstance(cs, str) else cs.decode("utf-8", errors="replace") @@ -270,7 +270,6 @@ class SfpTransport: pl_size = header.SFP_PLSIZE pl_offset = header.SFP_PLOFFSET total_size = header.SFP_TOTSIZE - flags = header.SFP_FLAGS key = (flow, tid) @@ -286,9 +285,6 @@ class SfpTransport: if frag == 0: self._cleanup_lingering_transactions(flow, tid) - # logger.debug( - # f"New transaction started for key={key}. Total size: {total_size} bytes." - # ) self._fragments[key] = {} try: self._buffers[key] = bytearray(total_size) @@ -320,21 +316,18 @@ class SfpTransport: ] if len(self._fragments[key]) == total_frags: - # logger.debug( - # f"Transaction complete for key={key}. Handing off to application layer." - # ) - completed_payload = self._buffers.pop(key) self._fragments.pop(key) - handler = self._payload_handlers.get(flow) - if handler: - try: - handler(completed_payload) - except Exception: - logger.exception( - f"Error executing payload handler for flow {flow}." - ) + handlers = self._payload_handlers.get(flow) + if handlers: + for handler in handlers: + try: + handler(completed_payload) + except Exception: + logger.exception( + f"Error executing payload handler for flow {flow}." + ) else: logger.warning(f"No payload handler registered for flow ID {flow}.") diff --git a/target_simulator/core/simulation_payload_handler.py b/target_simulator/core/simulation_payload_handler.py index fbacde2..a056dd8 100644 --- a/target_simulator/core/simulation_payload_handler.py +++ b/target_simulator/core/simulation_payload_handler.py @@ -50,7 +50,7 @@ class SimulationPayloadHandler: Parses an SFP_RIS::status_message_t payload and updates the hub. """ payload_size = len(payload) - self.logger.debug(f"Received RIS payload of {payload_size} bytes") + #self.logger.debug(f"Received RIS payload of {payload_size} bytes") expected_size = SfpRisStatusPayload.size() if payload_size < expected_size: diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index f3e1de8..c307351 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -1,3 +1,4 @@ + # target_simulator/gui/main_view.py """ @@ -70,6 +71,11 @@ class MainView(tk.Tk): self.is_simulation_running = tk.BooleanVar(value=False) self.time_multiplier = 1.0 self.update_time = tk.DoubleVar(value=1.0) + # Simulation progress tracking + self.total_sim_time = 0.0 + self.sim_elapsed_time = 0.0 + self.sim_slider_var = tk.DoubleVar(value=0.0) + self._slider_is_dragging = False # --- Window and UI Setup --- self.title("Radar Target Simulator") @@ -104,14 +110,15 @@ class MainView(tk.Tk): ) self.h_pane.add(self.ppi_widget, weight=2) - # Add Connect button to the PPI's own control frame for better layout - if hasattr(self.ppi_widget, "controls_frame"): - connect_btn = ttk.Button( - self.ppi_widget.controls_frame, - text="Connect", - command=self._on_connect_button, - ) - connect_btn.pack(side=tk.RIGHT, padx=10) + # Wire the PPI's built-in connect toggle to the MainView connect handler + try: + if hasattr(self.ppi_widget, "set_connect_callback"): + self.ppi_widget.set_connect_callback(self._on_connect_button) + # Reflect initial connection state (likely disconnected) + if hasattr(self.ppi_widget, "update_connect_state"): + self.ppi_widget.update_connect_state(False) + except Exception: + pass # --- Left Pane --- left_pane_container = ttk.Frame(self.h_pane) @@ -163,10 +170,19 @@ class MainView(tk.Tk): engine_frame = ttk.LabelFrame(simulation_tab, text="Live Simulation Engine") engine_frame.pack(fill=tk.X, padx=5, pady=10, anchor="n") + # Use grid within engine_frame for a tidy multi-row layout that + # doesn't force the window to expand horizontally and keeps the PPI + # area visible. Configure columns so the middle spacer expands. + for i in range(10): + engine_frame.grid_columnconfigure(i, weight=0) + # Give the spacer column (3) and the main left column (0) flexible weight + engine_frame.grid_columnconfigure(0, weight=0) + engine_frame.grid_columnconfigure(3, weight=1) + self.start_button = ttk.Button( engine_frame, text="Start Live", command=self._on_start_simulation ) - self.start_button.pack(side=tk.LEFT, padx=5, pady=5) + self.start_button.grid(row=0, column=0, sticky="w", padx=5, pady=5) self.stop_button = ttk.Button( engine_frame, @@ -174,27 +190,21 @@ class MainView(tk.Tk): command=self._on_stop_simulation, state=tk.DISABLED, ) - self.stop_button.pack(side=tk.LEFT, padx=5, pady=5) - + self.stop_button.grid(row=0, column=1, sticky="w", padx=5, pady=5) + self.analysis_button = ttk.Button( engine_frame, text="Show Analysis", command=self._open_analysis_window, - state=tk.DISABLED + state=tk.DISABLED, ) - self.analysis_button.pack(side=tk.LEFT, padx=15, pady=5) + self.analysis_button.grid(row=0, column=2, sticky="w", padx=5, pady=5) - self.reset_button = ttk.Button( - engine_frame, text="Reset State", command=self._on_reset_simulation - ) - self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5) - - self.reset_radar_button = ttk.Button( - engine_frame, text="Reset Radar", command=self._reset_radar_state - ) - self.reset_radar_button.pack(side=tk.RIGHT, padx=5, pady=5) + # spacer to push the following controls to the right + spacer = ttk.Frame(engine_frame) + spacer.grid(row=0, column=3, sticky="ew") - ttk.Label(engine_frame, text="Speed:").pack(side=tk.LEFT, padx=(10, 2), pady=5) + ttk.Label(engine_frame, text="Speed:").grid(row=0, column=4, sticky="e", padx=(10, 2), pady=5) self.time_multiplier_var = tk.StringVar(value="1x") self.multiplier_combo = ttk.Combobox( engine_frame, @@ -203,18 +213,64 @@ class MainView(tk.Tk): state="readonly", width=4, ) - self.multiplier_combo.pack(side=tk.LEFT, padx=(0, 5), pady=5) + self.multiplier_combo.grid(row=0, column=5, sticky="w", padx=(0, 5), pady=5) self.multiplier_combo.bind( "<>", self._on_time_multiplier_changed ) - ttk.Label(engine_frame, text="Update Time (s):").pack( - side=tk.LEFT, padx=(10, 2), pady=5 - ) + ttk.Label(engine_frame, text="Update Time (s):").grid(row=0, column=6, sticky="e", padx=(10, 2), pady=5) self.update_time_entry = ttk.Entry( engine_frame, textvariable=self.update_time, width=5 ) - self.update_time_entry.pack(side=tk.LEFT, padx=(0, 5), pady=5) + self.update_time_entry.grid(row=0, column=7, sticky="w", padx=(0, 5), pady=5) + + self.reset_button = ttk.Button( + engine_frame, text="Reset State", command=self._on_reset_simulation + ) + self.reset_button.grid(row=0, column=8, sticky="e", padx=5, pady=5) + + self.reset_radar_button = ttk.Button( + engine_frame, text="Reset Radar", command=self._reset_radar_state + ) + self.reset_radar_button.grid(row=0, column=9, sticky="e", padx=5, pady=5) + + # --- Simulation progress bar / slider --- + # Place the progress frame on its own row below the control buttons + progress_frame = ttk.Frame(engine_frame) + # Place the progress frame on a dedicated grid row below the controls + progress_frame.grid(row=1, column=0, columnspan=10, sticky="ew", padx=5, pady=(6, 2)) + self.sim_slider = ttk.Scale( + progress_frame, + orient=tk.HORIZONTAL, + variable=self.sim_slider_var, + from_=0.0, + to=1.0, + command=lambda v: None, + # let grid manage length via sticky and column weights + ) + # configure progress_frame grid so slider expands and labels stay compact + progress_frame.grid_columnconfigure(0, weight=1) + progress_frame.grid_columnconfigure(1, weight=0) + self.sim_slider.grid(row=0, column=0, sticky="ew", padx=(4, 8)) + # Bind press/release to support seeking + try: + self.sim_slider.bind("", lambda e: setattr(self, '_slider_is_dragging', True)) + self.sim_slider.bind("", lambda e: (setattr(self, '_slider_is_dragging', False), self._on_seek())) + except Exception: + pass + + # Time labels showing elapsed and total separately at the end of the bar + labels_frame = ttk.Frame(progress_frame) + labels_frame.grid(row=0, column=1, sticky="e", padx=(4, 4)) + + self.sim_elapsed_label = ttk.Label(labels_frame, text="0.0s", width=8, anchor=tk.E) + self.sim_elapsed_label.grid(row=0, column=0) + + slash_label = ttk.Label(labels_frame, text="/") + slash_label.grid(row=0, column=1, padx=(2, 2)) + + self.sim_total_label = ttk.Label(labels_frame, text="0.0s", width=8, anchor=tk.W) + self.sim_total_label.grid(row=0, column=2) # --- TAB 3: LRU SIMULATION --- lru_tab = ttk.Frame(left_notebook) @@ -316,6 +372,19 @@ class MainView(tk.Tk): color = "#2ecc40" if is_connected else "#e74c3c" self._draw_status_indicator(canvas, color) + def _on_connection_state_change(self, is_connected: bool): + """Callback for communicator connection state changes.""" + self.logger.info(f"MainView received connection state change: Connected={is_connected}") + self._update_communicator_status("Target", is_connected) + + if hasattr(self.ppi_widget, 'update_connect_state'): + self.ppi_widget.update_connect_state(is_connected) + + # Also update the debug window if it's open + if self.sfp_debug_window and self.sfp_debug_window.winfo_exists(): + if hasattr(self.sfp_debug_window, 'update_toggle_state'): + self.sfp_debug_window.update_toggle_state(is_connected) + def _initialize_communicators(self): # Disconnect any existing connections if self.target_communicator and self.target_communicator.is_open: @@ -354,6 +423,7 @@ class MainView(tk.Tk): elif comm_type == "sfp": # --- MODIFICATION: Pass the hub and GUI update queue to the communicator --- communicator = SFPCommunicator(simulation_hub=self.simulation_hub, update_queue=self.gui_update_queue) + communicator.add_connection_state_callback(self._on_connection_state_change) config_data = config.get("sfp", {}) if self.defer_sfp_connection: # Return the communicator object but indicate it's not yet connected @@ -377,8 +447,40 @@ class MainView(tk.Tk): ConnectionSettingsWindow(self, self.config_manager, self.connection_config) def _on_connect_button(self): - self.logger.info("Connection requested by user.") - self._initialize_communicators() + self.logger.info("Connection toggle requested by user via PPI button.") + try: + # If communicator exists and is open, disconnect. + if self.target_communicator and self.target_communicator.is_open: + self.logger.info("Requesting disconnect.") + self.target_communicator.disconnect() + return + + # Otherwise, attempt to connect. + self.logger.info("Requesting connect.") + + # Ensure we have a communicator instance. + if not self.target_communicator: + self.logger.info("No target communicator instance. Initializing communicators.") + self._initialize_communicators() + # If it's still null after init, we can't proceed. + if not self.target_communicator: + self.logger.error("Failed to create target communicator on demand.") + messagebox.showerror("Error", "Could not create communicator.") + return + + # Now, connect using the existing or new instance. + cfg = self.connection_config.get("target", {}) + sfp_cfg = cfg.get("sfp") + if cfg.get("type") == "sfp" and sfp_cfg: + if not self.target_communicator.connect(sfp_cfg): + self.logger.error("Failed to connect target communicator.") + messagebox.showerror("Connection Failed", "Could not connect to target. Check settings and logs.") + else: + self.logger.warning("Connection attempt without valid SFP config. Running full re-initialization.") + self._initialize_communicators() + + except Exception: + self.logger.exception("Unhandled exception in _on_connect_button") def _reset_radar_state(self) -> bool: """ @@ -550,6 +652,22 @@ class MainView(tk.Tk): self.simulation_engine.set_time_multiplier(self.time_multiplier) self.simulation_engine.set_update_interval(update_interval) self.simulation_engine.load_scenario(self.scenario) + + # Initialize simulation progress tracking + try: + durations = [getattr(t, '_total_duration_s', 0.0) for t in self.scenario.get_all_targets()] + self.total_sim_time = max(durations) if durations else 0.0 + except Exception: + self.total_sim_time = 0.0 + + # Reset slider and label + self.sim_elapsed_time = 0.0 + try: + self.sim_slider_var.set(0.0) + except Exception: + pass + self._update_simulation_progress_display() + self.simulation_engine.start() self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) @@ -571,6 +689,10 @@ class MainView(tk.Tk): self.logger.exception("Error while disconnecting target communicator.") # Update visual status self._update_communicator_status("Target", False) + try: + self.ppi_widget.update_connect_state(False) + except Exception: + pass except Exception: self.logger.exception("Unexpected error while attempting to disconnect target communicator.") @@ -592,7 +714,15 @@ class MainView(tk.Tk): if update == "SIMULATION_FINISHED": self.logger.info("Simulation finished signal received.") + # Ensure engine is stopped and UI reset self._on_stop_simulation() + # Reset progress UI to final state + try: + self.sim_elapsed_time = self.total_sim_time + self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0) + except Exception: + pass + self._update_simulation_progress_display() elif isinstance(update, list): # The engine normally enqueues a List[Target] (simulated targets). @@ -616,12 +746,37 @@ class MainView(tk.Tk): display_data = self._build_display_data_from_hub() self.ppi_widget.update_targets(display_data) + # Update progress using target times from scenario + try: + # Use the engine's scenario simulated time as elapsed if available + if self.simulation_engine and self.simulation_engine.scenario: + # Derive elapsed as the max of target sim times + times = [getattr(t, '_sim_time_s', 0.0) for t in self.simulation_engine.scenario.get_all_targets()] + self.sim_elapsed_time = max(times) if times else 0.0 + else: + self.sim_elapsed_time += 0.0 + + # Update slider only if user is not interacting with it + if self.total_sim_time > 0 and not getattr(self, '_slider_is_dragging', False): + progress_frac = min(1.0, max(0.0, self.sim_elapsed_time / self.total_sim_time)) + self.sim_slider_var.set(progress_frac) + + self._update_simulation_progress_display() + except Exception: + # Do not allow progress UI failures to interrupt GUI updates + self.logger.debug("Progress UI update failed", exc_info=True) + except Empty: # If the queue is empty, we don't need to do anything pass finally: - if self.is_simulation_running.get(): + # Always continue polling the GUI update queue so we can show + # real-time server updates on the PPI even when the live + # simulation engine is not running. + try: self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) + except Exception: + pass def _update_button_states(self): is_running = self.is_simulation_running.get() @@ -665,6 +820,43 @@ class MainView(tk.Tk): ) self.time_multiplier = 1.0 + def _update_simulation_progress_display(self): + """Updates the elapsed/total time label from internal state.""" + try: + elapsed = self.sim_elapsed_time + total = self.total_sim_time + # Update separate labels for elapsed and total time + try: + self.sim_elapsed_label.config(text=f"{elapsed:.1f}s") + self.sim_total_label.config(text=f"{total:.1f}s") + except Exception: + # Fallback for older layouts + if hasattr(self, 'sim_time_label'): + self.sim_time_label.config(text=f"{elapsed:.1f}s / {total:.1f}s") + except Exception: + pass + + def _on_seek(self): + """Called when the user releases the progress slider to seek.""" + try: + if not self.simulation_engine or not self.simulation_engine.scenario: + return + + frac = float(self.sim_slider_var.get()) + # Compute the new time and clamp + new_time = max(0.0, min(self.total_sim_time, frac * self.total_sim_time)) + + # Ask engine to seek to this new time + try: + self.simulation_engine.set_simulation_time(new_time) + # Immediately update internal elapsed time and label + self.sim_elapsed_time = new_time + self._update_simulation_progress_display() + except Exception: + self.logger.exception("Failed to seek simulation time.") + except Exception: + self.logger.exception("Error in _on_seek handler.") + def _on_targets_changed(self, targets: List[Target]): """Callback executed when the target list is modified by the user.""" # 1. Update the internal scenario object @@ -858,8 +1050,12 @@ class MainView(tk.Tk): self.config_manager.save_general_settings(settings_to_save) self.config_manager.save_connection_settings(self.connection_config) - if self.target_communicator and self.target_communicator.is_open: - self.target_communicator.disconnect() + if self.target_communicator: + if hasattr(self.target_communicator, 'remove_connection_state_callback'): + self.target_communicator.remove_connection_state_callback(self._on_connection_state_change) + if self.target_communicator.is_open: + self.target_communicator.disconnect() + if self.lru_communicator and self.lru_communicator.is_open: self.lru_communicator.disconnect() diff --git a/target_simulator/gui/payload_router.py b/target_simulator/gui/payload_router.py index 70bb39a..a957d1c 100644 --- a/target_simulator/gui/payload_router.py +++ b/target_simulator/gui/payload_router.py @@ -40,19 +40,21 @@ class DebugPayloadRouter: os.makedirs(self._persist_dir, exist_ok=True) except Exception: pass + + # Create handlers once and store them to ensure stable object references + self._handlers = { + ord("M"): lambda p: self._update_last_payload("MFD", p), + ord("S"): lambda p: self._update_last_payload("SAR", p), + ord("B"): lambda p: self._update_last_payload("BIN", p), + ord("J"): lambda p: self._update_last_payload("JSON", p), + ord("R"): self._handle_ris_status, + ord("r"): self._handle_ris_status, + } logging.info(f"{self._log_prefix} Initialized.") def get_handlers(self) -> Dict[int, Any]: - """Returns handlers that update the internal last-payload buffer.""" - return { - ord("M"): lambda payload: self._update_last_payload("MFD", payload), - ord("S"): lambda payload: self._update_last_payload("SAR", payload), - ord("B"): lambda payload: self._update_last_payload("BIN", payload), - ord("J"): lambda payload: self._update_last_payload("JSON", payload), - # Support both uppercase 'R' and lowercase 'r' as RIS/status flows - ord("R"): lambda payload: self._handle_ris_status(payload), - ord("r"): lambda payload: self._handle_ris_status(payload), - } + """Returns the stored handler instances.""" + return self._handlers def _update_last_payload(self, flow_id: str, payload: bytearray): """Thread-safely stores the latest payload for a given flow.""" diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index 5310a70..59a6ef9 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -84,6 +84,12 @@ class PPIDisplay(ttk.Frame): self.range_selector.pack(side=tk.LEFT, padx=5) self.range_selector.bind("<>", self._on_range_selected) + # Connection toggle (Connect / Disconnect) for SFP + self._connect_callback = None + self._is_connected = False + self.connect_btn = ttk.Button(self.controls_frame, text="Connect", command=self._on_connect_btn) + self.connect_btn.pack(side=tk.RIGHT, padx=10) + # --- Display Options --- options_frame = ttk.LabelFrame(top_frame, text="Display Options") options_frame.pack(side=tk.RIGHT, padx=(10, 0)) @@ -224,6 +230,59 @@ class PPIDisplay(ttk.Frame): self.canvas.draw_idle() + # --- Connection toggle API --- + def _on_connect_btn(self): + if callable(self._connect_callback): + try: + self._connect_callback() + except Exception: + pass + + def set_connect_callback(self, cb): + """Register a callback to be executed when the PPI Connect button is pressed. + + The callback should handle connecting/disconnecting logic at the application level. + """ + self._connect_callback = cb + + def update_connect_state(self, is_connected: bool): + """Update the Connect button label/state to reflect current connection status.""" + try: + self._is_connected = bool(is_connected) + if self._is_connected: + self.connect_btn.config(text="Disconnect") + else: + self.connect_btn.config(text="Connect") + except Exception: + pass + + # --- Connection toggle API --- + def _on_connect_btn(self): + if callable(self._connect_callback): + try: + self._connect_callback() + except Exception: + # Allow caller to handle exceptions and update state + pass + + def set_connect_callback(self, cb): + """Register a callback to be executed when the PPI Connect button is pressed. + + The callback should handle connecting/disconnecting logic at the application level. + """ + self._connect_callback = cb + + def update_connect_state(self, is_connected: bool): + """Update the Connect button label/state to reflect current connection status.""" + try: + self._is_connected = bool(is_connected) + if self._is_connected: + self.connect_btn.config(text="Disconnect") + else: + self.connect_btn.config(text="Connect") + except Exception: + pass + def _draw_target_visuals(self, targets: List[Target], color: str, artist_list: List): """Helper to draw dots and vectors for a list of targets.""" vector_len_nm = self.range_var.get() / 20.0 diff --git a/target_simulator/gui/sfp_debug_window.py b/target_simulator/gui/sfp_debug_window.py index 7d3a590..bf07a45 100644 --- a/target_simulator/gui/sfp_debug_window.py +++ b/target_simulator/gui/sfp_debug_window.py @@ -68,17 +68,25 @@ class SfpDebugWindow(tk.Toplevel): self.geometry("1100x700") except Exception: pass + try: + self.protocol("WM_DELETE_WINDOW", self._on_close) + except Exception: + pass self.logger = logging.getLogger(__name__) self.payload_router = DebugPayloadRouter() - self.sfp_transport: Optional[SfpTransport] = None + self.shared_communicator = getattr(self.master, 'target_communicator', None) + + if self.shared_communicator: + self.shared_communicator.add_connection_state_callback(self._update_toggle_state) + handlers = self.payload_router.get_handlers() + self.shared_communicator.add_payload_handlers(handlers) + self.image_area_size = 150 self._ppi_visible = False # --- TK Variables --- self.ip_var = tk.StringVar(value="127.0.0.1") - # Local port to bind the client socket (where server will send status) self.local_port_var = tk.StringVar(value="60002") - # Server port where we send script/command messages self.server_port_var = tk.StringVar(value="60001") self.script_var = tk.StringVar(value="print('hello from client')") @@ -94,90 +102,56 @@ class SfpDebugWindow(tk.Toplevel): self.tgt_restart_var = tk.BooleanVar(value=False) self._master_mode_names = [ - "idle_master_mode", - "int_bit_master_mode", - "gm_master_mode", - "dbs_master_mode", - "rws_master_mode", - "vs_master_mode", - "acm_master_mode", - "tws_master_mode", - "sea_low_master_mode", - "sea_high_master_mode", - "gmti_master_mode", - "bcn_master_mode", - "sam_master_mode", - "ta_master_mode", - "wa_master_mode", - "stt_master_mode", - "dtt_master_mode", - "sstt_master_mode", - "acq_master_mode", - "ftt_master_mode", - "agr_master_mode", - "sar_master_mode", - "invalid_master_mode_", - "xtst_dummy_mode", - "xtst_hw_validation_mode", - "boot_master_mode", - "master_mode_id_cardinality_", + "idle_master_mode", "int_bit_master_mode", "gm_master_mode", "dbs_master_mode", + "rws_master_mode", "vs_master_mode", "acm_master_mode", "tws_master_mode", + "sea_low_master_mode", "sea_high_master_mode", "gmti_master_mode", "bcn_master_mode", + "sam_master_mode", "ta_master_mode", "wa_master_mode", "stt_master_mode", + "dtt_master_mode", "sstt_master_mode", "acq_master_mode", "ftt_master_mode", + "agr_master_mode", "sar_master_mode", "invalid_master_mode_", "xtst_dummy_mode", + "xtst_hw_validation_mode", "boot_master_mode", "master_mode_id_cardinality_", ] - # --- UI Construction --- self._create_widgets() + if self.shared_communicator: + is_open = self.shared_communicator.is_open + self.logger.info(f"SfpDebugWindow __init__: shared_communicator.is_open = {is_open}") + self._update_toggle_state(is_open) - # Start the periodic GUI poll loop - try: - self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) - except Exception: - pass + self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) def _create_widgets(self): - # --- Top Controls Container --- top_controls_frame = ttk.Frame(self) top_controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) - # --- Connection Frame --- conn_frame = ttk.Frame(top_controls_frame) conn_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self._create_connection_widgets(conn_frame) - # --- Simple Target Sender Frame --- - target_sender_frame = ttk.LabelFrame( - top_controls_frame, text="Simple Target Sender" - ) + target_sender_frame = ttk.LabelFrame(top_controls_frame, text="Simple Target Sender") target_sender_frame.pack(side=tk.TOP, fill=tk.X, pady=(5, 5)) self._create_target_sender_widgets(target_sender_frame) - # --- Script Sender Frame (optional, can be removed if not needed) --- script_frame = ttk.LabelFrame(top_controls_frame, text="Script to send") script_frame.pack(side=tk.TOP, fill=tk.X, pady=(0, 5)) self._create_script_sender_widgets(script_frame) - # --- Data Display Notebook --- self.notebook = ttk.Notebook(self) self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) self._create_notebook_tabs() - # (PPI widget is created inside the RIS tab right pane; see _create_notebook_tabs) - def _toggle_ppi(self): - # Swap RIS table and RIS PPI container try: if self._ppi_visible: - # show table, hide ppi self.ris_ppi_container.pack_forget() self.ris_table_container.pack(fill=tk.BOTH, expand=True) self._ppi_visible = False self.ppi_toggle_btn.config(text="Show PPI Map") else: - # hide table, show ppi self.ris_table_container.pack_forget() self.ris_ppi_container.pack(fill=tk.BOTH, expand=True) self._ppi_visible = True self.ppi_toggle_btn.config(text="Hide PPI Map") except Exception: - # Fallback: if containers are missing, do nothing self.logger.exception("Toggle PPI failed") def update_ppi_targets(self, targets): @@ -188,189 +162,82 @@ class SfpDebugWindow(tk.Toplevel): self.logger.exception("Failed to update RIS PPI targets") def on_ris_status_update(self, ris_status_payload): - # Convert RIS targets to Target objects (minimal, for display) targets = [] for i in range(getattr(ris_status_payload.tgt, "tgt", []).__len__()): ris_tgt = ris_status_payload.tgt.tgt[i] - # Only show if valid/active (customize as needed) if getattr(ris_tgt, "flags", 0) & 1: - t = Target( - target_id=i, - trajectory=[], - active=True, - traceable=True, - ) + t = Target(target_id=i, trajectory=[], active=True, traceable=True) t.current_range_nm = (ris_tgt.x**2 + ris_tgt.y**2) ** 0.5 / 6076.12 try: - # Use atan2(y, x) so azimuth follows the conventional (x East, y North) - t.current_azimuth_deg = math.degrees( - math.atan2(ris_tgt.y, ris_tgt.x) - ) + t.current_azimuth_deg = math.degrees(math.atan2(ris_tgt.y, ris_tgt.x)) except Exception: t.current_azimuth_deg = 0.0 t.current_altitude_ft = ris_tgt.z t.current_heading_deg = getattr(ris_tgt, "heading", 0.0) - try: - self._log_to_widget( - f"RIS JSON heading raw[{i}]: {getattr(ris_tgt, 'heading', None)}", - "DEBUG", - ) - except Exception: - pass t.active = True targets.append(t) self.update_ppi_targets(targets) def _create_connection_widgets(self, parent): ttk.Label(parent, text="IP:").pack(side=tk.LEFT, padx=(4, 2)) - ttk.Entry(parent, textvariable=self.ip_var, width=18).pack( - side=tk.LEFT, padx=(0, 6) - ) + ttk.Entry(parent, textvariable=self.ip_var, width=18).pack(side=tk.LEFT, padx=(0, 6)) ttk.Label(parent, text="Local Port:").pack(side=tk.LEFT, padx=(0, 2)) - ttk.Entry(parent, textvariable=self.local_port_var, width=8).pack( - side=tk.LEFT, padx=(0, 6) - ) + ttk.Entry(parent, textvariable=self.local_port_var, width=8).pack(side=tk.LEFT, padx=(0, 6)) ttk.Label(parent, text="Server Port:").pack(side=tk.LEFT, padx=(0, 2)) - ttk.Entry(parent, textvariable=self.server_port_var, width=8).pack( - side=tk.LEFT, padx=(0, 6) - ) - self.connect_btn = ttk.Button(parent, text="Connect", command=self._on_connect) - self.connect_btn.pack(side=tk.LEFT, padx=(0, 6)) - self.disconnect_btn = ttk.Button( - parent, text="Disconnect", command=self._on_disconnect, state=tk.DISABLED - ) - self.disconnect_btn.pack(side=tk.LEFT, padx=(0, 6)) - self.send_probe_btn = ttk.Button( - parent, text="Send Probe", command=self._on_send_probe - ) + ttk.Entry(parent, textvariable=self.server_port_var, width=8).pack(side=tk.LEFT, padx=(0, 6)) + self.connect_toggle_btn = ttk.Button(parent, text="Connect", command=self._on_toggle_connect) + self.connect_toggle_btn.pack(side=tk.LEFT, padx=(0, 6)) + self.send_probe_btn = ttk.Button(parent, text="Send Probe", command=self._on_send_probe) self.send_probe_btn.pack(side=tk.LEFT, padx=(6, 4)) - # Quick commands frame - quick_frame = ttk.Frame(parent) - quick_frame.pack(side=tk.RIGHT) - # Always prefix commands with '$' to satisfy server's mex parser - - # Quick command buttons moved to the Simple Target Sender area def _create_target_sender_widgets(self, parent): grid = ttk.Frame(parent, padding=5) grid.pack(fill=tk.X) - # Configure columns to have padding between them grid.columnconfigure(1, pad=15) grid.columnconfigure(3, pad=15) grid.columnconfigure(5, pad=15) - # --- Column 0 --- ttk.Label(grid, text="ID:").grid(row=0, column=0, sticky=tk.W) - ttk.Spinbox(grid, from_=0, to=15, textvariable=self.tgt_id_var, width=8).grid( - row=1, column=0, sticky=tk.W - ) - - # --- Column 1 --- + ttk.Spinbox(grid, from_=0, to=15, textvariable=self.tgt_id_var, width=8).grid(row=1, column=0, sticky=tk.W) ttk.Label(grid, text="Range (NM):").grid(row=0, column=1, sticky=tk.W) - ttk.Spinbox( - grid, from_=0, to=500, textvariable=self.tgt_range_var, width=10 - ).grid(row=1, column=1, sticky=tk.W) - - # --- Column 2 --- + ttk.Spinbox(grid, from_=0, to=500, textvariable=self.tgt_range_var, width=10).grid(row=1, column=1, sticky=tk.W) ttk.Label(grid, text="Azimuth (°):").grid(row=0, column=2, sticky=tk.W) - ttk.Spinbox( - grid, from_=-180, to=180, textvariable=self.tgt_az_var, width=10 - ).grid(row=1, column=2, sticky=tk.W) - - # --- Column 3 --- + ttk.Spinbox(grid, from_=-180, to=180, textvariable=self.tgt_az_var, width=10).grid(row=1, column=2, sticky=tk.W) ttk.Label(grid, text="Velocity (kn):").grid(row=0, column=3, sticky=tk.W) - ttk.Spinbox( - grid, from_=0, to=2000, textvariable=self.tgt_vel_var, width=10 - ).grid(row=1, column=3, sticky=tk.W) - - # --- Column 4 --- + ttk.Spinbox(grid, from_=0, to=2000, textvariable=self.tgt_vel_var, width=10).grid(row=1, column=3, sticky=tk.W) ttk.Label(grid, text="Heading (°):").grid(row=0, column=4, sticky=tk.W) - ttk.Spinbox( - grid, from_=0, to=360, textvariable=self.tgt_hdg_var, width=10 - ).grid(row=1, column=4, sticky=tk.W) - - # --- Column 5 --- + ttk.Spinbox(grid, from_=0, to=360, textvariable=self.tgt_hdg_var, width=10).grid(row=1, column=4, sticky=tk.W) ttk.Label(grid, text="Altitude (ft):").grid(row=0, column=5, sticky=tk.W) - ttk.Spinbox( - grid, from_=0, to=80000, textvariable=self.tgt_alt_var, width=12 - ).grid(row=1, column=5, sticky=tk.W) + ttk.Spinbox(grid, from_=0, to=80000, textvariable=self.tgt_alt_var, width=12).grid(row=1, column=5, sticky=tk.W) - # --- Column 6 (Controls) --- controls_frame = ttk.Frame(grid) controls_frame.grid(row=1, column=6, sticky="nsew", padx=(20, 0)) - ttk.Checkbutton( - controls_frame, text="Active", variable=self.tgt_active_var - ).pack(side=tk.LEFT, anchor="w") - ttk.Checkbutton( - controls_frame, text="Traceable", variable=self.tgt_traceable_var - ).pack(side=tk.LEFT, anchor="w", padx=5) - ttk.Checkbutton( - controls_frame, text="Restart", variable=self.tgt_restart_var - ).pack(side=tk.LEFT, anchor="w", padx=5) + ttk.Checkbutton(controls_frame, text="Active", variable=self.tgt_active_var).pack(side=tk.LEFT, anchor="w") + ttk.Checkbutton(controls_frame, text="Traceable", variable=self.tgt_traceable_var).pack(side=tk.LEFT, anchor="w", padx=5) + ttk.Checkbutton(controls_frame, text="Restart", variable=self.tgt_restart_var).pack(side=tk.LEFT, anchor="w", padx=5) - send_button = ttk.Button( - controls_frame, text="Send Target", command=self._on_send_target - ) + send_button = ttk.Button(controls_frame, text="Send Target", command=self._on_send_target) send_button.pack(side=tk.LEFT, padx=(10, 0)) - # Small helper label to clarify that this debug button will include - # state qualifiers (/s /t /r) in the sent tgtset command. - ttk.Label( - controls_frame, - text="(debug: sends /s /t /r if set)", - foreground="#555555", - font=("Segoe UI", 8), - ).pack(side=tk.LEFT, padx=(6, 0)) + ttk.Label(controls_frame, text="(debug: sends /s /t /r if set)", foreground="#555555", font=("Segoe UI", 8)).pack(side=tk.LEFT, padx=(6, 0)) - # --- Quick command buttons (moved here from connection frame) --- quick_cmd_frame = ttk.Frame(parent) quick_cmd_frame.pack(fill=tk.X, pady=(6, 0)) - ttk.Button( - quick_cmd_frame, - text="tgtreset", - command=lambda: self._on_send_simple_command( - command_builder.build_tgtreset() - ), - ).pack(side=tk.LEFT, padx=4) - ttk.Button( - quick_cmd_frame, - text="pause", - command=lambda: self._on_send_simple_command(command_builder.build_pause()), - ).pack(side=tk.LEFT, padx=4) - ttk.Button( - quick_cmd_frame, - text="continue", - command=lambda: self._on_send_simple_command( - command_builder.build_continue() - ), - ).pack(side=tk.LEFT, padx=4) - ttk.Button( - quick_cmd_frame, text="tgtset (cur)", command=lambda: self._on_send_tgtset() - ).pack(side=tk.LEFT, padx=8) - # PPI toggle button (moved here) - self.ppi_toggle_btn = ttk.Button( - quick_cmd_frame, text="Show PPI Map", command=self._toggle_ppi - ) + ttk.Button(quick_cmd_frame, text="tgtreset", command=lambda: self._on_send_simple_command(command_builder.build_tgtreset())).pack(side=tk.LEFT, padx=4) + ttk.Button(quick_cmd_frame, text="pause", command=lambda: self._on_send_simple_command(command_builder.build_pause())).pack(side=tk.LEFT, padx=4) + ttk.Button(quick_cmd_frame, text="continue", command=lambda: self._on_send_simple_command(command_builder.build_continue())).pack(side=tk.LEFT, padx=4) + ttk.Button(quick_cmd_frame, text="tgtset (cur)", command=lambda: self._on_send_tgtset()).pack(side=tk.LEFT, padx=8) + self.ppi_toggle_btn = ttk.Button(quick_cmd_frame, text="Show PPI Map", command=self._toggle_ppi) self.ppi_toggle_btn.pack(side=tk.RIGHT, padx=4) def _create_script_sender_widgets(self, parent): ttk.Label(parent, text="Script to send:").pack(side=tk.LEFT, padx=(5, 2)) - ttk.Entry(parent, textvariable=self.script_var, width=60).pack( - side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 5) - ) - self.send_script_btn = ttk.Button( - parent, text="Send script", command=self._on_send_script - ) + ttk.Entry(parent, textvariable=self.script_var, width=60).pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 5)) + self.send_script_btn = ttk.Button(parent, text="Send script", command=self._on_send_script) self.send_script_btn.pack(side=tk.LEFT, padx=5) def _create_notebook_tabs(self): - # The implementation of tab creation is unchanged, so it's omitted for brevity - # but would be here in the full file. - self.log_tab = scrolledtext.ScrolledText( - self.notebook, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 9) - ) + 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") - # ... (rest of the tab creation code is identical) ... - # --- (The rest of the file content remains the same as your provided version) --- if _IMAGE_LIBS_AVAILABLE: self.mfd_tab = self._create_image_tab("MFD Image") self.notebook.add(self.mfd_tab["frame"], text="MFD Image") @@ -380,9 +247,7 @@ class SfpDebugWindow(tk.Toplevel): paned = ttk.Panedwindow(ris_frame, orient=tk.HORIZONTAL) paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) left = ttk.Frame(paned) - self.scenario_tree = ttk.Treeview( - left, columns=("field", "value"), show="headings", height=12 - ) + 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") @@ -390,14 +255,11 @@ class SfpDebugWindow(tk.Toplevel): self.scenario_tree.pack(fill=tk.BOTH, expand=True) paned.add(left, weight=1) right = ttk.Frame(paned) - # Containers: one for the numeric table, one for the PPI (swapable) self.ris_table_container = ttk.Frame(right) self.ris_ppi_container = ttk.Frame(right) cols = ("idx", "flags", "heading", "x", "y", "z") - self.ris_tree = ttk.Treeview( - self.ris_table_container, columns=cols, show="headings", height=12 - ) + self.ris_tree = ttk.Treeview(self.ris_table_container, 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") @@ -410,12 +272,9 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass self.ris_tree.pack(fill=tk.BOTH, expand=True) - # Initially show table container, keep ppi container ready (hidden) self.ris_table_container.pack(fill=tk.BOTH, expand=True) paned.add(right, weight=2) - # Create PPI widget inside ris_ppi_container but keep it hidden try: - # Determine trail length from the configuration manager if available gm = getattr(self.master, "config_manager", None) trail_len = None try: @@ -433,32 +292,17 @@ class SfpDebugWindow(tk.Toplevel): 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) + 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) self.simplified_decimals = tk.IntVar(value=4) ttk.Label(mode_frame, text=" Decimals:").pack(side=tk.LEFT, padx=(8, 2)) try: - sp = tk.Spinbox( - mode_frame, - from_=0, - to=8, - width=3, - textvariable=self.simplified_decimals, - ) + sp = tk.Spinbox(mode_frame, from_=0, to=8, width=3, textvariable=self.simplified_decimals) sp.pack(side=tk.LEFT) except Exception: 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 = 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_frame = ttk.Frame(self.notebook) @@ -472,9 +316,7 @@ class SfpDebugWindow(tk.Toplevel): list_container = ttk.Frame(history_frame) list_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=(2, 4)) columns = ("ts", "flow", "tid", "size") - self.history_tree = ttk.Treeview( - list_container, columns=columns, show="headings", height=20 - ) + 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") @@ -490,99 +332,40 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass self.history_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - self.history_vscroll = ttk.Scrollbar( - list_container, orient=tk.VERTICAL, command=self.history_tree.yview - ) + 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)) - self.history_settings_btn = ttk.Button( - hb_frame, - text="Settings", - command=lambda: self._open_history_settings_dialog(), - ) + 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 = ttk.Button(hb_frame, text="Clear", command=lambda: self._on_clear_history()) self.history_clear_btn.pack(side=tk.RIGHT) - 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 - ) + 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: self.notebook.insert(1, raw_frame, text="SFP Raw") except Exception: self.notebook.add(raw_frame, text="SFP Raw") 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("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.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.json_tab = scrolledtext.ScrolledText(self.notebook, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 10)) self.notebook.add(self.json_tab, text="JSON") def _on_send_target(self): - """Callback to build and send a debug 'tgtset' command for a simple target. - - Notes: - - This debug sender uses `tgtset` with qualifiers (/s, /t, /r) when the - corresponding checkboxes are set in the UI. The runtime simulation - should continue to use `tgtset` without qualifiers for continuous - updates; qualifiers are only included here for manual debugging. - """ - - # 1. Collect data from UI - ip = self.ip_var.get() - port = int(self.server_port_var.get()) - destination = (ip, port) - - target_id = self.tgt_id_var.get() - range_nm = self.tgt_range_var.get() - az_deg = self.tgt_az_var.get() - alt_ft = self.tgt_alt_var.get() - vel_kn = self.tgt_vel_var.get() - hdg_deg = self.tgt_hdg_var.get() - is_active = self.tgt_active_var.get() - is_traceable = self.tgt_traceable_var.get() - is_restart = self.tgt_restart_var.get() - self._log_to_widget( - f"DEBUG: is_active={is_active}, is_traceable={is_traceable}, is_restart={is_restart}", - "DEBUG", - ) - - if not self.sfp_transport or not self.sfp_transport._socket: + if not self.shared_communicator or not self.shared_communicator.is_open: self._log_to_widget("ERROR: Cannot send target, not connected.", "ERROR") - messagebox.showerror( - "Connection Error", - "SFP transport is not connected. Please connect first.", - parent=self, - ) + messagebox.showerror("Connection Error", "Communicator is not connected.", parent=self) return try: - # 1. Collect data from UI - ip = self.ip_var.get() - port = int(self.server_port_var.get()) - destination = (ip, port) - target_id = self.tgt_id_var.get() range_nm = self.tgt_range_var.get() az_deg = self.tgt_az_var.get() @@ -595,7 +378,6 @@ class SfpDebugWindow(tk.Toplevel): vel_fps = vel_kn * KNOTS_TO_FPS - # 2. Create a temporary Target object to feed the command builder initial_waypoint = Waypoint( maneuver_type=ManeuverType.FLY_TO_POINT, target_range_nm=range_nm, @@ -604,11 +386,7 @@ class SfpDebugWindow(tk.Toplevel): target_velocity_fps=vel_fps, target_heading_deg=hdg_deg, ) - temp_target = Target( - target_id=target_id, - trajectory=[initial_waypoint], - ) - # Imposta le proprietà dopo reset_simulation + temp_target = Target(target_id=target_id, trajectory=[initial_waypoint]) temp_target.active = is_active temp_target.traceable = is_traceable temp_target.restart = is_restart @@ -618,31 +396,18 @@ class SfpDebugWindow(tk.Toplevel): temp_target.current_heading_deg = hdg_deg temp_target.current_altitude_ft = alt_ft - # 3. Build the command string: use tgtset but INCLUDE qualifiers - # so that the debug window sends the flags (/s, /t, /r) while the - # simulation runtime continues to use tgtset without flags. - command_str = command_builder.build_tgtset_from_target_state( - temp_target, include_flags=True - ) - # Ensure the command is trimmed, prefixed with '$' and terminated with a newline + command_str = command_builder.build_tgtset_from_target_state(temp_target, include_flags=True) command_str = command_str.strip() - # if not command_str.startswith("$"): - # command_str = "$" + command_str.lstrip() if not command_str.endswith("\n"): - command_str = command_str + "\n" + command_str += "\n" self._log_to_widget(f"Built command: {command_str!r}", "INFO") - # 4. Send using the transport layer - success = self.sfp_transport.send_script_command(command_str, destination) + success = self.shared_communicator._send_single_command(command_str) if success: - self._log_to_widget( - f"Successfully sent command for target {target_id}.", "INFO" - ) + self._log_to_widget(f"Successfully sent command for target {target_id}.", "INFO") else: - self._log_to_widget( - f"Failed to send command for target {target_id}.", "ERROR" - ) + self._log_to_widget(f"Failed to send command for target {target_id}.", "ERROR") except (ValueError, tk.TclError) as e: error_msg = f"Invalid input value: {e}" @@ -653,24 +418,14 @@ class SfpDebugWindow(tk.Toplevel): self._log_to_widget(f"ERROR: {e}", "CRITICAL") messagebox.showerror("Unexpected Error", str(e), parent=self) - # --- (The rest of the file content remains the same) --- def _create_image_tab(self, title: str) -> Dict: frame = ttk.Frame(self.notebook) - image_container = ttk.Frame( - frame, - width=self.image_area_size, - height=self.image_area_size, - relief=tk.SUNKEN, - ) + 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 = 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 = 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, @@ -719,42 +474,28 @@ class SfpDebugWindow(tk.Toplevel): 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="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.local_port_var.get()) - except ValueError: - self._log_to_widget("ERROR: Invalid port number.", "ERROR") + def _on_toggle_connect(self): + """Toggles the connection state of the shared communicator.""" + if not self.shared_communicator: + self._log_to_widget("ERROR: No shared communicator available.", "ERROR") + messagebox.showerror("Error", "No shared communicator available.", parent=self) 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) + + if self.shared_communicator.is_open: + self.shared_communicator.disconnect() else: - self._log_to_widget("Connection failed. Check IP/Port and logs.", "ERROR") - self.sfp_transport = None - try: - self.history_tree.bind( - "<>", lambda e: self._on_history_select() - ) - except Exception: - pass + try: + ip = self.ip_var.get() + server_port = int(self.server_port_var.get()) + local_port = int(self.local_port_var.get()) + config = {"ip": ip, "port": server_port, "local_port": local_port} + self.shared_communicator.connect(config) + except (ValueError, tk.TclError) as e: + self._log_to_widget(f"ERROR: Invalid connection settings: {e}", "ERROR") + messagebox.showerror("Input Error", f"Invalid connection settings: {e}", parent=self) def _on_send_probe(self): ip = self.ip_var.get() @@ -774,53 +515,23 @@ class SfpDebugWindow(tk.Toplevel): self._log_to_widget(f"Failed to send probe to {ip}:{port}: {e}", "ERROR") def _on_send_script(self): - if not self.sfp_transport or not self.sfp_transport._socket: + if not self.shared_communicator or not self.shared_communicator.is_open: self._log_to_widget("ERROR: Cannot send script, not connected.", "ERROR") return try: - ip = self.ip_var.get() - port = int(self.server_port_var.get()) - destination = (ip, port) command_str = self.script_var.get() - command_str = command_str.strip() - # if command_str and not command_str.startswith("$"): - # command_str = "$" + command_str.lstrip() - if command_str and not command_str.endswith("\n"): - command_str = command_str + "\n" - self.sfp_transport.send_script_command(command_str, destination) + self.shared_communicator._send_single_command(command_str) except (ValueError, tk.TclError) as e: - self._log_to_widget( - f"ERROR: Invalid input for script sending: {e}", "ERROR" - ) + self._log_to_widget(f"ERROR: Invalid input for script sending: {e}", "ERROR") def _on_send_simple_command(self, command_str: str): - """Send a simple script command string to the configured server port. - - Validates transport/socket and logs the result. - """ - if not self.sfp_transport or not getattr(self.sfp_transport, "_socket", None): + if not self.shared_communicator or not self.shared_communicator.is_open: self._log_to_widget("ERROR: Cannot send command, not connected.", "ERROR") - messagebox.showerror( - "Connection Error", - "SFP transport is not connected. Please connect first.", - parent=self, - ) + messagebox.showerror("Connection Error", "Communicator is not connected.", parent=self) return False try: - ip = self.ip_var.get() - port = int(self.server_port_var.get()) - destination = (ip, port) - command_str = (command_str or "").strip() - # Always prefix with '$' (no space after $) - # if not command_str.startswith("$"): - # command_str = "$" + command_str.lstrip() - if command_str and not command_str.endswith("\n"): - command_str = command_str + "\n" - self._log_to_widget( - f"Sending command to {destination}: {command_str!r}", "INFO" - ) - success = self.sfp_transport.send_script_command(command_str, destination) + success = self.shared_communicator._send_single_command(command_str) if success: self._log_to_widget(f"Successfully sent command: {command_str}", "INFO") else: @@ -832,7 +543,6 @@ class SfpDebugWindow(tk.Toplevel): return False def _on_send_tgtset(self): - """Build and send a 'tgtset' command using current UI values.""" try: target_id = self.tgt_id_var.get() range_nm = self.tgt_range_var.get() @@ -863,26 +573,31 @@ class SfpDebugWindow(tk.Toplevel): self._log_to_widget(f"ERROR: Invalid input for tgtset: {e}", "ERROR") return False - 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() + if self.shared_communicator: + self.shared_communicator.remove_connection_state_callback(self._update_toggle_state) + handlers = self.payload_router.get_handlers() + self.shared_communicator.remove_payload_handlers(handlers) self.destroy() + def _update_toggle_state(self, connected: bool): + """Update the toggle button label and internal UI state to reflect connection.""" + try: + if connected: + self.connect_toggle_btn.config(text="Disconnect") + else: + self.connect_toggle_btn.config(text="Connect") + except Exception: + pass + + def update_toggle_state(self, connected: bool): + self._update_toggle_state(connected) + def _process_latest_payloads(self): new_payloads = self.payload_router.get_and_clear_latest_payloads() if new_payloads: - self._log_to_widget( - f"Processing {len(new_payloads)} new payload(s) for flows: {list(new_payloads.keys())}" - ) + 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") @@ -895,101 +610,48 @@ class SfpDebugWindow(tk.Toplevel): elif flow_id == "RIS_STATUS_JSON": try: import json - - struct = ( - json.loads(payload.decode("utf-8")) - if isinstance(payload, (bytes, bytearray)) - else payload - ) + struct = json.loads(payload.decode("utf-8")) if isinstance(payload, (bytes, bytearray)) else payload for iid in self.scenario_tree.get_children(): self.scenario_tree.delete(iid) - scenario = ( - struct.get("scenario", {}) - if isinstance(struct, dict) - else {} - ) + scenario = struct.get("scenario", {}) 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 + def to_deg(v): return float(v) * (180.0 / math.pi) if isinstance(v, (int, float)) else v + def m_s_to_ft_s(v): return float(v) * 3.280839895 if isinstance(v, (int, float)) else v + def m_to_ft(v): return float(v) * 3.280839895 if isinstance(v, (int, float)) else 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", "°"), + ("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 - ) + 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}" + if key_name in ("latitude", "longitude"): return f"{fv:.8f}" return f"{fv:.6f}" - except Exception: - return str(v) + 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) + return f"{fv:.{decimals}f} {unit_str}" if unit_str else f"{fv:.{decimals}f}" + except Exception: return str(v) def try_float(v): - try: - return float(v) - except Exception: - return None + try: return float(v) + except Exception: return None def decimal_deg_to_dms(deg, is_lat=True): - try: - d = float(deg) - except Exception: - return str(deg) - if is_lat: - direction = "N" if d >= 0 else "S" - else: - direction = "E" if d >= 0 else "W" + try: d = float(deg) + except Exception: return str(deg) + direction = ("N" if d >= 0 else "S") if is_lat else ("E" if d >= 0 else "W") ad = abs(d) degrees = int(ad) minutes_full = (ad - degrees) * 60 @@ -1002,409 +664,163 @@ class SfpDebugWindow(tk.Toplevel): 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) + 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) + try: display_val = f"{int(val)} (0x{int(val):X})" + except Exception: display_val = str(val) + else: display_val = str(val) else: if key in ("platform_azimuth", "true_heading"): fv = try_float(val) - if fv is not None: - conv = to_deg(fv) - display_val = fmt_simplified_number( - conv, "°", dec_simp - ) - else: - display_val = str(val) + if fv is not None: display_val = fmt_simplified_number(to_deg(fv), "°", dec_simp) + else: display_val = str(val) elif key in ("ant_nav_az", "ant_nav_el"): fv = try_float(val) - if fv is not None: - conv = to_deg(fv) - display_val = fmt_simplified_number( - conv, "°", dec_simp - ) - else: - display_val = str(val) + if fv is not None: display_val = fmt_simplified_number(to_deg(fv), "°", dec_simp) + else: display_val = str(val) elif key in ("vx", "vy", "vz"): fv = try_float(val) - if fv is not None: - conv = m_s_to_ft_s(fv) - display_val = fmt_simplified_number( - conv, "ft/s", dec_simp - ) - else: - display_val = str(val) + if fv is not None: display_val = fmt_simplified_number(m_s_to_ft_s(fv), "ft/s", dec_simp) + else: display_val = str(val) elif key == "baro_altitude": fv = try_float(val) - if fv is not None: - conv = m_to_ft(fv) - display_val = fmt_simplified_number( - conv, "ft", dec_simp - ) - else: - display_val = str(val) + if fv is not None: display_val = fmt_simplified_number(m_to_ft(fv), "ft", dec_simp) + else: display_val = str(val) elif key in ("latitude", "longitude"): fv = try_float(val) - if fv is not None: - is_lat = key == "latitude" - display_val = decimal_deg_to_dms( - fv, is_lat=is_lat - ) - else: - display_val = str(val) + if fv is not None: display_val = decimal_deg_to_dms(fv, is_lat=(key == "latitude")) + else: display_val = str(val) elif key == "flags": - try: - display_val = ( - f"{int(val)} (0x{int(val):X})" - ) - except Exception: - display_val = str(val) + try: display_val = f"{int(val)} (0x{int(val):X})" + except Exception: display_val = str(val) elif key == "mode": try: midx = int(val) - if ( - 0 - <= midx - < len(self._master_mode_names) - ): + if 0 <= midx < len(self._master_mode_names): name = self._master_mode_names[midx] - short = name - for suffix in ( - "_master_mode", - "_mode", - "_master_mode_", - "_", - ): - if short.endswith(suffix): - short = short[ - : -len(suffix) - ] - short = short.replace( - "master", "" - ).strip("_") + short = name.replace("_master_mode", "").replace("_mode", "").replace("master", "").strip("_") display_val = short - else: - display_val = str(midx) - except Exception: - display_val = str(val) - else: - display_val = str(val) + else: display_val = str(midx) + except Exception: display_val = str(val) + else: display_val = str(val) scenario_rows.append((label, display_val)) for l, v in scenario_rows: - self.scenario_tree.insert( - "", tk.END, values=(f"{l}", v) - ) - for iid in self.ris_tree.get_children(): - self.ris_tree.delete(iid) - targets = ( - struct.get("targets", []) - if isinstance(struct, dict) - else [] - ) + self.scenario_tree.insert("", tk.END, values=(f"{l}", v)) + + for iid in self.ris_tree.get_children(): self.ris_tree.delete(iid) + targets = struct.get("targets", []) if isinstance(struct, dict) else [] try: - view_mode = ( - self.scenario_view_mode.get() - if hasattr(self, "scenario_view_mode") - else "simplified" - ) + view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" 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 - ) + 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: try: - idx = t.get("index") - if idx is None: - idx = t.get("idx") - if idx is None: - idx = t.get("#") + idx = t.get("index", t.get("idx", t.get("#"))) raw_flags = t.get("flags", t.get("flag", 0)) try: flags_val = int(raw_flags) flags_display = f"{flags_val} (0x{flags_val:X})" - except Exception: - flags_display = str(raw_flags) + except Exception: flags_display = str(raw_flags) if view_mode == "raw": hfv = try_float(t.get("heading")) - heading_val = ( - f"{hfv:.6f}" - if hfv is not None - else str(t.get("heading")) - ) - xfv = try_float(t.get("x")) - yfv = try_float(t.get("y")) - zfv = try_float(t.get("z")) - x_val = ( - f"{xfv:.6f}" if xfv is not None else t.get("x") - ) - y_val = ( - f"{yfv:.6f}" if yfv is not None else t.get("y") - ) - z_val = ( - f"{zfv:.6f}" if zfv is not None else t.get("z") - ) - vals = ( - idx, - flags_display, - heading_val, - x_val, - y_val, - z_val, - ) + heading_val = f"{hfv:.6f}" if hfv is not None else str(t.get("heading")) + xfv, yfv, zfv = try_float(t.get("x")), try_float(t.get("y")), try_float(t.get("z")) + x_val = f"{xfv:.6f}" if xfv is not None else t.get("x") + y_val = f"{yfv:.6f}" if yfv is not None else t.get("y") + z_val = f"{zfv:.6f}" if zfv is not None else t.get("z") + vals = (idx, flags_display, heading_val, x_val, y_val, z_val) else: hfv = try_float(t.get("heading")) - if hfv is not None: - heading_deg = hfv * (180.0 / 3.141592653589793) - heading_val = f"{heading_deg:.{dec_simp}f} °" - else: - heading_val = str(t.get("heading")) - xfv = try_float(t.get("x")) - yfv = try_float(t.get("y")) - zfv = try_float(t.get("z")) - x_val = ( - f"{xfv:.{dec_simp}f} m" - if xfv is not None - else str(t.get("x")) - ) - y_val = ( - f"{yfv:.{dec_simp}f} m" - if yfv is not None - else str(t.get("y")) - ) - if zfv is not None: - z_val = f"{(zfv * 3.280839895):.{dec_simp}f} ft" - else: - z_val = str(t.get("z")) - vals = ( - idx, - flags_display, - heading_val, - x_val, - y_val, - z_val, - ) - if ( - not isinstance(vals, (list, tuple)) - or len(vals) != 6 - ): + heading_val = f"{(hfv * 180.0 / math.pi):.{dec_simp}f} °" if hfv is not None else str(t.get("heading")) + xfv, yfv, zfv = try_float(t.get("x")), try_float(t.get("y")), try_float(t.get("z")) + x_val = f"{xfv:.{dec_simp}f} m" if xfv is not None else str(t.get("x")) + y_val = f"{yfv:.{dec_simp}f} m" if yfv is not None else str(t.get("y")) + z_val = f"{(zfv * 3.280839895):.{dec_simp}f} ft" if zfv is not None else str(t.get("z")) + vals = (idx, flags_display, heading_val, x_val, y_val, z_val) + if not isinstance(vals, (list, tuple)) or len(vals) != 6: vals = (idx, flags_display, "", "", "", "") self.ris_tree.insert("", tk.END, values=vals) except Exception as _e: - try: - self.ris_tree.insert( - "", - tk.END, - values=( - None, - None, - str(t.get("heading")), - str(t.get("x")), - str(t.get("y")), - str(t.get("z")), - ), - ) - except Exception: - pass - # --- Update PPI with active targets that fit the current PPI scale/sector --- + try: self.ris_tree.insert("", tk.END, values=(None, None, str(t.get("heading")), str(t.get("x")), str(t.get("y")), str(t.get("z")))) + except Exception: pass try: - # Only proceed if we have a PPI widget available ppi = getattr(self, "ris_ppi_widget", None) if ppi is not None: ppi_targets = [] METERS_PER_NM = 1852.0 - # Determine current display range from the PPI widget - current_range = ( - ppi.range_var.get() - if hasattr(ppi, "range_var") - else ppi.max_range - ) - # Debug: show parsed target count and sample raw values - try: - # self._log_to_widget( - # f"PPI: parsed {len(targets)} JSON target(s); current_range={current_range}", - # "DEBUG", - # ) - for i, tt in enumerate(targets[:6]): - try: - fx = tt.get("flags", None) - tx = tt.get("x", None) - ty = tt.get("y", None) - tz = tt.get("z", None) - # compute derived quantities - try: - xm = float(tx) - ym = float(ty) - zm = float(tz) - rng_m = (xm * xm + ym * ym) ** 0.5 - rng_nm = rng_m / 1852.0 - # Use atan2(y, x) for conventional azimuth (x East, y North) - az_deg = math.degrees( - math.atan2(ym, xm) - ) - # elevation from z and slant range - elev_rad = ( - math.atan2(zm, rng_m) - if rng_m > 0 - else 0.0 - ) - elev_deg = math.degrees(elev_rad) - except Exception: - rng_m = rng_nm = az_deg = elev_deg = ( - None - ) - # msg = ( - # f"PPI RAW[{i}]: flags={fx} x={tx} y={ty} z={tz} | range_m={rng_m:.1f} range_nm={rng_nm:.2f} az={az_deg:.2f}° elev={elev_deg:.2f}°" - # if rng_m is not None - # else f"PPI RAW[{i}]: flags={fx} x={tx} y={ty} z={tz}" - # ) - # self._log_to_widget(msg, "DEBUG") - except Exception: - pass - except Exception: - pass + current_range = ppi.range_var.get() if hasattr(ppi, "range_var") else ppi.max_range for t in targets: - raw_flags = t.get("flags", 0) - # Only show enabled/active targets (non-zero flags) - if int(raw_flags) == 0: - continue - x = float(t.get("x", 0.0)) - y = float(t.get("y", 0.0)) - z = float(t.get("z", 0.0)) - # Compute range in NM assuming x/y are meters + if int(t.get("flags", 0)) == 0: continue + x, y, z = float(t.get("x", 0.0)), float(t.get("y", 0.0)), float(t.get("z", 0.0)) range_nm = ((x**2 + y**2) ** 0.5) / METERS_PER_NM - if range_nm > current_range: - continue - # Compute azimuth in degrees using atan2(y, x) + if range_nm > current_range: continue az_deg = math.degrees(math.atan2(y, x)) - # Heading in JSON is in radians -> convert to degrees - heading = t.get("heading", 0.0) - try: - hdg_deg = float(heading) * (180.0 / math.pi) - except Exception: - hdg_deg = 0.0 + try: hdg_deg = float(t.get("heading", 0.0)) * (180.0 / math.pi) + except Exception: hdg_deg = 0.0 - tgt = Target( - target_id=int(t.get("index", 0)), - trajectory=[], - active=True, - traceable=True, - ) + tgt = Target(target_id=int(t.get("index", 0)), trajectory=[], active=True, traceable=True) tgt.current_range_nm = range_nm tgt.current_azimuth_deg = az_deg tgt.current_heading_deg = hdg_deg - # convert altitude from meters to feet - try: - tgt.current_altitude_ft = float(z) * 3.280839895 - except Exception: - tgt.current_altitude_ft = 0.0 + try: tgt.current_altitude_ft = float(z) * 3.280839895 + except Exception: tgt.current_altitude_ft = 0.0 tgt.active = True ppi_targets.append(tgt) - # Push to PPI (log debug info) try: - self._log_to_widget( - f"PPI: prepared {len(ppi_targets)} target(s) for display", - "DEBUG", - ) + self._log_to_widget(f"PPI: prepared {len(ppi_targets)} target(s) for display", "DEBUG") if ppi_targets: for pt in ppi_targets[:5]: - self._log_to_widget( - f"PPI target sample: id={getattr(pt, 'target_id', None)} r_nm={getattr(pt,'current_range_nm',None):.2f} az={getattr(pt,'current_azimuth_deg',None):.2f} hdg={getattr(pt,'current_heading_deg',None):.2f}", - "DEBUG", - ) - # Mark these as server-sent 'real' targets so - # PPIDisplay will draw them using the 'real' - # styling (red). Use the dict form expected - # by PPIDisplay to distinguish origins. + self._log_to_widget(f"PPI target sample: id={getattr(pt, 'target_id', None)} r_nm={getattr(pt,'current_range_nm',None):.2f} az={getattr(pt,'current_azimuth_deg',None):.2f} hdg={getattr(pt,'current_heading_deg',None):.2f}", "DEBUG") self.update_ppi_targets({"real": ppi_targets}) - except Exception: - self.logger.exception( - "Failed to push targets to PPI" - ) - except Exception: - self.logger.exception( - "Error while preparing RIS targets for PPI" - ) - except Exception: - pass + except Exception: self.logger.exception("Failed to push targets to PPI") + except Exception: self.logger.exception("Error while preparing RIS targets for PPI") + except Exception: pass self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) 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) - try: - self._refresh_history_tree() - except Exception: - pass + try: self._refresh_history_tree() + except Exception: pass def _refresh_history_tree(self): try: hist = self.payload_router.get_history() - for iid in self.history_tree.get_children(): - self.history_tree.delete(iid) + for iid in self.history_tree.get_children(): self.history_tree.delete(iid) 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 + 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 + if not sel: return iid = sel[0] children = list(self.history_tree.get_children()) - try: - idx = children.index(iid) - except ValueError: - idx = None + 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 + 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 + except Exception: pass def _on_clear_history(self): try: self.payload_router.clear_history() self._refresh_history_tree() - except Exception: - pass + except Exception: pass def _open_history_settings_dialog(self): dlg = tk.Toplevel(self) @@ -1414,40 +830,26 @@ class SfpDebugWindow(tk.Toplevel): try: hist_size = self.payload_router._history_size persist = self.payload_router._persist - except Exception: - hist_size = 20 - persist = False + except Exception: hist_size, persist = 20, 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)) + 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 + try: v = int(size_var.get()) + except Exception: + try: messagebox.showerror("Invalid value", "Please enter a positive integer for history size.", parent=dlg) + except Exception: pass return try: self.payload_router.set_history_size(v) self.payload_router.set_persist(bool(persist_var.get())) - except Exception: - pass + except Exception: pass try: gm = getattr(self.master, "config_manager", None) if gm: @@ -1457,106 +859,52 @@ class SfpDebugWindow(tk.Toplevel): sfp_debug["persist_raw"] = bool(persist_var.get()) general["sfp_debug"] = sfp_debug gm.save_general_settings(general) - except Exception: - pass + except Exception: pass dlg.destroy() - def on_cancel(): - 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="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): try: header_size = SFPHeader.size() - if len(raw_bytes) < header_size: - raise ValueError("Packet smaller than SFP header") + 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:] 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", + "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", ] pairs = [] flag_val = None for f in field_list: - try: - val = getattr(header, f) - except Exception: - val = "" + try: val = getattr(header, f) + except Exception: val = "" if f == "SFP_FLAGS": flag_val = val - pairs.append( - (f, f"{val} (0x{val:X})" if isinstance(val, int) else str(val)) - ) + 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))) + if isinstance(val, int): pairs.append((f, f"{val} (0x{val:X})")) + else: pairs.append((f, str(val))) out_lines = [f"From {addr}\n\nSFP Header:\n\n"] col_width = 36 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]}" - line = f"{left_text:<{col_width}} {right_text}" - else: - line = left_text + if right: line = f"{left_text:<{col_width}} {right[0]:12s}: {right[1]}" + else: line = left_text out_lines.append(line + "\n") - flag_defs = [ - (0, "ACQ_REQ"), - (1, "RESENT"), - (2, "TRAILER_ACK"), - (3, "RESV3"), - (4, "RESV4"), - (5, "RESV5"), - (6, "RESV6"), - (7, "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}) ") - for bit, name in flag_defs: - is_set = bool((flag_val >> bit) & 1) - out_lines.append(f" [{name}]") + for bit, name in flag_defs: out_lines.append(f" [{(name if bool((flag_val >> bit) & 1) else ' ')}]") 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") + 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: out_lines.append(f" {name:12s}: {legend_map.get(name, '')}\n") out_lines.append("\nBODY (hex):\n") hex_dump = self._format_hex_dump(body) out_lines.append(hex_dump) @@ -1566,8 +914,7 @@ class SfpDebugWindow(tk.Toplevel): 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) - try: - self._display_hex_data(body, self.bin_tab) + try: self._display_hex_data(body, self.bin_tab) except Exception: self.bin_tab.config(state=tk.NORMAL) self.bin_tab.delete("1.0", tk.END) @@ -1581,83 +928,46 @@ class SfpDebugWindow(tk.Toplevel): 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 - ): + def _display_image_data(self, payload: bytearray, tab_widgets: Dict[str, Any], photo_attr: str): try: - if len(payload) < ctypes.sizeof(ImageLeaderData): - raise ValueError("Payload smaller than ImageLeaderData header.") + 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 + h, w, bpp, stride = leader.HEADER_DATA.DY, leader.HEADER_DATA.DX, leader.HEADER_DATA.BPP, 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: {w}x{h}, bpp={bpp}, stride={stride}" - ) + if not (h > 0 and w > 0 and bpp in [1, 2] and stride >= w): raise ValueError(f"Invalid image dimensions: {w}x{h}, bpp={bpp}, stride={stride}") dtype = np.uint8 if bpp == 1 else np.uint16 expected_size = stride * h * bpp if (offset + expected_size) > len(payload): - 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}, got {len(payload) - offset}" - ) - pixel_data_view = np.ndarray( - shape=(h, stride), dtype=dtype, buffer=payload, offset=offset - ) + 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}, got {len(payload) - offset}") + pixel_data_view = np.ndarray(shape=(h, stride), dtype=dtype, buffer=payload, offset=offset) 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) - ) - resized = self._resize_pil_to_label( - img_pil, tab_widgets.get("image_container", tab_widgets["image_label"]) - ) + 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)) + resized = self._resize_pil_to_label(img_pil, tab_widgets.get("image_container", tab_widgets["image_label"])) 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}" - ) + tab_widgets["image_label"].config(image=None, text=f"Error parsing image:\n{e}") setattr(self, photo_attr, None) - try: - self._display_hex_data(payload, self.bin_tab) - except Exception: - try: - self._display_hex_data(payload, tab_widgets["hex_view"]) - except Exception: - pass + try: self._display_hex_data(payload, self.bin_tab) + except Exception: + 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": + def _resize_pil_to_label(self, img: "Image.Image", label_widget: ttk.Label) -> "Image.Image": try: width, height = label_widget.winfo_width(), label_widget.winfo_height() - if width <= 1 or height <= 1: - return img + if width <= 1 or height <= 1: return img img_w, img_h = img.size scale = min(width / img_w, height / img_h) - if scale >= 1.0: - return img + if scale >= 1.0: return img new_w, new_h = max(1, int(img_w * scale)), max(1, int(img_h * scale)) return img.resize((new_w, new_h), Image.LANCZOS) - except Exception: - return img + except Exception: return img def _display_hex_data(self, payload: bytearray, widget: scrolledtext.ScrolledText): hex_dump = self._format_hex_dump(payload) @@ -1669,7 +979,6 @@ class SfpDebugWindow(tk.Toplevel): 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 ---" @@ -1689,21 +998,10 @@ class SfpDebugWindow(tk.Toplevel): def _on_save_ris_csv(self): try: import csv - - scenario_rows = [ - self.scenario_tree.item(iid, "values") - for iid in self.scenario_tree.get_children() - ] - 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 - project_root = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..") - ) + scenario_rows = [self.scenario_tree.item(iid, "values") for iid in self.scenario_tree.get_children()] + 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 + 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") @@ -1713,12 +1011,10 @@ class SfpDebugWindow(tk.Toplevel): writer = csv.writer(f) if scenario_rows: writer.writerow(["Scenario Field", "Value"]) - for s in scenario_rows: - writer.writerow(s) + for s in scenario_rows: writer.writerow(s) writer.writerow([]) writer.writerow(["index", "flags", "heading", "x", "y", "z"]) - for r in rows: - writer.writerow(r) + 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") @@ -1730,4 +1026,4 @@ class SfpDebugWindow(tk.Toplevel): 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) + return "\n".join(lines) \ No newline at end of file diff --git a/target_simulator/utils/config_manager.py b/target_simulator/utils/config_manager.py index 49da573..98a9508 100644 --- a/target_simulator/utils/config_manager.py +++ b/target_simulator/utils/config_manager.py @@ -60,6 +60,18 @@ class ConfigManager: # Load scenarios from separate file if present, otherwise keep any scenarios # found inside settings.json (fallback). self._scenarios = self._load_or_initialize_scenarios() + # Apply debug overrides (if present) into the global DEBUG_CONFIG so + # runtime helpers (e.g., csv_logger) pick up user-configured values. + try: + from target_simulator.config import DEBUG_CONFIG + + debug_block = self._settings.get("debug", {}) if isinstance(self._settings, dict) else {} + if isinstance(debug_block, dict): + for k, v in debug_block.items(): + DEBUG_CONFIG[k] = v + except Exception: + # If anything goes wrong here, we don't want to fail initialization. + pass def _load_or_initialize_settings(self) -> Dict[str, Any]: """Loads settings from the JSON file or initializes with a default structure.""" diff --git a/target_simulator/utils/network.py b/target_simulator/utils/network.py index a6c26d9..d68f6cc 100644 --- a/target_simulator/utils/network.py +++ b/target_simulator/utils/network.py @@ -36,6 +36,9 @@ def create_udp_socket(local_ip, local_port): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) logging.debug(f"{log_prefix} Socket object created.") + # Set a short timeout to prevent the receive loop from blocking indefinitely + sock.settimeout(0.1) + # --- Receive Buffer Size Adjustment --- try: # Get the default buffer size (INFO level is okay for this setup detail)