refactoring della gestione della comunicazione con il server e condivisione tra finestre

This commit is contained in:
VALLONGOL 2025-10-22 15:34:51 +02:00
parent c9ab1a792d
commit 92b7a12492
10 changed files with 687 additions and 1113 deletions

View File

@ -3,7 +3,7 @@
"scan_limit": 60,
"max_range": 100,
"geometry": "1599x1024+626+57",
"last_selected_scenario": null,
"last_selected_scenario": "scenario_dritto",
"connection": {
"target": {
"type": "sfp",

View File

@ -6,12 +6,12 @@ Handles SFP (Simple Fragmentation Protocol) communication with the target device
import socket
import time
from typing import List, Optional, Dict, Any
from typing import List, Optional, Dict, Any, Callable
from queue import Queue
from target_simulator.core.communicator_interface import CommunicatorInterface
from target_simulator.core.models import Scenario
from target_simulator.core.sfp_transport import SfpTransport
from target_simulator.core.sfp_transport import SfpTransport, PayloadHandler
from target_simulator.core import command_builder
from target_simulator.utils.logger import get_logger
from target_simulator.core.simulation_payload_handler import SimulationPayloadHandler
@ -21,7 +21,7 @@ from target_simulator.analysis.simulation_state_hub import SimulationStateHub
class SFPCommunicator(CommunicatorInterface):
"""
A communicator that uses the SFP transport layer to send commands
and (eventually) receive status updates.
and receive status updates. Manages a single shared transport instance.
"""
def __init__(self, simulation_hub: Optional[SimulationStateHub] = None, update_queue: Optional["Queue"] = None):
@ -29,20 +29,33 @@ class SFPCommunicator(CommunicatorInterface):
self.transport: Optional[SfpTransport] = None
self.config: Optional[Dict[str, Any]] = None
self._destination: Optional[tuple] = None
self.simulation_hub = simulation_hub # Store the hub instance
self.simulation_hub = simulation_hub
self.update_queue = update_queue
self._connection_state_callbacks: List[Callable[[bool], None]] = []
self._extra_payload_handlers: Dict[int, PayloadHandler] = {}
def add_connection_state_callback(self, callback: Callable[[bool], None]):
if callback not in self._connection_state_callbacks:
self._connection_state_callbacks.append(callback)
def remove_connection_state_callback(self, callback: Callable[[bool], None]):
try:
self._connection_state_callbacks.remove(callback)
except ValueError:
pass
def _notify_connection_state_changed(self):
is_open = self.is_open
for callback in self._connection_state_callbacks:
try:
callback(is_open)
except Exception:
self.logger.exception("Error in connection state callback")
def connect(self, config: Dict[str, Any]) -> bool:
"""
Initializes the SFP transport.
Config must contain:
- 'ip': Remote IP address (server)
- 'port': Remote port (server's listening port)
- 'local_port': Local port to bind for receiving data
"""
if self.is_open:
self.logger.warning("Already connected. Disconnecting first.")
self.disconnect()
self.logger.warning("Already connected. Returning True.")
return True
remote_ip = config.get("ip")
remote_port = config.get("port")
@ -54,6 +67,7 @@ class SFPCommunicator(CommunicatorInterface):
)
return False
result = False
try:
self._destination = (remote_ip, int(remote_port))
local_port_int = int(local_port)
@ -62,16 +76,13 @@ class SFPCommunicator(CommunicatorInterface):
f"Initializing SFP Transport: Bind {local_port_int} -> Remote {self._destination}"
)
# --- MODIFICATION START ---
# Create payload handlers if a simulation hub is provided
payload_handlers = {}
if self.simulation_hub:
self.logger.info("Simulation hub provided. Setting up simulation payload handlers.")
sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue)
payload_handlers = sim_handler.get_handlers()
else:
self.logger.warning("No simulation hub provided. SFP communicator will only send data.")
# --- MODIFICATION END ---
payload_handlers.update(sim_handler.get_handlers())
payload_handlers.update(self._extra_payload_handlers)
self.transport = SfpTransport(
host="0.0.0.0",
@ -82,16 +93,36 @@ class SFPCommunicator(CommunicatorInterface):
if self.transport.start():
self.config = config
self.logger.info("SFP Transport started successfully.")
return True
result = True
else:
self.logger.error("Failed to start SFP Transport.")
self.transport = None
return False
result = False
except Exception as e:
self.logger.error(f"Exception during SFP connect: {e}", exc_info=True)
self.transport = None
return False
result = False
self._notify_connection_state_changed()
return result
def add_payload_handlers(self, handlers: Dict[int, PayloadHandler]):
if not handlers:
return
self._extra_payload_handlers.update(handlers)
if self.transport:
self.transport.add_payload_handlers(handlers)
self.logger.info("Attached extra payload handlers to running transport.")
def remove_payload_handlers(self, handlers: Dict[int, PayloadHandler]):
if not handlers:
return
for flow in handlers.keys():
self._extra_payload_handlers.pop(flow, None)
if self.transport:
self.transport.remove_payload_handlers(handlers)
self.logger.info("Detached extra payload handlers from running transport.")
def disconnect(self) -> None:
if self.transport:
@ -100,35 +131,29 @@ class SFPCommunicator(CommunicatorInterface):
self.transport = None
self.config = None
self._destination = None
self._notify_connection_state_changed()
@property
def is_open(self) -> bool:
return self.transport is not None
return self.transport is not None and self.transport._socket is not None
def send_scenario(self, scenario: Scenario) -> bool:
"""
Sends the initial scenario state using a sequence of 'tgtinit' commands.
"""
if not self.is_open or not self._destination:
self.logger.error("Cannot send scenario: SFP not connected.")
return False
self.logger.info(f"Sending scenario '{scenario.name}' via SFP...")
# 1. Pause simulation on server
if not self._send_single_command(command_builder.build_pause()):
return False
# 2. Send init for all targets
for target in scenario.get_all_targets():
cmd = command_builder.build_tgtinit(target)
if not self._send_single_command(cmd):
self.logger.error(f"Failed to send init for target {target.target_id}")
return False
# Small delay to avoid overwhelming the server's command buffer if it has one
time.sleep(0.01)
# 3. Resume simulation
if not self._send_single_command(command_builder.build_continue()):
return False
@ -136,9 +161,6 @@ class SFPCommunicator(CommunicatorInterface):
return True
def send_commands(self, commands: List[str]) -> bool:
"""
Sends a batch of commands (typically 'tgtset' for live updates).
"""
if not self.is_open:
return False
@ -146,27 +168,19 @@ class SFPCommunicator(CommunicatorInterface):
for cmd in commands:
if not self._send_single_command(cmd):
all_success = False
# We continue trying to send the rest of the batch even if one fails
return all_success
def _send_single_command(self, command: str) -> bool:
"""Internal helper to send a single command via the transport."""
if not self.transport or not self._destination:
return False
return self.transport.send_script_command(command, self._destination)
@staticmethod
def test_connection(config: Dict[str, Any]) -> bool:
"""
Tests if we can bind to the specified local port.
Does NOT strictly test reachability of the remote server, as SFP is UDPless.
"""
local_port = config.get("local_port")
if not local_port:
return False
try:
# Try to bind a UDP socket to the local port to see if it's free
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", int(local_port)))
sock.close()
@ -176,5 +190,4 @@ class SFPCommunicator(CommunicatorInterface):
@staticmethod
def list_available_ports() -> List[str]:
"""SFP (UDP) doesn't have enumerable ports in the same way as Serial."""
return []

View File

@ -14,7 +14,7 @@ import logging
import threading
import time
import ctypes
from typing import Dict, Callable, Optional
from typing import Dict, Callable, Optional, List
from target_simulator.utils.network import create_udp_socket, close_udp_socket
from target_simulator.core.sfp_structures import SFPHeader
@ -27,9 +27,7 @@ logger = logging.getLogger(__name__)
class SfpTransport:
"""Manages SFP communication, payload reassembly, and sending."""
# Max size for a script payload, conservative to fit in server buffers.
MAX_SCRIPT_PAYLOAD_SIZE = 1020
# Size of the fixed part of the script message (the DataTag).
SCRIPT_TAG_SIZE = 8
def __init__(
@ -45,26 +43,49 @@ class SfpTransport:
self._host = host
self._port = port
self._payload_handlers = payload_handlers
self._payload_handlers: Dict[int, List[PayloadHandler]] = {}
self.add_payload_handlers(payload_handlers)
self._ack_config = ack_config if ack_config is not None else {}
self._raw_packet_callback = raw_packet_callback
self._socket: Optional[socket.socket] = None
self._receiver_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._tid_counter = 0 # Simple transaction ID counter for sending
self._send_lock = threading.Lock() # Protects TID counter and socket sending
self._tid_counter = 0
self._send_lock = threading.Lock()
# transaction state: key=(flow, tid) -> {frag_index: total_frags}
self._fragments: Dict[tuple, Dict[int, int]] = {}
# buffers for reassembly: key=(flow, tid) -> bytearray(total_size)
self._buffers: Dict[tuple, bytearray] = {}
logger.debug(f"{self._log_prefix} ACK window config: {self._ack_config}")
def add_payload_handlers(self, handlers: Dict[int, PayloadHandler]):
"""Adds payload handlers to the transport."""
if not handlers:
return
for flow, handler in handlers.items():
if flow not in self._payload_handlers:
self._payload_handlers[flow] = []
if handler not in self._payload_handlers[flow]:
self._payload_handlers[flow].append(handler)
logger.debug(
f"{self._log_prefix} Registered handlers for flows: "
f"{[chr(k) if 32 <= k <= 126 else k for k in self._payload_handlers.keys()]}"
)
logger.debug(f"{self._log_prefix} ACK window config: {self._ack_config}")
def remove_payload_handlers(self, handlers: Dict[int, PayloadHandler]):
"""Removes specified payload handlers from the transport."""
if not handlers:
return
for flow, handler in handlers.items():
if flow in self._payload_handlers:
try:
self._payload_handlers[flow].remove(handler)
if not self._payload_handlers[flow]:
del self._payload_handlers[flow]
except ValueError:
logger.warning(f"{self._log_prefix} Handler for flow {flow} not found for removal.")
def start(self) -> bool:
"""Starts the receiving thread."""
@ -107,30 +128,19 @@ class SfpTransport:
logger.info(f"{self._log_prefix} Shutdown complete.")
def send_script_command(
self, command_string: str, destination: tuple, flow_id: int = ord("R")
self,
command_string: str,
destination: tuple,
flow_id: int = ord("R")
) -> bool:
"""
Encapsulates and sends a text command as a single-fragment SFP packet.
This method constructs a payload similar to the server's `script_message_t`,
wraps it in an SFP header, and sends it to the specified destination.
Args:
command_string: The text command to send (e.g., "tgtinit ...").
destination: A tuple (ip, port) for the destination.
flow_id: The SFP flow ID to use for this message.
Returns:
True if the packet was sent successfully, False otherwise.
"""
log_prefix = f"{self._log_prefix} Send"
if not self._socket:
logger.error(f"{log_prefix} Cannot send: socket is not open.")
return False
# Define the expected payload structures using ctypes
# The server expects script_payload_t which contains a ctrl_tag and ctrl[32]
# before the script_tag, so replicate that layout here.
class LocalDataTag(ctypes.Structure):
_pack_ = 1
_fields_ = [
@ -150,15 +160,11 @@ class SfpTransport:
]
try:
# Normalize command: ensure it starts with '$' (no blank after $) and ends with newline
cs = command_string or ""
cs = cs.strip()
# if cs and not cs.startswith("$"):
# cs = "$" + cs.lstrip()
if cs and not cs.endswith("\n"):
cs = cs + "\n"
# (no transformation) send cs as-is; server-side now accepts spaces
command_bytes = cs.encode("utf-8")
if len(command_bytes) > self.MAX_SCRIPT_PAYLOAD_SIZE:
logger.error(
@ -167,7 +173,6 @@ class SfpTransport:
)
return False
# Create and populate the script payload structure
payload_struct = ScriptPayload()
payload_struct.script_tag.ID[0] = ord("C")
payload_struct.script_tag.ID[1] = ord("S")
@ -177,17 +182,14 @@ class SfpTransport:
ctypes.memmove(payload_struct.script, command_bytes, len(command_bytes))
payload_bytes = bytes(payload_struct)
# Compute the offset of the script buffer within the payload structure
try:
script_offset = ScriptPayload.script.offset
except Exception:
# Fallback: assume script tag only (legacy)
script_offset = self.SCRIPT_TAG_SIZE
actual_payload_size = script_offset + len(command_bytes)
payload_bytes = payload_bytes[:actual_payload_size]
# Create and populate the SFP header
header = SFPHeader()
with self._send_lock:
self._tid_counter = (self._tid_counter + 1) % 256
@ -195,7 +197,7 @@ class SfpTransport:
header.SFP_DIRECTION = ord(">")
header.SFP_FLOW = flow_id
header.SFP_TOTFRGAS = 1 # Single fragment message
header.SFP_TOTFRGAS = 1
header.SFP_FRAG = 0
header.SFP_PLSIZE = len(payload_bytes)
header.SFP_TOTSIZE = len(payload_bytes)
@ -203,9 +205,7 @@ class SfpTransport:
full_packet = bytes(header) + payload_bytes
# Send the packet
self._socket.sendto(full_packet, destination)
# Log the actual normalized command that was placed into the payload
try:
sent_preview = (
cs if isinstance(cs, str) else cs.decode("utf-8", errors="replace")
@ -270,7 +270,6 @@ class SfpTransport:
pl_size = header.SFP_PLSIZE
pl_offset = header.SFP_PLOFFSET
total_size = header.SFP_TOTSIZE
flags = header.SFP_FLAGS
key = (flow, tid)
@ -286,9 +285,6 @@ class SfpTransport:
if frag == 0:
self._cleanup_lingering_transactions(flow, tid)
# logger.debug(
# f"New transaction started for key={key}. Total size: {total_size} bytes."
# )
self._fragments[key] = {}
try:
self._buffers[key] = bytearray(total_size)
@ -320,21 +316,18 @@ class SfpTransport:
]
if len(self._fragments[key]) == total_frags:
# logger.debug(
# f"Transaction complete for key={key}. Handing off to application layer."
# )
completed_payload = self._buffers.pop(key)
self._fragments.pop(key)
handler = self._payload_handlers.get(flow)
if handler:
try:
handler(completed_payload)
except Exception:
logger.exception(
f"Error executing payload handler for flow {flow}."
)
handlers = self._payload_handlers.get(flow)
if handlers:
for handler in handlers:
try:
handler(completed_payload)
except Exception:
logger.exception(
f"Error executing payload handler for flow {flow}."
)
else:
logger.warning(f"No payload handler registered for flow ID {flow}.")

View File

@ -50,7 +50,7 @@ class SimulationPayloadHandler:
Parses an SFP_RIS::status_message_t payload and updates the hub.
"""
payload_size = len(payload)
self.logger.debug(f"Received RIS payload of {payload_size} bytes")
#self.logger.debug(f"Received RIS payload of {payload_size} bytes")
expected_size = SfpRisStatusPayload.size()
if payload_size < expected_size:

View File

@ -1,3 +1,4 @@
# target_simulator/gui/main_view.py
"""
@ -70,6 +71,11 @@ class MainView(tk.Tk):
self.is_simulation_running = tk.BooleanVar(value=False)
self.time_multiplier = 1.0
self.update_time = tk.DoubleVar(value=1.0)
# Simulation progress tracking
self.total_sim_time = 0.0
self.sim_elapsed_time = 0.0
self.sim_slider_var = tk.DoubleVar(value=0.0)
self._slider_is_dragging = False
# --- Window and UI Setup ---
self.title("Radar Target Simulator")
@ -104,14 +110,15 @@ class MainView(tk.Tk):
)
self.h_pane.add(self.ppi_widget, weight=2)
# Add Connect button to the PPI's own control frame for better layout
if hasattr(self.ppi_widget, "controls_frame"):
connect_btn = ttk.Button(
self.ppi_widget.controls_frame,
text="Connect",
command=self._on_connect_button,
)
connect_btn.pack(side=tk.RIGHT, padx=10)
# Wire the PPI's built-in connect toggle to the MainView connect handler
try:
if hasattr(self.ppi_widget, "set_connect_callback"):
self.ppi_widget.set_connect_callback(self._on_connect_button)
# Reflect initial connection state (likely disconnected)
if hasattr(self.ppi_widget, "update_connect_state"):
self.ppi_widget.update_connect_state(False)
except Exception:
pass
# --- Left Pane ---
left_pane_container = ttk.Frame(self.h_pane)
@ -163,10 +170,19 @@ class MainView(tk.Tk):
engine_frame = ttk.LabelFrame(simulation_tab, text="Live Simulation Engine")
engine_frame.pack(fill=tk.X, padx=5, pady=10, anchor="n")
# Use grid within engine_frame for a tidy multi-row layout that
# doesn't force the window to expand horizontally and keeps the PPI
# area visible. Configure columns so the middle spacer expands.
for i in range(10):
engine_frame.grid_columnconfigure(i, weight=0)
# Give the spacer column (3) and the main left column (0) flexible weight
engine_frame.grid_columnconfigure(0, weight=0)
engine_frame.grid_columnconfigure(3, weight=1)
self.start_button = ttk.Button(
engine_frame, text="Start Live", command=self._on_start_simulation
)
self.start_button.pack(side=tk.LEFT, padx=5, pady=5)
self.start_button.grid(row=0, column=0, sticky="w", padx=5, pady=5)
self.stop_button = ttk.Button(
engine_frame,
@ -174,27 +190,21 @@ class MainView(tk.Tk):
command=self._on_stop_simulation,
state=tk.DISABLED,
)
self.stop_button.pack(side=tk.LEFT, padx=5, pady=5)
self.stop_button.grid(row=0, column=1, sticky="w", padx=5, pady=5)
self.analysis_button = ttk.Button(
engine_frame,
text="Show Analysis",
command=self._open_analysis_window,
state=tk.DISABLED
state=tk.DISABLED,
)
self.analysis_button.pack(side=tk.LEFT, padx=15, pady=5)
self.analysis_button.grid(row=0, column=2, sticky="w", padx=5, pady=5)
self.reset_button = ttk.Button(
engine_frame, text="Reset State", command=self._on_reset_simulation
)
self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5)
# spacer to push the following controls to the right
spacer = ttk.Frame(engine_frame)
spacer.grid(row=0, column=3, sticky="ew")
self.reset_radar_button = ttk.Button(
engine_frame, text="Reset Radar", command=self._reset_radar_state
)
self.reset_radar_button.pack(side=tk.RIGHT, padx=5, pady=5)
ttk.Label(engine_frame, text="Speed:").pack(side=tk.LEFT, padx=(10, 2), pady=5)
ttk.Label(engine_frame, text="Speed:").grid(row=0, column=4, sticky="e", padx=(10, 2), pady=5)
self.time_multiplier_var = tk.StringVar(value="1x")
self.multiplier_combo = ttk.Combobox(
engine_frame,
@ -203,18 +213,64 @@ class MainView(tk.Tk):
state="readonly",
width=4,
)
self.multiplier_combo.pack(side=tk.LEFT, padx=(0, 5), pady=5)
self.multiplier_combo.grid(row=0, column=5, sticky="w", padx=(0, 5), pady=5)
self.multiplier_combo.bind(
"<<ComboboxSelected>>", self._on_time_multiplier_changed
)
ttk.Label(engine_frame, text="Update Time (s):").pack(
side=tk.LEFT, padx=(10, 2), pady=5
)
ttk.Label(engine_frame, text="Update Time (s):").grid(row=0, column=6, sticky="e", padx=(10, 2), pady=5)
self.update_time_entry = ttk.Entry(
engine_frame, textvariable=self.update_time, width=5
)
self.update_time_entry.pack(side=tk.LEFT, padx=(0, 5), pady=5)
self.update_time_entry.grid(row=0, column=7, sticky="w", padx=(0, 5), pady=5)
self.reset_button = ttk.Button(
engine_frame, text="Reset State", command=self._on_reset_simulation
)
self.reset_button.grid(row=0, column=8, sticky="e", padx=5, pady=5)
self.reset_radar_button = ttk.Button(
engine_frame, text="Reset Radar", command=self._reset_radar_state
)
self.reset_radar_button.grid(row=0, column=9, sticky="e", padx=5, pady=5)
# --- Simulation progress bar / slider ---
# Place the progress frame on its own row below the control buttons
progress_frame = ttk.Frame(engine_frame)
# Place the progress frame on a dedicated grid row below the controls
progress_frame.grid(row=1, column=0, columnspan=10, sticky="ew", padx=5, pady=(6, 2))
self.sim_slider = ttk.Scale(
progress_frame,
orient=tk.HORIZONTAL,
variable=self.sim_slider_var,
from_=0.0,
to=1.0,
command=lambda v: None,
# let grid manage length via sticky and column weights
)
# configure progress_frame grid so slider expands and labels stay compact
progress_frame.grid_columnconfigure(0, weight=1)
progress_frame.grid_columnconfigure(1, weight=0)
self.sim_slider.grid(row=0, column=0, sticky="ew", padx=(4, 8))
# Bind press/release to support seeking
try:
self.sim_slider.bind("<ButtonPress-1>", lambda e: setattr(self, '_slider_is_dragging', True))
self.sim_slider.bind("<ButtonRelease-1>", lambda e: (setattr(self, '_slider_is_dragging', False), self._on_seek()))
except Exception:
pass
# Time labels showing elapsed and total separately at the end of the bar
labels_frame = ttk.Frame(progress_frame)
labels_frame.grid(row=0, column=1, sticky="e", padx=(4, 4))
self.sim_elapsed_label = ttk.Label(labels_frame, text="0.0s", width=8, anchor=tk.E)
self.sim_elapsed_label.grid(row=0, column=0)
slash_label = ttk.Label(labels_frame, text="/")
slash_label.grid(row=0, column=1, padx=(2, 2))
self.sim_total_label = ttk.Label(labels_frame, text="0.0s", width=8, anchor=tk.W)
self.sim_total_label.grid(row=0, column=2)
# --- TAB 3: LRU SIMULATION ---
lru_tab = ttk.Frame(left_notebook)
@ -316,6 +372,19 @@ class MainView(tk.Tk):
color = "#2ecc40" if is_connected else "#e74c3c"
self._draw_status_indicator(canvas, color)
def _on_connection_state_change(self, is_connected: bool):
"""Callback for communicator connection state changes."""
self.logger.info(f"MainView received connection state change: Connected={is_connected}")
self._update_communicator_status("Target", is_connected)
if hasattr(self.ppi_widget, 'update_connect_state'):
self.ppi_widget.update_connect_state(is_connected)
# Also update the debug window if it's open
if self.sfp_debug_window and self.sfp_debug_window.winfo_exists():
if hasattr(self.sfp_debug_window, 'update_toggle_state'):
self.sfp_debug_window.update_toggle_state(is_connected)
def _initialize_communicators(self):
# Disconnect any existing connections
if self.target_communicator and self.target_communicator.is_open:
@ -354,6 +423,7 @@ class MainView(tk.Tk):
elif comm_type == "sfp":
# --- MODIFICATION: Pass the hub and GUI update queue to the communicator ---
communicator = SFPCommunicator(simulation_hub=self.simulation_hub, update_queue=self.gui_update_queue)
communicator.add_connection_state_callback(self._on_connection_state_change)
config_data = config.get("sfp", {})
if self.defer_sfp_connection:
# Return the communicator object but indicate it's not yet connected
@ -377,8 +447,40 @@ class MainView(tk.Tk):
ConnectionSettingsWindow(self, self.config_manager, self.connection_config)
def _on_connect_button(self):
self.logger.info("Connection requested by user.")
self._initialize_communicators()
self.logger.info("Connection toggle requested by user via PPI button.")
try:
# If communicator exists and is open, disconnect.
if self.target_communicator and self.target_communicator.is_open:
self.logger.info("Requesting disconnect.")
self.target_communicator.disconnect()
return
# Otherwise, attempt to connect.
self.logger.info("Requesting connect.")
# Ensure we have a communicator instance.
if not self.target_communicator:
self.logger.info("No target communicator instance. Initializing communicators.")
self._initialize_communicators()
# If it's still null after init, we can't proceed.
if not self.target_communicator:
self.logger.error("Failed to create target communicator on demand.")
messagebox.showerror("Error", "Could not create communicator.")
return
# Now, connect using the existing or new instance.
cfg = self.connection_config.get("target", {})
sfp_cfg = cfg.get("sfp")
if cfg.get("type") == "sfp" and sfp_cfg:
if not self.target_communicator.connect(sfp_cfg):
self.logger.error("Failed to connect target communicator.")
messagebox.showerror("Connection Failed", "Could not connect to target. Check settings and logs.")
else:
self.logger.warning("Connection attempt without valid SFP config. Running full re-initialization.")
self._initialize_communicators()
except Exception:
self.logger.exception("Unhandled exception in _on_connect_button")
def _reset_radar_state(self) -> bool:
"""
@ -550,6 +652,22 @@ class MainView(tk.Tk):
self.simulation_engine.set_time_multiplier(self.time_multiplier)
self.simulation_engine.set_update_interval(update_interval)
self.simulation_engine.load_scenario(self.scenario)
# Initialize simulation progress tracking
try:
durations = [getattr(t, '_total_duration_s', 0.0) for t in self.scenario.get_all_targets()]
self.total_sim_time = max(durations) if durations else 0.0
except Exception:
self.total_sim_time = 0.0
# Reset slider and label
self.sim_elapsed_time = 0.0
try:
self.sim_slider_var.set(0.0)
except Exception:
pass
self._update_simulation_progress_display()
self.simulation_engine.start()
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
@ -571,6 +689,10 @@ class MainView(tk.Tk):
self.logger.exception("Error while disconnecting target communicator.")
# Update visual status
self._update_communicator_status("Target", False)
try:
self.ppi_widget.update_connect_state(False)
except Exception:
pass
except Exception:
self.logger.exception("Unexpected error while attempting to disconnect target communicator.")
@ -592,7 +714,15 @@ class MainView(tk.Tk):
if update == "SIMULATION_FINISHED":
self.logger.info("Simulation finished signal received.")
# Ensure engine is stopped and UI reset
self._on_stop_simulation()
# Reset progress UI to final state
try:
self.sim_elapsed_time = self.total_sim_time
self.sim_slider_var.set(1.0 if self.total_sim_time > 0 else 0.0)
except Exception:
pass
self._update_simulation_progress_display()
elif isinstance(update, list):
# The engine normally enqueues a List[Target] (simulated targets).
@ -616,12 +746,37 @@ class MainView(tk.Tk):
display_data = self._build_display_data_from_hub()
self.ppi_widget.update_targets(display_data)
# Update progress using target times from scenario
try:
# Use the engine's scenario simulated time as elapsed if available
if self.simulation_engine and self.simulation_engine.scenario:
# Derive elapsed as the max of target sim times
times = [getattr(t, '_sim_time_s', 0.0) for t in self.simulation_engine.scenario.get_all_targets()]
self.sim_elapsed_time = max(times) if times else 0.0
else:
self.sim_elapsed_time += 0.0
# Update slider only if user is not interacting with it
if self.total_sim_time > 0 and not getattr(self, '_slider_is_dragging', False):
progress_frac = min(1.0, max(0.0, self.sim_elapsed_time / self.total_sim_time))
self.sim_slider_var.set(progress_frac)
self._update_simulation_progress_display()
except Exception:
# Do not allow progress UI failures to interrupt GUI updates
self.logger.debug("Progress UI update failed", exc_info=True)
except Empty:
# If the queue is empty, we don't need to do anything
pass
finally:
if self.is_simulation_running.get():
# Always continue polling the GUI update queue so we can show
# real-time server updates on the PPI even when the live
# simulation engine is not running.
try:
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
except Exception:
pass
def _update_button_states(self):
is_running = self.is_simulation_running.get()
@ -665,6 +820,43 @@ class MainView(tk.Tk):
)
self.time_multiplier = 1.0
def _update_simulation_progress_display(self):
"""Updates the elapsed/total time label from internal state."""
try:
elapsed = self.sim_elapsed_time
total = self.total_sim_time
# Update separate labels for elapsed and total time
try:
self.sim_elapsed_label.config(text=f"{elapsed:.1f}s")
self.sim_total_label.config(text=f"{total:.1f}s")
except Exception:
# Fallback for older layouts
if hasattr(self, 'sim_time_label'):
self.sim_time_label.config(text=f"{elapsed:.1f}s / {total:.1f}s")
except Exception:
pass
def _on_seek(self):
"""Called when the user releases the progress slider to seek."""
try:
if not self.simulation_engine or not self.simulation_engine.scenario:
return
frac = float(self.sim_slider_var.get())
# Compute the new time and clamp
new_time = max(0.0, min(self.total_sim_time, frac * self.total_sim_time))
# Ask engine to seek to this new time
try:
self.simulation_engine.set_simulation_time(new_time)
# Immediately update internal elapsed time and label
self.sim_elapsed_time = new_time
self._update_simulation_progress_display()
except Exception:
self.logger.exception("Failed to seek simulation time.")
except Exception:
self.logger.exception("Error in _on_seek handler.")
def _on_targets_changed(self, targets: List[Target]):
"""Callback executed when the target list is modified by the user."""
# 1. Update the internal scenario object
@ -858,8 +1050,12 @@ class MainView(tk.Tk):
self.config_manager.save_general_settings(settings_to_save)
self.config_manager.save_connection_settings(self.connection_config)
if self.target_communicator and self.target_communicator.is_open:
self.target_communicator.disconnect()
if self.target_communicator:
if hasattr(self.target_communicator, 'remove_connection_state_callback'):
self.target_communicator.remove_connection_state_callback(self._on_connection_state_change)
if self.target_communicator.is_open:
self.target_communicator.disconnect()
if self.lru_communicator and self.lru_communicator.is_open:
self.lru_communicator.disconnect()

View File

@ -40,19 +40,21 @@ class DebugPayloadRouter:
os.makedirs(self._persist_dir, exist_ok=True)
except Exception:
pass
# Create handlers once and store them to ensure stable object references
self._handlers = {
ord("M"): lambda p: self._update_last_payload("MFD", p),
ord("S"): lambda p: self._update_last_payload("SAR", p),
ord("B"): lambda p: self._update_last_payload("BIN", p),
ord("J"): lambda p: self._update_last_payload("JSON", p),
ord("R"): self._handle_ris_status,
ord("r"): self._handle_ris_status,
}
logging.info(f"{self._log_prefix} Initialized.")
def get_handlers(self) -> Dict[int, Any]:
"""Returns handlers that update the internal last-payload buffer."""
return {
ord("M"): lambda payload: self._update_last_payload("MFD", payload),
ord("S"): lambda payload: self._update_last_payload("SAR", payload),
ord("B"): lambda payload: self._update_last_payload("BIN", payload),
ord("J"): lambda payload: self._update_last_payload("JSON", payload),
# Support both uppercase 'R' and lowercase 'r' as RIS/status flows
ord("R"): lambda payload: self._handle_ris_status(payload),
ord("r"): lambda payload: self._handle_ris_status(payload),
}
"""Returns the stored handler instances."""
return self._handlers
def _update_last_payload(self, flow_id: str, payload: bytearray):
"""Thread-safely stores the latest payload for a given flow."""

View File

@ -84,6 +84,12 @@ class PPIDisplay(ttk.Frame):
self.range_selector.pack(side=tk.LEFT, padx=5)
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
# Connection toggle (Connect / Disconnect) for SFP
self._connect_callback = None
self._is_connected = False
self.connect_btn = ttk.Button(self.controls_frame, text="Connect", command=self._on_connect_btn)
self.connect_btn.pack(side=tk.RIGHT, padx=10)
# --- Display Options ---
options_frame = ttk.LabelFrame(top_frame, text="Display Options")
options_frame.pack(side=tk.RIGHT, padx=(10, 0))
@ -224,6 +230,59 @@ class PPIDisplay(ttk.Frame):
self.canvas.draw_idle()
# --- Connection toggle API ---
def _on_connect_btn(self):
if callable(self._connect_callback):
try:
self._connect_callback()
except Exception:
pass
def set_connect_callback(self, cb):
"""Register a callback to be executed when the PPI Connect button is pressed.
The callback should handle connecting/disconnecting logic at the application level.
"""
self._connect_callback = cb
def update_connect_state(self, is_connected: bool):
"""Update the Connect button label/state to reflect current connection status."""
try:
self._is_connected = bool(is_connected)
if self._is_connected:
self.connect_btn.config(text="Disconnect")
else:
self.connect_btn.config(text="Connect")
except Exception:
pass
# --- Connection toggle API ---
def _on_connect_btn(self):
if callable(self._connect_callback):
try:
self._connect_callback()
except Exception:
# Allow caller to handle exceptions and update state
pass
def set_connect_callback(self, cb):
"""Register a callback to be executed when the PPI Connect button is pressed.
The callback should handle connecting/disconnecting logic at the application level.
"""
self._connect_callback = cb
def update_connect_state(self, is_connected: bool):
"""Update the Connect button label/state to reflect current connection status."""
try:
self._is_connected = bool(is_connected)
if self._is_connected:
self.connect_btn.config(text="Disconnect")
else:
self.connect_btn.config(text="Connect")
except Exception:
pass
def _draw_target_visuals(self, targets: List[Target], color: str, artist_list: List):
"""Helper to draw dots and vectors for a list of targets."""
vector_len_nm = self.range_var.get() / 20.0

File diff suppressed because it is too large Load Diff

View File

@ -60,6 +60,18 @@ class ConfigManager:
# Load scenarios from separate file if present, otherwise keep any scenarios
# found inside settings.json (fallback).
self._scenarios = self._load_or_initialize_scenarios()
# Apply debug overrides (if present) into the global DEBUG_CONFIG so
# runtime helpers (e.g., csv_logger) pick up user-configured values.
try:
from target_simulator.config import DEBUG_CONFIG
debug_block = self._settings.get("debug", {}) if isinstance(self._settings, dict) else {}
if isinstance(debug_block, dict):
for k, v in debug_block.items():
DEBUG_CONFIG[k] = v
except Exception:
# If anything goes wrong here, we don't want to fail initialization.
pass
def _load_or_initialize_settings(self) -> Dict[str, Any]:
"""Loads settings from the JSON file or initializes with a default structure."""

View File

@ -36,6 +36,9 @@ def create_udp_socket(local_ip, local_port):
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
logging.debug(f"{log_prefix} Socket object created.")
# Set a short timeout to prevent the receive loop from blocking indefinitely
sock.settimeout(0.1)
# --- Receive Buffer Size Adjustment ---
try:
# Get the default buffer size (INFO level is okay for this setup detail)