update scenario save with waypoint for trayectory

This commit is contained in:
VALLONGOL 2025-10-08 13:17:51 +02:00
parent fcb13c437b
commit 2ce621f505
9 changed files with 1021 additions and 753 deletions

View File

@ -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
}
]
}
]
}

View File

@ -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"

View File

@ -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
# 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

View File

@ -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()

View File

@ -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:

View File

@ -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("<<ComboboxSelected>>", 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("<<ComboboxSelected>>", 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()

View File

@ -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

View File

@ -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('<Double-1>', 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)
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)

View File