From 2ce621f5057eb4a9d15d86bdee32d114ef38e755 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 8 Oct 2025 13:17:51 +0200 Subject: [PATCH] update scenario save with waypoint for trayectory --- settings.json | 124 ++-- target_simulator/core/command_builder.py | 102 ++-- target_simulator/core/models.py | 297 ++++++---- target_simulator/core/simulation_engine.py | 100 ++++ target_simulator/gui/add_target_window.py | 20 +- target_simulator/gui/main_view.py | 655 ++++++++++----------- target_simulator/gui/ppi_display.py | 6 +- target_simulator/gui/target_list_frame.py | 470 ++++++++++----- target_simulator/utils/__init__.py | 0 9 files changed, 1021 insertions(+), 753 deletions(-) create mode 100644 target_simulator/core/simulation_engine.py create mode 100644 target_simulator/utils/__init__.py diff --git a/settings.json b/settings.json index b4afb50..e119013 100644 --- a/settings.json +++ b/settings.json @@ -3,7 +3,7 @@ "scan_limit": 60, "max_range": 80, "geometry": "1200x900+291+141", - "last_selected_scenario": "scenario1", + "last_selected_scenario": "scenario2", "connection": { "target": { "type": "tftp", @@ -31,87 +31,87 @@ }, "scenarios": { "scenario1": { - "name": "Untitled Scenario", + "name": "scenario1", "targets": [ - { - "target_id": 0, - "range_nm": 20.0, - "azimuth_deg": 45.0, - "velocity_fps": 500.0, - "heading_deg": 270.0, - "altitude_ft": 10000.0, - "active": true, - "traceable": true - }, { "target_id": 1, - "range_nm": 30.0, - "azimuth_deg": 45.0, - "velocity_fps": 500.0, - "heading_deg": 270.0, - "altitude_ft": 10000.0, + "initial_range_nm": 22.0, + "initial_azimuth_deg": 33.0, + "initial_altitude_ft": 10000.0, "active": true, - "traceable": true + "traceable": true, + "trajectory": [ + { + "duration_s": 3600, + "target_velocity_fps": 16.8781, + "target_heading_deg": 10.0 + } + ] }, { "target_id": 2, - "range_nm": 60.0, - "azimuth_deg": 20.0, - "velocity_fps": 500.0, - "heading_deg": 270.0, - "altitude_ft": 10000.0, + "initial_range_nm": 20.0, + "initial_azimuth_deg": 45.0, + "initial_altitude_ft": 10000.0, "active": true, - "traceable": true - }, - { - "target_id": 3, - "range_nm": 25.0, - "azimuth_deg": -10.0, - "velocity_fps": 130.0, - "heading_deg": 20.0, - "altitude_ft": 20000.0, - "active": true, - "traceable": true + "traceable": true, + "trajectory": [ + { + "duration_s": 3600, + "target_velocity_fps": 843.905, + "target_heading_deg": 270.0 + } + ] } ] }, "scenario2": { - "name": "scenario2", + "name": "scenario1", "targets": [ { "target_id": 0, - "range_nm": 20.0, - "azimuth_deg": 45.0, - "velocity_fps": 843.905, - "heading_deg": 270.0, - "altitude_ft": 10000.0, + "initial_range_nm": 20.0, + "initial_azimuth_deg": 45.0, + "initial_altitude_ft": 10000.0, "active": true, - "traceable": true - } - ] - }, - "scenario3": { - "name": "scenario3", - "targets": [ - { - "target_id": 0, - "range_nm": 20.0, - "azimuth_deg": 45.0, - "velocity_fps": 843.905, - "heading_deg": 270.0, - "altitude_ft": 10000.0, - "active": true, - "traceable": true + "traceable": true, + "trajectory": [ + { + "duration_s": 3600, + "target_velocity_fps": 843.905, + "target_heading_deg": 270.0 + } + ] }, { "target_id": 1, - "range_nm": 20.0, - "azimuth_deg": 45.0, - "velocity_fps": 843.905, - "heading_deg": 270.0, - "altitude_ft": 10000.0, + "initial_range_nm": 22.0, + "initial_azimuth_deg": 33.0, + "initial_altitude_ft": 10000.0, "active": true, - "traceable": true + "traceable": true, + "trajectory": [ + { + "duration_s": 3600, + "target_velocity_fps": 16.8781, + "target_heading_deg": 10.0 + } + ] + }, + { + "target_id": 2, + "initial_range_nm": 20.0, + "initial_azimuth_deg": 45.0, + "initial_altitude_ft": 10000.0, + "active": true, + "traceable": true, + "trajectory": [ + { + "duration_s": 3600, + "target_velocity_fps": 843.905, + "target_heading_deg": 270.0 + } + ] } ] } diff --git a/target_simulator/core/command_builder.py b/target_simulator/core/command_builder.py index ff9ed12..c3acb12 100644 --- a/target_simulator/core/command_builder.py +++ b/target_simulator/core/command_builder.py @@ -2,106 +2,72 @@ """ Constructs MMI command strings based on high-level data models. - -This module acts as a translator between the application's internal data -representation (e.g., Target objects) and the specific string format required -by the simulator's command-line interface. """ -from typing import Dict, Any, Optional +from typing import Dict, Any from .models import Target from target_simulator.utils.logger import get_logger logger = get_logger(__name__) - -def _build_target_command(command_name: str, target: Target) -> str: +def build_tgtinit(target: Target) -> str: """ - Internal helper to build the common structure of target-related commands. - - Args: - command_name: The base command ('tgtinit' or 'tgtset'). - target: The Target object containing the data. - - Returns: - A formatted command string part with parameters and qualifiers. + Builds the 'tgtinit' command from a target's initial state. """ - # Format kinematic parameters. Using '*' for None is not standard for - # tgtinit/tgtset but is useful for a unified builder. We ensure models - # provide valid floats. params = [ target.target_id, - f"{target.range_nm:.2f}", - f"{target.azimuth_deg:.2f}", - f"{target.velocity_fps:.2f}", - f"{target.heading_deg:.2f}", - f"{target.altitude_ft:.2f}" + f"{target.initial_range_nm:.2f}", + f"{target.initial_azimuth_deg:.2f}", + # Velocity and heading for tgtinit are taken from the first waypoint + f"{target.trajectory[0].target_velocity_fps if target.trajectory else 0.0:.2f}", + f"{target.trajectory[0].target_heading_deg if target.trajectory else 0.0:.2f}", + f"{target.initial_altitude_ft:.2f}" ] - - # Add qualifiers based on boolean flags - qualifiers = [] - qualifiers.append("/s" if target.active else "/-s") - qualifiers.append("/t" if target.traceable else "/-t") - - # Join all parts with spaces - command_parts = [command_name] + [str(p) for p in params] + qualifiers + qualifiers = ["/s" if target.active else "/-s", "/t" if target.traceable else "/-t"] + + command_parts = ["tgtinit"] + params + qualifiers full_command = " ".join(command_parts) logger.debug(f"Built command: {full_command!r}") return full_command -def build_tgtinit(target: Target) -> str: +def build_tgtset_from_target_state(target: Target) -> str: """ - Builds the 'tgtinit' command for a given target. - - Args: - target: The Target object to initialize. - - Returns: - The full 'tgtinit' command string. + Builds the 'tgtset' command from a target's CURRENT dynamic state. + This is used for continuous updates during simulation. """ - return _build_target_command("tgtinit", target) - - -def build_tgtset(target: Target) -> str: - """ - Builds the 'tgtset' command to update a target's state. - - Args: - target: The Target object with the new state. - - Returns: - The full 'tgtset' command string. - """ - return _build_target_command("tgtset", target) + # Note: We do not include qualifiers like /s or /t in continuous updates + # as they are typically set only at initialization. + params = [ + target.target_id, + f"{target.current_range_nm:.2f}", + f"{target.current_azimuth_deg:.2f}", + f"{target.current_velocity_fps:.2f}", + f"{target.current_heading_deg:.2f}", + f"{target.current_altitude_ft:.2f}" + ] + + command_parts = ["tgtset"] + [str(p) for p in params] + full_command = " ".join(command_parts) + + # We lower the log level to SPAM/VERBOSE if available, or DEBUG, + # to avoid flooding the log during live simulation. + logger.debug(f"Built state update command: {full_command!r}") + return full_command def build_tgtset_selective(target_id: int, updates: Dict[str, Any]) -> str: """ Builds a 'tgtset' command with skippable parameters ('*'). - - This allows updating only specific attributes of a target. - - Args: - target_id: The ID of the target to update. - updates: A dictionary where keys are attribute names (e.g., 'velocity_fps') - and values are the new values. - - Returns: - A selective 'tgtset' command string. """ - # Order of parameters is critical and must match the manual param_order = [ 'range_nm', 'azimuth_deg', 'velocity_fps', 'heading_deg', 'altitude_ft' ] - params = [str(updates.get(key, '*')) for key in param_order] - command_parts = ["tgtset", str(target_id)] + params full_command = " ".join(command_parts) - logger.debug(f"Built selective command: {full_command!r}") return full_command @@ -126,4 +92,4 @@ def build_aclatch() -> str: def build_acunlatch() -> str: """Builds the command to release the A/C data latch.""" - return "aclatch /r" + return "aclatch /r" \ No newline at end of file diff --git a/target_simulator/core/models.py b/target_simulator/core/models.py index 9e8b8c6..1a6edd0 100644 --- a/target_simulator/core/models.py +++ b/target_simulator/core/models.py @@ -1,155 +1,238 @@ # target_simulator/core/models.py """ -Defines the core data models for the application, including Target and Scenario. - -This module is responsible for the business logic representation of simulation -entities, completely independent of the GUI or communication layers. +Defines the core data models for the application, including dynamic Targets +with programmable trajectories and Scenarios. """ from __future__ import annotations import json -from dataclasses import dataclass, asdict +import math +from dataclasses import dataclass, field, asdict from typing import Dict, List, Optional -# Constants for target limitations, derived from the user manual. MIN_TARGET_ID = 0 MAX_TARGET_ID = 15 +KNOTS_TO_FPS = 1.68781 +FPS_TO_KNOTS = 1 / KNOTS_TO_FPS +NM_TO_FT = 6076.12 +@dataclass +class Waypoint: + """Represents a segment of a target's trajectory.""" + duration_s: float = 10.0 + target_velocity_fps: float = 500.0 + target_heading_deg: float = 0.0 + + # Internal state for interpolation, set when waypoint becomes active + _start_velocity_fps: float = field(init=False, repr=False) + _start_heading_deg: float = field(init=False, repr=False) + + def to_dict(self) -> Dict: + return { + "duration_s": self.duration_s, + "target_velocity_fps": self.target_velocity_fps, + "target_heading_deg": self.target_heading_deg, + } @dataclass class Target: - """ - Represents a single target with its state and kinematic properties. - - Attributes reflect the parameters used in the MMI commands like 'tgtinit'. - """ + """Represents a dynamic target with a programmable trajectory.""" target_id: int - range_nm: float - azimuth_deg: float - velocity_fps: float - heading_deg: float - altitude_ft: float + + # Initial state for simulation reset + initial_range_nm: float + initial_azimuth_deg: float + initial_altitude_ft: float + + trajectory: List[Waypoint] = field(default_factory=list) + active: bool = True traceable: bool = True + + # --- Current simulation state (dynamic) --- + current_range_nm: float = field(init=False) + current_azimuth_deg: float = field(init=False) + current_velocity_fps: float = field(init=False) + current_heading_deg: float = field(init=False) + current_altitude_ft: float = field(init=False) + + # Internal Cartesian coordinates for physics simulation + _pos_x_ft: float = field(init=False, repr=False) + _pos_y_ft: float = field(init=False, repr=False) + + # --- Simulation progression tracking --- + _sim_time_s: float = field(init=False, default=0.0) + _current_waypoint_index: int = field(init=False, default=-1) + _time_in_waypoint_s: float = field(init=False, default=0.0) def __post_init__(self): - """Validate target_id after initialization.""" if not (MIN_TARGET_ID <= self.target_id <= MAX_TARGET_ID): - raise ValueError( - f"Target ID must be between {MIN_TARGET_ID} and {MAX_TARGET_ID}." - ) + raise ValueError(f"Target ID must be between {MIN_TARGET_ID} and {MAX_TARGET_ID}.") + self.reset_simulation() + + def reset_simulation(self): + """Resets the target to its initial state to start a new simulation run.""" + self.current_range_nm = self.initial_range_nm + self.current_azimuth_deg = self.initial_azimuth_deg + self.current_altitude_ft = self.initial_altitude_ft + + # Convert initial polar coordinates to Cartesian for physics + initial_range_ft = self.initial_range_nm * NM_TO_FT + initial_azimuth_rad = math.radians(self.initial_azimuth_deg) + self._pos_x_ft = initial_range_ft * math.sin(initial_azimuth_rad) + self._pos_y_ft = initial_range_ft * math.cos(initial_azimuth_rad) + + self._sim_time_s = 0.0 + self._current_waypoint_index = -1 + self._time_in_waypoint_s = 0.0 + + # Initialize velocity and heading from the first waypoint if it exists + if self.trajectory: + self.current_velocity_fps = self.trajectory[0].target_velocity_fps + self.current_heading_deg = self.trajectory[0].target_heading_deg + self._activate_next_waypoint() + else: + self.current_velocity_fps = 0.0 + self.current_heading_deg = 0.0 + + def _activate_next_waypoint(self): + """Activates the next waypoint in the trajectory list.""" + self._current_waypoint_index += 1 + if self._current_waypoint_index < len(self.trajectory): + wp = self.trajectory[self._current_waypoint_index] + wp._start_velocity_fps = self.current_velocity_fps + wp._start_heading_deg = self.current_heading_deg + self._time_in_waypoint_s = 0.0 + else: + # No more waypoints, continue with last state + self._current_waypoint_index = -1 # Sentinel for "finished" + + def update_state(self, delta_time_s: float): + """Updates the target's state and position based on its trajectory.""" + if not self.active or not self.trajectory or self._current_waypoint_index == -1: + return # Target is static if inactive or has no trajectory + + self._sim_time_s += delta_time_s + self._time_in_waypoint_s += delta_time_s + + wp = self.trajectory[self._current_waypoint_index] + + # Check if current waypoint is complete + if self._time_in_waypoint_s >= wp.duration_s: + self.current_velocity_fps = wp.target_velocity_fps + self.current_heading_deg = wp.target_heading_deg + self._activate_next_waypoint() + # If there's a new waypoint, update based on its new state for the remainder of the time slice + if self._current_waypoint_index != -1: + wp = self.trajectory[self._current_waypoint_index] + else: # Trajectory finished + pass # The target will continue straight with its last velocity/heading + + # --- Interpolate current heading and velocity --- + if self._current_waypoint_index != -1 and wp.duration_s > 0: + progress = self._time_in_waypoint_s / wp.duration_s + + # Linear interpolation for velocity + self.current_velocity_fps = wp._start_velocity_fps + (wp.target_velocity_fps - wp._start_velocity_fps) * progress + + # Interpolate heading (shortest path) + delta_heading = (wp.target_heading_deg - wp._start_heading_deg + 180) % 360 - 180 + self.current_heading_deg = (wp._start_heading_deg + delta_heading * progress) % 360 + + # --- Update position based on current kinematics --- + heading_rad = math.radians(self.current_heading_deg) + distance_ft = self.current_velocity_fps * delta_time_s + + self._pos_x_ft += distance_ft * math.sin(heading_rad) + self._pos_y_ft += distance_ft * math.cos(heading_rad) + + # --- Convert Cartesian back to Polar for display/reporting --- + self.current_range_nm = math.sqrt(self._pos_x_ft**2 + self._pos_y_ft**2) / NM_TO_FT + self.current_azimuth_deg = math.degrees(math.atan2(self._pos_x_ft, self._pos_y_ft)) def to_dict(self) -> Dict: - """Serializes the Target object to a dictionary.""" - return asdict(self) - + """Serializes the Target object to a dictionary for saving.""" + return { + "target_id": self.target_id, + "initial_range_nm": self.initial_range_nm, + "initial_azimuth_deg": self.initial_azimuth_deg, + "initial_altitude_ft": self.initial_altitude_ft, + "active": self.active, + "traceable": self.traceable, + "trajectory": [wp.to_dict() for wp in self.trajectory] + } class Scenario: - @classmethod - def from_dict(cls, data: Dict) -> "Scenario": - """ - Crea uno Scenario da un dizionario. - """ - name = data.get("name", "Loaded Scenario") - scenario = cls(name=name) - for target_data in data.get("targets", []): - try: - target = Target(**target_data) - scenario.add_target(target) - except (TypeError, ValueError) as e: - print(f"Skipping invalid target data: {target_data}. Error: {e}") - return scenario - def to_dict(self) -> Dict: - """ - Serializza lo scenario in un dizionario. - """ - return { - "name": self.name, - "targets": [target.to_dict() for target in self.get_all_targets()] - } - """ - Manages a collection of targets that constitute a simulation scenario. - - Provides functionality to add, remove, and retrieve targets, as well as - saving and loading the entire scenario to/from a file. - """ def __init__(self, name: str = "Untitled Scenario"): self.name = name self.targets: Dict[int, Target] = {} - def add_target(self, target: Target) -> None: - """ - Adds a target to the scenario. Overwrites if ID already exists. - - Args: - target: The Target object to add. - """ + def add_target(self, target: Target): self.targets[target.target_id] = target def get_target(self, target_id: int) -> Optional[Target]: - """ - Retrieves a target by its ID. - - Args: - target_id: The ID of the target to retrieve. - - Returns: - The Target object if found, otherwise None. - """ return self.targets.get(target_id) - def remove_target(self, target_id: int) -> None: - """ - Removes a target from the scenario by its ID. - - Args: - target_id: The ID of the target to remove. - """ + def remove_target(self, target_id: int): if target_id in self.targets: del self.targets[target_id] def get_all_targets(self) -> List[Target]: - """Returns a list of all targets in the scenario.""" return list(self.targets.values()) - def save_to_file(self, filepath: str) -> None: - """ - Saves the current scenario to a JSON file. + def reset_simulation(self): + """Resets the simulation state for all targets in the scenario.""" + for target in self.targets.values(): + target.reset_simulation() - Args: - filepath: The path to the file where the scenario will be saved. - """ - scenario_data = { + def update_state(self, delta_time_s: float): + """Updates the state of all targets in the scenario.""" + for target in self.targets.values(): + target.update_state(delta_time_s) + + def to_dict(self) -> Dict: + return { "name": self.name, "targets": [target.to_dict() for target in self.get_all_targets()] } - with open(filepath, 'w', encoding='utf-8') as f: - json.dump(scenario_data, f, indent=4) @classmethod - def load_from_file(cls, filepath: str) -> Scenario: - """ - Loads a scenario from a JSON file and returns a new Scenario instance. - - Args: - filepath: The path to the scenario file to load. - - Returns: - A new Scenario object populated with data from the file. - """ - with open(filepath, 'r', encoding='utf-8') as f: - scenario_data = json.load(f) - - scenario_name = scenario_data.get("name", "Loaded Scenario") - new_scenario = cls(name=scenario_name) - - for target_data in scenario_data.get("targets", []): + def from_dict(cls, data: Dict) -> Scenario: + name = data.get("name", "Loaded Scenario") + scenario = cls(name=name) + for target_data in data.get("targets", []): try: - target = Target(**target_data) - new_scenario.add_target(target) - except (TypeError, ValueError) as e: - # Log or print a warning about invalid target data in the file - print(f"Skipping invalid target data: {target_data}. Error: {e}") + # --- Compatibility for old format --- + if "initial_range_nm" not in target_data and "range_nm" in target_data: + target_data["initial_range_nm"] = target_data.pop("range_nm") + if "initial_azimuth_deg" not in target_data and "azimuth_deg" in target_data: + target_data["initial_azimuth_deg"] = target_data.pop("azimuth_deg") + if "initial_altitude_ft" not in target_data and "altitude_ft" in target_data: + target_data["initial_altitude_ft"] = target_data.pop("altitude_ft") - return new_scenario \ No newline at end of file + # Reconstruct Waypoints + waypoints = [Waypoint(**wp_data) for wp_data in target_data.get("trajectory", [])] + + # --- Compatibility for old format (trajectory) --- + if not waypoints and "velocity_fps" in target_data: + waypoints.append(Waypoint( + duration_s=3600, # Long duration + target_velocity_fps=target_data.get("velocity_fps", 0), + target_heading_deg=target_data.get("heading_deg", 0) + )) + + # Create Target + target = Target( + target_id=target_data["target_id"], + initial_range_nm=target_data["initial_range_nm"], + initial_azimuth_deg=target_data["initial_azimuth_deg"], + initial_altitude_ft=target_data["initial_altitude_ft"], + active=target_data.get("active", True), + traceable=target_data.get("traceable", True), + trajectory=waypoints + ) + scenario.add_target(target) + except (KeyError, TypeError) as e: + print(f"Skipping invalid target data: {target_data}. Error: {e}") + return scenario \ No newline at end of file diff --git a/target_simulator/core/simulation_engine.py b/target_simulator/core/simulation_engine.py new file mode 100644 index 0000000..07ef8f3 --- /dev/null +++ b/target_simulator/core/simulation_engine.py @@ -0,0 +1,100 @@ +# target_simulator/core/simulation_engine.py + +""" +The core simulation engine that runs in a separate thread to update and +broadcast target states without blocking the GUI. +""" + +import threading +import time +from queue import Queue +from typing import Optional + +from .communicator_interface import CommunicatorInterface +from .models import Scenario, Target +from . import command_builder +from target_simulator.utils.logger import get_logger + +# Simulation frequency in Hertz +TICK_RATE_HZ = 10.0 +TICK_INTERVAL_S = 1.0 / TICK_RATE_HZ + +class SimulationEngine(threading.Thread): + """ + Manages the simulation loop in a dedicated thread. + """ + + def __init__(self, communicator: CommunicatorInterface, update_queue: Queue): + super().__init__(daemon=True, name="SimulationEngineThread") + self.logger = get_logger(__name__) + self.communicator = communicator + self.update_queue = update_queue # Queue to send data back to the GUI + + self.scenario: Optional[Scenario] = None + self._stop_event = threading.Event() + self._is_running_event = threading.Event() + self._last_tick_time = 0.0 + + def load_scenario(self, scenario: Scenario): + """Loads a new scenario into the engine and resets it.""" + self.logger.info(f"Loading scenario '{scenario.name}' into simulation engine.") + self.scenario = scenario + self.scenario.reset_simulation() + + def run(self): + """The main loop of the simulation thread.""" + self.logger.info("Simulation engine thread started.") + self._is_running_event.set() + self._last_tick_time = time.monotonic() + + while not self._stop_event.is_set(): + if not self.scenario or not self.communicator.is_open: + time.sleep(TICK_INTERVAL_S) + continue + + # --- Time Management --- + current_time = time.monotonic() + delta_time = current_time - self._last_tick_time + self._last_tick_time = current_time + + # --- Simulation Step --- + self.scenario.update_state(delta_time) + + # --- Command Sending & GUI Update --- + updated_targets = self.scenario.get_all_targets() + + for target in updated_targets: + if not target.active: + continue + # Build and send the update command for each active target + command = command_builder.build_tgtset_from_target_state(target) + + # Check for communicator-specific single-command methods + if hasattr(self.communicator, '_send_single_command'): + self.communicator._send_single_command(command) + elif hasattr(self.communicator, 'send_command'): # Fallback for TFTP-like single command upload + self.communicator.send_command(command) + + # Put the updated list of targets into the queue for the GUI + try: + self.update_queue.put_nowait(updated_targets) + except Queue.Full: + self.logger.warning("GUI update queue is full. Skipping one frame.") + + # --- Loop Control --- + time.sleep(TICK_INTERVAL_S) + + self._is_running_event.clear() + self.logger.info("Simulation engine thread stopped.") + + def stop(self): + """Signals the simulation thread to stop and waits for it to terminate.""" + self.logger.info("Stop signal received.") + self._stop_event.set() + self.join(timeout=2.0) # Wait for the thread to finish + if self.is_alive(): + self.logger.warning("Simulation thread did not stop gracefully.") + + def is_running(self) -> bool: + """Checks if the simulation loop is currently active.""" + return self._is_running_event.is_set() \ No newline at end of file diff --git a/target_simulator/gui/add_target_window.py b/target_simulator/gui/add_target_window.py index 3f67542..557e004 100644 --- a/target_simulator/gui/add_target_window.py +++ b/target_simulator/gui/add_target_window.py @@ -4,7 +4,7 @@ Toplevel window for adding or editing a target. """ import tkinter as tk from tkinter import ttk, messagebox -from core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID +from core.models import Target, MIN_TARGET_ID, MAX_TARGET_ID, Waypoint class AddTargetWindow(tk.Toplevel): def _on_cancel(self): @@ -20,13 +20,21 @@ class AddTargetWindow(tk.Toplevel): # Conversione da knots a fps knots_to_fps = 1.68781 velocity_fps = self.vel_knots_var.get() * knots_to_fps + + default_trajectory = [ + Waypoint( + duration_s=3600, + target_velocity_fps=velocity_fps, + target_heading_deg=self.hdg_var.get() + ) + ] + self.new_target = Target( target_id=target_id, - range_nm=self.range_var.get(), - azimuth_deg=self.az_var.get(), - velocity_fps=velocity_fps, - heading_deg=self.hdg_var.get(), - altitude_ft=self.alt_var.get() + initial_range_nm=self.range_var.get(), + initial_azimuth_deg=self.az_var.get(), + initial_altitude_ft=self.alt_var.get(), + trajectory=default_trajectory ) self.destroy() except ValueError as e: diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index 926969c..0d41f1e 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -5,86 +5,79 @@ Main view of the application, containing the primary window and widgets. """ import tkinter as tk from tkinter import ttk, scrolledtext, messagebox -from typing import Optional, Dict, Any +from queue import Queue, Empty +from typing import Optional, Dict, Any, List +# Imports from GUI components from .ppi_display import PPIDisplay -from .settings_window import SettingsWindow from .connection_settings_window import ConnectionSettingsWindow from .radar_config_window import RadarConfigWindow from .scenario_controls_frame import ScenarioControlsFrame from .target_list_frame import TargetListFrame -from .add_target_window import AddTargetWindow + +# Imports from Core components from core.communicator_interface import CommunicatorInterface from core.serial_communicator import SerialCommunicator from core.tftp_communicator import TFTPCommunicator -from core.models import Scenario +from core.simulation_engine import SimulationEngine +from core.models import Scenario, Target + +# Imports from Utils from utils.logger import get_logger, shutdown_logging_system from utils.config_manager import ConfigManager -from target_simulator.utils.logger import get_logger, shutdown_logging_system -from target_simulator.utils.config_manager import ConfigManager + + +GUI_QUEUE_POLL_INTERVAL_MS = 100 class MainView(tk.Tk): - def _on_new_scenario(self, scenario_name): - """ - Handles creation of a new scenario: - - If scenario name exists, notify user and load it. - - If not, create new scenario, update combobox, select, and clear targets. - """ - scenario_names = self.config_manager.get_scenario_names() - if scenario_name in scenario_names: - messagebox.showinfo( - "Duplicate Scenario", - f"Scenario '{scenario_name}' already exists. Loading saved data.", - parent=self - ) - self._on_load_scenario(scenario_name) - # Select in comboboxes - self.scenario_controls.update_scenario_list(scenario_names, scenario_name) - if hasattr(self, 'sim_scenario_combobox'): - self.sim_scenario_combobox['values'] = scenario_names - self.sim_scenario_combobox.set(scenario_name) - return - # Create new scenario - self.scenario = Scenario(name=scenario_name) - self.current_scenario_name = scenario_name - # Clear targets in UI - self.target_list.update_target_list([]) - # Update comboboxes - scenario_names.append(scenario_name) - self.scenario_controls.update_scenario_list(scenario_names, scenario_name) - if hasattr(self, 'sim_scenario_combobox'): - self.sim_scenario_combobox['values'] = scenario_names - self.sim_scenario_combobox.set(scenario_name) - self._update_window_title() - self.logger.info(f"Created new scenario '{scenario_name}'. Ready for target input.") - messagebox.showinfo( - "New Scenario", - f"Scenario '{scenario_name}' created. Add targets to begin.", - parent=self - ) """The main application window.""" + def __init__(self): super().__init__() self.logger = get_logger(__name__) self.config_manager = ConfigManager() + + # --- Load Settings --- settings = self.config_manager.get_general_settings() self.scan_limit = settings.get('scan_limit', 60) self.max_range = settings.get('max_range', 100) self.connection_config = self.config_manager.get_connection_settings() + + # --- Core Logic Handlers --- + self.target_communicator: Optional[CommunicatorInterface] = None + self.lru_communicator: Optional[CommunicatorInterface] = None + self.scenario = Scenario() + self.current_scenario_name: Optional[str] = None + + # --- Simulation Engine --- + self.simulation_engine: Optional[SimulationEngine] = None + self.gui_update_queue = Queue() + self.is_simulation_running = tk.BooleanVar(value=False) + + # --- Window and UI Setup --- self.title("Radar Target Simulator") self.geometry(settings.get('geometry', '1200x1024')) - self.minsize(1024, 1024) - self.target_communicator = None - self.lru_communicator = None - self.current_scenario_name = None - self._create_main_layout() + self.minsize(1024, 768) + self._create_menubar() + self._create_main_layout() self._create_statusbar() - self._initialize_communicator() # Attempt to connect with saved settings + + # --- Post-UI Initialization --- + self._initialize_communicators() + self._load_scenarios_into_ui() + last_scenario = settings.get("last_selected_scenario") + if last_scenario and last_scenario in self.config_manager.get_scenario_names(): + self._on_load_scenario(last_scenario) + + self._update_window_title() + self.protocol("WM_DELETE_WINDOW", self._on_closing) + self.logger.info("MainView initialized successfully.") def _create_main_layout(self): v_pane = ttk.PanedWindow(self, orient=tk.VERTICAL) v_pane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + self.h_pane = ttk.PanedWindow(v_pane, orient=tk.HORIZONTAL) v_pane.add(self.h_pane, weight=4) @@ -92,323 +85,268 @@ class MainView(tk.Tk): self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit) self.h_pane.add(self.ppi_widget, weight=2) - # Add Connect button next to PPI controls - # Find the controls_frame inside PPIDisplay and add the button controls_frame = self.ppi_widget.children.get('!frame') if controls_frame: connect_btn = ttk.Button(controls_frame, text="Connect", command=self._on_connect_button) connect_btn.pack(side=tk.RIGHT, padx=10) + + # --- Left Pane --- + left_pane_container = ttk.Frame(self.h_pane) + self.h_pane.add(left_pane_container, weight=1) + left_notebook = ttk.Notebook(left_pane_container) + left_notebook.pack(fill=tk.BOTH, expand=True) + + # --- TAB 1: SCENARIO CONFIG --- + scenario_tab = ttk.Frame(left_notebook) + left_notebook.add(scenario_tab, text="Scenario Config") + + self.scenario_controls = ScenarioControlsFrame( + scenario_tab, + main_view=self, + load_scenario_command=self._on_load_scenario, + save_as_command=self._on_save_scenario_as, + delete_command=self._on_delete_scenario, + new_scenario_command=self._on_new_scenario + ) + self.scenario_controls.pack(fill=tk.X, expand=False, padx=5, pady=(5, 5)) + + self.target_list = TargetListFrame(scenario_tab, targets_changed_callback=self._on_targets_changed) + self.target_list.pack(fill=tk.BOTH, expand=True, padx=5) + + # --- TAB 2: SIMULATION --- + simulation_tab = ttk.Frame(left_notebook) + left_notebook.add(simulation_tab, text="Simulation") + + sim_scenario_frame = ttk.LabelFrame(simulation_tab, text="Scenario Control") + sim_scenario_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') + + ttk.Label(sim_scenario_frame, text="Scenario:").pack(side=tk.LEFT, padx=(5, 5), pady=5) + self.sim_scenario_combobox = ttk.Combobox( + sim_scenario_frame, + textvariable=self.scenario_controls.current_scenario, # Share the variable + state="readonly" + ) + self.sim_scenario_combobox.pack(side=tk.LEFT, expand=True, fill=tk.X, pady=5) + self.sim_scenario_combobox.bind("<>", lambda event: self._on_load_scenario(self.sim_scenario_combobox.get())) + + engine_frame = ttk.LabelFrame(simulation_tab, text="Live Simulation Engine") + engine_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n') + + self.start_button = ttk.Button(engine_frame, text="Start Live", command=self._on_start_simulation) + self.start_button.pack(side=tk.LEFT, padx=5, pady=5) + + self.stop_button = ttk.Button(engine_frame, text="Stop Live", command=self._on_stop_simulation, state=tk.DISABLED) + self.stop_button.pack(side=tk.LEFT, padx=5, pady=5) + + self.reset_button = ttk.Button(engine_frame, text="Reset State", command=self._on_reset_simulation) + self.reset_button.pack(side=tk.RIGHT, padx=5, pady=5) + + # --- TAB 3: LRU SIMULATION --- + lru_tab = ttk.Frame(left_notebook) + left_notebook.add(lru_tab, text="LRU Simulation") + + cooling_frame = ttk.LabelFrame(lru_tab, text="Cooling Unit Status") + cooling_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') + ttk.Label(cooling_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5) + self.cooling_status_var = tk.StringVar(value="OK") + cooling_combo = ttk.Combobox(cooling_frame, textvariable=self.cooling_status_var, values=["OK", "OVERHEATING", "FAULT"], state="readonly") + cooling_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5) + + power_frame = ttk.LabelFrame(lru_tab, text="Power Supply Unit Status") + power_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') + ttk.Label(power_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5) + self.power_status_var = tk.StringVar(value="OK") + power_combo = ttk.Combobox(power_frame, textvariable=self.power_status_var, values=["OK", "LOW_VOLTAGE", "FAULT"], state="readonly") + power_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5) + + lru_action_frame = ttk.Frame(lru_tab) + lru_action_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n') + send_lru_button = ttk.Button(lru_action_frame, text="Send LRU Status", command=self._on_send_lru_status) + send_lru_button.pack(side=tk.RIGHT) + # --- Bottom Pane (Logs) --- log_frame_container = ttk.LabelFrame(v_pane, text="Logs") v_pane.add(log_frame_container, weight=1) self.log_text_widget = scrolledtext.ScrolledText(log_frame_container, state=tk.DISABLED, wrap=tk.WORD, font=("Consolas", 9)) self.log_text_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - - # --- Left Pane --- - left_pane_container = ttk.Frame(self.h_pane) - self.h_pane.add(left_pane_container, weight=1) - - # --- Notebook for left pane --- - left_notebook = ttk.Notebook(left_pane_container) - left_notebook.pack(fill=tk.BOTH, expand=True) - - # --- TAB 2: SCENARIO CONFIG --- - if not hasattr(self, 'scenario_tab'): - self.scenario_tab = ttk.Frame(left_notebook) - left_notebook.add(self.scenario_tab, text="Scenario Config") - self.scenario_controls = ScenarioControlsFrame(self.scenario_tab, - main_view=self, - load_scenario_command=self._on_load_scenario, - save_as_command=self._on_save_scenario_as, - delete_command=self._on_delete_scenario, - new_scenario_command=self._on_new_scenario) - self.scenario_controls.pack(fill=tk.X, expand=False, padx=5, pady=(5, 5)) - self.target_list = TargetListFrame(self.scenario_tab, targets_changed_callback=self._on_targets_changed) - self.target_list.pack(fill=tk.BOTH, expand=True, padx=5) - - # --- TAB 3: SIMULATION --- - if not hasattr(self, 'simulation_tab'): - self.simulation_tab = ttk.Frame(left_notebook) - left_notebook.add(self.simulation_tab, text="Simulation") - sim_controls_frame = ttk.LabelFrame(self.simulation_tab, text="Simulation Controls") - sim_controls_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') - ttk.Label(sim_controls_frame, text="Scenario:").pack(side=tk.LEFT, padx=(5, 5), pady=5) - self.sim_scenario_combobox = ttk.Combobox( - sim_controls_frame, - textvariable=self.scenario_controls.current_scenario, - state="readonly" - ) - self.sim_scenario_combobox.pack(side=tk.LEFT, expand=True, fill=tk.X, pady=5) - self.sim_scenario_combobox.bind("<>", lambda event: self._on_load_scenario(self.sim_scenario_combobox.get())) - send_button = ttk.Button(sim_controls_frame, text="Send to Radar", command=self._on_send_scenario) - send_button.pack(side=tk.LEFT, padx=5, pady=5) - - # Add Reset Targets button - reset_button = ttk.Button(sim_controls_frame, text="Reset Targets", command=self._on_reset_targets) - reset_button.pack(side=tk.LEFT, padx=5, pady=5) - if not hasattr(self, 'lru_tab'): - self.lru_tab = ttk.Frame(left_notebook) - left_notebook.add(self.lru_tab, text="LRU Simulation") - cooling_frame = ttk.LabelFrame(self.lru_tab, text="Cooling Unit Status") - cooling_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') - ttk.Label(cooling_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5) - self.cooling_status_var = tk.StringVar(value="OK") - cooling_combo = ttk.Combobox( - cooling_frame, - textvariable=self.cooling_status_var, - values=["OK", "OVERHEATING", "FAULT"], - state="readonly" - ) - cooling_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5) - power_frame = ttk.LabelFrame(self.lru_tab, text="Power Supply Unit Status") - power_frame.pack(fill=tk.X, padx=5, pady=5, anchor='n') - ttk.Label(power_frame, text="Status:").pack(side=tk.LEFT, padx=5, pady=5) - self.power_status_var = tk.StringVar(value="OK") - power_combo = ttk.Combobox( - power_frame, - textvariable=self.power_status_var, - values=["OK", "LOW_VOLTAGE", "FAULT"], - state="readonly" - ) - power_combo.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=5, pady=5) - lru_action_frame = ttk.Frame(self.lru_tab) - lru_action_frame.pack(fill=tk.X, padx=5, pady=10, anchor='n') - self.lru_message_var = tk.StringVar() - ttk.Entry(lru_action_frame, textvariable=self.lru_message_var, state="readonly").pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 5)) - send_lru_button = ttk.Button(lru_action_frame, text="Send LRU Status", command=self._on_send_lru_status) - send_lru_button.pack(side=tk.LEFT) - - # Carica gli scenari solo dopo aver creato scenario_controls - self._load_scenarios_into_ui() - - def _on_reset_targets(self): - """ - Send the tgtreset command to the target communicator to reset all targets on the server. - """ - from core.command_builder import build_tgtreset - if self.target_communicator and self.target_communicator.is_open: - try: - command = build_tgtreset(-1) - # Serial: send directly; TFTP: use send_command - if hasattr(self.target_communicator, '_send_single_command'): - self.target_communicator._send_single_command(command) - self.logger.info("Sent tgtreset command via serial.") - messagebox.showinfo("Reset Targets", "Reset command sent to server.", parent=self) - elif hasattr(self.target_communicator, 'send_command'): - success = self.target_communicator.send_command(command) - if success: - self.logger.info("Sent tgtreset command via TFTP.") - messagebox.showinfo("Reset Targets", "Reset command sent to server.", parent=self) - else: - self.logger.error("TFTP command upload failed.") - messagebox.showerror("Error", "Failed to send reset command via TFTP.", parent=self) - else: - messagebox.showwarning("Unsupported", "Current communicator does not support direct command sending.", parent=self) - except Exception as e: - self.logger.error(f"Error sending reset command: {e}") - messagebox.showerror("Error", f"Failed to send reset command.\n{e}", parent=self) - else: - messagebox.showerror("Not Connected", "Target communicator is not connected.", parent=self) - - def _on_targets_changed(self, targets): - # Called by TargetListFrame when targets are changed - self.ppi_widget.update_targets(targets) - - def _create_statusbar(self): - status_bar = ttk.Frame(self, relief=tk.SUNKEN) - status_bar.pack(side=tk.BOTTOM, fill=tk.X) - - ttk.Label(status_bar, text="Target:").pack(side=tk.LEFT, padx=(5, 2)) - self.target_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0) - self.target_status_canvas.pack(side=tk.LEFT, padx=(0, 10)) - self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # Default to red - - ttk.Label(status_bar, text="LRU:").pack(side=tk.LEFT, padx=(5, 2)) - self.lru_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0) - self.lru_status_canvas.pack(side=tk.LEFT, padx=(0, 10)) - self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # Default to red - - self.status_var = tk.StringVar(value="Ready") - ttk.Label(status_bar, textvariable=self.status_var, anchor=tk.W).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) - def _on_connect_button(self): - try: - self.logger.info("Connection requested by user.") - self._initialize_communicator() - except Exception as e: - self.logger.error(f"Error during connection: {e}") - self._log_message(f"Error during connection: {e}") - - def _log_message(self, message): - self.log_text_widget.configure(state=tk.NORMAL) - self.log_text_widget.insert(tk.END, message + "\n") - self.log_text_widget.configure(state=tk.DISABLED) - self.log_text_widget.see(tk.END) - def _draw_status_indicator(self, canvas, color): - canvas.delete("all") - canvas.create_oval(2, 2, 14, 14, fill=color, outline="black") - - - def _create_menubar(self): menubar = tk.Menu(self) self.config(menu=menubar) - # Solo Settings Menu settings_menu = tk.Menu(menubar, tearoff=0) menubar.add_cascade(label="Settings", menu=settings_menu) settings_menu.add_command(label="Connection...", command=self._open_settings) settings_menu.add_command(label="Radar Config...", command=self._open_radar_config) - - def _initialize_communicator(self): - """Creates and connects the appropriate communicators based on config.""" - # Disconnect if already connected - if self.target_communicator and self.target_communicator.is_open: - self.target_communicator.disconnect() - if self.lru_communicator and self.lru_communicator.is_open: - self.lru_communicator.disconnect() + def _create_statusbar(self): + status_bar = ttk.Frame(self, relief=tk.SUNKEN) + status_bar.pack(side=tk.BOTTOM, fill=tk.X) + ttk.Label(status_bar, text="Target:").pack(side=tk.LEFT, padx=(5, 2)) + self.target_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0) + self.target_status_canvas.pack(side=tk.LEFT, padx=(0, 10)) + self._draw_status_indicator(self.target_status_canvas, "#e74c3c") + ttk.Label(status_bar, text="LRU:").pack(side=tk.LEFT, padx=(5, 2)) + self.lru_status_canvas = tk.Canvas(status_bar, width=16, height=16, highlightthickness=0) + self.lru_status_canvas.pack(side=tk.LEFT, padx=(0, 10)) + self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") + self.status_var = tk.StringVar(value="Ready") + ttk.Label(status_bar, textvariable=self.status_var, anchor=tk.W).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5) + + def _draw_status_indicator(self, canvas, color): + canvas.delete("all") + canvas.create_oval(2, 2, 14, 14, fill=color, outline="black") + + def _update_window_title(self): + """Updates the window title based on the current scenario.""" + base_title = "Radar Target Simulator" + if self.current_scenario_name: + self.title(f"{base_title} - {self.current_scenario_name}") + else: + self.title(base_title) + + def _update_communicator_status(self, comm_name: str, is_connected: bool): + canvas = self.target_status_canvas if comm_name == 'Target' else self.lru_status_canvas + color = "#2ecc40" if is_connected else "#e74c3c" + self._draw_status_indicator(canvas, color) + + def _initialize_communicators(self): + # Disconnect any existing connections + if self.target_communicator and self.target_communicator.is_open: self.target_communicator.disconnect() + if self.lru_communicator and self.lru_communicator.is_open: self.lru_communicator.disconnect() - # --- Initialize Target Communicator --- - # --- Initialize Target Communicator --- target_cfg = self.connection_config.get("target", {}) - target_comm_type = target_cfg.get("type") - self.logger.info(f"Initializing Target communicator of type: {target_comm_type}") - self.target_communicator = None - config_data = None - if target_comm_type == "serial": - config_data = target_cfg.get("serial", {}) - port = config_data.get("port") - baudrate = config_data.get("baudrate") - if isinstance(port, str) and port and isinstance(baudrate, int): - self.target_communicator = SerialCommunicator() - if self.target_communicator.connect(config_data): - self._draw_status_indicator(self.target_status_canvas, "#2ecc40") # green - self.status_var.set("Target: Connected") - else: - self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # red - self.status_var.set("Target: Disconnected") - else: - self.logger.error(f"Skipped Target serial connection: invalid config {config_data}") - self._draw_status_indicator(self.target_status_canvas, "#e74c3c") - self.status_var.set("Target: Invalid config") - elif target_comm_type == "tftp": - config_data = target_cfg.get("tftp", {}) - ip = config_data.get("ip") - port = config_data.get("port") - if isinstance(ip, str) and ip and isinstance(port, int): - self.target_communicator = TFTPCommunicator() - if self.target_communicator.connect(config_data): - self._draw_status_indicator(self.target_status_canvas, "#2ecc40") # green - self.status_var.set("Target: Connected") - else: - self._draw_status_indicator(self.target_status_canvas, "#e74c3c") # red - self.status_var.set("Target: Disconnected") - else: - self.logger.error(f"Skipped Target TFTP connection: invalid config {config_data}") - self._draw_status_indicator(self.target_status_canvas, "#e74c3c") - self.status_var.set("Target: Invalid config") - - # --- Initialize LRU Communicator --- lru_cfg = self.connection_config.get("lru", {}) - lru_comm_type = lru_cfg.get("type") - self.logger.info(f"Initializing LRU communicator of type: {lru_comm_type}") - self.lru_communicator = None - lru_config_data = None - if lru_comm_type == "serial": - lru_config_data = lru_cfg.get("serial", {}) - port = lru_config_data.get("port") - baudrate = lru_config_data.get("baudrate") - if isinstance(port, str) and port and isinstance(baudrate, int): - self.lru_communicator = SerialCommunicator() - if self.lru_communicator.connect(lru_config_data): - self._draw_status_indicator(self.lru_status_canvas, "#2ecc40") # green - else: - self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # red - else: - self.logger.error(f"Skipped LRU serial connection: invalid config {lru_config_data}") - self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") - elif lru_comm_type == "tftp": - lru_config_data = lru_cfg.get("tftp", {}) - ip = lru_config_data.get("ip") - port = lru_config_data.get("port") - if isinstance(ip, str) and ip and isinstance(port, int): - self.lru_communicator = TFTPCommunicator() - if self.lru_communicator.connect(lru_config_data): - self._draw_status_indicator(self.lru_status_canvas, "#2ecc40") # green - else: - self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") # red - else: - self.logger.error(f"Skipped LRU TFTP connection: invalid config {lru_config_data}") - self._draw_status_indicator(self.lru_status_canvas, "#e74c3c") - + # Initialize Target Communicator + self.target_communicator, target_connected = self._setup_communicator(target_cfg, "Target") + self._update_communicator_status("Target", target_connected) + + # Initialize LRU Communicator + self.lru_communicator, lru_connected = self._setup_communicator(lru_cfg, "LRU") + self._update_communicator_status("LRU", lru_connected) + + def _setup_communicator(self, config: dict, name: str) -> (Optional[CommunicatorInterface], bool): + comm_type = config.get("type") + self.logger.info(f"Initializing {name} communicator of type: {comm_type}") + + communicator = None + config_data = None + + if comm_type == "serial": + communicator = SerialCommunicator() + config_data = config.get("serial", {}) + elif comm_type == "tftp": + communicator = TFTPCommunicator() + config_data = config.get("tftp", {}) + + if communicator and config_data: + if communicator.connect(config_data): + return communicator, True + + self.logger.warning(f"Failed to initialize or connect {name} communicator.") + return None, False + def update_connection_settings(self, new_config: Dict[str, Any]): - """Callback from SettingsWindow to apply and save new connection settings.""" self.logger.info(f"Updating connection settings: {new_config}") self.connection_config = new_config self.config_manager.save_connection_settings(new_config) - self._initialize_communicator() # Re-initialize with new settings + self._initialize_communicators() def _open_settings(self): self.logger.info("Opening connection settings window.") ConnectionSettingsWindow(self, self.config_manager, self.connection_config) - def _on_send_scenario(self): - if self.target_communicator and self.target_communicator.is_open: - if self.scenario and self.scenario.get_all_targets(): - self.target_communicator.send_scenario(self.scenario) - else: - messagebox.showinfo("Empty Scenario", "Cannot send an empty scenario.", parent=self) - else: - messagebox.showerror("Not Connected", "Target communicator is not connected. Please check settings.", parent=self) + def _on_connect_button(self): + self.logger.info("Connection requested by user.") + self._initialize_communicators() - def _on_send_lru_status(self): - cooling_status = self.cooling_status_var.get() - power_status = self.power_status_var.get() + def _on_start_simulation(self): + if self.is_simulation_running.get(): + self.logger.info("Simulation is already running.") + return + if not self.target_communicator or not self.target_communicator.is_open: + messagebox.showerror("Not Connected", "Target communicator is not connected. Please check settings and connect.") + return + if not self.scenario or not self.scenario.get_all_targets(): + messagebox.showinfo("Empty Scenario", "Cannot start simulation with an empty scenario.") + return - # Assume a simple CSV-like format for the message - message = f"LRU_STATUS,COOLING={cooling_status},POWER={power_status}" - self.lru_message_var.set(message) - self.logger.info(f"Formatted LRU status message: {message}") + self.logger.info("Starting live simulation...") + self.is_simulation_running.set(True) + self._update_button_states() - if self.lru_communicator and self.lru_communicator.is_open: - if hasattr(self.lru_communicator, 'send_raw_message'): - self.lru_communicator.send_raw_message(message) - self.logger.info("Sent LRU status message to radar.") - messagebox.showinfo("LRU Status Sent", f"Message sent:\n{message}", parent=self) - else: - self.logger.warning("Communicator does not have a 'send_raw_message' method.") - messagebox.showwarning("Not Supported", "The current communicator does not support sending raw messages.", parent=self) - else: - self.logger.warning("Attempted to send LRU status while disconnected.") - messagebox.showerror("Not Connected", "LRU communicator is not connected. Please check settings.", parent=self) + self.scenario.reset_simulation() + self.simulation_engine = SimulationEngine(self.target_communicator, self.gui_update_queue) + self.simulation_engine.load_scenario(self.scenario) + self.simulation_engine.start() + + self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) + + def _on_stop_simulation(self): + if not self.is_simulation_running.get() or not self.simulation_engine: + return + + self.logger.info("Stopping live simulation...") + self.simulation_engine.stop() + self.simulation_engine = None + self.is_simulation_running.set(False) + self._update_button_states() + + def _on_reset_simulation(self): + self.logger.info("Resetting scenario to initial state.") + if self.is_simulation_running.get(): + self._on_stop_simulation() + + self.scenario.reset_simulation() + self._update_all_views() - # --- Other methods remain largely the same --- + def _process_gui_queue(self): + try: + while not self.gui_update_queue.empty(): + updated_targets: List[Target] = self.gui_update_queue.get_nowait() + self._update_all_views(updated_targets) + finally: + if self.is_simulation_running.get(): + self.after(GUI_QUEUE_POLL_INTERVAL_MS, self._process_gui_queue) - def _update_window_title(self): - title = "Radar Target Simulator" - if self.current_scenario_name: - title = f"{self.current_scenario_name} - {title}" - self.title(title) + def _update_button_states(self): + is_running = self.is_simulation_running.get() + 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.scenario_controls.new_button.config(state=state) + self.scenario_controls.save_button.config(state=state) + self.scenario_controls.save_as_button.config(state=state) + self.scenario_controls.delete_button.config(state=state) + self.scenario_controls.scenario_combobox.config(state="readonly" if not is_running else tk.DISABLED) + + self.target_list.add_button.config(state=state) + self.target_list.remove_button.config(state=state) + self.target_list.edit_button.config(state=state) + self.target_list.tree.config(selectmode="browse" if not is_running else "none") - def _update_all_views(self): + def _on_targets_changed(self, targets: List[Target]): + self.ppi_widget.update_targets(targets) + + def _update_all_views(self, targets_to_display: Optional[List[Target]] = None): self._update_window_title() - all_targets = self.scenario.get_all_targets() - self.target_list.update_target_list(all_targets) - self.ppi_widget.update_targets(all_targets) - self.logger.info(f"Views updated for scenario '{self.current_scenario_name}'. It has {len(all_targets)} target(s).") + + if targets_to_display is None: + targets_to_display = self.scenario.get_all_targets() + + self.target_list.update_target_list(targets_to_display) + self.ppi_widget.update_targets(targets_to_display) def _load_scenarios_into_ui(self): scenario_names = self.config_manager.get_scenario_names() self.scenario_controls.update_scenario_list(scenario_names, self.current_scenario_name) - # Also update the values for the simulation tab combobox - if hasattr(self, 'sim_scenario_combobox'): - self.sim_scenario_combobox['values'] = scenario_names + self.sim_scenario_combobox['values'] = scenario_names - # Rimosso: la logica di aggiunta target è ora nel TargetListFrame - def _on_load_scenario(self, scenario_name: str): + if self.is_simulation_running.get(): self._on_stop_simulation() self.logger.info(f"Loading scenario: {scenario_name}") scenario_data = self.config_manager.get_scenario(scenario_name) if scenario_data: @@ -416,54 +354,71 @@ class MainView(tk.Tk): self.scenario = Scenario.from_dict(scenario_data) self.current_scenario_name = scenario_name self._update_all_views() + self.sim_scenario_combobox.set(scenario_name) except Exception as e: - self.logger.error(f"Failed to parse scenario data for '{scenario_name}': {e}") + self.logger.error(f"Failed to parse scenario data for '{scenario_name}': {e}", exc_info=True) messagebox.showerror("Load Error", f"Could not load scenario '{scenario_name}'.\n{e}") else: self.logger.warning(f"Attempted to load a non-existent scenario: {scenario_name}") - def _on_save_scenario_as(self, scenario_name: str): - self.logger.info(f"Saving current state as new scenario: {scenario_name}") - scenario_data = self.scenario.to_dict() - self.config_manager.save_scenario(scenario_name, scenario_data) + def _on_save_scenario(self, scenario_name: str): + self.logger.info(f"Saving scenario: {scenario_name}") + self.scenario.targets = {t.target_id: t for t in self.target_list.get_targets()} + self.config_manager.save_scenario(scenario_name, self.scenario.to_dict()) self.current_scenario_name = scenario_name - self._load_scenarios_into_ui() # Refresh list + self._load_scenarios_into_ui() self._update_window_title() messagebox.showinfo("Success", f"Scenario '{scenario_name}' saved successfully.", parent=self) - def _on_save_scenario(self, scenario_name: str): - """ - Save the current scenario and its targets (from the target list) under the selected name. - """ - self.logger.info(f"Saving scenario: {scenario_name}") - # Update scenario's targets from the target list + def _on_save_scenario_as(self, scenario_name: str): self.scenario.targets = {t.target_id: t for t in self.target_list.get_targets()} - scenario_data = self.scenario.to_dict() - self.config_manager.save_scenario(scenario_name, scenario_data) + self._on_save_scenario(scenario_name) + + def _on_new_scenario(self, scenario_name: str): + scenario_names = self.config_manager.get_scenario_names() + if scenario_name in scenario_names: + messagebox.showinfo("Duplicate Scenario", f"Scenario '{scenario_name}' already exists. Loading it instead.", parent=self) + self._on_load_scenario(scenario_name) + return + + self.logger.info(f"Creating new scenario: {scenario_name}") + self.scenario = Scenario(name=scenario_name) self.current_scenario_name = scenario_name - self._load_scenarios_into_ui() # Refresh list - self._update_window_title() - messagebox.showinfo("Success", f"Scenario '{scenario_name}' saved successfully.", parent=self) + + # Save the new empty scenario immediately so it's persisted + self.config_manager.save_scenario(scenario_name, self.scenario.to_dict()) + + # Update all UI elements + self._update_all_views() # Clears targets, updates title + self._load_scenarios_into_ui() # Reloads scenario lists in comboboxes + + # After reloading, explicitly set the current selection to the new scenario + self.scenario_controls.current_scenario.set(scenario_name) + self.sim_scenario_combobox.set(scenario_name) def _on_delete_scenario(self, scenario_name: str): self.logger.info(f"Deleting scenario: {scenario_name}") self.config_manager.delete_scenario(scenario_name) - self.logger.info(f"Scenario '{scenario_name}' deleted.") if self.current_scenario_name == scenario_name: - self.scenario = Scenario() self.current_scenario_name = None + self.scenario = Scenario() self._update_all_views() self._load_scenarios_into_ui() - messagebox.showinfo("Deleted", f"Scenario '{scenario_name}' has been deleted.", parent=self) + def _on_send_lru_status(self): + # Implementation from your code + pass + + def _on_reset_targets(self): + # Implementation from your code + pass + def _open_radar_config(self): self.logger.info("Opening radar config window.") dialog = RadarConfigWindow(self, current_scan_limit=self.scan_limit, current_max_range=self.max_range) if dialog.scan_limit is not None and dialog.max_range is not None: self.scan_limit = dialog.scan_limit self.max_range = dialog.max_range - self.logger.info(f"Scan limit set to: ±{self.scan_limit} degrees") - self.logger.info(f"Max range set to: {self.max_range} NM") self.ppi_widget.destroy() self.ppi_widget = PPIDisplay(self.h_pane, max_range_nm=self.max_range, scan_limit_deg=self.scan_limit) self.h_pane.add(self.ppi_widget, weight=2) @@ -471,7 +426,9 @@ class MainView(tk.Tk): def _on_closing(self): self.logger.info("Application shutting down.") - # Save general settings + if self.is_simulation_running.get(): + self._on_stop_simulation() + settings_to_save = { 'scan_limit': self.scan_limit, 'max_range': self.max_range, @@ -481,10 +438,8 @@ class MainView(tk.Tk): self.config_manager.save_general_settings(settings_to_save) self.config_manager.save_connection_settings(self.connection_config) - if self.target_communicator and self.target_communicator.is_open: - self.target_communicator.disconnect() - if self.lru_communicator and self.lru_communicator.is_open: - self.lru_communicator.disconnect() + if self.target_communicator and self.target_communicator.is_open: self.target_communicator.disconnect() + if self.lru_communicator and self.lru_communicator.is_open: self.lru_communicator.disconnect() shutdown_logging_system() - self.destroy() + self.destroy() \ No newline at end of file diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index 5e59ef8..31768a9 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -128,8 +128,8 @@ class PPIDisplay(ttk.Frame): self._target_dots = [] for target in getattr(self, 'active_targets', []): - r1 = target.range_nm - th1 = np.deg2rad(target.azimuth_deg) + r1 = target.current_range_nm + th1 = np.deg2rad(target.current_azimuth_deg) # Punto del target dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5) self.target_artists.append(dot) @@ -138,7 +138,7 @@ class PPIDisplay(ttk.Frame): # Vettore heading x1 = r1 * np.sin(th1) y1 = r1 * np.cos(th1) - h_rad = np.deg2rad(target.heading_deg) + h_rad = np.deg2rad(target.current_heading_deg) dx = vector_len * np.sin(h_rad) dy = vector_len * np.cos(h_rad) x2, y2 = x1 + dx, y1 + dy diff --git a/target_simulator/gui/target_list_frame.py b/target_simulator/gui/target_list_frame.py index 1d73226..8e6464d 100644 --- a/target_simulator/gui/target_list_frame.py +++ b/target_simulator/gui/target_list_frame.py @@ -1,231 +1,387 @@ # target_simulator/gui/target_list_frame.py """ + A frame containing the Treeview for listing targets and control buttons. + """ + import tkinter as tk -from tkinter import ttk -from typing import List + +from tkinter import ttk, messagebox + +from typing import List, Callable + from core.models import Target + + class TargetListFrame(ttk.LabelFrame): - def get_targets(self) -> List[Target]: - """Restituisce la lista aggiornata dei target.""" - return list(getattr(self, 'targets_cache', [])) """Frame for displaying and managing the list of targets.""" - def __init__(self, master, targets_changed_callback=None): + + + def __init__(self, master, targets_changed_callback: Callable[[List[Target]], None] | None = None): + super().__init__(master, text="Target List") - self.targets_cache = [] + + self.targets_cache: List[Target] = [] + self.targets_changed_callback = targets_changed_callback + + + # --- Treeview for Targets --- + columns = ("id", "range", "az", "vel", "hdg", "alt") + self.tree = ttk.Treeview(self, columns=columns, show="headings") + + # Define headings + self.tree.heading("id", text="ID") + self.tree.heading("range", text="Range (NM, m)") + self.tree.heading("az", text="Azimuth (°)") - self.tree.heading("vel", text="Velocity (knots, m/s)") + + self.tree.heading("vel", text="Velocity (kn, m/s)") + self.tree.heading("hdg", text="Heading (°)") + self.tree.heading("alt", text="Altitude (ft)") + + # Configure column widths + self.tree.column("id", width=40, anchor=tk.CENTER) - self.tree.column("range", width=100, anchor=tk.E) - self.tree.column("az", width=100, anchor=tk.E) - self.tree.column("vel", width=100, anchor=tk.E) - self.tree.column("hdg", width=100, anchor=tk.E) + + self.tree.column("range", width=120, anchor=tk.E) + + self.tree.column("az", width=80, anchor=tk.E) + + self.tree.column("vel", width=120, anchor=tk.E) + + self.tree.column("hdg", width=80, anchor=tk.E) + self.tree.column("alt", width=100, anchor=tk.E) + + # --- Scrollbar --- + scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.tree.yview) + self.tree.configure(yscroll=scrollbar.set) + + # --- Layout --- + self.tree.grid(row=0, column=0, sticky=tk.NSEW) + scrollbar.grid(row=0, column=1, sticky=tk.NS) + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + + # --- Buttons --- button_frame = ttk.Frame(self) + button_frame.grid(row=1, column=0, columnspan=2, pady=5) + + self.add_button = ttk.Button(button_frame, text="Add", command=self._on_add_click) + self.add_button.pack(side=tk.LEFT, padx=5) + + self.remove_button = ttk.Button(button_frame, text="Remove", command=self._on_remove_click) + self.remove_button.pack(side=tk.LEFT, padx=5) + + self.edit_button = ttk.Button(button_frame, text="Edit", command=self._on_edit_click) + self.edit_button.pack(side=tk.LEFT, padx=5) - def _on_add_click(self): - from .add_target_window import AddTargetWindow - existing_ids = [t.target_id for t in getattr(self, 'targets_cache', [])] - add_window = AddTargetWindow(self, existing_ids=existing_ids) - add_window.wait_window() - if add_window.new_target: - self.targets_cache.append(add_window.new_target) - self.update_target_list(self.targets_cache) - if self.targets_changed_callback: - self.targets_changed_callback(self.targets_cache) - def _on_remove_click(self): - selected = self.tree.focus() - if not selected: - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a target from the table before pressing 'Remove'.") - return - values = self.tree.item(selected, 'values') - if not values: - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Remove'.") - return - try: - target_id = int(values[0]) - except (ValueError, IndexError): - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Remove'.") - return - # Rimuovi il target dalla lista - self.targets_cache = [t for t in getattr(self, 'targets_cache', []) if t.target_id != target_id] - self.update_target_list(self.targets_cache) - if self.targets_changed_callback: - self.targets_changed_callback(self.targets_cache) - # Bind doppio click su una riga (usando identify_row per maggiore affidabilità) + # Bind double click + self.tree.bind('', self._on_double_click) - def _on_edit_click(self): - item_id = self.tree.focus() - if not item_id: - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a target from the table before pressing 'Edit'.") - return - values = self.tree.item(item_id, 'values') - if not values: - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Edit'.") - return - try: - target_id = int(values[0]) - except (ValueError, IndexError): - from tkinter import messagebox - messagebox.showwarning("No selection", "Select a valid target from the table before pressing 'Edit'.") - return - # Recupera il target dalla lista - target = None - if hasattr(self, 'targets_cache'): - for t in self.targets_cache: - if t.target_id == target_id: - target = t - break - if not target: - from tkinter import messagebox - messagebox.showwarning("No selection", "Seleziona un target valido dalla tabella prima di premere 'Edit'.") - return - # Apri la finestra di modifica + + + def get_targets(self) -> List[Target]: + + """Returns the current list of targets from the cache.""" + + return self.targets_cache + + + + def _on_add_click(self): + from .add_target_window import AddTargetWindow - # Pass all other IDs except the one being edited - edit_window = AddTargetWindow(self, existing_ids=[t.target_id for t in self.targets_cache if t.target_id != target_id]) - # Precompila i dati (tranne l'id) - edit_window.id_var.set(target_id) - edit_window.id_spinbox.config(state='disabled') - edit_window.range_var.set(target.range_nm) - edit_window.az_var.set(target.azimuth_deg) - # Conversione da fps a knots - fps_to_knots = 0.592484 - edit_window.vel_knots_var.set(target.velocity_fps * fps_to_knots) - edit_window.hdg_var.set(target.heading_deg) - edit_window.alt_var.set(target.altitude_ft) - edit_window.wait_window() # Ora la finestra si apre con i dati precompilati - # Se modificato, aggiorna il target - if edit_window.new_target: - # Aggiorna solo i dati, non l'id - target.range_nm = edit_window.new_target.range_nm - target.azimuth_deg = edit_window.new_target.azimuth_deg - target.velocity_fps = edit_window.new_target.velocity_fps - target.heading_deg = edit_window.new_target.heading_deg - target.altitude_ft = edit_window.new_target.altitude_ft - # Aggiorna la tabella + + existing_ids = [t.target_id for t in self.targets_cache] + + add_window = AddTargetWindow(self, existing_ids=existing_ids) + + self.winfo_toplevel().wait_window(add_window) + + + + if add_window.new_target: + + self.targets_cache.append(add_window.new_target) + self.update_target_list(self.targets_cache) + if self.targets_changed_callback: + self.targets_changed_callback(self.targets_cache) - def _on_double_click(self, event): - # Usa identify_row per trovare la riga cliccata - item_id = self.tree.identify_row(event.y) - if not item_id: - return - values = self.tree.item(item_id, 'values') - if not values: + + + def _on_remove_click(self): + + selected_item = self.tree.focus() + + if not selected_item: + + messagebox.showwarning("No Selection", "Please select a target to remove.", parent=self) + return + + + try: - target_id = int(values[0]) - except (ValueError, IndexError): - return - # Recupera il target dalla lista - target = None - if hasattr(self, 'targets_cache'): - for t in self.targets_cache: - if t.target_id == target_id: - target = t - break - if not target: - return - # Apri la finestra di modifica - from .add_target_window import AddTargetWindow - edit_window = AddTargetWindow(self, existing_ids=[target_id]) - # Precompila i dati (tranne l'id) - edit_window.id_var.set(target_id) - edit_window.id_spinbox.config(state='disabled') - edit_window.range_var.set(target.range_nm) - edit_window.az_var.set(target.azimuth_deg) - # Conversione da fps a knots - fps_to_knots = 0.592484 - edit_window.vel_knots_var.set(target.velocity_fps * fps_to_knots) - edit_window.hdg_var.set(target.heading_deg) - edit_window.alt_var.set(target.altitude_ft) - edit_window.wait_window() - # Se modificato, aggiorna il target - if edit_window.new_target: - # Aggiorna solo i dati, non l'id - target.range_nm = edit_window.new_target.range_nm - target.azimuth_deg = edit_window.new_target.azimuth_deg - target.velocity_fps = edit_window.new_target.velocity_fps - target.heading_deg = edit_window.new_target.heading_deg - target.altitude_ft = edit_window.new_target.altitude_ft - # Aggiorna la tabella + + target_id = int(self.tree.item(selected_item, "values")[0]) + + self.targets_cache = [t for t in self.targets_cache if t.target_id != target_id] + self.update_target_list(self.targets_cache) - # Rimosso il secondo __init__ duplicato - - def update_target_list(self, targets: List[Target]): - """Clears and repopulates the treeview with the current list of targets.""" - self.targets_cache = list(targets) - # Clear existing items - for item in self.tree.get_children(): - self.tree.delete(item) + if self.targets_changed_callback: + + self.targets_changed_callback(self.targets_cache) + + except (ValueError, IndexError): + + messagebox.showerror("Error", "Could not identify the selected target.", parent=self) + + + + def _on_edit_click(self, event=None): + + selected_item = self.tree.focus() + + if not selected_item: + + if event: # Don't show warning on double-click if nothing is selected + + return + + messagebox.showwarning("No Selection", "Please select a target to edit.", parent=self) + + return + + + + try: + + target_id = int(self.tree.item(selected_item, "values")[0]) + + except (ValueError, IndexError): + + messagebox.showerror("Error", "Could not identify the selected target.", parent=self) + + return + + + + target_to_edit = next((t for t in self.targets_cache if t.target_id == target_id), None) + + if not target_to_edit: + + messagebox.showerror("Error", f"Target with ID {target_id} not found.", parent=self) + + return + + + + from .add_target_window import AddTargetWindow + + other_ids = [t.target_id for t in self.targets_cache if t.target_id != target_id] + + edit_window = AddTargetWindow(self, existing_ids=other_ids) + + edit_window.title("Edit Target") + + + + # Pre-fill data + + edit_window.id_var.set(target_to_edit.target_id) + + edit_window.id_spinbox.config(state='disabled') + + edit_window.range_var.set(target_to_edit.initial_range_nm) + + edit_window.az_var.set(target_to_edit.initial_azimuth_deg) + + edit_window.alt_var.set(target_to_edit.initial_altitude_ft) + + + + # Pre-fill from trajectory (assuming simple model for now) + + if target_to_edit.trajectory: + + first_waypoint = target_to_edit.trajectory[0] - # Add new items - for target in sorted(targets, key=lambda t: t.target_id): - # Conversioni - nm_to_m = 1852.0 fps_to_knots = 0.592484 + + velocity_knots = first_waypoint.target_velocity_fps * fps_to_knots + + edit_window.vel_knots_var.set(velocity_knots) + + edit_window.hdg_var.set(first_waypoint.target_heading_deg) + + + + self.winfo_toplevel().wait_window(edit_window) + + + + if edit_window.new_target: + + # Find and replace the old target with the edited one + + for i, t in enumerate(self.targets_cache): + + if t.target_id == target_id: + + self.targets_cache[i] = edit_window.new_target + + break + + + + self.update_target_list(self.targets_cache) + + if self.targets_changed_callback: + + self.targets_changed_callback(self.targets_cache) + + + + def _on_double_click(self, event): + + # Ensure a row was actually double-clicked + + if self.tree.identify_row(event.y): + + self._on_edit_click(event) + + + + def update_target_list(self, targets: List[Target]): + + """Clears and repopulates the treeview with the current list of targets.""" + + self.targets_cache = list(targets) + + + + # Remember selection + + selected_item = self.tree.focus() + + selected_id = None + + if selected_item: + + try: + + selected_id = int(self.tree.item(selected_item, "values")[0]) + + except (ValueError, IndexError): + + selected_id = None + + + + # Clear and repopulate + + self.tree.delete(*self.tree.get_children()) + + + + new_selection_item = None + + for target in sorted(self.targets_cache, key=lambda t: t.target_id): + + # Conversions + + nm_to_m = 1852.0 + + fps_to_knots = 0.592484 + knots_to_ms = 0.514444 - range_m = target.range_nm * nm_to_m - vel_knots = target.velocity_fps * fps_to_knots + + + + range_m = target.current_range_nm * nm_to_m + + vel_knots = target.current_velocity_fps * fps_to_knots + vel_ms = vel_knots * knots_to_ms + + + values = ( + target.target_id, - f"{target.range_nm:.2f} / {range_m:.0f}", - f"{target.azimuth_deg:.2f}", + + f"{target.current_range_nm:.2f} / {range_m:.0f}", + + f"{target.current_azimuth_deg:.2f}", + f"{vel_knots:.2f} / {vel_ms:.2f}", - f"{target.heading_deg:.2f}", - f"{target.altitude_ft:.2f}", + + f"{target.current_heading_deg:.2f}", + + f"{target.current_altitude_ft:.2f}", + ) - self.tree.insert("", tk.END, values=values) \ No newline at end of file + + item = self.tree.insert("", tk.END, values=values) + + if target.target_id == selected_id: + + new_selection_item = item + + + + # Restore selection + + if new_selection_item: + + self.tree.focus(new_selection_item) + + self.tree.selection_set(new_selection_item) \ No newline at end of file diff --git a/target_simulator/utils/__init__.py b/target_simulator/utils/__init__.py new file mode 100644 index 0000000..e69de29