refactoring della gestione della comunicazione con il server e condivisione tra finestre
This commit is contained in:
parent
c9ab1a792d
commit
92b7a12492
@ -3,7 +3,7 @@
|
|||||||
"scan_limit": 60,
|
"scan_limit": 60,
|
||||||
"max_range": 100,
|
"max_range": 100,
|
||||||
"geometry": "1599x1024+626+57",
|
"geometry": "1599x1024+626+57",
|
||||||
"last_selected_scenario": null,
|
"last_selected_scenario": "scenario_dritto",
|
||||||
"connection": {
|
"connection": {
|
||||||
"target": {
|
"target": {
|
||||||
"type": "sfp",
|
"type": "sfp",
|
||||||
|
|||||||
@ -6,12 +6,12 @@ Handles SFP (Simple Fragmentation Protocol) communication with the target device
|
|||||||
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any, Callable
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
|
|
||||||
from target_simulator.core.communicator_interface import CommunicatorInterface
|
from target_simulator.core.communicator_interface import CommunicatorInterface
|
||||||
from target_simulator.core.models import Scenario
|
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.core import command_builder
|
||||||
from target_simulator.utils.logger import get_logger
|
from target_simulator.utils.logger import get_logger
|
||||||
from target_simulator.core.simulation_payload_handler import SimulationPayloadHandler
|
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):
|
class SFPCommunicator(CommunicatorInterface):
|
||||||
"""
|
"""
|
||||||
A communicator that uses the SFP transport layer to send commands
|
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):
|
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.transport: Optional[SfpTransport] = None
|
||||||
self.config: Optional[Dict[str, Any]] = None
|
self.config: Optional[Dict[str, Any]] = None
|
||||||
self._destination: Optional[tuple] = 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.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:
|
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:
|
if self.is_open:
|
||||||
self.logger.warning("Already connected. Disconnecting first.")
|
self.logger.warning("Already connected. Returning True.")
|
||||||
self.disconnect()
|
return True
|
||||||
|
|
||||||
remote_ip = config.get("ip")
|
remote_ip = config.get("ip")
|
||||||
remote_port = config.get("port")
|
remote_port = config.get("port")
|
||||||
@ -54,6 +67,7 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
result = False
|
||||||
try:
|
try:
|
||||||
self._destination = (remote_ip, int(remote_port))
|
self._destination = (remote_ip, int(remote_port))
|
||||||
local_port_int = int(local_port)
|
local_port_int = int(local_port)
|
||||||
@ -62,17 +76,14 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
f"Initializing SFP Transport: Bind {local_port_int} -> Remote {self._destination}"
|
f"Initializing SFP Transport: Bind {local_port_int} -> Remote {self._destination}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- MODIFICATION START ---
|
|
||||||
# Create payload handlers if a simulation hub is provided
|
|
||||||
payload_handlers = {}
|
payload_handlers = {}
|
||||||
if self.simulation_hub:
|
if self.simulation_hub:
|
||||||
self.logger.info("Simulation hub provided. Setting up simulation payload handlers.")
|
self.logger.info("Simulation hub provided. Setting up simulation payload handlers.")
|
||||||
sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue)
|
sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue)
|
||||||
payload_handlers = sim_handler.get_handlers()
|
payload_handlers.update(sim_handler.get_handlers())
|
||||||
else:
|
|
||||||
self.logger.warning("No simulation hub provided. SFP communicator will only send data.")
|
|
||||||
# --- MODIFICATION END ---
|
|
||||||
|
|
||||||
|
payload_handlers.update(self._extra_payload_handlers)
|
||||||
|
|
||||||
self.transport = SfpTransport(
|
self.transport = SfpTransport(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0",
|
||||||
port=local_port_int,
|
port=local_port_int,
|
||||||
@ -82,16 +93,36 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
if self.transport.start():
|
if self.transport.start():
|
||||||
self.config = config
|
self.config = config
|
||||||
self.logger.info("SFP Transport started successfully.")
|
self.logger.info("SFP Transport started successfully.")
|
||||||
return True
|
result = True
|
||||||
else:
|
else:
|
||||||
self.logger.error("Failed to start SFP Transport.")
|
self.logger.error("Failed to start SFP Transport.")
|
||||||
self.transport = None
|
self.transport = None
|
||||||
return False
|
result = False
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Exception during SFP connect: {e}", exc_info=True)
|
self.logger.error(f"Exception during SFP connect: {e}", exc_info=True)
|
||||||
self.transport = None
|
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:
|
def disconnect(self) -> None:
|
||||||
if self.transport:
|
if self.transport:
|
||||||
@ -100,35 +131,29 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
self.transport = None
|
self.transport = None
|
||||||
self.config = None
|
self.config = None
|
||||||
self._destination = None
|
self._destination = None
|
||||||
|
self._notify_connection_state_changed()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_open(self) -> bool:
|
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:
|
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:
|
if not self.is_open or not self._destination:
|
||||||
self.logger.error("Cannot send scenario: SFP not connected.")
|
self.logger.error("Cannot send scenario: SFP not connected.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.logger.info(f"Sending scenario '{scenario.name}' via SFP...")
|
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()):
|
if not self._send_single_command(command_builder.build_pause()):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 2. Send init for all targets
|
|
||||||
for target in scenario.get_all_targets():
|
for target in scenario.get_all_targets():
|
||||||
cmd = command_builder.build_tgtinit(target)
|
cmd = command_builder.build_tgtinit(target)
|
||||||
if not self._send_single_command(cmd):
|
if not self._send_single_command(cmd):
|
||||||
self.logger.error(f"Failed to send init for target {target.target_id}")
|
self.logger.error(f"Failed to send init for target {target.target_id}")
|
||||||
return False
|
return False
|
||||||
# Small delay to avoid overwhelming the server's command buffer if it has one
|
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
# 3. Resume simulation
|
|
||||||
if not self._send_single_command(command_builder.build_continue()):
|
if not self._send_single_command(command_builder.build_continue()):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -136,9 +161,6 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def send_commands(self, commands: List[str]) -> bool:
|
def send_commands(self, commands: List[str]) -> bool:
|
||||||
"""
|
|
||||||
Sends a batch of commands (typically 'tgtset' for live updates).
|
|
||||||
"""
|
|
||||||
if not self.is_open:
|
if not self.is_open:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -146,27 +168,19 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
if not self._send_single_command(cmd):
|
if not self._send_single_command(cmd):
|
||||||
all_success = False
|
all_success = False
|
||||||
# We continue trying to send the rest of the batch even if one fails
|
|
||||||
|
|
||||||
return all_success
|
return all_success
|
||||||
|
|
||||||
def _send_single_command(self, command: str) -> bool:
|
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:
|
if not self.transport or not self._destination:
|
||||||
return False
|
return False
|
||||||
return self.transport.send_script_command(command, self._destination)
|
return self.transport.send_script_command(command, self._destination)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def test_connection(config: Dict[str, Any]) -> bool:
|
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")
|
local_port = config.get("local_port")
|
||||||
if not local_port:
|
if not local_port:
|
||||||
return False
|
return False
|
||||||
try:
|
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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.bind(("0.0.0.0", int(local_port)))
|
sock.bind(("0.0.0.0", int(local_port)))
|
||||||
sock.close()
|
sock.close()
|
||||||
@ -176,5 +190,4 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_available_ports() -> List[str]:
|
def list_available_ports() -> List[str]:
|
||||||
"""SFP (UDP) doesn't have enumerable ports in the same way as Serial."""
|
|
||||||
return []
|
return []
|
||||||
@ -14,7 +14,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import ctypes
|
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.utils.network import create_udp_socket, close_udp_socket
|
||||||
from target_simulator.core.sfp_structures import SFPHeader
|
from target_simulator.core.sfp_structures import SFPHeader
|
||||||
@ -27,9 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
class SfpTransport:
|
class SfpTransport:
|
||||||
"""Manages SFP communication, payload reassembly, and sending."""
|
"""Manages SFP communication, payload reassembly, and sending."""
|
||||||
|
|
||||||
# Max size for a script payload, conservative to fit in server buffers.
|
|
||||||
MAX_SCRIPT_PAYLOAD_SIZE = 1020
|
MAX_SCRIPT_PAYLOAD_SIZE = 1020
|
||||||
# Size of the fixed part of the script message (the DataTag).
|
|
||||||
SCRIPT_TAG_SIZE = 8
|
SCRIPT_TAG_SIZE = 8
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -45,26 +43,49 @@ class SfpTransport:
|
|||||||
|
|
||||||
self._host = host
|
self._host = host
|
||||||
self._port = port
|
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._ack_config = ack_config if ack_config is not None else {}
|
||||||
self._raw_packet_callback = raw_packet_callback
|
self._raw_packet_callback = raw_packet_callback
|
||||||
|
|
||||||
self._socket: Optional[socket.socket] = None
|
self._socket: Optional[socket.socket] = None
|
||||||
self._receiver_thread: Optional[threading.Thread] = None
|
self._receiver_thread: Optional[threading.Thread] = None
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
self._tid_counter = 0 # Simple transaction ID counter for sending
|
self._tid_counter = 0
|
||||||
self._send_lock = threading.Lock() # Protects TID counter and socket sending
|
self._send_lock = threading.Lock()
|
||||||
|
|
||||||
# transaction state: key=(flow, tid) -> {frag_index: total_frags}
|
|
||||||
self._fragments: Dict[tuple, Dict[int, int]] = {}
|
self._fragments: Dict[tuple, Dict[int, int]] = {}
|
||||||
# buffers for reassembly: key=(flow, tid) -> bytearray(total_size)
|
|
||||||
self._buffers: Dict[tuple, bytearray] = {}
|
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(
|
logger.debug(
|
||||||
f"{self._log_prefix} Registered handlers for flows: "
|
f"{self._log_prefix} Registered handlers for flows: "
|
||||||
f"{[chr(k) if 32 <= k <= 126 else k for k in self._payload_handlers.keys()]}"
|
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:
|
def start(self) -> bool:
|
||||||
"""Starts the receiving thread."""
|
"""Starts the receiving thread."""
|
||||||
@ -107,30 +128,19 @@ class SfpTransport:
|
|||||||
logger.info(f"{self._log_prefix} Shutdown complete.")
|
logger.info(f"{self._log_prefix} Shutdown complete.")
|
||||||
|
|
||||||
def send_script_command(
|
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:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Encapsulates and sends a text command as a single-fragment SFP packet.
|
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"
|
log_prefix = f"{self._log_prefix} Send"
|
||||||
if not self._socket:
|
if not self._socket:
|
||||||
logger.error(f"{log_prefix} Cannot send: socket is not open.")
|
logger.error(f"{log_prefix} Cannot send: socket is not open.")
|
||||||
return False
|
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):
|
class LocalDataTag(ctypes.Structure):
|
||||||
_pack_ = 1
|
_pack_ = 1
|
||||||
_fields_ = [
|
_fields_ = [
|
||||||
@ -150,15 +160,11 @@ class SfpTransport:
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Normalize command: ensure it starts with '$' (no blank after $) and ends with newline
|
|
||||||
cs = command_string or ""
|
cs = command_string or ""
|
||||||
cs = cs.strip()
|
cs = cs.strip()
|
||||||
# if cs and not cs.startswith("$"):
|
|
||||||
# cs = "$" + cs.lstrip()
|
|
||||||
if cs and not cs.endswith("\n"):
|
if cs and not cs.endswith("\n"):
|
||||||
cs = cs + "\n"
|
cs = cs + "\n"
|
||||||
|
|
||||||
# (no transformation) send cs as-is; server-side now accepts spaces
|
|
||||||
command_bytes = cs.encode("utf-8")
|
command_bytes = cs.encode("utf-8")
|
||||||
if len(command_bytes) > self.MAX_SCRIPT_PAYLOAD_SIZE:
|
if len(command_bytes) > self.MAX_SCRIPT_PAYLOAD_SIZE:
|
||||||
logger.error(
|
logger.error(
|
||||||
@ -167,7 +173,6 @@ class SfpTransport:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Create and populate the script payload structure
|
|
||||||
payload_struct = ScriptPayload()
|
payload_struct = ScriptPayload()
|
||||||
payload_struct.script_tag.ID[0] = ord("C")
|
payload_struct.script_tag.ID[0] = ord("C")
|
||||||
payload_struct.script_tag.ID[1] = ord("S")
|
payload_struct.script_tag.ID[1] = ord("S")
|
||||||
@ -177,17 +182,14 @@ class SfpTransport:
|
|||||||
ctypes.memmove(payload_struct.script, command_bytes, len(command_bytes))
|
ctypes.memmove(payload_struct.script, command_bytes, len(command_bytes))
|
||||||
|
|
||||||
payload_bytes = bytes(payload_struct)
|
payload_bytes = bytes(payload_struct)
|
||||||
# Compute the offset of the script buffer within the payload structure
|
|
||||||
try:
|
try:
|
||||||
script_offset = ScriptPayload.script.offset
|
script_offset = ScriptPayload.script.offset
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback: assume script tag only (legacy)
|
|
||||||
script_offset = self.SCRIPT_TAG_SIZE
|
script_offset = self.SCRIPT_TAG_SIZE
|
||||||
|
|
||||||
actual_payload_size = script_offset + len(command_bytes)
|
actual_payload_size = script_offset + len(command_bytes)
|
||||||
payload_bytes = payload_bytes[:actual_payload_size]
|
payload_bytes = payload_bytes[:actual_payload_size]
|
||||||
|
|
||||||
# Create and populate the SFP header
|
|
||||||
header = SFPHeader()
|
header = SFPHeader()
|
||||||
with self._send_lock:
|
with self._send_lock:
|
||||||
self._tid_counter = (self._tid_counter + 1) % 256
|
self._tid_counter = (self._tid_counter + 1) % 256
|
||||||
@ -195,7 +197,7 @@ class SfpTransport:
|
|||||||
|
|
||||||
header.SFP_DIRECTION = ord(">")
|
header.SFP_DIRECTION = ord(">")
|
||||||
header.SFP_FLOW = flow_id
|
header.SFP_FLOW = flow_id
|
||||||
header.SFP_TOTFRGAS = 1 # Single fragment message
|
header.SFP_TOTFRGAS = 1
|
||||||
header.SFP_FRAG = 0
|
header.SFP_FRAG = 0
|
||||||
header.SFP_PLSIZE = len(payload_bytes)
|
header.SFP_PLSIZE = len(payload_bytes)
|
||||||
header.SFP_TOTSIZE = len(payload_bytes)
|
header.SFP_TOTSIZE = len(payload_bytes)
|
||||||
@ -203,9 +205,7 @@ class SfpTransport:
|
|||||||
|
|
||||||
full_packet = bytes(header) + payload_bytes
|
full_packet = bytes(header) + payload_bytes
|
||||||
|
|
||||||
# Send the packet
|
|
||||||
self._socket.sendto(full_packet, destination)
|
self._socket.sendto(full_packet, destination)
|
||||||
# Log the actual normalized command that was placed into the payload
|
|
||||||
try:
|
try:
|
||||||
sent_preview = (
|
sent_preview = (
|
||||||
cs if isinstance(cs, str) else cs.decode("utf-8", errors="replace")
|
cs if isinstance(cs, str) else cs.decode("utf-8", errors="replace")
|
||||||
@ -270,7 +270,6 @@ class SfpTransport:
|
|||||||
pl_size = header.SFP_PLSIZE
|
pl_size = header.SFP_PLSIZE
|
||||||
pl_offset = header.SFP_PLOFFSET
|
pl_offset = header.SFP_PLOFFSET
|
||||||
total_size = header.SFP_TOTSIZE
|
total_size = header.SFP_TOTSIZE
|
||||||
flags = header.SFP_FLAGS
|
|
||||||
|
|
||||||
key = (flow, tid)
|
key = (flow, tid)
|
||||||
|
|
||||||
@ -286,9 +285,6 @@ class SfpTransport:
|
|||||||
|
|
||||||
if frag == 0:
|
if frag == 0:
|
||||||
self._cleanup_lingering_transactions(flow, tid)
|
self._cleanup_lingering_transactions(flow, tid)
|
||||||
# logger.debug(
|
|
||||||
# f"New transaction started for key={key}. Total size: {total_size} bytes."
|
|
||||||
# )
|
|
||||||
self._fragments[key] = {}
|
self._fragments[key] = {}
|
||||||
try:
|
try:
|
||||||
self._buffers[key] = bytearray(total_size)
|
self._buffers[key] = bytearray(total_size)
|
||||||
@ -320,21 +316,18 @@ class SfpTransport:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if len(self._fragments[key]) == total_frags:
|
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)
|
completed_payload = self._buffers.pop(key)
|
||||||
self._fragments.pop(key)
|
self._fragments.pop(key)
|
||||||
|
|
||||||
handler = self._payload_handlers.get(flow)
|
handlers = self._payload_handlers.get(flow)
|
||||||
if handler:
|
if handlers:
|
||||||
try:
|
for handler in handlers:
|
||||||
handler(completed_payload)
|
try:
|
||||||
except Exception:
|
handler(completed_payload)
|
||||||
logger.exception(
|
except Exception:
|
||||||
f"Error executing payload handler for flow {flow}."
|
logger.exception(
|
||||||
)
|
f"Error executing payload handler for flow {flow}."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No payload handler registered for flow ID {flow}.")
|
logger.warning(f"No payload handler registered for flow ID {flow}.")
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class SimulationPayloadHandler:
|
|||||||
Parses an SFP_RIS::status_message_t payload and updates the hub.
|
Parses an SFP_RIS::status_message_t payload and updates the hub.
|
||||||
"""
|
"""
|
||||||
payload_size = len(payload)
|
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()
|
expected_size = SfpRisStatusPayload.size()
|
||||||
|
|
||||||
if payload_size < expected_size:
|
if payload_size < expected_size:
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# target_simulator/gui/main_view.py
|
# target_simulator/gui/main_view.py
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -70,6 +71,11 @@ class MainView(tk.Tk):
|
|||||||
self.is_simulation_running = tk.BooleanVar(value=False)
|
self.is_simulation_running = tk.BooleanVar(value=False)
|
||||||
self.time_multiplier = 1.0
|
self.time_multiplier = 1.0
|
||||||
self.update_time = tk.DoubleVar(value=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 ---
|
# --- Window and UI Setup ---
|
||||||
self.title("Radar Target Simulator")
|
self.title("Radar Target Simulator")
|
||||||
@ -104,14 +110,15 @@ class MainView(tk.Tk):
|
|||||||
)
|
)
|
||||||
self.h_pane.add(self.ppi_widget, weight=2)
|
self.h_pane.add(self.ppi_widget, weight=2)
|
||||||
|
|
||||||
# Add Connect button to the PPI's own control frame for better layout
|
# Wire the PPI's built-in connect toggle to the MainView connect handler
|
||||||
if hasattr(self.ppi_widget, "controls_frame"):
|
try:
|
||||||
connect_btn = ttk.Button(
|
if hasattr(self.ppi_widget, "set_connect_callback"):
|
||||||
self.ppi_widget.controls_frame,
|
self.ppi_widget.set_connect_callback(self._on_connect_button)
|
||||||
text="Connect",
|
# Reflect initial connection state (likely disconnected)
|
||||||
command=self._on_connect_button,
|
if hasattr(self.ppi_widget, "update_connect_state"):
|
||||||
)
|
self.ppi_widget.update_connect_state(False)
|
||||||
connect_btn.pack(side=tk.RIGHT, padx=10)
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# --- Left Pane ---
|
# --- Left Pane ---
|
||||||
left_pane_container = ttk.Frame(self.h_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 = ttk.LabelFrame(simulation_tab, text="Live Simulation Engine")
|
||||||
engine_frame.pack(fill=tk.X, padx=5, pady=10, anchor="n")
|
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(
|
self.start_button = ttk.Button(
|
||||||
engine_frame, text="Start Live", command=self._on_start_simulation
|
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(
|
self.stop_button = ttk.Button(
|
||||||
engine_frame,
|
engine_frame,
|
||||||
@ -174,27 +190,21 @@ class MainView(tk.Tk):
|
|||||||
command=self._on_stop_simulation,
|
command=self._on_stop_simulation,
|
||||||
state=tk.DISABLED,
|
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(
|
self.analysis_button = ttk.Button(
|
||||||
engine_frame,
|
engine_frame,
|
||||||
text="Show Analysis",
|
text="Show Analysis",
|
||||||
command=self._open_analysis_window,
|
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(
|
# spacer to push the following controls to the right
|
||||||
engine_frame, text="Reset State", command=self._on_reset_simulation
|
spacer = ttk.Frame(engine_frame)
|
||||||
)
|
spacer.grid(row=0, column=3, sticky="ew")
|
||||||
self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5)
|
|
||||||
|
|
||||||
self.reset_radar_button = ttk.Button(
|
|
||||||
engine_frame, text="Reset Radar", command=self._reset_radar_state
|
|
||||||
)
|
|
||||||
self.reset_radar_button.pack(side=tk.RIGHT, padx=5, pady=5)
|
|
||||||
|
|
||||||
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.time_multiplier_var = tk.StringVar(value="1x")
|
||||||
self.multiplier_combo = ttk.Combobox(
|
self.multiplier_combo = ttk.Combobox(
|
||||||
engine_frame,
|
engine_frame,
|
||||||
@ -203,18 +213,64 @@ class MainView(tk.Tk):
|
|||||||
state="readonly",
|
state="readonly",
|
||||||
width=4,
|
width=4,
|
||||||
)
|
)
|
||||||
self.multiplier_combo.pack(side=tk.LEFT, padx=(0, 5), pady=5)
|
self.multiplier_combo.grid(row=0, column=5, sticky="w", padx=(0, 5), pady=5)
|
||||||
self.multiplier_combo.bind(
|
self.multiplier_combo.bind(
|
||||||
"<<ComboboxSelected>>", self._on_time_multiplier_changed
|
"<<ComboboxSelected>>", self._on_time_multiplier_changed
|
||||||
)
|
)
|
||||||
|
|
||||||
ttk.Label(engine_frame, text="Update Time (s):").pack(
|
ttk.Label(engine_frame, text="Update Time (s):").grid(row=0, column=6, sticky="e", padx=(10, 2), pady=5)
|
||||||
side=tk.LEFT, padx=(10, 2), pady=5
|
|
||||||
)
|
|
||||||
self.update_time_entry = ttk.Entry(
|
self.update_time_entry = ttk.Entry(
|
||||||
engine_frame, textvariable=self.update_time, width=5
|
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 ---
|
# --- TAB 3: LRU SIMULATION ---
|
||||||
lru_tab = ttk.Frame(left_notebook)
|
lru_tab = ttk.Frame(left_notebook)
|
||||||
@ -316,6 +372,19 @@ class MainView(tk.Tk):
|
|||||||
color = "#2ecc40" if is_connected else "#e74c3c"
|
color = "#2ecc40" if is_connected else "#e74c3c"
|
||||||
self._draw_status_indicator(canvas, color)
|
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):
|
def _initialize_communicators(self):
|
||||||
# Disconnect any existing connections
|
# Disconnect any existing connections
|
||||||
if self.target_communicator and self.target_communicator.is_open:
|
if self.target_communicator and self.target_communicator.is_open:
|
||||||
@ -354,6 +423,7 @@ class MainView(tk.Tk):
|
|||||||
elif comm_type == "sfp":
|
elif comm_type == "sfp":
|
||||||
# --- MODIFICATION: Pass the hub and GUI update queue to the communicator ---
|
# --- 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 = 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", {})
|
config_data = config.get("sfp", {})
|
||||||
if self.defer_sfp_connection:
|
if self.defer_sfp_connection:
|
||||||
# Return the communicator object but indicate it's not yet connected
|
# 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)
|
ConnectionSettingsWindow(self, self.config_manager, self.connection_config)
|
||||||
|
|
||||||
def _on_connect_button(self):
|
def _on_connect_button(self):
|
||||||
self.logger.info("Connection requested by user.")
|
self.logger.info("Connection toggle requested by user via PPI button.")
|
||||||
self._initialize_communicators()
|
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:
|
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_time_multiplier(self.time_multiplier)
|
||||||
self.simulation_engine.set_update_interval(update_interval)
|
self.simulation_engine.set_update_interval(update_interval)
|
||||||
self.simulation_engine.load_scenario(self.scenario)
|
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.simulation_engine.start()
|
||||||
|
|
||||||
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
|
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.")
|
self.logger.exception("Error while disconnecting target communicator.")
|
||||||
# Update visual status
|
# Update visual status
|
||||||
self._update_communicator_status("Target", False)
|
self._update_communicator_status("Target", False)
|
||||||
|
try:
|
||||||
|
self.ppi_widget.update_connect_state(False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Unexpected error while attempting to disconnect target communicator.")
|
self.logger.exception("Unexpected error while attempting to disconnect target communicator.")
|
||||||
|
|
||||||
@ -592,7 +714,15 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
if update == "SIMULATION_FINISHED":
|
if update == "SIMULATION_FINISHED":
|
||||||
self.logger.info("Simulation finished signal received.")
|
self.logger.info("Simulation finished signal received.")
|
||||||
|
# Ensure engine is stopped and UI reset
|
||||||
self._on_stop_simulation()
|
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):
|
elif isinstance(update, list):
|
||||||
# The engine normally enqueues a List[Target] (simulated targets).
|
# 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()
|
display_data = self._build_display_data_from_hub()
|
||||||
self.ppi_widget.update_targets(display_data)
|
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:
|
except Empty:
|
||||||
# If the queue is empty, we don't need to do anything
|
# If the queue is empty, we don't need to do anything
|
||||||
pass
|
pass
|
||||||
finally:
|
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)
|
self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def _update_button_states(self):
|
def _update_button_states(self):
|
||||||
is_running = self.is_simulation_running.get()
|
is_running = self.is_simulation_running.get()
|
||||||
@ -665,6 +820,43 @@ class MainView(tk.Tk):
|
|||||||
)
|
)
|
||||||
self.time_multiplier = 1.0
|
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]):
|
def _on_targets_changed(self, targets: List[Target]):
|
||||||
"""Callback executed when the target list is modified by the user."""
|
"""Callback executed when the target list is modified by the user."""
|
||||||
# 1. Update the internal scenario object
|
# 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_general_settings(settings_to_save)
|
||||||
self.config_manager.save_connection_settings(self.connection_config)
|
self.config_manager.save_connection_settings(self.connection_config)
|
||||||
|
|
||||||
if self.target_communicator and self.target_communicator.is_open:
|
if self.target_communicator:
|
||||||
self.target_communicator.disconnect()
|
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:
|
if self.lru_communicator and self.lru_communicator.is_open:
|
||||||
self.lru_communicator.disconnect()
|
self.lru_communicator.disconnect()
|
||||||
|
|
||||||
|
|||||||
@ -40,19 +40,21 @@ class DebugPayloadRouter:
|
|||||||
os.makedirs(self._persist_dir, exist_ok=True)
|
os.makedirs(self._persist_dir, exist_ok=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.")
|
logging.info(f"{self._log_prefix} Initialized.")
|
||||||
|
|
||||||
def get_handlers(self) -> Dict[int, Any]:
|
def get_handlers(self) -> Dict[int, Any]:
|
||||||
"""Returns handlers that update the internal last-payload buffer."""
|
"""Returns the stored handler instances."""
|
||||||
return {
|
return self._handlers
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _update_last_payload(self, flow_id: str, payload: bytearray):
|
def _update_last_payload(self, flow_id: str, payload: bytearray):
|
||||||
"""Thread-safely stores the latest payload for a given flow."""
|
"""Thread-safely stores the latest payload for a given flow."""
|
||||||
|
|||||||
@ -84,6 +84,12 @@ class PPIDisplay(ttk.Frame):
|
|||||||
self.range_selector.pack(side=tk.LEFT, padx=5)
|
self.range_selector.pack(side=tk.LEFT, padx=5)
|
||||||
self.range_selector.bind("<<ComboboxSelected>>", self._on_range_selected)
|
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 ---
|
# --- Display Options ---
|
||||||
options_frame = ttk.LabelFrame(top_frame, text="Display Options")
|
options_frame = ttk.LabelFrame(top_frame, text="Display Options")
|
||||||
options_frame.pack(side=tk.RIGHT, padx=(10, 0))
|
options_frame.pack(side=tk.RIGHT, padx=(10, 0))
|
||||||
@ -224,6 +230,59 @@ class PPIDisplay(ttk.Frame):
|
|||||||
|
|
||||||
self.canvas.draw_idle()
|
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):
|
def _draw_target_visuals(self, targets: List[Target], color: str, artist_list: List):
|
||||||
"""Helper to draw dots and vectors for a list of targets."""
|
"""Helper to draw dots and vectors for a list of targets."""
|
||||||
vector_len_nm = self.range_var.get() / 20.0
|
vector_len_nm = self.range_var.get() / 20.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -60,6 +60,18 @@ class ConfigManager:
|
|||||||
# Load scenarios from separate file if present, otherwise keep any scenarios
|
# Load scenarios from separate file if present, otherwise keep any scenarios
|
||||||
# found inside settings.json (fallback).
|
# found inside settings.json (fallback).
|
||||||
self._scenarios = self._load_or_initialize_scenarios()
|
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]:
|
def _load_or_initialize_settings(self) -> Dict[str, Any]:
|
||||||
"""Loads settings from the JSON file or initializes with a default structure."""
|
"""Loads settings from the JSON file or initializes with a default structure."""
|
||||||
|
|||||||
@ -36,6 +36,9 @@ def create_udp_socket(local_ip, local_port):
|
|||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
logging.debug(f"{log_prefix} Socket object created.")
|
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 ---
|
# --- Receive Buffer Size Adjustment ---
|
||||||
try:
|
try:
|
||||||
# Get the default buffer size (INFO level is okay for this setup detail)
|
# Get the default buffer size (INFO level is okay for this setup detail)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user