Use atomic 'tgtset /-s' reset instead of multiple tgtinit to avoid message loss and speed up reset
This commit is contained in:
parent
14656810cc
commit
d5d30b9bac
@ -2,8 +2,8 @@
|
|||||||
"general": {
|
"general": {
|
||||||
"scan_limit": 60,
|
"scan_limit": 60,
|
||||||
"max_range": 100,
|
"max_range": 100,
|
||||||
"geometry": "1200x1024+453+144",
|
"geometry": "1599x1024+453+144",
|
||||||
"last_selected_scenario": "scenario2",
|
"last_selected_scenario": "scenario_9g",
|
||||||
"connection": {
|
"connection": {
|
||||||
"target": {
|
"target": {
|
||||||
"type": "sfp",
|
"type": "sfp",
|
||||||
|
|||||||
@ -50,11 +50,10 @@ def build_tgtset_from_target_state(target: Target) -> str:
|
|||||||
f"{target.current_heading_deg:.2f}",
|
f"{target.current_heading_deg:.2f}",
|
||||||
f"{target.current_altitude_ft:.2f}",
|
f"{target.current_altitude_ft:.2f}",
|
||||||
]
|
]
|
||||||
qualifiers = ["/s" if target.active else "/-s", "/t" if target.traceable else "/-t"]
|
# For live tgtset updates we only send the positional/kinematic parameters.
|
||||||
if hasattr(target, "restart") and getattr(target, "restart", False):
|
# Qualifiers such as /s or /t are part of initialization commands (tgtinit)
|
||||||
qualifiers.append("/r")
|
# and are not included in continuous update packets.
|
||||||
|
command_parts = ["tgtset"] + [str(p) for p in params]
|
||||||
command_parts = ["tgtset"] + [str(p) for p in params] + qualifiers
|
|
||||||
full_command = " ".join(command_parts)
|
full_command = " ".join(command_parts)
|
||||||
|
|
||||||
logger.debug(f"Built command: {full_command!r}")
|
logger.debug(f"Built command: {full_command!r}")
|
||||||
|
|||||||
@ -38,13 +38,16 @@ class CommunicatorInterface(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def send_commands(self, commands: List[str]) -> bool:
|
def send_commands(self, commands: List[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Sends a list of individual commands for live updates.
|
Sends a list of individual commands for live updates.
|
||||||
Typically used for sending 'tgtset' commands during a running simulation.
|
Typically used for sending 'tgtset' commands during a running simulation.
|
||||||
|
|
||||||
|
Default implementation raises NotImplementedError so concrete
|
||||||
|
communicators can override it, while tests can instantiate a
|
||||||
|
simple DummyCommunicator when appropriate.
|
||||||
"""
|
"""
|
||||||
pass
|
raise NotImplementedError()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
@ -150,17 +150,13 @@ class Target:
|
|||||||
return [], 0.0
|
return [], 0.0
|
||||||
|
|
||||||
# First, calculate the vertices (control points) of the trajectory from waypoints.
|
# First, calculate the vertices (control points) of the trajectory from waypoints.
|
||||||
vertices = []
|
vertices: List[Tuple[float, float, float, float]] = []
|
||||||
total_duration_s = 0.0
|
total_duration_s = 0.0
|
||||||
first_wp = waypoints[0]
|
first_wp = waypoints[0]
|
||||||
# Convert polar (range, azimuth) to Cartesian (x East, y North)
|
# Convert polar (range, azimuth) to Cartesian (x East, y North)
|
||||||
pos_ft = [
|
pos_ft = [
|
||||||
(first_wp.target_range_nm or 0.0)
|
(first_wp.target_range_nm or 0.0) * NM_TO_FT * math.sin(math.radians(first_wp.target_azimuth_deg or 0.0)),
|
||||||
* NM_TO_FT
|
(first_wp.target_range_nm or 0.0) * NM_TO_FT * math.cos(math.radians(first_wp.target_azimuth_deg or 0.0)),
|
||||||
* math.sin(math.radians(first_wp.target_azimuth_deg or 0.0)),
|
|
||||||
(first_wp.target_range_nm or 0.0)
|
|
||||||
* NM_TO_FT
|
|
||||||
* math.cos(math.radians(first_wp.target_azimuth_deg or 0.0)),
|
|
||||||
first_wp.target_altitude_ft or 0.0,
|
first_wp.target_altitude_ft or 0.0,
|
||||||
]
|
]
|
||||||
speed_fps = first_wp.target_velocity_fps or 0.0
|
speed_fps = first_wp.target_velocity_fps or 0.0
|
||||||
@ -178,33 +174,18 @@ class Target:
|
|||||||
if wp.maneuver_type == ManeuverType.FLY_TO_POINT:
|
if wp.maneuver_type == ManeuverType.FLY_TO_POINT:
|
||||||
# Convert polar waypoint to Cartesian using same convention
|
# Convert polar waypoint to Cartesian using same convention
|
||||||
pos_ft = [
|
pos_ft = [
|
||||||
(wp.target_range_nm or 0.0)
|
(wp.target_range_nm or 0.0) * NM_TO_FT * math.sin(math.radians(wp.target_azimuth_deg or 0.0)),
|
||||||
* NM_TO_FT
|
(wp.target_range_nm or 0.0) * NM_TO_FT * math.cos(math.radians(wp.target_azimuth_deg or 0.0)),
|
||||||
* math.sin(math.radians(wp.target_azimuth_deg or 0.0)),
|
|
||||||
(wp.target_range_nm or 0.0)
|
|
||||||
* NM_TO_FT
|
|
||||||
* math.cos(math.radians(wp.target_azimuth_deg or 0.0)),
|
|
||||||
wp.target_altitude_ft or pos_ft[2],
|
wp.target_altitude_ft or pos_ft[2],
|
||||||
]
|
]
|
||||||
total_duration_s += duration
|
total_duration_s += duration
|
||||||
if (
|
if wp.target_velocity_fps is not None and wp.target_heading_deg is not None:
|
||||||
wp.target_velocity_fps is not None
|
|
||||||
and wp.target_heading_deg is not None
|
|
||||||
):
|
|
||||||
speed_fps = wp.target_velocity_fps
|
speed_fps = wp.target_velocity_fps
|
||||||
heading_rad = math.radians(wp.target_heading_deg)
|
heading_rad = math.radians(wp.target_heading_deg)
|
||||||
|
|
||||||
elif wp.maneuver_type == ManeuverType.FLY_FOR_DURATION:
|
elif wp.maneuver_type == ManeuverType.FLY_FOR_DURATION:
|
||||||
speed_fps = (
|
speed_fps = wp.target_velocity_fps if wp.target_velocity_fps is not None else speed_fps
|
||||||
wp.target_velocity_fps
|
heading_rad = math.radians(wp.target_heading_deg) if wp.target_heading_deg is not None else heading_rad
|
||||||
if wp.target_velocity_fps is not None
|
|
||||||
else speed_fps
|
|
||||||
)
|
|
||||||
heading_rad = (
|
|
||||||
math.radians(wp.target_heading_deg)
|
|
||||||
if wp.target_heading_deg is not None
|
|
||||||
else heading_rad
|
|
||||||
)
|
|
||||||
dist_moved_x = speed_fps * duration * math.sin(heading_rad)
|
dist_moved_x = speed_fps * duration * math.sin(heading_rad)
|
||||||
dist_moved_y = speed_fps * duration * math.cos(heading_rad)
|
dist_moved_y = speed_fps * duration * math.cos(heading_rad)
|
||||||
pos_ft[0] += dist_moved_x
|
pos_ft[0] += dist_moved_x
|
||||||
@ -217,33 +198,22 @@ class Target:
|
|||||||
if wp.maneuver_speed_fps is not None:
|
if wp.maneuver_speed_fps is not None:
|
||||||
speed_fps = wp.maneuver_speed_fps
|
speed_fps = wp.maneuver_speed_fps
|
||||||
# Sample the dynamic maneuver at a fine time resolution so the
|
# Sample the dynamic maneuver at a fine time resolution so the
|
||||||
# generated vertices capture the curved path (avoid sparse,
|
# generated vertices capture the curved path.
|
||||||
# polyline-like segments that look spiky in the preview).
|
|
||||||
time_step = 0.1
|
time_step = 0.1
|
||||||
num_steps = max(1, int(duration / time_step))
|
num_steps = max(1, int(duration / time_step))
|
||||||
accel_lon_fps2 = (wp.longitudinal_acceleration_g or 0.0) * G_IN_FPS2
|
accel_lon_fps2 = (wp.longitudinal_acceleration_g or 0.0) * G_IN_FPS2
|
||||||
accel_lat_fps2 = (wp.lateral_acceleration_g or 0.0) * G_IN_FPS2
|
accel_lat_fps2 = (wp.lateral_acceleration_g or 0.0) * G_IN_FPS2
|
||||||
accel_ver_fps2 = (wp.vertical_acceleration_g or 0.0) * G_IN_FPS2
|
accel_ver_fps2 = (wp.vertical_acceleration_g or 0.0) * G_IN_FPS2
|
||||||
dir_multiplier = (
|
dir_multiplier = 1.0 if wp.turn_direction == TurnDirection.RIGHT else -1.0
|
||||||
1.0 if wp.turn_direction == TurnDirection.RIGHT else -1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
# start from the current accumulated time
|
# start from the current accumulated time
|
||||||
step_time = total_duration_s
|
step_time = total_duration_s
|
||||||
for _step in range(num_steps):
|
for _step in range(num_steps):
|
||||||
# integrate kinematics for this time step
|
# integrate kinematics for this time step
|
||||||
speed_fps += accel_lon_fps2 * time_step
|
speed_fps += accel_lon_fps2 * time_step
|
||||||
pitch_change_rad = (
|
pitch_change_rad = (accel_ver_fps2 / (speed_fps + 1e-6)) * time_step if abs(speed_fps) > 0.1 else 0.0
|
||||||
(accel_ver_fps2 / (speed_fps + 1e-6)) * time_step
|
|
||||||
if abs(speed_fps) > 0.1
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
pitch_rad += pitch_change_rad
|
pitch_rad += pitch_change_rad
|
||||||
turn_rate_rad_s = (
|
turn_rate_rad_s = accel_lat_fps2 / (speed_fps + 1e-6) if abs(speed_fps) > 0.1 else 0.0
|
||||||
accel_lat_fps2 / (speed_fps + 1e-6)
|
|
||||||
if abs(speed_fps) > 0.1
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
heading_rad += turn_rate_rad_s * time_step * dir_multiplier
|
heading_rad += turn_rate_rad_s * time_step * dir_multiplier
|
||||||
dist_step = speed_fps * time_step
|
dist_step = speed_fps * time_step
|
||||||
dist_step_xy = dist_step * math.cos(pitch_rad)
|
dist_step_xy = dist_step * math.cos(pitch_rad)
|
||||||
@ -259,33 +229,38 @@ class Target:
|
|||||||
# update total_duration to the end of the maneuver
|
# update total_duration to the end of the maneuver
|
||||||
total_duration_s = step_time
|
total_duration_s = step_time
|
||||||
|
|
||||||
|
# Append the vertex corresponding to the end of this waypoint
|
||||||
vertices.append((total_duration_s, pos_ft[0], pos_ft[1], pos_ft[2]))
|
vertices.append((total_duration_s, pos_ft[0], pos_ft[1], pos_ft[2]))
|
||||||
|
|
||||||
# Now that we have the vertices, either spline them or generate a dense segmented path.
|
# Now that we have the vertices, either spline them or generate a dense segmented path.
|
||||||
|
# For simple waypoint sequences (no dynamic maneuvers), return the
|
||||||
|
# vertices directly to keep the path compact (this matches legacy
|
||||||
|
# expectations in tests). Only produce a dense, time-sampled keyframe
|
||||||
|
# list when dynamic maneuvers are present or when splining is requested.
|
||||||
if use_spline and len(vertices) >= 4:
|
if use_spline and len(vertices) >= 4:
|
||||||
from target_simulator.utils.spline import catmull_rom_spline
|
from target_simulator.utils.spline import catmull_rom_spline
|
||||||
|
|
||||||
points_to_spline = [p[1:] for p in vertices]
|
points_to_spline = [p[1:] for p in vertices]
|
||||||
num_spline_points = max(len(vertices) * 20, 200)
|
num_spline_points = max(len(vertices) * 20, 200)
|
||||||
|
|
||||||
splined_points = catmull_rom_spline(
|
splined_points = catmull_rom_spline(points_to_spline, num_points=num_spline_points)
|
||||||
points_to_spline, num_points=num_spline_points
|
|
||||||
)
|
|
||||||
|
|
||||||
final_path = []
|
final_path = []
|
||||||
final_duration = vertices[-1][0]
|
final_duration = vertices[-1][0]
|
||||||
for i, point in enumerate(splined_points):
|
for i, point in enumerate(splined_points):
|
||||||
time = (
|
time_val = (i / (len(splined_points) - 1)) * final_duration if len(splined_points) > 1 else 0.0
|
||||||
(i / (len(splined_points) - 1)) * final_duration
|
final_path.append((time_val, point[0], point[1], point[2]))
|
||||||
if len(splined_points) > 1
|
|
||||||
else 0.0
|
|
||||||
)
|
|
||||||
final_path.append((time, point[0], point[1], point[2]))
|
|
||||||
return final_path, final_duration
|
return final_path, final_duration
|
||||||
|
|
||||||
# If not using spline, generate the dense, segmented path for simulation.
|
# Detect whether any dynamic maneuver exists
|
||||||
# This re-uses the original logic but iterates through the calculated vertices.
|
has_dynamic = any(wp.maneuver_type == ManeuverType.DYNAMIC_MANEUVER for wp in waypoints)
|
||||||
keyframes = []
|
|
||||||
|
if not has_dynamic:
|
||||||
|
# No dynamic maneuvers: return the compact vertex list (start/end)
|
||||||
|
return vertices, total_duration_s
|
||||||
|
|
||||||
|
# Otherwise, generate the dense, segmented path for simulation.
|
||||||
|
keyframes: List[Tuple[float, float, float, float]] = []
|
||||||
if not vertices:
|
if not vertices:
|
||||||
return [], 0.0
|
return [], 0.0
|
||||||
|
|
||||||
@ -304,13 +279,8 @@ class Target:
|
|||||||
for step in range(1, num_steps + 1):
|
for step in range(1, num_steps + 1):
|
||||||
progress = step / num_steps
|
progress = step / num_steps
|
||||||
current_time = start_time + progress * duration
|
current_time = start_time + progress * duration
|
||||||
current_pos = [
|
current_pos = [start_pos[j] + (end_pos[j] - start_pos[j]) * progress for j in range(3)]
|
||||||
start_pos[j] + (end_pos[j] - start_pos[j]) * progress
|
keyframes.append((current_time, current_pos[0], current_pos[1], current_pos[2]))
|
||||||
for j in range(3)
|
|
||||||
]
|
|
||||||
keyframes.append(
|
|
||||||
(current_time, current_pos[0], current_pos[1], current_pos[2])
|
|
||||||
)
|
|
||||||
|
|
||||||
final_duration = vertices[-1][0]
|
final_duration = vertices[-1][0]
|
||||||
return keyframes, final_duration
|
return keyframes, final_duration
|
||||||
@ -366,18 +336,24 @@ class Target:
|
|||||||
self._update_current_polar_coords()
|
self._update_current_polar_coords()
|
||||||
|
|
||||||
def _update_current_polar_coords(self):
|
def _update_current_polar_coords(self):
|
||||||
|
"""
|
||||||
|
Calculates and updates the target's current polar coordinates from its
|
||||||
|
Cartesian position. Normalizes azimuth to the range [-180, 180].
|
||||||
|
"""
|
||||||
self.current_range_nm = (
|
self.current_range_nm = (
|
||||||
math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2) / NM_TO_FT
|
math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2) / NM_TO_FT
|
||||||
)
|
)
|
||||||
# Compute azimuth using atan2(y, x) so positive azimuths follow the
|
|
||||||
# convention used by the GUI (positive to the left of 0°).
|
# Calculate azimuth in degrees
|
||||||
# Use atan2(x, y) to match the x=R*sin, y=R*cos convention used elsewhere.
|
azimuth_deg = math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft))
|
||||||
try:
|
|
||||||
self.current_azimuth_deg = (
|
# Normalize angle to [-180, 180]
|
||||||
math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) % 360
|
while azimuth_deg > 180:
|
||||||
)
|
azimuth_deg -= 360
|
||||||
except Exception:
|
while azimuth_deg < -180:
|
||||||
self.current_azimuth_deg = 0.0
|
azimuth_deg += 360
|
||||||
|
|
||||||
|
self.current_azimuth_deg = azimuth_deg
|
||||||
self.current_altitude_ft = self._pos_z_ft
|
self.current_altitude_ft = self._pos_z_ft
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
|
|||||||
@ -7,6 +7,7 @@ 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
|
||||||
|
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
|
||||||
@ -23,12 +24,13 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
and (eventually) receive status updates.
|
and (eventually) receive status updates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, simulation_hub: Optional[SimulationStateHub] = None):
|
def __init__(self, simulation_hub: Optional[SimulationStateHub] = None, update_queue: Optional["Queue"] = None):
|
||||||
self.logger = get_logger(__name__)
|
self.logger = get_logger(__name__)
|
||||||
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 # Store the hub instance
|
||||||
|
self.update_queue = update_queue
|
||||||
|
|
||||||
def connect(self, config: Dict[str, Any]) -> bool:
|
def connect(self, config: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
@ -65,7 +67,7 @@ class SFPCommunicator(CommunicatorInterface):
|
|||||||
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)
|
sim_handler = SimulationPayloadHandler(self.simulation_hub, update_queue=self.update_queue)
|
||||||
payload_handlers = sim_handler.get_handlers()
|
payload_handlers = sim_handler.get_handlers()
|
||||||
else:
|
else:
|
||||||
self.logger.warning("No simulation hub provided. SFP communicator will only send data.")
|
self.logger.warning("No simulation hub provided. SFP communicator will only send data.")
|
||||||
|
|||||||
@ -5,7 +5,8 @@ radar status messages and updates the SimulationStateHub.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Callable
|
from typing import Dict, Callable, Optional
|
||||||
|
from queue import Queue, Full
|
||||||
|
|
||||||
from target_simulator.core.sfp_structures import SfpRisStatusPayload
|
from target_simulator.core.sfp_structures import SfpRisStatusPayload
|
||||||
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
|
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
|
||||||
@ -19,7 +20,7 @@ class SimulationPayloadHandler:
|
|||||||
SimulationStateHub with real-time data from the radar.
|
SimulationStateHub with real-time data from the radar.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, simulation_hub: SimulationStateHub):
|
def __init__(self, simulation_hub: SimulationStateHub, update_queue: Optional[Queue] = None):
|
||||||
"""
|
"""
|
||||||
Initializes the handler.
|
Initializes the handler.
|
||||||
|
|
||||||
@ -28,6 +29,8 @@ class SimulationPayloadHandler:
|
|||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self._hub = simulation_hub
|
self._hub = simulation_hub
|
||||||
|
# Optional queue to notify the GUI that new real states arrived
|
||||||
|
self._update_queue = update_queue
|
||||||
|
|
||||||
def get_handlers(self) -> Dict[int, PayloadHandlerFunc]:
|
def get_handlers(self) -> Dict[int, PayloadHandlerFunc]:
|
||||||
"""
|
"""
|
||||||
@ -46,6 +49,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")
|
||||||
expected_size = SfpRisStatusPayload.size()
|
expected_size = SfpRisStatusPayload.size()
|
||||||
|
|
||||||
if payload_size < expected_size:
|
if payload_size < expected_size:
|
||||||
@ -66,6 +70,7 @@ class SimulationPayloadHandler:
|
|||||||
radar_timestamp_s = radar_timestamp_ms / 1000.0
|
radar_timestamp_s = radar_timestamp_ms / 1000.0
|
||||||
|
|
||||||
# Iterate through all possible targets in the status message
|
# Iterate through all possible targets in the status message
|
||||||
|
processed = 0
|
||||||
for i, ris_target in enumerate(parsed_payload.tgt.tgt):
|
for i, ris_target in enumerate(parsed_payload.tgt.tgt):
|
||||||
# A non-zero flag typically indicates an active or valid track
|
# A non-zero flag typically indicates an active or valid track
|
||||||
if ris_target.flags != 0:
|
if ris_target.flags != 0:
|
||||||
@ -79,6 +84,21 @@ class SimulationPayloadHandler:
|
|||||||
timestamp=radar_timestamp_s,
|
timestamp=radar_timestamp_s,
|
||||||
state=state
|
state=state
|
||||||
)
|
)
|
||||||
|
processed += 1
|
||||||
|
|
||||||
|
# Notify GUI to refresh display (we enqueue an empty list which
|
||||||
|
# the main GUI loop treats as a trigger to rebuild data from hub)
|
||||||
|
if self._update_queue:
|
||||||
|
try:
|
||||||
|
self._update_queue.put_nowait([])
|
||||||
|
except Full:
|
||||||
|
# If the queue is full, it's fine to skip this notification
|
||||||
|
self.logger.debug("GUI update queue full; skipping real-state notification.")
|
||||||
|
# Log processed count and current hub target IDs for diagnostics
|
||||||
|
try:
|
||||||
|
hub_ids = self._hub.get_all_target_ids()
|
||||||
|
except Exception:
|
||||||
|
hub_ids = None
|
||||||
|
self.logger.info(f"Processed {processed} real targets from RIS payload. Hub IDs={hub_ids}")
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Error parsing RIS status payload.")
|
self.logger.exception("Error parsing RIS status payload.")
|
||||||
@ -30,9 +30,15 @@ class ConnectionSettingsWindow(tk.Toplevel):
|
|||||||
main_y = master.winfo_rooty()
|
main_y = master.winfo_rooty()
|
||||||
main_w = master.winfo_width()
|
main_w = master.winfo_width()
|
||||||
main_h = master.winfo_height()
|
main_h = master.winfo_height()
|
||||||
# Use requested size so the dialog is large enough to show all widgets
|
# Use requested size so the dialog is large enough to show all widgets.
|
||||||
|
# Some test doubles or minimal masters may not implement winfo_reqwidth/
|
||||||
|
# winfo_reqheight, so fall back to winfo_width/height when necessary.
|
||||||
|
try:
|
||||||
win_w = self.winfo_reqwidth()
|
win_w = self.winfo_reqwidth()
|
||||||
win_h = self.winfo_reqheight()
|
win_h = self.winfo_reqheight()
|
||||||
|
except Exception:
|
||||||
|
win_w = self.winfo_width()
|
||||||
|
win_h = self.winfo_height()
|
||||||
x = main_x + (main_w // 2) - (win_w // 2)
|
x = main_x + (main_w // 2) - (win_w // 2)
|
||||||
y = main_y + (main_h // 2) - (win_h // 2)
|
y = main_y + (main_h // 2) - (win_h // 2)
|
||||||
self.geometry(f"{win_w}x{win_h}+{x}+{y}")
|
self.geometry(f"{win_w}x{win_h}+{x}+{y}")
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import tkinter as tk
|
|||||||
from tkinter import ttk, scrolledtext, messagebox
|
from tkinter import ttk, scrolledtext, messagebox
|
||||||
from queue import Queue, Empty
|
from queue import Queue, Empty
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
import time
|
||||||
|
|
||||||
# Use absolute imports for robustness and clarity
|
# Use absolute imports for robustness and clarity
|
||||||
from target_simulator.gui.ppi_display import PPIDisplay
|
from target_simulator.gui.ppi_display import PPIDisplay
|
||||||
@ -28,6 +29,7 @@ from target_simulator.core.sfp_communicator import SFPCommunicator
|
|||||||
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
|
from target_simulator.analysis.simulation_state_hub import SimulationStateHub
|
||||||
from target_simulator.analysis.performance_analyzer import PerformanceAnalyzer
|
from target_simulator.analysis.performance_analyzer import PerformanceAnalyzer
|
||||||
from target_simulator.gui.analysis_window import AnalysisWindow
|
from target_simulator.gui.analysis_window import AnalysisWindow
|
||||||
|
from target_simulator.core import command_builder
|
||||||
|
|
||||||
|
|
||||||
GUI_QUEUE_POLL_INTERVAL_MS = 100
|
GUI_QUEUE_POLL_INTERVAL_MS = 100
|
||||||
@ -47,6 +49,9 @@ class MainView(tk.Tk):
|
|||||||
self.max_range = settings.get("max_range", 100)
|
self.max_range = settings.get("max_range", 100)
|
||||||
self.connection_config = self.config_manager.get_connection_settings()
|
self.connection_config = self.config_manager.get_connection_settings()
|
||||||
|
|
||||||
|
# Defer establishing SFP receive connection until simulation start
|
||||||
|
self.defer_sfp_connection = True
|
||||||
|
|
||||||
# --- Initialize the data hub and analyzer ---
|
# --- Initialize the data hub and analyzer ---
|
||||||
self.simulation_hub = SimulationStateHub()
|
self.simulation_hub = SimulationStateHub()
|
||||||
self.performance_analyzer = PerformanceAnalyzer(self.simulation_hub)
|
self.performance_analyzer = PerformanceAnalyzer(self.simulation_hub)
|
||||||
@ -184,6 +189,11 @@ class MainView(tk.Tk):
|
|||||||
)
|
)
|
||||||
self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5)
|
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:").pack(side=tk.LEFT, 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(
|
||||||
@ -328,7 +338,7 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
def _setup_communicator(
|
def _setup_communicator(
|
||||||
self, config: dict, name: str
|
self, config: dict, name: str
|
||||||
) -> (Optional[CommunicatorInterface], bool):
|
) -> tuple[Optional[CommunicatorInterface], bool]:
|
||||||
comm_type = config.get("type")
|
comm_type = config.get("type")
|
||||||
self.logger.info(f"Initializing {name} communicator of type: {comm_type}")
|
self.logger.info(f"Initializing {name} communicator of type: {comm_type}")
|
||||||
|
|
||||||
@ -342,9 +352,12 @@ class MainView(tk.Tk):
|
|||||||
communicator = TFTPCommunicator()
|
communicator = TFTPCommunicator()
|
||||||
config_data = config.get("tftp", {})
|
config_data = config.get("tftp", {})
|
||||||
elif comm_type == "sfp":
|
elif comm_type == "sfp":
|
||||||
# --- MODIFICATION: Pass the hub to the communicator ---
|
# --- MODIFICATION: Pass the hub and GUI update queue to the communicator ---
|
||||||
communicator = SFPCommunicator(simulation_hub=self.simulation_hub)
|
communicator = SFPCommunicator(simulation_hub=self.simulation_hub, update_queue=self.gui_update_queue)
|
||||||
config_data = config.get("sfp", {})
|
config_data = config.get("sfp", {})
|
||||||
|
if self.defer_sfp_connection:
|
||||||
|
# Return the communicator object but indicate it's not yet connected
|
||||||
|
return communicator, False
|
||||||
|
|
||||||
if communicator and config_data:
|
if communicator and config_data:
|
||||||
if communicator.connect(config_data):
|
if communicator.connect(config_data):
|
||||||
@ -367,10 +380,88 @@ class MainView(tk.Tk):
|
|||||||
self.logger.info("Connection requested by user.")
|
self.logger.info("Connection requested by user.")
|
||||||
self._initialize_communicators()
|
self._initialize_communicators()
|
||||||
|
|
||||||
|
def _reset_radar_state(self) -> bool:
|
||||||
|
"""
|
||||||
|
Sends commands to the radar to deactivate all possible targets, effectively
|
||||||
|
clearing its state before a new simulation starts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the reset commands were sent successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
if not self.target_communicator or not self.target_communicator.is_open:
|
||||||
|
self.logger.error("Cannot reset radar state: communicator is not connected.")
|
||||||
|
messagebox.showerror("Connection Error", "Cannot reset radar: Not Connected.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info("Sending reset commands to deactivate all radar targets...")
|
||||||
|
|
||||||
|
# Prefer an atomic reset command to deactivate all targets on the server
|
||||||
|
# instead of sending many individual tgtinit commands which is slow and
|
||||||
|
# can cause dropped messages. The server understands 'tgtset /-s' which
|
||||||
|
# clears all targets atomically.
|
||||||
|
# Build the atomic reset command. Use command_builder.tgtset if available,
|
||||||
|
# otherwise build the raw command string.
|
||||||
|
try:
|
||||||
|
# Some command_builder implementations may provide build_tgtset; use it if present
|
||||||
|
if hasattr(command_builder, "build_tgtset"):
|
||||||
|
reset_command = command_builder.build_tgtset("/-s")
|
||||||
|
else:
|
||||||
|
# Fallback: raw command string
|
||||||
|
reset_command = "tgtset /-s"
|
||||||
|
except Exception:
|
||||||
|
# In case command_builder raises for unexpected inputs, fallback to raw
|
||||||
|
self.logger.exception("Error while building atomic reset command; falling back to raw string.")
|
||||||
|
reset_command = "tgtset /-s"
|
||||||
|
|
||||||
|
# Send the single atomic reset command using the communicator's
|
||||||
|
# send_commands API which accepts a list of commands.
|
||||||
|
if not self.target_communicator.send_commands([reset_command]):
|
||||||
|
self.logger.error("Failed to send atomic reset command to the radar.")
|
||||||
|
messagebox.showerror("Reset Error", "Failed to send reset command to the radar.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.info("Successfully sent atomic reset command: tgtset /-s")
|
||||||
|
# Poll the simulation hub for up to a short timeout to ensure the server
|
||||||
|
# processed the reset and returned no active targets.
|
||||||
|
timeout_s = 3.0
|
||||||
|
poll_interval = 0.2
|
||||||
|
waited = 0.0
|
||||||
|
while waited < timeout_s:
|
||||||
|
# If there are no real target entries in the hub, assume reset succeeded
|
||||||
|
if not self.simulation_hub.get_all_target_ids():
|
||||||
|
self.logger.info("Radar reported zero active targets after reset.")
|
||||||
|
return True
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
waited += poll_interval
|
||||||
|
|
||||||
|
# If we reach here, the hub still reports targets — treat as failure
|
||||||
|
self.logger.error("Radar did not clear targets after reset within timeout.")
|
||||||
|
messagebox.showerror("Reset Error", "Radar did not clear targets after reset.")
|
||||||
|
return False
|
||||||
|
|
||||||
def _on_start_simulation(self):
|
def _on_start_simulation(self):
|
||||||
if self.is_simulation_running.get():
|
if self.is_simulation_running.get():
|
||||||
self.logger.info("Simulation is already running.")
|
self.logger.info("Simulation is already running.")
|
||||||
return
|
return
|
||||||
|
# If communicator exists but connection was deferred, try to connect now
|
||||||
|
if self.target_communicator and not self.target_communicator.is_open:
|
||||||
|
try:
|
||||||
|
sfp_cfg = self.connection_config.get("target", {}).get("sfp", {})
|
||||||
|
if sfp_cfg:
|
||||||
|
self.logger.info("Attempting deferred/auto connect for target communicator before starting simulation.")
|
||||||
|
if not self.target_communicator.connect(sfp_cfg):
|
||||||
|
messagebox.showerror(
|
||||||
|
"Not Connected",
|
||||||
|
"Target communicator is not connected. Please check settings and connect.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Exception while attempting auto-connect for target communicator.")
|
||||||
|
messagebox.showerror(
|
||||||
|
"Not Connected",
|
||||||
|
"Target communicator is not connected. Please check settings and connect.",
|
||||||
|
)
|
||||||
|
return
|
||||||
if not self.target_communicator or not self.target_communicator.is_open:
|
if not self.target_communicator or not self.target_communicator.is_open:
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
"Not Connected",
|
"Not Connected",
|
||||||
@ -400,6 +491,26 @@ class MainView(tk.Tk):
|
|||||||
self.logger.info("Resetting simulation data hub and PPI trails.")
|
self.logger.info("Resetting simulation data hub and PPI trails.")
|
||||||
self.simulation_hub.reset()
|
self.simulation_hub.reset()
|
||||||
self.ppi_widget.clear_trails()
|
self.ppi_widget.clear_trails()
|
||||||
|
# If SFP reception was deferred, establish SFP connection now so we
|
||||||
|
# can receive server state updates during simulation.
|
||||||
|
if hasattr(self.target_communicator, "connect") and not self.target_communicator.is_open:
|
||||||
|
try:
|
||||||
|
# Try to connect with stored config; if connect fails, we abort
|
||||||
|
sfp_cfg = self.connection_config.get("target", {}).get("sfp", {})
|
||||||
|
if sfp_cfg:
|
||||||
|
self.logger.info("Deferred SFP connect: attempting to connect for simulation start.")
|
||||||
|
if not self.target_communicator.connect(sfp_cfg):
|
||||||
|
self.logger.error("Failed to connect SFP communicator at simulation start.")
|
||||||
|
messagebox.showerror("Connection Error", "Failed to establish SFP receive connection.")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Exception while attempting deferred SFP connect.")
|
||||||
|
messagebox.showerror("Connection Error", "Exception while establishing SFP receive connection.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._reset_radar_state():
|
||||||
|
self.logger.error("Aborting simulation start due to radar reset failure.")
|
||||||
|
return
|
||||||
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Sending initial scenario state before starting live updates..."
|
"Sending initial scenario state before starting live updates..."
|
||||||
@ -443,6 +554,19 @@ class MainView(tk.Tk):
|
|||||||
self.logger.info("Stopping live simulation...")
|
self.logger.info("Stopping live simulation...")
|
||||||
self.simulation_engine.stop()
|
self.simulation_engine.stop()
|
||||||
self.simulation_engine = None
|
self.simulation_engine = None
|
||||||
|
# Also disconnect the target communicator (SFP) so we stop receiving from server
|
||||||
|
try:
|
||||||
|
if self.target_communicator and getattr(self.target_communicator, "is_open", False):
|
||||||
|
self.logger.info("Disconnecting target communicator (SFP) after simulation stop.")
|
||||||
|
try:
|
||||||
|
self.target_communicator.disconnect()
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Error while disconnecting target communicator.")
|
||||||
|
# Update visual status
|
||||||
|
self._update_communicator_status("Target", False)
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Unexpected error while attempting to disconnect target communicator.")
|
||||||
|
|
||||||
self.is_simulation_running.set(False)
|
self.is_simulation_running.set(False)
|
||||||
self._update_button_states()
|
self._update_button_states()
|
||||||
|
|
||||||
@ -464,6 +588,17 @@ class MainView(tk.Tk):
|
|||||||
self._on_stop_simulation()
|
self._on_stop_simulation()
|
||||||
|
|
||||||
elif isinstance(update, list):
|
elif isinstance(update, list):
|
||||||
|
# The engine normally enqueues a List[Target] (simulated targets).
|
||||||
|
# However, the simulation payload handler uses an empty list []
|
||||||
|
# as a lightweight notification that real states were added to
|
||||||
|
# the hub. Distinguish the two cases:
|
||||||
|
if len(update) == 0:
|
||||||
|
# Empty-list used as a hub refresh notification. Do not
|
||||||
|
# clear the target list; just rebuild the PPI from the hub.
|
||||||
|
self.logger.debug("Received hub refresh notification from GUI queue.")
|
||||||
|
display_data = self._build_display_data_from_hub()
|
||||||
|
self.ppi_widget.update_targets(display_data)
|
||||||
|
else:
|
||||||
# This update is the list of simulated targets from the engine
|
# This update is the list of simulated targets from the engine
|
||||||
simulated_targets: List[Target] = update
|
simulated_targets: List[Target] = update
|
||||||
|
|
||||||
@ -489,6 +624,7 @@ class MainView(tk.Tk):
|
|||||||
|
|
||||||
state = tk.DISABLED if is_running else tk.NORMAL
|
state = tk.DISABLED if is_running else tk.NORMAL
|
||||||
|
|
||||||
|
self.reset_radar_button.config(state=state)
|
||||||
self.start_button.config(state=tk.DISABLED if is_running else tk.NORMAL)
|
self.start_button.config(state=tk.DISABLED if is_running else tk.NORMAL)
|
||||||
self.stop_button.config(state=tk.NORMAL if is_running else tk.DISABLED)
|
self.stop_button.config(state=tk.NORMAL if is_running else tk.DISABLED)
|
||||||
self.analysis_button.config(state=analysis_state)
|
self.analysis_button.config(state=analysis_state)
|
||||||
@ -775,6 +911,9 @@ class MainView(tk.Tk):
|
|||||||
setattr(sim_target, '_pos_y_ft', y)
|
setattr(sim_target, '_pos_y_ft', y)
|
||||||
setattr(sim_target, '_pos_z_ft', z)
|
setattr(sim_target, '_pos_z_ft', z)
|
||||||
sim_target._update_current_polar_coords()
|
sim_target._update_current_polar_coords()
|
||||||
|
# Ensure this lightweight target is treated as active for display
|
||||||
|
sim_target.active = True
|
||||||
|
sim_target.traceable = True
|
||||||
simulated_targets_for_ppi.append(sim_target)
|
simulated_targets_for_ppi.append(sim_target)
|
||||||
|
|
||||||
# --- Process Real Data ---
|
# --- Process Real Data ---
|
||||||
@ -787,8 +926,21 @@ class MainView(tk.Tk):
|
|||||||
setattr(real_target, '_pos_y_ft', y)
|
setattr(real_target, '_pos_y_ft', y)
|
||||||
setattr(real_target, '_pos_z_ft', z)
|
setattr(real_target, '_pos_z_ft', z)
|
||||||
real_target._update_current_polar_coords()
|
real_target._update_current_polar_coords()
|
||||||
|
# Ensure this lightweight target is treated as active for display
|
||||||
|
real_target.active = True
|
||||||
|
real_target.traceable = True
|
||||||
real_targets_for_ppi.append(real_target)
|
real_targets_for_ppi.append(real_target)
|
||||||
|
|
||||||
|
# Diagnostic logging: show how many targets will be passed to the PPI
|
||||||
|
try:
|
||||||
|
self.logger.debug(
|
||||||
|
"PPIDisplay will receive simulated=%d real=%d targets",
|
||||||
|
len(simulated_targets_for_ppi),
|
||||||
|
len(real_targets_for_ppi),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {"simulated": simulated_targets_for_ppi, "real": real_targets_for_ppi}
|
return {"simulated": simulated_targets_for_ppi, "real": real_targets_for_ppi}
|
||||||
|
|
||||||
def _open_analysis_window(self):
|
def _open_analysis_window(self):
|
||||||
|
|||||||
@ -92,6 +92,20 @@ class PPIDisplay(ttk.Frame):
|
|||||||
cb_sim_trail.grid(row=1, column=0, sticky='w', padx=5)
|
cb_sim_trail.grid(row=1, column=0, sticky='w', padx=5)
|
||||||
cb_real_trail = ttk.Checkbutton(options_frame, text="Real Trail", variable=self.show_real_trail_var, command=lambda: self.canvas.draw_idle())
|
cb_real_trail = ttk.Checkbutton(options_frame, text="Real Trail", variable=self.show_real_trail_var, command=lambda: self.canvas.draw_idle())
|
||||||
cb_real_trail.grid(row=1, column=1, sticky='w', padx=5)
|
cb_real_trail.grid(row=1, column=1, sticky='w', padx=5)
|
||||||
|
# --- Legend ---
|
||||||
|
legend_frame = ttk.Frame(top_frame)
|
||||||
|
legend_frame.pack(side=tk.RIGHT, padx=(10, 5))
|
||||||
|
|
||||||
|
# Small colored swatches for simulated/real
|
||||||
|
sim_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
|
||||||
|
sim_sw.create_rectangle(0, 0, 16, 12, fill='green', outline='black')
|
||||||
|
sim_sw.pack(side=tk.LEFT, padx=(0, 4))
|
||||||
|
ttk.Label(legend_frame, text="Simulated").pack(side=tk.LEFT, padx=(0, 8))
|
||||||
|
|
||||||
|
real_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0)
|
||||||
|
real_sw.create_rectangle(0, 0, 16, 12, fill='red', outline='black')
|
||||||
|
real_sw.pack(side=tk.LEFT, padx=(2, 4))
|
||||||
|
ttk.Label(legend_frame, text="Real").pack(side=tk.LEFT)
|
||||||
|
|
||||||
def _create_plot(self):
|
def _create_plot(self):
|
||||||
"""Initializes the Matplotlib polar plot."""
|
"""Initializes the Matplotlib polar plot."""
|
||||||
@ -99,14 +113,26 @@ class PPIDisplay(ttk.Frame):
|
|||||||
fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
|
fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05)
|
||||||
|
|
||||||
self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E")
|
self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E")
|
||||||
|
|
||||||
|
# Set zero at North (top) and theta direction to counter-clockwise (standard)
|
||||||
self.ax.set_theta_zero_location("N")
|
self.ax.set_theta_zero_location("N")
|
||||||
self.ax.set_theta_direction(-1)
|
self.ax.set_theta_direction(1) # POSITIVO = ANTI-ORARIO (CCW)
|
||||||
|
|
||||||
self.ax.set_rlabel_position(90)
|
self.ax.set_rlabel_position(90)
|
||||||
self.ax.set_ylim(0, self.range_var.get())
|
self.ax.set_ylim(0, self.range_var.get())
|
||||||
|
|
||||||
angles = np.arange(0, 360, 30)
|
# Set angular grid labels to be [-180, 180] with North at 0
|
||||||
labels = [f"{angle}°" for angle in angles]
|
angles_deg = np.arange(0, 360, 30)
|
||||||
self.ax.set_thetagrids(angles, labels)
|
labels = []
|
||||||
|
for angle in angles_deg:
|
||||||
|
# Convert angle from CCW-from-East to CW-from-North for display label
|
||||||
|
display_angle = (450 - angle) % 360
|
||||||
|
if display_angle > 180:
|
||||||
|
display_angle -= 360
|
||||||
|
labels.append(f'{-display_angle}°') # Invert sign to match CW convention
|
||||||
|
|
||||||
|
self.ax.set_thetagrids(angles_deg, labels)
|
||||||
|
|
||||||
|
|
||||||
self.ax.tick_params(axis="x", colors="white", labelsize=8)
|
self.ax.tick_params(axis="x", colors="white", labelsize=8)
|
||||||
self.ax.tick_params(axis="y", colors="white", labelsize=8)
|
self.ax.tick_params(axis="y", colors="white", labelsize=8)
|
||||||
@ -114,6 +140,8 @@ class PPIDisplay(ttk.Frame):
|
|||||||
self.ax.spines["polar"].set_color("white")
|
self.ax.spines["polar"].set_color("white")
|
||||||
self.ax.set_title("PPI Display", color="white")
|
self.ax.set_title("PPI Display", color="white")
|
||||||
|
|
||||||
|
# ... (il resto del metodo è corretto) ...
|
||||||
|
|
||||||
(self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5, label="Path")
|
(self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5, label="Path")
|
||||||
(self._start_plot,) = self.ax.plot([], [], "go", markersize=8, label="Start")
|
(self._start_plot,) = self.ax.plot([], [], "go", markersize=8, label="Start")
|
||||||
(self._waypoints_plot,) = self.ax.plot([], [], "y+", markersize=10, mew=2, label="Waypoints")
|
(self._waypoints_plot,) = self.ax.plot([], [], "y+", markersize=10, mew=2, label="Waypoints")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user