diff --git a/settings.json b/settings.json index 5ee9cc5..b1b8a18 100644 --- a/settings.json +++ b/settings.json @@ -3,7 +3,7 @@ "scan_limit": 60, "max_range": 100, "geometry": "1200x1024+463+195", - "last_selected_scenario": "scenario2", + "last_selected_scenario": "scenario_9g", "connection": { "target": { "type": "tftp", @@ -316,6 +316,63 @@ "use_spline": true } ] + }, + "scenario_9g": { + "name": "scenario2", + "targets": [ + { + "target_id": 2, + "active": true, + "traceable": true, + "trajectory": [ + { + "maneuver_type": "Fly to Point", + "duration_s": 1.0, + "target_range_nm": 28.0, + "target_azimuth_deg": 0.0, + "target_altitude_ft": 10000.0, + "target_velocity_fps": 506.343, + "target_heading_deg": 180.0, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 0.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + }, + { + "maneuver_type": "Fly for Duration", + "duration_s": 300.0, + "target_altitude_ft": 10000.0, + "target_velocity_fps": 506.343, + "target_heading_deg": 180.0, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 0.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + }, + { + "maneuver_type": "Dynamic Maneuver", + "duration_s": 9.0, + "maneuver_speed_fps": 1519.0289999999995, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 9.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + }, + { + "maneuver_type": "Fly for Duration", + "duration_s": 100.0, + "target_altitude_ft": 10000.0, + "target_velocity_fps": 1012.686, + "target_heading_deg": -90.0, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 0.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + } + ], + "use_spline": false + } + ] } } } \ No newline at end of file diff --git a/target_simulator/core/payload_router.py b/target_simulator/core/payload_router.py new file mode 100644 index 0000000..a763211 --- /dev/null +++ b/target_simulator/core/payload_router.py @@ -0,0 +1,90 @@ +# core/payload_router.py +""" +Defines the PayloadRouter class, responsible for directing incoming, +reassembled SFP payloads to the correct application-level handler. +""" + +import logging +import json +from typing import Dict, Callable + +# Definisci il tipo per un handler, se vuoi essere più esplicito +PayloadHandler = Callable[[bytearray], None] + +class PayloadRouter: + """ + Inspects SFP flow IDs and routes payloads to registered handlers. + Also contains the implementations for each specific payload handler. + """ + def __init__(self): + self._log_prefix = "[PayloadRouter]" + logging.info(f"{self._log_prefix} Initializing...") + + def get_handlers(self) -> Dict[int, PayloadHandler]: + """ + Returns a dictionary mapping SFP_FLOW IDs to their handler methods. + This is the primary method used to connect this router to the transport layer. + + Returns: + Dict[int, PayloadHandler]: The dictionary of flow IDs and handlers. + """ + handlers = { + ord('M'): self.handle_mfd_image_payload, + ord('S'): self.handle_sar_image_payload, + ord('B'): self.handle_binary_data_payload, + ord('J'): self.handle_json_payload, + # Aggiungi qui nuovi flussi e handler + } + logging.debug(f"{self._log_prefix} Providing handlers for flows: {[chr(k) if 32<=k<=126 else k for k in handlers.keys()]}") + return handlers + + # --- Handler Implementations --- + + def handle_mfd_image_payload(self, payload: bytearray): + """Handler for MFD image payloads.""" + log_prefix = f"{self._log_prefix} MFD" + logging.info(f"{log_prefix} Received payload. Size: {len(payload)} bytes. Processing...") + # Qui la logica per interpretare il payload dell'immagine MFD + # Ad esempio, potresti usare ctypes per mappare una struttura dati: + # from .sfp_structures import ImageLeaderData + # image_leader = ImageLeaderData.from_buffer(payload) + # logging.debug(f"{log_prefix} Frame counter: {image_leader.HEADER_DATA.FCOUNTER}") + pass + + def handle_sar_image_payload(self, payload: bytearray): + """Handler for SAR image payloads.""" + log_prefix = f"{self._log_prefix} SAR" + logging.info(f"{log_prefix} Received payload. Size: {len(payload)} bytes. Processing...") + # Qui la logica per interpretare il payload dell'immagine SAR + pass + + def handle_binary_data_payload(self, payload: bytearray): + """Handler for generic binary data payloads.""" + log_prefix = f"{self._log_prefix} BIN" + logging.info(f"{log_prefix} Received payload. Size: {len(payload)} bytes.") + try: + # Esempio: leggi un ID (2 byte), una lunghezza (4 byte) e dei dati + if len(payload) >= 6: + message_id = int.from_bytes(payload[0:2], 'little') + data_length = int.from_bytes(payload[2:6], 'little') + actual_data = payload[6:6+data_length] + logging.info(f"{log_prefix} Parsed binary message: ID={message_id}, Length={data_length}, Data size={len(actual_data)}") + else: + logging.warning(f"{log_prefix} Payload too small for expected binary format.") + except Exception as e: + logging.error(f"{log_prefix} Error parsing binary payload: {e}") + + def handle_json_payload(self, payload: bytearray): + """Handler for JSON payloads.""" + log_prefix = f"{self._log_prefix} JSON" + logging.info(f"{log_prefix} Received payload. Size: {len(payload)} bytes.") + try: + json_string = payload.decode('utf-8') + data = json.loads(json_string) + message_type = data.get('type', 'Unknown') + logging.info(f"{log_prefix} Decoded JSON message of type '{message_type}'.") + # Qui la logica per processare l'oggetto JSON + # if message_type == 'status_update': + # handle_status_update(data) + except (UnicodeDecodeError, json.JSONDecodeError) as e: + logging.error(f"{log_prefix} Error decoding JSON payload: {e}") \ No newline at end of file diff --git a/target_simulator/core/sfp_structures.py b/target_simulator/core/sfp_structures.py new file mode 100644 index 0000000..101e474 --- /dev/null +++ b/target_simulator/core/sfp_structures.py @@ -0,0 +1,162 @@ +# core/sfp_structures.py +""" +Defines the ctypes Structures for the Simple Fragmentation Protocol (SFP) +and the application-specific image data format. + +This centralizes data structure definitions, allowing them to be shared +between the transport layer (which uses SFPHeader) and the application +layer (which interprets the image-specific structures). +""" + +import ctypes + +# --- SFP Transport Layer Structures --- + +class SFPHeader(ctypes.Structure): + """Structure representing the SFP header (32 bytes).""" + _pack_ = 1 + _fields_ = [ + ("SFP_MARKER", ctypes.c_uint8), + ("SFP_DIRECTION", ctypes.c_uint8), + ("SFP_PROT_VER", ctypes.c_uint8), + ("SFP_PT_SPARE", ctypes.c_uint8), + ("SFP_TAG", ctypes.c_uint8), + ("SFP_SRC", ctypes.c_uint8), + ("SFP_FLOW", ctypes.c_uint8), + ("SFP_TID", ctypes.c_uint8), + ("SFP_FLAGS", ctypes.c_uint8), + ("SFP_WIN", ctypes.c_uint8), + ("SFP_ERR", ctypes.c_uint8), + ("SFP_ERR_INFO", ctypes.c_uint8), + ("SFP_TOTFRGAS", ctypes.c_uint16), + ("SFP_FRAG", ctypes.c_uint16), + ("SFP_RECTYPE", ctypes.c_uint8), + ("SFP_RECSPARE", ctypes.c_uint8), + ("SFP_PLDAP", ctypes.c_uint8), + ("SFP_PLEXT", ctypes.c_uint8), + ("SFP_RECCOUNTER", ctypes.c_uint16), + ("SFP_PLSIZE", ctypes.c_uint16), + ("SFP_TOTSIZE", ctypes.c_uint32), + ("SFP_PLOFFSET", ctypes.c_uint32), + ] + + @classmethod + def size(cls): + """Returns the size of the structure in bytes.""" + return ctypes.sizeof(cls) + + @staticmethod + def get_field_offset(field_name: str) -> int: + """Returns the byte offset of a specific field within the structure.""" + try: + return getattr(SFPHeader, field_name).offset + except AttributeError: + return -1 + +# --- Application Layer (Image Format) Structures --- + +class DataTag(ctypes.Structure): + """Structure representing a generic data tag (8 bytes).""" + _pack_ = 1 + _fields_ = [ + ("ID", ctypes.c_uint8 * 2), + ("VALID", ctypes.c_uint8), + ("VERSION", ctypes.c_uint8), + ("SIZE", ctypes.c_uint32), + ] + + @classmethod + def size(cls): + """Returns the size of the structure in bytes.""" + return ctypes.sizeof(cls) + + +class HeaderData(ctypes.Structure): + """Structure representing the HEADER_DATA block (~48 bytes).""" + _pack_ = 1 + _fields_ = [ + ("TYPE", ctypes.c_uint8), + ("SUBTYPE", ctypes.c_uint8), + ("LOGCOLORS", ctypes.c_uint8), + ("IMG_RESERVED", ctypes.c_uint8), + ("PRODINFO", ctypes.c_uint32 * 2), + ("TOD", ctypes.c_uint16 * 2), + ("RESERVED", ctypes.c_uint32), + ("FCOUNTER", ctypes.c_uint32), + ("TIMETAG", ctypes.c_uint32), + ("NOMINALFRATE", ctypes.c_uint16), + ("FRAMETAG", ctypes.c_uint16), + ("OX", ctypes.c_uint16), + ("OY", ctypes.c_uint16), + ("DX", ctypes.c_uint16), + ("DY", ctypes.c_uint16), + ("STRIDE", ctypes.c_uint16), + ("BPP", ctypes.c_uint8), + ("COMP", ctypes.c_uint8), + ("SPARE", ctypes.c_uint16), + ("PALTYPE", ctypes.c_uint8), + ("GAP", ctypes.c_uint8), + ] + + @classmethod + def size(cls): + """Returns the size of the structure in bytes.""" + return ctypes.sizeof(cls) + + +class GeoData(ctypes.Structure): + """Structure representing the GEO_DATA block (~80 bytes).""" + _pack_ = 1 + _fields_ = [ + ("INVMASK", ctypes.c_uint32), + ("ORIENTATION", ctypes.c_float), + ("LATITUDE", ctypes.c_float), + ("LONGITUDE", ctypes.c_float), + ("REF_X", ctypes.c_uint16), + ("REF_Y", ctypes.c_uint16), + ("SCALE_X", ctypes.c_float), + ("SCALE_Y", ctypes.c_float), + ("POI_ORIENTATION", ctypes.c_float), + ("POI_LATITUDE", ctypes.c_float), + ("POI_LONGITUDE", ctypes.c_float), + ("POI_ALTITUDE", ctypes.c_float), + ("POI_X", ctypes.c_uint16), + ("POI_Y", ctypes.c_uint16), + ("SPARE", ctypes.c_uint32 * 7), + ] + + @classmethod + def size(cls): + """Returns the size of the structure in bytes.""" + return ctypes.sizeof(cls) + + +class ImageLeaderData(ctypes.Structure): + """Represents the complete Image Leader data structure (~1320 bytes).""" + _pack_ = 1 + _fields_ = [ + ("HEADER_TAG", DataTag), + ("HEADER_DATA", HeaderData), + ("GEO_TAG", DataTag), + ("GEO_DATA", GeoData), + ("RESERVED_TAG", DataTag), + ("RESERVED_DATA", ctypes.c_uint8 * 128), + ("CM_TAG", DataTag), + ("COLOUR_MAP", ctypes.c_uint32 * 256), + ("PIXEL_TAG", DataTag), + ] + + @classmethod + def size(cls): + """Returns the size of the structure in bytes.""" + return ctypes.sizeof(cls) + + @staticmethod + def get_reserved_data_size() -> int: + """Returns the static size of the RESERVED_DATA field.""" + return 128 + + @staticmethod + def get_colour_map_size() -> int: + """Returns the static size of the COLOUR_MAP field in bytes.""" + return 1024 \ No newline at end of file diff --git a/target_simulator/core/sfp_transport.py b/target_simulator/core/sfp_transport.py new file mode 100644 index 0000000..8909653 --- /dev/null +++ b/target_simulator/core/sfp_transport.py @@ -0,0 +1,240 @@ +# core/sfp_transport.py +""" +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. +""" + +import socket +import logging +import threading +import time +from typing import Dict, Callable, Optional + +from controlpanel import config +from controlpanel.utils.network import create_udp_socket, close_udp_socket +from controlpanel.core.sfp_structures import SFPHeader + +# Define a type hint for payload handlers +PayloadHandler = Callable[[bytearray], None] + +class SfpTransport: + """Manages SFP communication and payload reassembly.""" + + def __init__(self, host: str, port: int, payload_handlers: Dict[int, PayloadHandler]): + """ + Initializes the SFP Transport layer. + + Args: + host (str): The local IP address to bind the UDP socket to. + port (int): The local port to listen on. + payload_handlers (Dict[int, PayloadHandler]): A dictionary mapping + SFP_FLOW IDs (as integers) to their corresponding handler functions. + Each handler will be called with the complete payload (bytearray). + """ + self._log_prefix = "[SfpTransport]" + logging.info(f"{self._log_prefix} Initializing for {host}:{port}...") + + self._host = host + self._port = port + self._payload_handlers = payload_handlers + self._socket: Optional[socket.socket] = None + self._receiver_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + # Reassembly state dictionaries, managed by this transport layer + self._fragments: Dict[tuple, Dict[int, int]] = {} + self._buffers: Dict[tuple, bytearray] = {} + + logging.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()]}" + ) + + def start(self) -> bool: + """ + Starts the transport layer by creating the socket and launching the receiver thread. + + Returns: + bool: True if started successfully, False otherwise. + """ + if self._receiver_thread is not None and self._receiver_thread.is_alive(): + logging.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: + logging.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() + logging.info(f"{self._log_prefix} Receiver thread started.") + return True + + def shutdown(self): + """Stops the receiver thread and closes the socket.""" + logging.info(f"{self._log_prefix} Shutdown initiated.") + self._stop_event.set() + + # The socket is closed here to interrupt the blocking recvfrom call + if self._socket: + close_udp_socket(self._socket) + self._socket = None + + if self._receiver_thread and self._receiver_thread.is_alive(): + logging.debug(f"{self._log_prefix} Waiting for receiver thread to join...") + self._receiver_thread.join(timeout=2.0) + if self._receiver_thread.is_alive(): + logging.warning(f"{self._log_prefix} Receiver thread did not join cleanly.") + + logging.info(f"{self._log_prefix} Shutdown complete.") + + def _receive_loop(self): + """The main loop that listens for UDP packets and processes them.""" + log_prefix = f"{self._log_prefix} Loop" + logging.info(f"{log_prefix} Starting receive loop.") + + while not self._stop_event.is_set(): + if not self._socket: + logging.error(f"{log_prefix} Socket is not available. Stopping loop.") + break + + try: + data, addr = self._socket.recvfrom(65535) + if not data: + continue + except socket.timeout: + continue + except OSError: + # This is expected when the socket is closed during shutdown + if not self._stop_event.is_set(): + logging.error(f"{log_prefix} Socket error.", exc_info=True) + break + except Exception: + logging.exception(f"{log_prefix} Unexpected error in recvfrom.") + time.sleep(0.01) + continue + + self._process_packet(data, addr) + + logging.info(f"{log_prefix} Receive loop terminated.") + + def _process_packet(self, data: bytes, addr: tuple): + """Parses an SFP packet and handles fragment reassembly.""" + header_size = SFPHeader.size() + if len(data) < header_size: + logging.warning(f"Packet from {addr} is too small for SFP header. Ignoring.") + return + + try: + header = SFPHeader.from_buffer_copy(data) + except (ValueError, TypeError): + logging.error(f"Failed to parse SFP header from {addr}. Ignoring.") + return + + flow, tid = header.SFP_FLOW, header.SFP_TID + frag, total_frags = header.SFP_FRAG, header.SFP_TOTFRGAS + pl_size, pl_offset = header.SFP_PLSIZE, header.SFP_PLOFFSET + total_size = header.SFP_TOTSIZE + key = (flow, tid) + + # Handle ACK Request + if header.SFP_FLAGS & 0x01: + self._send_ack(addr, data[:header_size]) + + # Validate packet metadata + if total_frags == 0 or total_frags > 60000 or total_size <= 0: + logging.warning(f"Invalid metadata for {key}: total_frags={total_frags}, total_size={total_size}. Ignoring.") + return + + # Start of a new transaction + if frag == 0: + self._cleanup_lingering_transactions(flow, tid) + logging.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): + logging.error(f"Failed to allocate {total_size} bytes for key={key}. Ignoring transaction.") + self._fragments.pop(key, None) + return + + # Check if we are tracking this transaction + if key not in self._buffers or key not in self._fragments: + logging.debug(f"Ignoring fragment {frag} for untracked transaction key={key}.") + return + + # Store fragment info and copy payload + 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]): + logging.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] + + # Check for completion + if len(self._fragments[key]) == total_frags: + #logging.info(f"Transaction complete for key={key}. Handing off to application layer.") + + # Retrieve completed buffer and clean up state for this key + completed_payload = self._buffers.pop(key) + self._fragments.pop(key) + + # Find and call the appropriate handler + handler = self._payload_handlers.get(flow) + if handler: + try: + handler(completed_payload) + except Exception: + logging.exception(f"Error executing payload handler for flow {flow}.") + else: + logging.warning(f"No payload handler registered for flow ID {flow}.") + + def _send_ack(self, dest_addr: tuple, original_header_bytes: bytes): + """Sends an SFP ACK packet back to the sender.""" + 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")] + + # Determine window size based on flow + window_size = 0 + if flow == ord('M'): # MFD + window_size = config.ACK_WINDOW_SIZE_MFD + elif flow == ord('S'): # SAR + window_size = config.ACK_WINDOW_SIZE_SAR + + # Modify header for ACK response + 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) + logging.debug(f"{log_prefix} Sent to {dest_addr} for flow {chr(flow) if 32<=flow<=126 else flow}.") + except Exception: + logging.exception(f"{log_prefix} Failed to send to {dest_addr}.") + + def _cleanup_lingering_transactions(self, current_flow: int, current_tid: int): + """Removes old, incomplete transactions for the same flow.""" + # This is a simplified cleanup. The original was more complex for stats. + keys_to_remove = [ + key for key in self._fragments + if key[0] == current_flow and key[1] != current_tid + ] + for key in keys_to_remove: + logging.warning(f"Cleaning up lingering/incomplete transaction for key={key}.") + self._fragments.pop(key, None) + self._buffers.pop(key, None) \ No newline at end of file diff --git a/target_simulator/utils/network.py b/target_simulator/utils/network.py new file mode 100644 index 0000000..a6c26d9 --- /dev/null +++ b/target_simulator/utils/network.py @@ -0,0 +1,158 @@ +# network.py +""" +THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + +Handles basic UDP socket operations like creation, configuration (buffer size), +and closing for the application. Uses standardized logging prefixes. +""" + +# Standard library imports +import socket +import logging +import sys # Keep sys import if needed for platform-specific checks (not currently used) + +# No local application imports needed in this module typically + + +def create_udp_socket(local_ip, local_port): + """ + Creates a UDP socket, attempts to increase its receive buffer size, + and binds it to the specified local IP address and port. + + Args: + local_ip (str): The local IP address to bind to. + local_port (int): The local port to bind to. + + Returns: + socket.socket: The created and bound UDP socket, or None on error. + """ + log_prefix = "[Network]" # Standard prefix for this module + logging.debug( + f"{log_prefix} Attempting to create UDP socket for {local_ip}:{local_port}" + ) + + try: + # Create the UDP socket (IPv4) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + logging.debug(f"{log_prefix} Socket object created.") + + # --- Receive Buffer Size Adjustment --- + try: + # Get the default buffer size (INFO level is okay for this setup detail) + default_rcvbuf = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) + logging.info( + f"{log_prefix} Default socket receive buffer size: {default_rcvbuf} bytes" + ) + + # Set a larger buffer size (adjust value as needed) + desired_rcvbuf = 8 * 1024 * 1024 # Request 8MB + logging.debug( + f"{log_prefix} Requesting receive buffer size: {desired_rcvbuf} bytes" + ) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, desired_rcvbuf) + + # Verify the actual size set by the OS (INFO level okay) + actual_rcvbuf = sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) + logging.info( + f"{log_prefix} Actual receive buffer size set: {actual_rcvbuf} bytes" + ) + if actual_rcvbuf < desired_rcvbuf: + # WARNING if OS limited the size + logging.warning( + f"{log_prefix} OS capped the receive buffer size lower than requested." + ) + except OSError as buf_e: + # ERROR if setting buffer fails (might indicate permissions or OS limits issue) + logging.error( + f"{log_prefix} Failed to set receive buffer size: {buf_e}. Check OS limits (e.g., sysctl net.core.rmem_max)." + ) + except Exception as buf_e: + # Keep EXCEPTION for unexpected errors during buffer setting + logging.exception( + f"{log_prefix} Unexpected error setting receive buffer: {buf_e}" + ) + + # --- Bind the Socket --- + logging.debug(f"{log_prefix} Binding socket to ('{local_ip}', {local_port})...") + sock.bind((local_ip, local_port)) + # INFO for successful bind confirmation + logging.info( + f"{log_prefix} UDP socket created and bound successfully to {local_ip}:{local_port}" + ) + return sock + + except socket.error as e: + # ERROR for critical socket creation/binding failure + logging.error( + f"{log_prefix} Error creating/binding UDP socket for {local_ip}:{local_port}: {e}" + ) + return None + except Exception as e: + # Keep EXCEPTION for any other unexpected errors + logging.exception( + f"{log_prefix} Unexpected error during UDP socket creation for {local_ip}:{local_port}: {e}" + ) + return None + + +def receive_udp_packet(sock, buffer_size=65535): + """ + Receives a single UDP packet from the specified socket. + Note: Currently unused by the UdpReceiver class which uses sock.recvfrom directly. + + Args: + sock (socket.socket): The UDP socket to receive from. + buffer_size (int): The maximum buffer size for receiving data. + + Returns: + tuple: A tuple containing (data, sender_address), or (None, None) on error. + """ + log_prefix = "[Network]" # Standard prefix + # DEBUG for function call (if used) + logging.debug( + f"{log_prefix} Attempting to receive UDP packet (buffer size: {buffer_size})." + ) + try: + data, addr = sock.recvfrom(buffer_size) + logging.debug(f"{log_prefix} Received {len(data)} bytes from {addr}.") + return data, addr + except socket.timeout: + # DEBUG for expected timeout (if timeout is set on socket) + logging.debug(f"{log_prefix} Socket recvfrom timeout.") + return None, None + except socket.error as e: + # ERROR for socket errors during receive + logging.error(f"{log_prefix} Error receiving UDP packet: {e}") + return None, None + except Exception as e: + # Keep EXCEPTION for unexpected errors + logging.exception(f"{log_prefix} Unexpected error receiving UDP packet: {e}") + return None, None + + +def close_udp_socket(sock): + """ + Closes the specified UDP socket gracefully. + + Args: + sock (socket.socket): The UDP socket object to close. + """ + log_prefix = "[Network]" # Standard prefix + # Check if socket is valid before attempting close + if sock and hasattr(sock, "fileno") and sock.fileno() != -1: + try: + # INFO for significant action: closing the main socket + logging.info(f"{log_prefix} Closing UDP socket (fd={sock.fileno()})...") + sock.close() + logging.info(f"{log_prefix} UDP socket closed successfully.") + except socket.error as e: + # ERROR if closing fails + logging.error(f"{log_prefix} Error closing UDP socket: {e}") + except Exception as e: + # Keep EXCEPTION for unexpected errors + logging.exception(f"{log_prefix} Unexpected error closing UDP socket: {e}") + else: + # DEBUG if socket is already closed or invalid + logging.debug( + f"{log_prefix} Socket close requested, but socket was already closed or invalid." + )