diff --git a/SUM7056227 Rev. A.pdf b/SUM7056227 Rev. A.pdf new file mode 100644 index 0000000..e95cc7e Binary files /dev/null and b/SUM7056227 Rev. A.pdf differ diff --git a/target_simulator/core/command_builder.py b/target_simulator/core/command_builder.py index f0cc82c..92898d1 100644 --- a/target_simulator/core/command_builder.py +++ b/target_simulator/core/command_builder.py @@ -27,6 +27,8 @@ def build_tgtinit(target: Target) -> str: f"{target.current_altitude_ft:.2f}", ] qualifiers = ["/s" if target.active else "/-s", "/t" if target.traceable else "/-t"] + if hasattr(target, "restart") and getattr(target, "restart", False): + qualifiers.append("/r") command_parts = ["tgtinit"] + [str(p) for p in params] + qualifiers full_command = " ".join(command_parts) diff --git a/target_simulator/core/models.py b/target_simulator/core/models.py index e447bb5..6a88fc7 100644 --- a/target_simulator/core/models.py +++ b/target_simulator/core/models.py @@ -62,6 +62,7 @@ class Target: trajectory: List[Waypoint] = field(default_factory=list) active: bool = True traceable: bool = True + restart: bool = False use_spline: bool = False current_range_nm: float = field(init=False, default=0.0) current_azimuth_deg: float = field(init=False, default=0.0) diff --git a/target_simulator/core/sfp_transport.py b/target_simulator/core/sfp_transport.py index 473c595..3e0e111 100644 --- a/target_simulator/core/sfp_transport.py +++ b/target_simulator/core/sfp_transport.py @@ -1,13 +1,19 @@ -"""Clean SFP transport implementation with detailed logging. +# target_simulator/core/sfp_transport.py -Handles UDP receive loop, SFP header parsing, fragment reassembly, ACK -generation, and hands completed payloads to registered handlers. +""" +Provides a reusable transport layer for the Simple Fragmentation Protocol (SFP). + +This module handles UDP socket communication, SFP header parsing, fragment +reassembly, ACK generation, and payload sending. It is application-agnostic +and uses a callback/handler system to pass fully reassembled payloads to the +application layer based on the SFP_FLOW identifier. """ import socket import logging import threading import time +import ctypes from typing import Dict, Callable, Optional from target_simulator.utils.network import create_udp_socket, close_udp_socket @@ -15,14 +21,16 @@ from target_simulator.core.sfp_structures import SFPHeader PayloadHandler = Callable[[bytearray], None] -LOG_LEVEL: Optional[int] = logging.INFO logger = logging.getLogger(__name__) -if LOG_LEVEL is not None: - logger.setLevel(LOG_LEVEL) class SfpTransport: - """Manages SFP communication and payload reassembly.""" + """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__( self, @@ -44,6 +52,8 @@ class SfpTransport: 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 # transaction state: key=(flow, tid) -> {frag_index: total_frags} self._fragments: Dict[tuple, Dict[int, int]] = {} @@ -57,6 +67,7 @@ class SfpTransport: logger.debug(f"{self._log_prefix} ACK window config: {self._ack_config}") def start(self) -> bool: + """Starts the receiving thread.""" if self._receiver_thread is not None and self._receiver_thread.is_alive(): logger.warning( f"{self._log_prefix} Start called, but receiver is already running." @@ -78,6 +89,7 @@ class SfpTransport: return True def shutdown(self): + """Stops the receiving thread and closes the socket.""" self._stop_event.set() if self._socket: @@ -94,6 +106,120 @@ 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") + ) -> 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_ = [ + ("ID", ctypes.c_uint8 * 2), + ("VALID", ctypes.c_uint8), + ("VERSION", ctypes.c_uint8), + ("SIZE", ctypes.c_uint32), + ] + + class ScriptPayload(ctypes.Structure): + _pack_ = 1 + _fields_ = [ + ("ctrl_tag", LocalDataTag), + ("ctrl", ctypes.c_uint32 * 32), + ("script_tag", LocalDataTag), + ("script", ctypes.c_uint8 * self.MAX_SCRIPT_PAYLOAD_SIZE), + ] + 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( + f"{log_prefix} Command is too long ({len(command_bytes)} bytes). " + f"Max is {self.MAX_SCRIPT_PAYLOAD_SIZE}." + ) + 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") + payload_struct.script_tag.VALID = 1 + payload_struct.script_tag.VERSION = 1 + payload_struct.script_tag.SIZE = len(command_bytes) + 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 + header.SFP_TID = self._tid_counter + + header.SFP_DIRECTION = ord(">") + header.SFP_FLOW = flow_id + header.SFP_TOTFRGAS = 1 # Single fragment message + header.SFP_FRAG = 0 + header.SFP_PLSIZE = len(payload_bytes) + header.SFP_TOTSIZE = len(payload_bytes) + header.SFP_PLOFFSET = 0 + + 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') + except Exception: + sent_preview = repr(cs) + logger.info( + f"{log_prefix} Sent command to {destination} (TID: {header.SFP_TID}): {sent_preview!r}" + ) + return True + + except Exception as e: + logger.exception(f"{log_prefix} Failed to send script command.") + return False + def _receive_loop(self): log_prefix = f"{self._log_prefix} Loop" logger.info(f"{log_prefix} Starting receive loop.") @@ -107,18 +233,13 @@ class SfpTransport: data, addr = self._socket.recvfrom(65535) if not data: continue - try: - if self._raw_packet_callback: - self._raw_packet_callback(data, addr) - except Exception: - logger.exception( - f"{log_prefix} raw_packet_callback raised an exception" - ) + if self._raw_packet_callback: + self._raw_packet_callback(data, addr) except socket.timeout: continue - except OSError: + except OSError as e: if not self._stop_event.is_set(): - logger.error(f"{log_prefix} Socket error.", exc_info=True) + logger.error(f"{log_prefix} Socket error: {e}") break except Exception: logger.exception(f"{log_prefix} Unexpected error in recvfrom.") @@ -130,15 +251,11 @@ class SfpTransport: logger.info(f"{log_prefix} Receive loop terminated.") def _process_packet(self, data: bytes, addr: tuple): - """Parse SFP packet, log details, and reassemble fragments. - - Logging includes parsed header fields and a small payload preview. - The preview attempts JSON decode to detect text-based payloads; if - that fails the first bytes are logged in hex. - """ header_size = SFPHeader.size() if len(data) < header_size: - logger.warning(f"Packet from {addr} is too small for SFP header. Ignoring.") + logger.warning( + f"Packet from {addr} is too small for SFP header. Ignoring." + ) return try: @@ -147,7 +264,6 @@ class SfpTransport: logger.error(f"Failed to parse SFP header from {addr}. Ignoring.") return - # Extract header fields flow = header.SFP_FLOW tid = header.SFP_TID frag = header.SFP_FRAG @@ -157,41 +273,18 @@ class SfpTransport: total_size = header.SFP_TOTSIZE flags = header.SFP_FLAGS - try: - flow_name = chr(flow) if 32 <= flow < 127 else str(flow) - except Exception: - flow_name = str(flow) - - # Payload preview for logging - payload_preview = data[header_size : header_size + 256] - try: - import json - - json.loads(payload_preview.decode("utf-8", errors="strict")) - payload_preview_text = "JSON (preview)" - except Exception: - payload_preview_text = "Hex preview: " + " ".join( - f"{b:02X}" for b in payload_preview[:64] - ) - - logger.info( - f"{self._log_prefix} Packet from {addr} - flow={flow_name} ({flow}), tid={tid}, flags=0x{flags:X}, frag={frag}/{total_frags}, pl_size={pl_size}, pl_offset={pl_offset}, total_size={total_size}. {payload_preview_text}" - ) - key = (flow, tid) - # If sender requested an ACK bit in flags, reply if header.SFP_FLAGS & 0x01: self._send_ack(addr, data[:header_size]) - # Basic validation if total_frags == 0 or total_frags > 60000 or total_size <= 0: logger.warning( - f"Invalid metadata for {key}: total_frags={total_frags}, total_size={total_size}. Ignoring." + f"Invalid metadata for {key}: total_frags={total_frags}, " + f"total_size={total_size}. Ignoring." ) return - # Start a new transaction when frag==0 if frag == 0: self._cleanup_lingering_transactions(flow, tid) logger.debug( @@ -223,288 +316,9 @@ class SfpTransport: ) return - # Copy into buffer - self._buffers[key][pl_offset : pl_offset + bytes_to_copy] = payload[:bytes_to_copy] - - # If all fragments received, hand off to handler - 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}." - ) - else: - logger.warning(f"No payload handler registered for flow ID {flow}.") - - def _send_ack(self, dest_addr: tuple, original_header_bytes: bytes): - log_prefix = f"{self._log_prefix} ACK" - if not self._socket: - return - - try: - ack_header = bytearray(original_header_bytes) - flow = ack_header[SFPHeader.get_field_offset("SFP_FLOW")] - - window_size = self._ack_config.get(flow, 0) - - ack_header[SFPHeader.get_field_offset("SFP_DIRECTION")] = 0x3C # '<' - ack_header[SFPHeader.get_field_offset("SFP_WIN")] = window_size - original_flags = ack_header[SFPHeader.get_field_offset("SFP_FLAGS")] - ack_header[SFPHeader.get_field_offset("SFP_FLAGS")] = ( - original_flags | 0x01 - ) & ~0x02 - - self._socket.sendto(ack_header, dest_addr) - logger.debug( - f"{log_prefix} Sent to {dest_addr} for flow {chr(flow) if 32<=flow<=126 else flow} with WIN={window_size}." - ) - except Exception: - logger.exception(f"{log_prefix} Failed to send to {dest_addr}.") - - def _cleanup_lingering_transactions(self, current_flow: int, current_tid: int): - keys_to_remove = [ - key - for key in self._fragments - if key[0] == current_flow and key[1] != current_tid + self._buffers[key][pl_offset : pl_offset + bytes_to_copy] = payload[ + :bytes_to_copy ] - for key in keys_to_remove: - logger.warning( - f"Cleaning up lingering/incomplete transaction for key={key}." - ) - self._fragments.pop(key, None) - self._buffers.pop(key, None) -""" -Provides a reusable transport layer for the Simple Fragmentation Protocol (SFP). - -This module handles UDP socket communication, SFP header parsing, fragment -reassembly, and ACK generation. It is application-agnostic and uses a -callback/handler system to pass fully reassembled payloads to the -application layer based on the SFP_FLOW identifier. -""" - -""" -SFP transport layer (clean, single definition). -""" - -import socket -import logging -import threading -import time -from typing import Dict, Callable, Optional - -from target_simulator.utils.network import create_udp_socket, close_udp_socket -from target_simulator.core.sfp_structures import SFPHeader - -PayloadHandler = Callable[[bytearray], None] - -LOG_LEVEL: Optional[int] = logging.INFO -logger = logging.getLogger(__name__) -if LOG_LEVEL is not None: - logger.setLevel(LOG_LEVEL) - - -class SfpTransport: - """Manages SFP communication and payload reassembly.""" - - def __init__( - self, - host: str, - port: int, - payload_handlers: Dict[int, PayloadHandler], - ack_config: Optional[Dict[int, int]] = None, - raw_packet_callback: Optional[Callable[[bytes, tuple], None]] = None, - ): - self._log_prefix = "[SfpTransport]" - logger.info(f"{self._log_prefix} Initializing for {host}:{port}...") - - self._host = host - self._port = port - self._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._fragments: Dict[tuple, Dict[int, int]] = {} - self._buffers: Dict[tuple, bytearray] = {} - - 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 start(self) -> bool: - if self._receiver_thread is not None and self._receiver_thread.is_alive(): - logger.warning( - f"{self._log_prefix} Start called, but receiver is already running." - ) - return True - - self._socket = create_udp_socket(self._host, self._port) - if not self._socket: - logger.critical( - f"{self._log_prefix} Failed to create and bind socket. Cannot start." - ) - return False - - self._stop_event.clear() - self._receiver_thread = threading.Thread( - target=self._receive_loop, name="SfpTransportThread", daemon=True - ) - self._receiver_thread.start() - return True - - def shutdown(self): - self._stop_event.set() - - if self._socket: - close_udp_socket(self._socket) - self._socket = None - - if self._receiver_thread and self._receiver_thread.is_alive(): - logger.debug(f"{self._log_prefix} Waiting for receiver thread to join...") - self._receiver_thread.join(timeout=2.0) - if self._receiver_thread.is_alive(): - logger.warning( - f"{self._log_prefix} Receiver thread did not join cleanly." - ) - - logger.info(f"{self._log_prefix} Shutdown complete.") - - def _receive_loop(self): - log_prefix = f"{self._log_prefix} Loop" - logger.info(f"{log_prefix} Starting receive loop.") - - while not self._stop_event.is_set(): - if not self._socket: - logger.error(f"{log_prefix} Socket is not available. Stopping loop.") - break - - try: - data, addr = self._socket.recvfrom(65535) - if not data: - continue - try: - if self._raw_packet_callback: - self._raw_packet_callback(data, addr) - except Exception: - logger.exception( - f"{log_prefix} raw_packet_callback raised an exception" - ) - except socket.timeout: - continue - except OSError: - if not self._stop_event.is_set(): - logger.error(f"{log_prefix} Socket error.", exc_info=True) - break - except Exception: - logger.exception(f"{log_prefix} Unexpected error in recvfrom.") - time.sleep(0.01) - continue - - self._process_packet(data, addr) - - logger.info(f"{log_prefix} Receive loop terminated.") - - def _process_packet(self, data: bytes, addr: tuple): - header_size = SFPHeader.size() - if len(data) < header_size: - logger.warning(f"Packet from {addr} is too small for SFP header. Ignoring.") - return - - try: - header = SFPHeader.from_buffer_copy(data) - except (ValueError, TypeError): - logger.error(f"Failed to parse SFP header from {addr}. Ignoring.") - return - - # Extract fields - flow = header.SFP_FLOW - tid = header.SFP_TID - frag = header.SFP_FRAG - total_frags = header.SFP_TOTFRGAS - pl_size = header.SFP_PLSIZE - pl_offset = header.SFP_PLOFFSET - total_size = header.SFP_TOTSIZE - flags = header.SFP_FLAGS - - try: - flow_name = chr(flow) if 32 <= flow < 127 else str(flow) - except Exception: - flow_name = str(flow) - - # Preview payload for logging - payload_preview = data[header_size : header_size + 256] - try: - import json - - json.loads(payload_preview.decode("utf-8", errors="strict")) - payload_preview_text = "JSON (preview)" - except Exception: - payload_preview_text = "Hex preview: " + " ".join( - f"{b:02X}" for b in payload_preview[:64] - ) - - logger.debug( - f"{self._log_prefix} Packet from {addr} - flow={flow_name} ({flow}), tid={tid}, flags=0x{flags:X}, frag={frag}/{total_frags}, pl_size={pl_size}, pl_offset={pl_offset}, total_size={total_size}. {payload_preview_text}" - ) - - key = (flow, tid) - - if header.SFP_FLAGS & 0x01: - self._send_ack(addr, data[:header_size]) - - if total_frags == 0 or total_frags > 60000 or total_size <= 0: - logger.warning( - f"Invalid metadata for {key}: total_frags={total_frags}, total_size={total_size}. Ignoring." - ) - return - - 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) - except (MemoryError, ValueError): - logger.error( - f"Failed to allocate {total_size} bytes for key={key}. Ignoring transaction." - ) - self._fragments.pop(key, None) - return - - if key not in self._buffers or key not in self._fragments: - logger.debug( - f"Ignoring fragment {frag} for untracked transaction key={key}." - ) - return - - self._fragments[key][frag] = total_frags - payload = data[header_size:] - bytes_to_copy = min(pl_size, len(payload)) - - if (pl_offset + bytes_to_copy) > len(self._buffers[key]): - logger.error( - f"Payload for key={key}, frag={frag} would overflow buffer. Ignoring." - ) - return - - self._buffers[key][pl_offset : pl_offset + bytes_to_copy] = payload[:bytes_to_copy] if len(self._fragments[key]) == total_frags: logger.debug( @@ -533,10 +347,9 @@ class SfpTransport: try: ack_header = bytearray(original_header_bytes) flow = ack_header[SFPHeader.get_field_offset("SFP_FLOW")] - window_size = self._ack_config.get(flow, 0) - ack_header[SFPHeader.get_field_offset("SFP_DIRECTION")] = 0x3C # '<' + ack_header[SFPHeader.get_field_offset("SFP_DIRECTION")] = ord("<") ack_header[SFPHeader.get_field_offset("SFP_WIN")] = window_size original_flags = ack_header[SFPHeader.get_field_offset("SFP_FLAGS")] ack_header[SFPHeader.get_field_offset("SFP_FLAGS")] = ( @@ -544,18 +357,15 @@ class SfpTransport: ) & ~0x02 self._socket.sendto(ack_header, dest_addr) + flow_char = chr(flow) if 32 <= flow <= 126 else flow logger.debug( - f"{log_prefix} Sent to {dest_addr} for flow {chr(flow) if 32<=flow<=126 else flow} with WIN={window_size}." + f"{log_prefix} Sent to {dest_addr} for flow {flow_char} with WIN={window_size}." ) except Exception: logger.exception(f"{log_prefix} Failed to send to {dest_addr}.") def _cleanup_lingering_transactions(self, current_flow: int, current_tid: int): - """Remove old, incomplete transactions for the same flow but different TID. - - This prevents buffers from previous transactions (same flow) from - interfering with a newly started transaction. - """ + """Remove old, incomplete transactions for the same flow.""" keys_to_remove = [ key for key in list(self._fragments.keys()) @@ -566,4 +376,4 @@ class SfpTransport: f"Cleaning up lingering/incomplete transaction for key={key}." ) self._fragments.pop(key, None) - self._buffers.pop(key, None) + self._buffers.pop(key, None) \ No newline at end of file diff --git a/target_simulator/gui/sfp_debug_window.py b/target_simulator/gui/sfp_debug_window.py index 8735b9d..b6bec54 100644 --- a/target_simulator/gui/sfp_debug_window.py +++ b/target_simulator/gui/sfp_debug_window.py @@ -6,7 +6,7 @@ without overwhelming the GUI thread. """ import tkinter as tk -from tkinter import ttk, scrolledtext +from tkinter import ttk, scrolledtext, messagebox import logging import threading import collections @@ -28,46 +28,67 @@ except ImportError: _IMAGE_LIBS_AVAILABLE = False # Imports from the project structure -from target_simulator.core.sfp_transport import SfpTransport, PayloadHandler +from target_simulator.core.sfp_transport import SfpTransport from target_simulator.core.sfp_structures import ( ImageLeaderData, SFPHeader, SfpRisStatusPayload, ) from target_simulator.gui.payload_router import DebugPayloadRouter +from target_simulator.core.models import ( + Target, + Waypoint, + ManeuverType, + KNOTS_TO_FPS, +) +from target_simulator.core import command_builder + + +# default value for testing fdx protocolo +DEF_TEST_ID = 1 +DEF_TEST_RANGE = 30.0 +DEF_TEST_AZIMUTH = 10.0 +DEF_TEST_VELOCITY = 300.0 +DEF_TEST_HEADING = 0.0 +DEF_TEST_ALTITUDE = 10000.0 class SfpDebugWindow(tk.Toplevel): - """Top-level window for SFP debugging and payload inspection. - - This class was previously defining the DebugPayloadRouter inline; the - router implementation has been moved to `target_simulator.gui.payload_router` - to decouple routing logic from the Tk window and allow independent tests. - """ + """Top-level window for SFP debugging and payload inspection.""" GUI_POLL_INTERVAL_MS = 250 def __init__(self, master=None): super().__init__(master) self.master = master - # Make the debug window slightly larger by default try: self.geometry("1100x700") except Exception: pass self.logger = logging.getLogger(__name__) - # Router instance (buffers latest payloads per flow) self.payload_router = DebugPayloadRouter() - # Transport reference (set when connecting) - self.sfp_transport = None - # Image display defaults + self.sfp_transport: Optional[SfpTransport] = None self.image_area_size = 150 - # Connection fields - self.ip_var = tk.StringVar(value="127.0.0.1") - self.port_var = tk.StringVar(value="60002") - # Master mode names (from C++ enum ordering) — used to display readable mode - # NOTE: keep in sync with the C++ enum if it changes + # --- 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')") + + # Variables for the new target sender UI + self.tgt_id_var = tk.IntVar(value=DEF_TEST_ID) + self.tgt_range_var = tk.DoubleVar(value=DEF_TEST_RANGE) + self.tgt_az_var = tk.DoubleVar(value=DEF_TEST_AZIMUTH) + self.tgt_alt_var = tk.DoubleVar(value=DEF_TEST_ALTITUDE) + self.tgt_vel_var = tk.DoubleVar(value=DEF_TEST_VELOCITY) + self.tgt_hdg_var = tk.DoubleVar(value=DEF_TEST_HEADING) + self.tgt_active_var = tk.BooleanVar(value=True) + self.tgt_traceable_var = tk.BooleanVar(value=True) + self.tgt_restart_var = tk.BooleanVar(value=False) + self._master_mode_names = [ "idle_master_mode", "int_bit_master_mode", @@ -98,68 +119,163 @@ class SfpDebugWindow(tk.Toplevel): "master_mode_id_cardinality_", ] - # --- Connection Frame (IP / Port / Connect controls) --- - conn_frame = ttk.Frame(self) - conn_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2)) + # --- UI Construction --- + self._create_widgets() - ttk.Label(conn_frame, text="IP:").pack(side=tk.LEFT, padx=(4, 2)) - ttk.Entry(conn_frame, textvariable=self.ip_var, width=18).pack( - side=tk.LEFT, padx=(0, 6) - ) - ttk.Label(conn_frame, text="Port:").pack(side=tk.LEFT, padx=(0, 2)) - ttk.Entry(conn_frame, textvariable=self.port_var, width=8).pack( - side=tk.LEFT, padx=(0, 6) - ) - - self.connect_btn = ttk.Button(conn_frame, text="Connect", command=self._on_connect) - self.connect_btn.pack(side=tk.LEFT, padx=(0, 6)) - self.disconnect_btn = ttk.Button(conn_frame, text="Disconnect", command=self._on_disconnect) - self.disconnect_btn.pack(side=tk.LEFT, padx=(0, 6)) - # Start with disconnect disabled until connected + # Start the periodic GUI poll loop try: - self.disconnect_btn.config(state=tk.DISABLED) + self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) except Exception: pass - # Quick utility buttons - self.send_probe_btn = ttk.Button(conn_frame, text="Send probe", command=self._on_send_probe) + 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.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.Frame(top_controls_frame) + 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() + + 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.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.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 + ) self.send_probe_btn.pack(side=tk.LEFT, padx=(6, 4)) - self.send_ack_btn = ttk.Button(conn_frame, text="Send ACK", command=self._on_send_ack) - self.send_ack_btn.pack(side=tk.LEFT) + # Quick commands frame + quick_frame = ttk.Frame(parent) + quick_frame.pack(side=tk.RIGHT) + # Always prefix commands with '$' to satisfy server's mex parser + ttk.Button(quick_frame, text="tgtreset", command=lambda: self._on_send_simple_command(command_builder.build_tgtreset())).pack(side=tk.LEFT, padx=2) + ttk.Button(quick_frame, text="pause", command=lambda: self._on_send_simple_command(command_builder.build_pause())).pack(side=tk.LEFT, padx=2) + ttk.Button(quick_frame, text="continue", command=lambda: self._on_send_simple_command(command_builder.build_continue())).pack(side=tk.LEFT, padx=2) + ttk.Button(quick_frame, text="tgtset (cur)", command=lambda: self._on_send_tgtset()).pack(side=tk.LEFT, padx=6) - # Note: DebugPayloadRouter has been moved to `target_simulator.gui.payload_router`. + 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) - # --- Script Sender Frame (below connection) --- - script_frame = ttk.Frame(self) - script_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(0, 5)) - ttk.Label(script_frame, text="Script to send:").pack(side=tk.LEFT, padx=(5, 2)) - self.script_var = tk.StringVar(value="print('hello from client')") - ttk.Entry(script_frame, textvariable=self.script_var, width=60).pack( - side=tk.LEFT, padx=(0, 5) + # --- 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.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.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.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.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.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) + + # --- 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) + + send_button = ttk.Button( + controls_frame, text="Send Target", command=self._on_send_target + ) + send_button.pack(side=tk.LEFT, padx=(10, 0)) + + 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( - script_frame, text="Send script", command=self._on_send_script + parent, text="Send script", command=self._on_send_script ) self.send_script_btn.pack(side=tk.LEFT, padx=5) - # --- Data Display Notebook (unchanged) --- - self.notebook = ttk.Notebook(self) - self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + 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.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") self.sar_tab = self._create_image_tab("SAR Image") self.notebook.add(self.sar_tab["frame"], text="SAR Image") - # RIS status tab: two-column layout with scenario (left) and targets (right) ris_frame = ttk.Frame(self.notebook) paned = ttk.Panedwindow(ris_frame, orient=tk.HORIZONTAL) paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # Left: scenario table (field, value) left = ttk.Frame(paned) self.scenario_tree = ttk.Treeview(left, columns=("field", "value"), show="headings", height=12) self.scenario_tree.heading("field", text="Field") @@ -168,16 +284,12 @@ class SfpDebugWindow(tk.Toplevel): self.scenario_tree.column("value", width=160, anchor="w") self.scenario_tree.pack(fill=tk.BOTH, expand=True) paned.add(left, weight=1) - - # Right: compact targets table right = ttk.Frame(paned) cols = ("idx", "flags", "heading", "x", "y", "z") self.ris_tree = ttk.Treeview(right, columns=cols, show="headings", height=12) for c, txt in zip(cols, ("#", "flags", "heading", "x", "y", "z")): self.ris_tree.heading(c, text=txt) self.ris_tree.column(c, width=70, anchor="center") - - # Apply smaller font to make table compact try: style = ttk.Style() small_font = ("Consolas", 8) @@ -186,52 +298,37 @@ class SfpDebugWindow(tk.Toplevel): self.scenario_tree.configure(style="Small.Treeview") except Exception: pass - self.ris_tree.pack(fill=tk.BOTH, expand=True) paned.add(right, weight=2) - - # Save CSV button under the paned window btn_frame = ttk.Frame(ris_frame) btn_frame.pack(fill=tk.X, padx=5, pady=(0, 5)) - # View mode for scenario/targets: raw vs simplified self.scenario_view_mode = tk.StringVar(value="simplified") mode_frame = ttk.Frame(btn_frame) mode_frame.pack(side=tk.LEFT, padx=(4, 0)) ttk.Label(mode_frame, text="View:").pack(side=tk.LEFT, padx=(0, 6)) ttk.Radiobutton(mode_frame, text="Raw", value="raw", variable=self.scenario_view_mode).pack(side=tk.LEFT) ttk.Radiobutton(mode_frame, text="Simplified", value="simplified", variable=self.scenario_view_mode).pack(side=tk.LEFT) - - # Decimals control for simplified view (user-settable) self.simplified_decimals = tk.IntVar(value=4) ttk.Label(mode_frame, text=" Decimals:").pack(side=tk.LEFT, padx=(8, 2)) try: - # Use tk.Spinbox; ttk.Spinbox may not be available in all tkinter versions sp = tk.Spinbox(mode_frame, from_=0, to=8, width=3, textvariable=self.simplified_decimals) sp.pack(side=tk.LEFT) except Exception: - # fallback to an Entry if Spinbox is not supported e = ttk.Entry(mode_frame, textvariable=self.simplified_decimals, width=3) e.pack(side=tk.LEFT) - self.ris_save_csv_btn = ttk.Button(btn_frame, text="Save CSV", command=lambda: self._on_save_ris_csv()) self.ris_save_csv_btn.pack(side=tk.RIGHT) - self.notebook.add(ris_frame, text="RIS Status") - # Raw SFP packet view with history on the left and details on the right raw_frame = ttk.Frame(self.notebook) - # Left: history listbox history_frame = ttk.Frame(raw_frame, width=380) history_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(5, 2), pady=5) ttk.Label(history_frame, text="History (latest)").pack(anchor=tk.W, padx=4) - # smaller font so more fits on one line try: history_font = ("Consolas", 8) except Exception: history_font = None - # container for Treeview + scrollbar so buttons can sit under it list_container = ttk.Frame(history_frame) list_container.pack(fill=tk.BOTH, expand=True, padx=4, pady=(2, 4)) - # Create a Treeview with columns: Timestamp | Flow | TID | Size columns = ("ts", "flow", "tid", "size") self.history_tree = ttk.Treeview( list_container, columns=columns, show="headings", height=20 @@ -240,12 +337,10 @@ class SfpDebugWindow(tk.Toplevel): self.history_tree.heading("flow", text="Flow") self.history_tree.heading("tid", text="TID") self.history_tree.heading("size", text="Size") - # set column widths (ts wider) self.history_tree.column("ts", width=100, anchor="w") self.history_tree.column("flow", width=50, anchor="w") self.history_tree.column("tid", width=40, anchor="center") self.history_tree.column("size", width=50, anchor="e") - # smaller font for tree rows try: style = ttk.Style() style.configure("Small.Treeview", font=history_font) @@ -253,7 +348,6 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass self.history_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - # scrollbar self.history_vscroll = ttk.Scrollbar( list_container, orient=tk.VERTICAL, command=self.history_tree.yview ) @@ -261,7 +355,6 @@ class SfpDebugWindow(tk.Toplevel): self.history_tree.config(yscrollcommand=self.history_vscroll.set) hb_frame = ttk.Frame(history_frame) hb_frame.pack(fill=tk.X, padx=4, pady=(4, 4)) - # Settings and clear buttons self.history_settings_btn = ttk.Button( hb_frame, text="Settings", @@ -272,7 +365,6 @@ class SfpDebugWindow(tk.Toplevel): hb_frame, text="Clear", command=lambda: self._on_clear_history() ) self.history_clear_btn.pack(side=tk.RIGHT) - # Right: details view (previously raw_tab) self.raw_tab_text = scrolledtext.ScrolledText( raw_frame, state=tk.DISABLED, wrap=tk.NONE, font=("Consolas", 9) ) @@ -280,12 +372,9 @@ class SfpDebugWindow(tk.Toplevel): side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(2, 5), pady=5 ) try: - # insert as second tab (index 1) self.notebook.insert(1, raw_frame, text="SFP Raw") except Exception: - # fallback self.notebook.add(raw_frame, text="SFP Raw") - # Configure visual tags for flags (set/unset) on raw_tab_text try: self.raw_tab_text.tag_config( "flag_set", background="#d4ffd4", foreground="#006400" @@ -308,16 +397,109 @@ class SfpDebugWindow(tk.Toplevel): ) self.notebook.add(self.json_tab, text="JSON") - # Start the periodic GUI poll loop to process latest payloads from the router - try: - self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) - except Exception: - # If the Tk mainloop isn't running in tests, this will be a no-op - pass + def _on_send_target(self): + """Callback to build and send a tgtinit command for a simple target.""" + + # 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: + 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, + ) + 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() + 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() + + 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, + target_azimuth_deg=az_deg, + target_altitude_ft=alt_ft, + 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.active = is_active + temp_target.traceable = is_traceable + temp_target.restart = is_restart + temp_target.current_range_nm = range_nm + temp_target.current_azimuth_deg = az_deg + temp_target.current_velocity_fps = vel_fps + temp_target.current_heading_deg = hdg_deg + temp_target.current_altitude_ft = alt_ft + + # 3. Build the command string + command_str = command_builder.build_tgtinit(temp_target) + # Ensure the command is trimmed, prefixed with '$' and terminated with a newline + 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" + 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 + ) + + if success: + 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") + + except (ValueError, tk.TclError) as e: + error_msg = f"Invalid input value: {e}" + self._log_to_widget(f"ERROR: {error_msg}", "ERROR") + messagebox.showerror("Input Error", error_msg, parent=self) + except Exception as e: + self.logger.exception("An unexpected error occurred in _on_send_target.") + 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) - # Fixed-size container to keep UI tidy. Image area will be size x size px. image_container = ttk.Frame( frame, width=self.image_area_size, @@ -340,9 +522,7 @@ class SfpDebugWindow(tk.Toplevel): "hex_view": hex_view, "image_container": image_container, } - def _open_image_size_dialog(self): - """Open a small dialog to change the image display size and persist it to settings.""" dlg = tk.Toplevel(self) dlg.title("Image Size") dlg.transient(self) @@ -351,10 +531,8 @@ class SfpDebugWindow(tk.Toplevel): size_var = tk.StringVar(value=str(self.image_area_size)) entry = ttk.Entry(dlg, textvariable=size_var, width=8) entry.pack(padx=10, pady=(0, 10)) - btn_frame = ttk.Frame(dlg) btn_frame.pack(padx=10, pady=(0, 10)) - def on_save(): try: v = int(size_var.get()) @@ -367,15 +545,10 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass return - - # Apply to current window self.image_area_size = v - # Update existing containers if present for tab in (getattr(self, "mfd_tab", None), getattr(self, "sar_tab", None)): if tab and "image_container" in tab: tab["image_container"].config(width=v, height=v) - - # Persist to settings via ConfigManager on master (if available) gm = getattr(self.master, "config_manager", None) if gm: general = gm.get_general_settings() or {} @@ -383,21 +556,17 @@ class SfpDebugWindow(tk.Toplevel): image_display["size"] = v general["image_display"] = image_display gm.save_general_settings(general) - dlg.destroy() - def on_cancel(): dlg.destroy() - ttk.Button(btn_frame, text="Cancel", command=on_cancel).pack( side=tk.RIGHT, padx=(0, 5) ) ttk.Button(btn_frame, text="Save", command=on_save).pack(side=tk.RIGHT) - def _on_connect(self): ip = self.ip_var.get() try: - port = int(self.port_var.get()) + port = int(self.local_port_var.get()) except ValueError: self._log_to_widget("ERROR: Invalid port number.", "ERROR") return @@ -419,30 +588,20 @@ class SfpDebugWindow(tk.Toplevel): else: self._log_to_widget("Connection failed. Check IP/Port and logs.", "ERROR") self.sfp_transport = None - # Register raw packet callback regardless of start result (safe no-op if None) try: self.history_tree.bind( "<>", lambda e: self._on_history_select() ) except Exception: pass - def _on_send_probe(self): - """Sends a small UDP probe to the configured IP:port to "wake" the server. - - The server expects any message on its listening port to begin sending SFP - messages, so we just send a short datagram. This function is intentionally - lightweight and does not depend on self.sfp_transport; it uses a temporary - UDP socket so it can be invoked even when not connected/listening. - """ ip = self.ip_var.get() try: - port = int(self.port_var.get()) + port = int(self.server_port_var.get()) except Exception: self._log_to_widget("ERROR: Invalid port number for probe.", "ERROR") return - - probe_payload = b"SFP_PROBE\n" # simple payload; server will accept any data + probe_payload = b"SFP_PROBE\n" try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(1.0) @@ -451,149 +610,83 @@ class SfpDebugWindow(tk.Toplevel): self._log_to_widget(f"Sent probe to {ip}:{port}", "INFO") except Exception as e: self._log_to_widget(f"Failed to send probe to {ip}:{port}: {e}", "ERROR") - - def _on_send_ack(self): - """Constructs a minimal SFP ACK header and sends it to the server. - - Uses the active transport socket when available so the packet originates - from the same local port; otherwise uses a temporary UDP socket. - """ - ip = self.ip_var.get() - try: - port = int(self.port_var.get()) - except Exception: - self._log_to_widget("ERROR: Invalid port number for ACK.", "ERROR") - return - - try: - # Construct a minimal valid SFP data fragment (frag 0 of 1) with a small payload. - payload = b"SFP_WAKE" # small payload so server sees valid metadata - - hdr = SFPHeader() - # Direction: normal data (keep 0 for unspecified) or use '<' if needed by server - hdr.SFP_DIRECTION = 0x3C - hdr.SFP_FLOW = ord("M") if isinstance("M", str) else 0 - hdr.SFP_TID = 1 - # No special flags except zero; server expects total_frags > 0 - hdr.SFP_FLAGS = 0x00 - hdr.SFP_WIN = 32 - - # Fragment metadata: this is the only fragment - hdr.SFP_TOTFRGAS = 1 - hdr.SFP_FRAG = 0 - hdr.SFP_PLSIZE = len(payload) - hdr.SFP_PLOFFSET = 0 - hdr.SFP_TOTSIZE = len(payload) - - pkt = bytes(hdr) + payload - - # Prefer to reuse the SfpTransport socket if available so source port matches - sock = None - used_temp_sock = False - if self.sfp_transport and getattr(self.sfp_transport, '_socket', None): - try: - sock = self.sfp_transport._socket - except Exception: - sock = None - - if not sock: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - used_temp_sock = True - - sock.sendto(pkt, (ip, port)) - if used_temp_sock: - sock.close() - - self._log_to_widget(f"Sent SFP data-fragment to {ip}:{port} (flow=M,t id=1)", "INFO") - except Exception as e: - self._log_to_widget(f"Failed to send SFP fragment to {ip}:{port}: {e}", "ERROR") - def _on_send_script(self): - """Constructs a script_message_t-like payload and sends it to the server. - - The server expects a data tag with tag 'C','S' and type_validity set. - We'll build the payload using ctypes to match layout and send it using - the transport socket (if available) so the server treats us as the client. - """ - ip = self.ip_var.get() - try: - port = int(self.port_var.get()) - except Exception: - self._log_to_widget("ERROR: Invalid port number for script send.", "ERROR") + if not self.sfp_transport or not self.sfp_transport._socket: + 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) + except (ValueError, tk.TclError) as e: + self._log_to_widget(f"ERROR: Invalid input for script sending: {e}", "ERROR") - script_text = self.script_var.get() or "" - # Limit script size to 1020 bytes to be conservative (server has ~1024) - script_bytes = script_text.encode("utf-8") - max_script = 1020 - if len(script_bytes) > max_script: - script_bytes = script_bytes[:max_script] + def _on_send_simple_command(self, command_str: str): + """Send a simple script command string to the configured server port. - # Local ctypes definitions that mirror what the C++ server expects - class LocalDataTag(ctypes.Structure): - _pack_ = 1 - _fields_ = [ - ("ID", ctypes.c_uint8 * 2), - ("VALID", ctypes.c_uint8), - ("VERSION", ctypes.c_uint8), - ("SIZE", ctypes.c_uint32), - ] - - class ScriptPayload(ctypes.Structure): - _pack_ = 1 - _fields_ = [ - ("script_tag", LocalDataTag), - ("script", ctypes.c_uint8 * 1024), - ] + Validates transport/socket and logs the result. + """ + if not self.sfp_transport or not getattr(self.sfp_transport, "_socket", None): + 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) + return False try: - payload = ScriptPayload() - # set tag ID to 'C','S' - payload.script_tag.ID[0] = ord("C") - payload.script_tag.ID[1] = ord("S") - payload.script_tag.VALID = 1 - payload.script_tag.VERSION = 1 - payload.script_tag.SIZE = len(script_bytes) - # copy script bytes - for i, b in enumerate(script_bytes): - payload.script[i] = b - - # Build SFP header - hdr = SFPHeader() - hdr.SFP_DIRECTION = 0x3C - hdr.SFP_FLOW = ord("R") # use 'R' for RIS script commands - hdr.SFP_TID = 1 - hdr.SFP_FLAGS = 0x00 - hdr.SFP_WIN = 32 - hdr.SFP_TOTFRGAS = 1 - hdr.SFP_FRAG = 0 - pl_bytes = bytes(payload) - hdr.SFP_PLSIZE = len(pl_bytes) - hdr.SFP_PLOFFSET = 0 - hdr.SFP_TOTSIZE = len(pl_bytes) - - pkt = bytes(hdr) + pl_bytes - - sock = None - used_temp = False - if self.sfp_transport and getattr(self.sfp_transport, "_socket", None): - try: - sock = self.sfp_transport._socket - except Exception: - sock = None - - if not sock: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - used_temp = True - - sock.sendto(pkt, (ip, port)) - if used_temp: - sock.close() - - self._log_to_widget(f"Sent script ({len(script_bytes)} bytes) to {ip}:{port}", "INFO") + 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) + if success: + self._log_to_widget(f"Successfully sent command: {command_str}", "INFO") + else: + self._log_to_widget(f"Failed to send command: {command_str}", "ERROR") + return success except Exception as e: - self._log_to_widget(f"Failed to send script to {ip}:{port}: {e}", "ERROR") + self.logger.exception("Unexpected error in _on_send_simple_command") + self._log_to_widget(f"ERROR: {e}", "ERROR") + 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() + 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() + + vel_fps = vel_kn * KNOTS_TO_FPS + + updates = { + "range_nm": f"{range_nm:.2f}", + "azimuth_deg": f"{az_deg:.2f}", + "velocity_fps": f"{vel_fps:.2f}", + "heading_deg": f"{hdg_deg:.2f}", + "altitude_ft": f"{alt_ft:.2f}", + } + command_str = command_builder.build_tgtset_selective(target_id, updates) + command_str = command_str.strip() + if command_str and not command_str.endswith("\n"): + command_str = command_str + "\n" + return self._on_send_simple_command(command_str) + except (ValueError, tk.TclError) as e: + 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") @@ -602,18 +695,12 @@ class SfpDebugWindow(tk.Toplevel): self.connect_btn.config(state=tk.NORMAL) self.disconnect_btn.config(state=tk.DISABLED) self._log_to_widget("Disconnected.", "INFO") - def _on_close(self): self.logger.info("SFP Debug Window closing.") self._on_disconnect() self.destroy() - def _process_latest_payloads(self): - """GUI-thread loop to sample and display the latest payloads.""" - # Get all new payloads that have arrived since the last check new_payloads = self.payload_router.get_and_clear_latest_payloads() - - # If there are new payloads, process them if new_payloads: self._log_to_widget( f"Processing {len(new_payloads)} new payload(s) for flows: {list(new_payloads.keys())}" @@ -621,74 +708,36 @@ class SfpDebugWindow(tk.Toplevel): for flow_id, payload in new_payloads.items(): if flow_id == "MFD" and _IMAGE_LIBS_AVAILABLE: self._display_image_data(payload, self.mfd_tab, "mfd_photo") - # self.notebook.select(self.mfd_tab["frame"]) elif flow_id == "SAR" and _IMAGE_LIBS_AVAILABLE: self._display_image_data(payload, self.sar_tab, "sar_photo") - # self.notebook.select(self.sar_tab["frame"]) elif flow_id == "BIN": self._display_hex_data(payload, self.bin_tab) - # self.notebook.select(self.bin_tab) elif flow_id == "JSON": self._display_json_data(payload, self.json_tab) - elif flow_id == "RIS_STATUS": - # textual fallback: we intentionally do not write the - # full RIS textual summary into the generic log to avoid - # clutter; the structured JSON payload is used for UI. - # Keep this branch present in case future handling is - # needed. - pass elif flow_id == "RIS_STATUS_JSON": - # Populate the scenario tree and the RIS targets tree from structured JSON try: import json - struct = json.loads(payload.decode("utf-8")) if isinstance(payload, (bytes, bytearray)) else payload - # Debug: log keys and a short sample of the payload to help - # diagnose any label/value mismatches coming from the server. - try: - if isinstance(struct, dict): - scenario_preview = struct.get("scenario") - #self.logger.debug("RIS payload keys: %s", list(struct.keys())) - #if isinstance(scenario_preview, dict): - #self.logger.debug("RIS scenario keys: %s", list(scenario_preview.keys())) - targets_preview = struct.get("targets") - if isinstance(targets_preview, list): - sample_keys = [list(t.keys()) for t in targets_preview[:3] if isinstance(t, dict)] - #self.logger.debug("RIS targets sample keys (first 3): %s", sample_keys) - # Also put a concise message in the Raw Log widget for convenience - try: - msg = f"RIS JSON: scenario_keys={len(scenario_preview) if isinstance(scenario_preview, dict) else 0}, targets={len(targets_preview) if isinstance(targets_preview, list) else 0}" - #self._log_to_widget(msg, "DEBUG") - except Exception: - pass - except Exception: - # Never raise from debug logging - pass - # scenario table (field, value) for iid in self.scenario_tree.get_children(): self.scenario_tree.delete(iid) scenario = struct.get("scenario", {}) if isinstance(struct, dict) else {} if scenario: import math - def to_deg(v): try: return float(v) * (180.0 / math.pi) except Exception: return v - def m_s_to_ft_s(v): try: return float(v) * 3.280839895 except Exception: return v - def m_to_ft(v): try: return float(v) * 3.280839895 except Exception: return v - order = [ ("timetag", "timetag", ""), ("platform_azimuth", "platform_azimuth", "°"), @@ -704,10 +753,8 @@ class SfpDebugWindow(tk.Toplevel): ("longitude", "longitude", "°"), ("true_heading", "true_heading", "°"), ] - view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" dec_simp = int(self.simplified_decimals.get() if hasattr(self, "simplified_decimals") else 4) - def fmt_raw_number(v, key_name=None): try: fv = float(v) @@ -716,49 +763,32 @@ class SfpDebugWindow(tk.Toplevel): return f"{fv:.6f}" except Exception: return str(v) - def fmt_simplified_number(v, unit_str, decimals=4): try: fv = float(v) return f"{fv:.{decimals}f} {unit_str}" if unit_str else f"{fv:.{decimals}f}" except Exception: return str(v) - def try_float(v): - """Attempt to convert value to float; return None on failure.""" try: return float(v) except Exception: return None - def decimal_deg_to_dms(deg, is_lat=True): - """Convert decimal degrees to DMS string with direction. - - Examples: - 45.50417 -> 45°30'15" N - -73.9876 -> 73°59'15" W - """ try: d = float(deg) except Exception: return str(deg) - - # direction if is_lat: direction = "N" if d >= 0 else "S" else: direction = "E" if d >= 0 else "W" - ad = abs(d) degrees = int(ad) minutes_full = (ad - degrees) * 60 minutes = int(minutes_full) seconds = (minutes_full - minutes) * 60 - - # Format seconds with 2 decimal places to keep it compact return f"{degrees}°{minutes}'{seconds:.2f}\" {direction}" - - # collect rows first so we can log the exact label->value mapping scenario_rows = [] for label, key, unit in order: if key in scenario: @@ -778,7 +808,6 @@ class SfpDebugWindow(tk.Toplevel): else: display_val = str(val) else: - # simplified view: show converted value and unit adjacent to number if key in ("platform_azimuth", "true_heading"): fv = try_float(val) if fv is not None: @@ -810,7 +839,6 @@ class SfpDebugWindow(tk.Toplevel): elif key in ("latitude", "longitude"): fv = try_float(val) if fv is not None: - # show as degrees/minutes/seconds with direction is_lat = key == "latitude" display_val = decimal_deg_to_dms(fv, is_lat=is_lat) else: @@ -821,17 +849,14 @@ class SfpDebugWindow(tk.Toplevel): except Exception: display_val = str(val) elif key == "mode": - # Map numeric mode to human-friendly short name try: midx = int(val) if 0 <= midx < len(self._master_mode_names): name = self._master_mode_names[midx] - # strip trailing parts like '_master_mode' or '_mode' or trailing underscores short = name for suffix in ("_master_mode", "_mode", "_master_mode_", "_"): if short.endswith(suffix): short = short[: -len(suffix)] - # also remove any remaining 'master' parts short = short.replace("master", "").strip("_") display_val = short else: @@ -840,86 +865,52 @@ class SfpDebugWindow(tk.Toplevel): display_val = str(val) else: display_val = str(val) - - # collect the pair for logging and insertion scenario_rows.append((label, display_val)) - - # Log the label->value pairs for diagnosis (concise) - try: - preview = ", ".join(f"{l}={v}" for l, v in scenario_rows) - #self.logger.debug("Scenario label->value: %s", preview) - # also write a shorter message to Raw Log for visibility - #self._log_to_widget(f"Scenario preview: {preview}", "DEBUG") - except Exception: - pass - - # Now insert collected rows into the Treeview for l, v in scenario_rows: self.scenario_tree.insert("", tk.END, values=(f"{l}", v)) - - # targets for iid in self.ris_tree.get_children(): self.ris_tree.delete(iid) targets = struct.get("targets", []) if isinstance(struct, dict) else [] - # Update target column headers to show units depending on view try: view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" - # Column headers should be plain; units shown next to values self.ris_tree.heading("heading", text="heading") self.ris_tree.heading("x", text="x") self.ris_tree.heading("y", text="y") self.ris_tree.heading("z", text="z") except Exception: pass - view_mode = self.scenario_view_mode.get() if hasattr(self, "scenario_view_mode") else "simplified" dec_simp = int(self.simplified_decimals.get() if hasattr(self, "simplified_decimals") else 4) for t in targets: try: - # normalize index (accept common aliases) idx = t.get("index") if idx is None: idx = t.get("idx") if idx is None: idx = t.get("#") - - # normalize flags and format consistently 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) - if view_mode == "raw": - # format raw with reasonable precision using try_float 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, - ) + vals = (idx, flags_display, heading_val, x_val, y_val, z_val) else: - # simplified: converted values with units next to number 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")) @@ -929,54 +920,31 @@ class SfpDebugWindow(tk.Toplevel): 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, - ) - - # ensure we always insert a 6-tuple (Treeview expects 6 columns) + vals = (idx, flags_display, heading_val, x_val, y_val, z_val) if not isinstance(vals, (list, tuple)) or len(vals) != 6: - # fallback: make a safe 6-element tuple vals = (idx, flags_display, "", "", "", "") - self.ris_tree.insert("", tk.END, values=vals) except Exception as _e: - # on malformed target entries, insert a visible placeholder 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 except Exception: - # ignore malformed JSON for now pass - # self.notebook.select(self.json_tab) - - # Reschedule the next check self.after(self.GUI_POLL_INTERVAL_MS, self._process_latest_payloads) - - # Also check and display last raw packet raw_pkt = self.payload_router.get_and_clear_raw_packet() if raw_pkt: raw_bytes, addr = raw_pkt self._display_raw_packet(raw_bytes, addr) - # Refresh history tree to show new entry try: self._refresh_history_tree() except Exception: pass - def _refresh_history_tree(self): try: hist = self.payload_router.get_history() - # clear current for iid in self.history_tree.get_children(): self.history_tree.delete(iid) - # insert reversed (latest first) for i, entry in enumerate(reversed(hist)): ts = entry["ts"].strftime("%H:%M:%S.%f")[:-3] flow_name = entry.get("flow_name", "") @@ -987,14 +955,12 @@ class SfpDebugWindow(tk.Toplevel): ) except Exception: pass - def _on_history_select(self): try: sel = self.history_tree.selection() if not sel: return iid = sel[0] - # find index of item among children (0-based latest-first) children = list(self.history_tree.get_children()) try: idx = children.index(iid) @@ -1007,40 +973,33 @@ class SfpDebugWindow(tk.Toplevel): self._display_raw_packet(entry["raw"], entry["addr"]) except Exception: pass - def _on_clear_history(self): try: self.payload_router.clear_history() - self._refresh_history_listbox() + self._refresh_history_tree() except Exception: pass - def _open_history_settings_dialog(self): dlg = tk.Toplevel(self) dlg.title("History Settings") dlg.transient(self) dlg.grab_set() - # Current values try: hist_size = self.payload_router._history_size persist = self.payload_router._persist except Exception: hist_size = 20 persist = False - ttk.Label(dlg, text="History size (entries):").pack(padx=10, pady=(10, 2)) size_var = tk.StringVar(value=str(hist_size)) entry = ttk.Entry(dlg, textvariable=size_var, width=8) entry.pack(padx=10, pady=(0, 10)) - persist_var = tk.BooleanVar(value=bool(persist)) ttk.Checkbutton( dlg, text="Persist raw packets to Temp/", variable=persist_var ).pack(padx=10, pady=(0, 10)) - btn_frame = ttk.Frame(dlg) btn_frame.pack(padx=10, pady=(0, 10), fill=tk.X) - def on_save(): try: v = int(size_var.get()) @@ -1056,13 +1015,11 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass return - # Apply try: self.payload_router.set_history_size(v) self.payload_router.set_persist(bool(persist_var.get())) except Exception: pass - # Persist into settings.json via ConfigManager (master.config_manager) try: gm = getattr(self.master, "config_manager", None) if gm: @@ -1075,52 +1032,26 @@ class SfpDebugWindow(tk.Toplevel): except Exception: pass dlg.destroy() - def on_cancel(): dlg.destroy() - ttk.Button(btn_frame, text="Cancel", command=on_cancel).pack( side=tk.RIGHT, padx=(0, 5) ) ttk.Button(btn_frame, text="Save", command=on_save).pack(side=tk.RIGHT) - def _display_raw_packet(self, raw_bytes: bytes, addr: tuple): - """Show the raw SFP packet bytes and the parsed header (if possible).""" try: header_size = SFPHeader.size() if len(raw_bytes) < header_size: raise ValueError("Packet smaller than SFP header") - header = SFPHeader.from_buffer_copy(raw_bytes) body = raw_bytes[header_size:] - - # Build a compact two-column header table to save horizontal space field_list = [ - "SFP_MARKER", - "SFP_DIRECTION", - "SFP_PROT_VER", - "SFP_PT_SPARE", - "SFP_TAG", - "SFP_SRC", - "SFP_FLOW", - "SFP_TID", - "SFP_FLAGS", - "SFP_WIN", - "SFP_ERR", - "SFP_ERR_INFO", - "SFP_TOTFRGAS", - "SFP_FRAG", - "SFP_RECTYPE", - "SFP_RECSPARE", - "SFP_PLDAP", - "SFP_PLEXT", - "SFP_RECCOUNTER", - "SFP_PLSIZE", - "SFP_TOTSIZE", - "SFP_PLOFFSET", + "SFP_MARKER", "SFP_DIRECTION", "SFP_PROT_VER", "SFP_PT_SPARE", + "SFP_TAG", "SFP_SRC", "SFP_FLOW", "SFP_TID", "SFP_FLAGS", + "SFP_WIN", "SFP_ERR", "SFP_ERR_INFO", "SFP_TOTFRGAS", "SFP_FRAG", + "SFP_RECTYPE", "SFP_RECSPARE", "SFP_PLDAP", "SFP_PLEXT", + "SFP_RECCOUNTER", "SFP_PLSIZE", "SFP_TOTSIZE", "SFP_PLOFFSET", ] - - # Collect (label, value) pairs, handle FLAGS specially pairs = [] flag_val = None for f in field_list: @@ -1130,7 +1061,6 @@ class SfpDebugWindow(tk.Toplevel): val = "" if f == "SFP_FLAGS": flag_val = val - # still include placeholder for alignment; actual flags printed later pairs.append( (f, f"{val} (0x{val:X})" if isinstance(val, int) else str(val)) ) @@ -1139,106 +1069,53 @@ class SfpDebugWindow(tk.Toplevel): pairs.append((f, f"{val} (0x{val:X})")) else: pairs.append((f, str(val))) - - # Render two columns: pair up items two-per-line - # Build a full formatted text string so we can both log it - # (helpful for external capture/tooling) and display it in the widget. - out_lines = [] - out_lines.append(f"From {addr}\n\nSFP Header:\n\n") - col_width = 36 # width for each column block + 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]}" - # Pad left_text to column width then append right_text line = f"{left_text:<{col_width}} {right_text}" else: line = left_text out_lines.append(line + "\n") - - # FLAG decoding based on provided enum frag_flags_t - # bit0 = frag_flag_acq_required - # bit1 = frag_flag_resent / please_resend - # bit2 = frag_flag_please_trailer_ack - # bit7 = frag_flag_error flag_defs = [ - (0, "ACQ_REQ"), - (1, "RESENT"), - (2, "TRAILER_ACK"), - (3, "RESV3"), - (4, "RESV4"), - (5, "RESV5"), - (6, "RESV6"), - (7, "ERROR"), + (0, "ACQ_REQ"), (1, "RESENT"), (2, "TRAILER_ACK"), + (3, "RESV3"), (4, "RESV4"), (5, "RESV5"), (6, "RESV6"), (7, "ERROR"), ] - out_lines.append(f"SFP_FLAGS : {flag_val} (0x{flag_val:X}) ") - # Append colored flag labels; use 'flag_error' tag for ERROR for bit, name in flag_defs: - is_set = False - try: - is_set = bool((flag_val >> bit) & 1) - except Exception: - is_set = False - if name == "ERROR" and is_set: - tag = "flag_error" - else: - tag = "flag_set" if is_set else "flag_unset" - # Append textual flag indicator; tags are only for widget display + is_set = bool((flag_val >> bit) & 1) out_lines.append(f" [{name}]") - - # Fixed legend text for flags (always visible) out_lines.append("\n\nFlags legend:\n") legend_map = { "ACQ_REQ": "Acquisition required/requested", "RESENT": "Fragment resent / please resend", "TRAILER_ACK": "Request trailer acknowledgement", - "ERROR": "Packet-level error flag", - "RESV3": "Reserved", - "RESV4": "Reserved", - "RESV5": "Reserved", - "RESV6": "Reserved", + "ERROR": "Packet-level error flag", "RESV3": "Reserved", + "RESV4": "Reserved", "RESV5": "Reserved", "RESV6": "Reserved", } for _, name in flag_defs: desc = legend_map.get(name, "") out_lines.append(f" {name:12s}: {desc}\n") - - # Build hex dump but do not insert it into the Raw tab; - # always show binary/body hex in the dedicated Binary tab. out_lines.append("\nBODY (hex):\n") hex_dump = self._format_hex_dump(body) out_lines.append(hex_dump) - - # Join into a single string and log it so external test-run captures - # include the full packet instead of attaching it. full_text = "".join(out_lines) - #try: - # Use info level to match other logs produced by this window - #self.logger.info(full_text) - #except Exception: - # Don't fail display on logging problems - # pass - - # Display header/parsed fields in the Raw tab, but move the - # full hex/body dump into the Binary tab to centralize binary data. self.raw_tab_text.config(state=tk.NORMAL) self.raw_tab_text.delete("1.0", tk.END) header_block, _, _ = full_text.partition("\nBODY (hex):\n") self.raw_tab_text.insert(tk.END, header_block + "\n", "hdr_field") self.raw_tab_text.config(state=tk.DISABLED) - - # Put the hex dump into the Binary tab try: self._display_hex_data(body, self.bin_tab) except Exception: - # fallback: ensure binary tab contains something self.bin_tab.config(state=tk.NORMAL) self.bin_tab.delete("1.0", tk.END) self.bin_tab.insert("1.0", hex_dump) self.bin_tab.config(state=tk.DISABLED) - return except Exception as e: text = f"Failed to format raw packet: {e}\n\nRaw dump:\n" text += self._format_hex_dump(raw_bytes) @@ -1246,143 +1123,65 @@ class SfpDebugWindow(tk.Toplevel): self.raw_tab_text.delete("1.0", tk.END) self.raw_tab_text.insert("1.0", text) self.raw_tab_text.config(state=tk.DISABLED) - - def _display_image_data( - self, payload: bytearray, tab_widgets: Dict[str, Any], photo_attr: str - ): - """Parses an image payload and displays it. Now handles simplified structure.""" + 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.") - leader = ImageLeaderData.from_buffer(payload) - h, w, bpp = ( - leader.HEADER_DATA.DY, - leader.HEADER_DATA.DX, - leader.HEADER_DATA.BPP, - ) + h, w, bpp = leader.HEADER_DATA.DY, leader.HEADER_DATA.DX, leader.HEADER_DATA.BPP stride = leader.HEADER_DATA.STRIDE offset = ctypes.sizeof(ImageLeaderData) - if not (h > 0 and w > 0 and bpp in [1, 2] and stride >= w): - raise ValueError( - f"Invalid image dimensions in header: {w}x{h}, bpp={bpp}, stride={stride}" - ) - - if bpp == 1: - dtype = np.uint8 - else: - dtype = np.uint16 - + 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): - # Fallback for old format where PIXEL_TAG was at the end of leader - offset_fallback = ( - ctypes.sizeof(SFPHeader) - + ctypes.sizeof(ImageLeaderData) - - ctypes.sizeof(leader.PIXEL_TAG) - ) + offset_fallback = (ctypes.sizeof(SFPHeader) + ctypes.sizeof(ImageLeaderData) - ctypes.sizeof(leader.PIXEL_TAG)) if (offset_fallback + expected_size) <= len(payload): offset = offset_fallback else: - raise ValueError( - f"Incomplete image data. Expected {expected_size} bytes, got {len(payload) - offset}" - ) - - pixel_data_view = np.ndarray( - shape=(h, stride), dtype=dtype, buffer=payload, offset=offset - ) - # Crop to actual width if stride is larger + 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) - ) - - # Resize image to fit the label area while preserving aspect ratio - try: - # Use the fixed-size container (150x150) for resizing target - resized = self._resize_pil_to_label( - img_pil, - tab_widgets.get("image_container", tab_widgets["image_label"]), - ) - except Exception: - # Fallback to original if anything goes wrong - resized = img_pil - + 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) - - # Always show binary/body hex in the dedicated Binary tab instead of - # attaching it to individual image tabs. try: self._display_hex_data(payload, self.bin_tab) except Exception: - # best-effort fallback to the image tab hex_view if Binary tab isn't available try: self._display_hex_data(payload, tab_widgets["hex_view"]) except Exception: pass - - def _resize_pil_to_label( - self, img: "Image.Image", label_widget: ttk.Label - ) -> "Image.Image": - """Resize a PIL Image to fit within the current label widget size. - - If the label widget has not been mapped yet (width/height == 1), this - will fallback to the image's original size. - """ + def _resize_pil_to_label(self, img: "Image.Image", label_widget: ttk.Label) -> "Image.Image": try: - # Get current allocated size for label (in pixels) - width = label_widget.winfo_width() - height = label_widget.winfo_height() - # If the widget isn't yet laid out, width/height may be 1 -> use geometry - if width <= 1 or height <= 1: - geom = self.geometry() # format: WxH+X+Y - if "x" in geom: - parts = geom.split("+", 1)[0].split("x") - win_w, win_h = int(parts[0]), int(parts[1]) - # Use a fraction of window size for image area - width = max(1, int(win_w * 0.9)) - height = max(1, int(win_h * 0.6)) - + width, height = label_widget.winfo_width(), label_widget.winfo_height() if width <= 1 or height <= 1: return img - img_w, img_h = img.size - # Compute scale preserving aspect ratio scale = min(width / img_w, height / img_h) if scale >= 1.0: return img - - new_w = max(1, int(img_w * scale)) - new_h = max(1, int(img_h * scale)) + 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 - def _display_hex_data(self, payload: bytearray, widget: scrolledtext.ScrolledText): hex_dump = self._format_hex_dump(payload) widget.config(state=tk.NORMAL) widget.delete("1.0", tk.END) widget.insert("1.0", hex_dump) widget.config(state=tk.DISABLED) - def _display_json_data(self, payload: bytearray, widget: scrolledtext.ScrolledText): try: import json - text = json.dumps(json.loads(payload.decode("utf-8")), indent=2) except Exception as e: text = f"--- FAILED TO PARSE JSON ---\n{e}\n\n--- RAW HEX DUMP ---\n" @@ -1391,30 +1190,20 @@ class SfpDebugWindow(tk.Toplevel): widget.delete("1.0", tk.END) widget.insert("1.0", text) widget.config(state=tk.DISABLED) - def _log_to_widget(self, message: str, level: str = "DEBUG"): self.logger.info(message) self.log_tab.config(state=tk.NORMAL) self.log_tab.insert(tk.END, f"[{level}] {message}\n") self.log_tab.config(state=tk.DISABLED) self.log_tab.see(tk.END) - def _on_save_ris_csv(self): try: import csv - - # collect rows from tree - - # collect scenario rows scenario_rows = [self.scenario_tree.item(iid, "values") for iid in self.scenario_tree.get_children()] - # collect target rows rows = [self.ris_tree.item(iid, "values") for iid in self.ris_tree.get_children()] - if not scenario_rows and not rows: self._log_to_widget("No RIS data to save.", "INFO") return - - # ensure Temp dir exists project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) temp_dir = os.path.join(project_root, "Temp") os.makedirs(temp_dir, exist_ok=True) @@ -1423,21 +1212,17 @@ class SfpDebugWindow(tk.Toplevel): path = os.path.join(temp_dir, fname) with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) - # write scenario fields first if scenario_rows: writer.writerow(["Scenario Field", "Value"]) for s in scenario_rows: writer.writerow(s) writer.writerow([]) - # write targets writer.writerow(["index", "flags", "heading", "x", "y", "z"]) for r in rows: writer.writerow(r) - self._log_to_widget(f"Saved RIS targets CSV to {path}", "INFO") except Exception as e: self._log_to_widget(f"Failed to save RIS CSV: {e}", "ERROR") - def _format_hex_dump(self, data: bytes, length=16) -> str: lines = [] for i in range(0, len(data), length): @@ -1445,4 +1230,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/tools/extract_pdf_commands.py b/tools/extract_pdf_commands.py new file mode 100644 index 0000000..2ca84be --- /dev/null +++ b/tools/extract_pdf_commands.py @@ -0,0 +1,57 @@ +import sys +from pathlib import Path +p = Path('SUM7056227 Rev. A.pdf') +if not p.exists(): + print('PDF not found at', p) + sys.exit(2) + +# Try multiple PDF libraries +reader = None +try: + from pypdf import PdfReader + reader = PdfReader(str(p)) +except Exception: + try: + import PyPDF2 + reader = PyPDF2.PdfReader(str(p)) + except Exception as e: + print('No suitable PDF reader installed:', e) + sys.exit(3) + +text = [] +for i,pg in enumerate(reader.pages): + try: + t = pg.extract_text() or '' + except Exception: + t = '' + text.append(t) + +full = '\n'.join(text) +# search for relevant keywords +keywords = ['tgtinit', 'tgtset', 'tgtreset', 'command', 'parameters', 'format'] +found = False +for kw in keywords: + idx = full.lower().find(kw) + if idx != -1: + found = True + start = max(0, idx-200) + end = min(len(full), idx+400) + ctx = full[start:end] + print('\n--- context around "{}" ---\n'.format(kw)) + print(ctx) + +if not found: + # fallback: print first 3000 chars for manual inspection + print('\n--- No keywords found; printing first 3000 chars of PDF text ---\n') + print(full[:3000]) +sys.exit(0) +else: + # Also print the specific pages around TOC entries (38-41) for clarity + print('\n--- Explicitly printing pages 38-41 ---\n') + for pi in range(max(0, 38-1), min(len(reader.pages), 41)): + print(f'--- PAGE {pi+1} ---\n') + try: + print(reader.pages[pi].extract_text() or '') + except Exception as e: + print('ERROR extracting page', pi+1, e) + sys.exit(0) diff --git a/tools/udp_test_send.py b/tools/udp_test_send.py new file mode 100644 index 0000000..85653e2 --- /dev/null +++ b/tools/udp_test_send.py @@ -0,0 +1,64 @@ +import threading +import socket +import time +import sys +import os + +# Ensure project root on PYTHONPATH when running from terminal if needed +# This script assumes you run it with PYTHONPATH set to the project root. + +from target_simulator.core.sfp_transport import SfpTransport + +LISTEN_IP = '127.0.0.1' +LISTEN_PORT = 60001 +CLIENT_BIND_PORT = 60002 + +received = [] + +def listener(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.bind((LISTEN_IP, LISTEN_PORT)) + print(f"Listener bound on {LISTEN_IP}:{LISTEN_PORT}") + s.settimeout(5.0) + try: + data, addr = s.recvfrom(65535) + print(f"Listener received {len(data)} bytes from {addr}") + print(data[:200]) + received.append((data, addr)) + except socket.timeout: + print("Listener timed out waiting for data") + finally: + s.close() + +# Start listener thread +lt = threading.Thread(target=listener, daemon=True) +lt.start() + +# Give listener a moment +time.sleep(0.2) + +# Create transport bound to CLIENT_BIND_PORT and send a command +transport = SfpTransport(host=LISTEN_IP, port=CLIENT_BIND_PORT, payload_handlers={}, raw_packet_callback=None) +if not transport.start(): + print("Failed to start SfpTransport (bind failed?)") + sys.exit(1) + +cmd = 'tgtreset -1' +print(f"Sending command: {cmd}") +sent = transport.send_script_command(cmd, (LISTEN_IP, LISTEN_PORT)) +print(f"send_script_command returned: {sent}") + +# Wait for listener +lt.join(timeout=6.0) + +transport.shutdown() + +if received: + print('SUCCESS: packet received by listener') + data, addr = received[0] + # Try to display a small header slice + print('raw bytes (first 80):', data[:80]) + sys.exit(0) +else: + print('FAIL: no packet received') + sys.exit(2) diff --git a/tools/udp_test_send2.py b/tools/udp_test_send2.py new file mode 100644 index 0000000..9e20b1b --- /dev/null +++ b/tools/udp_test_send2.py @@ -0,0 +1,60 @@ +import threading +import socket +import time +import sys + +from target_simulator.core.sfp_transport import SfpTransport + +LISTEN_IP = '127.0.0.1' +LISTEN_PORT = 60110 +# Use port 0 to let the OS pick a free ephemeral port to avoid bind conflicts during tests +CLIENT_BIND_PORT = 0 + +received = [] + +def listener(listen_ip, listen_port): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.bind((listen_ip, listen_port)) + except Exception as e: + print(f"Listener bind failed for {listen_ip}:{listen_port}: {e}") + return + print(f"Listener bound on {listen_ip}:{listen_port}") + s.settimeout(5.0) + try: + data, addr = s.recvfrom(65535) + print(f"Listener received {len(data)} bytes from {addr}") + print(data[:256]) + received.append((data, addr)) + except socket.timeout: + print("Listener timed out waiting for data") + finally: + s.close() + +if __name__ == '__main__': + lt = threading.Thread(target=listener, args=(LISTEN_IP, LISTEN_PORT), daemon=True) + lt.start() + time.sleep(0.1) + + transport = SfpTransport(host=LISTEN_IP, port=CLIENT_BIND_PORT, payload_handlers={}, raw_packet_callback=None) + ok = transport.start() + print(f"Transport started: {ok}") + time.sleep(0.05) + + cmd = 'tgtreset -1' + print(f"Sending command: {cmd} to {(LISTEN_IP, LISTEN_PORT)}") + sent = transport.send_script_command(cmd, (LISTEN_IP, LISTEN_PORT)) + print(f"send_script_command returned: {sent}") + + lt.join(timeout=6.0) + + transport.shutdown() + + if received: + print('SUCCESS: packet received by listener') + data, addr = received[0] + print('raw bytes (first 80):', data[:80]) + sys.exit(0) + else: + print('FAIL: no packet received') + sys.exit(2)