From 276cae0692815d1a879042ec744bc97fa37dc3a5 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 1 Oct 2025 15:10:55 +0200 Subject: [PATCH] add configuration manager, fix minor display ppi --- settings.json | 10 ++ target_simulator/gui/main_view.py | 120 ++++++++++++++---- target_simulator/gui/ppi_display.py | 72 +++++++++-- .../gui/scenario_controls_frame.py | 109 ++++++++++++++-- target_simulator/utils/config_manager.py | 99 +++++++++++++++ 5 files changed, 362 insertions(+), 48 deletions(-) create mode 100644 settings.json create mode 100644 target_simulator/utils/config_manager.py diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..800622f --- /dev/null +++ b/settings.json @@ -0,0 +1,10 @@ +{ + "general": { + "scan_limit": 60, + "max_range": 200, + "connection_port": null, + "geometry": "1200x900+338+338", + "last_selected_scenario": null + }, + "scenarios": {} +} \ No newline at end of file diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index b50c7d4..651181b 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -4,7 +4,9 @@ Main view of the application, containing the primary window and widgets. """ import tkinter as tk -from tkinter import ttk, scrolledtext +from tkinter import ttk, scrolledtext, messagebox +import os +from typing import Optional from .ppi_display import PPIDisplay from .settings_window import SettingsWindow @@ -15,57 +17,70 @@ from .add_target_window import AddTargetWindow from core.serial_communicator import SerialCommunicator from core.models import Scenario, Target from utils.logger import get_logger, shutdown_logging_system +from utils.config_manager import ConfigManager class MainView(tk.Tk): """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_port = settings.get('connection_port', None) + self.communicator = SerialCommunicator() self.scenario = Scenario() - self.connection_port: str | None = None - self.scan_limit = 60 - self.max_range = 100 - + self.current_scenario_name: Optional[str] = None + self.title("Radar Target Simulator") - self.geometry("1200x900") + self.geometry(settings.get('geometry', '1200x900')) self.minsize(900, 700) self._create_menubar() self._create_main_layout() self._create_statusbar() + self._load_scenarios_into_ui() + last_scenario = settings.get("last_selected_scenario") + if last_scenario: + 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): """Creates the main paned layout based on the new design.""" - # Main vertical pane: Top (Content) / Bottom (Logs) v_pane = ttk.PanedWindow(self, orient=tk.VERTICAL) v_pane.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) - # Top horizontal pane: Left (Controls) / Right (PPI) self.h_pane = ttk.PanedWindow(v_pane, orient=tk.HORIZONTAL) v_pane.add(self.h_pane, weight=4) - # --- Left Side Frame (for controls) --- left_frame = ttk.Frame(self.h_pane) self.h_pane.add(left_frame, weight=1) - # --- Right Side Frame (for PPI) --- 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) - # --- Populate Left Frame --- - self.scenario_controls = ScenarioControlsFrame(left_frame) + self.scenario_controls = ScenarioControlsFrame( + left_frame, + load_scenario_command=self._on_load_scenario, + save_as_command=self._on_save_scenario_as, + update_command=self._on_update_scenario, + delete_command=self._on_delete_scenario + ) self.scenario_controls.pack(fill=tk.X, expand=False, padx=5, pady=(0, 5)) self.target_list = TargetListFrame(left_frame) self.target_list.pack(fill=tk.BOTH, expand=True, padx=5) - # Connect button command self.target_list.add_button.config(command=self._on_add_target) - # --- Logger (Bottom) --- log_frame_container = ttk.LabelFrame(v_pane, text="Logs") v_pane.add(log_frame_container, weight=1) @@ -75,7 +90,6 @@ class MainView(tk.Tk): self.log_text_widget.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) def _create_menubar(self): - # (This method remains unchanged) menubar = tk.Menu(self) self.config(menu=menubar) @@ -85,24 +99,33 @@ class MainView(tk.Tk): settings_menu.add_command(label="Radar Config...", command=self._open_radar_config) def _create_statusbar(self): - # (This method remains unchanged) self.status_var = tk.StringVar(value="Status: Disconnected") status_bar = ttk.Label(self, textvariable=self.status_var, anchor=tk.W, relief=tk.SUNKEN) status_bar.pack(side=tk.BOTTOM, fill=tk.X) + def _update_window_title(self): + """Updates the window title with the current scenario name.""" + title = "Radar Target Simulator" + if self.current_scenario_name: + title = f"{self.current_scenario_name} - {title}" + self.title(title) + def _update_all_views(self): """Refreshes all GUI components based on the current scenario.""" + 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. Scenario now has {len(all_targets)} target(s).") - + self.logger.info(f"Views updated for scenario '{self.current_scenario_name}'. It has {len(all_targets)} target(s).") + + def _load_scenarios_into_ui(self): + """Loads scenario names from config and updates the combobox.""" + scenario_names = self.config_manager.get_scenario_names() + self.scenario_controls.update_scenario_list(scenario_names, self.current_scenario_name) + def _on_add_target(self): - """Handles the 'Add Target' button click.""" self.logger.debug("'Add Target' button clicked. Opening dialog.") existing_ids = list(self.scenario.targets.keys()) - - # This will block until the dialog is closed dialog = AddTargetWindow(self, existing_ids) new_target = dialog.new_target @@ -113,6 +136,50 @@ class MainView(tk.Tk): else: self.logger.debug("Add target dialog was cancelled.") + def _on_load_scenario(self, scenario_name: str): + self.logger.info(f"Loading scenario: {scenario_name}") + scenario_data = self.config_manager.get_scenario(scenario_name) + if scenario_data: + try: + self.scenario = Scenario.from_dict(scenario_data) + self.current_scenario_name = scenario_name + self._update_all_views() + except Exception as e: + self.logger.error(f"Failed to parse scenario data for '{scenario_name}': {e}") + 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) + 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) + + def _on_update_scenario(self, scenario_name: str): + self.logger.info(f"Updating scenario: {scenario_name}") + scenario_data = self.scenario.to_dict() + self.config_manager.save_scenario(scenario_name, scenario_data) + self._load_scenarios_into_ui() # Refresh list to be safe + messagebox.showinfo("Success", f"Scenario '{scenario_name}' updated successfully.", parent=self) + + 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.") + + # Clear current view if the deleted scenario was active + if self.current_scenario_name == scenario_name: + self.scenario = Scenario() + self.current_scenario_name = None + self._update_all_views() + + self._load_scenarios_into_ui() + messagebox.showinfo("Deleted", f"Scenario '{scenario_name}' has been deleted.", parent=self) + def _open_settings(self): self.logger.info("Opening settings window.") SettingsWindow(self, self.communicator) @@ -135,9 +202,18 @@ class MainView(tk.Tk): self.logger.info(f"Connection port set to: {self.connection_port}") self.status_var.set(f"Status: Configured for {port}. Ready to connect.") - def _on_closing(self): self.logger.info("Application shutting down.") + # Save general settings + settings_to_save = { + 'scan_limit': self.scan_limit, + 'max_range': self.max_range, + 'connection_port': self.connection_port, + 'geometry': self.winfo_geometry(), + 'last_selected_scenario': self.current_scenario_name + } + self.config_manager.save_general_settings(settings_to_save) + if self.communicator.is_open: self.communicator.disconnect() shutdown_logging_system() diff --git a/target_simulator/gui/ppi_display.py b/target_simulator/gui/ppi_display.py index 4a3cc7e..dd8e289 100644 --- a/target_simulator/gui/ppi_display.py +++ b/target_simulator/gui/ppi_display.py @@ -10,6 +10,8 @@ import math import numpy as np from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import matplotlib.transforms as transforms +import matplotlib.markers as markers from core.models import Target from typing import List @@ -22,6 +24,7 @@ class PPIDisplay(ttk.Frame): super().__init__(master) self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg + self.target_artists = [] self._create_controls() self._create_plot() @@ -62,6 +65,20 @@ class PPIDisplay(ttk.Frame): self.ax.set_theta_direction(-1) # Clockwise rotation self.ax.set_rlabel_position(90) self.ax.set_ylim(0, self.max_range) + + # Set custom theta labels for sector scan view + angles = np.arange(0, 360, 30) + labels = [] + for angle in angles: + if angle == 0: + labels.append("0°") + elif angle < 180: + labels.append(f"+{angle}°") + elif angle == 180: + labels.append("±180°") + else: # angle > 180 + labels.append(f"-{360 - angle}°") + self.ax.set_thetagrids(angles, labels) # Style the plot self.ax.tick_params(axis='x', colors='white') @@ -76,9 +93,6 @@ class PPIDisplay(ttk.Frame): self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) - # Initial plot for targets (an empty plot to be updated later) - self.target_plot, = self.ax.plot([], [], 'ro', markersize=6) # 'ro' = red circle - # Embed the plot in a Tkinter canvas self.canvas = FigureCanvasTkAgg(fig, master=self) self.canvas.draw() @@ -88,6 +102,8 @@ class PPIDisplay(ttk.Frame): """Handles the selection of a new range from the combobox.""" new_range = self.range_var.get() self.ax.set_ylim(0, new_range) + # Update heading vector lengths on zoom + self.update_targets([]) # Pass empty list to redraw existing ones with new scale self.canvas.draw() def update_targets(self, targets: List[Target]): @@ -97,15 +113,47 @@ class PPIDisplay(ttk.Frame): Args: targets: A list of Target objects to display. """ - active_targets = [t for t in targets if t.active] - - if not active_targets: - self.target_plot.set_data([], []) - else: - # Matplotlib polar plot needs angles in radians - theta = [math.radians(t.azimuth_deg) for t in active_targets] - r = [t.range_nm for t in active_targets] - self.target_plot.set_data(theta, r) + # If an empty list is passed, it means we just want to redraw existing targets + # (e.g., after a zoom change), not clear them. + if targets: + self.active_targets = [t for t in targets if t.active] + + # Clear previously drawn targets + for artist in self.target_artists: + artist.remove() + self.target_artists.clear() + + vector_len = self.range_var.get() / 25 # Length of heading vector relative to current view + + for target in getattr(self, 'active_targets', []): + # Target position in polar + r1 = target.range_nm + th1 = np.deg2rad(target.azimuth_deg) + + # Plot the target's center dot + dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red') + self.target_artists.append(dot) + + # --- Calculate and plot heading vector --- + # Convert target polar to cartesian + x1 = r1 * np.sin(th1) + y1 = r1 * np.cos(th1) + + # Calculate heading vector components + h_rad = np.deg2rad(target.heading_deg) + dx = vector_len * np.sin(h_rad) + dy = vector_len * np.cos(h_rad) + + # Calculate end point of the vector in cartesian + x2, y2 = x1 + dx, y1 + dy + + # Convert end point back to polar + r2 = np.sqrt(x2**2 + y2**2) + th2 = np.arctan2(x2, y2) + + # Plot the heading line + line, = self.ax.plot([th1, th2], [r1, r2], color='red', linewidth=1.2) + self.target_artists.append(line) # Redraw the canvas to show the changes self.canvas.draw() \ No newline at end of file diff --git a/target_simulator/gui/scenario_controls_frame.py b/target_simulator/gui/scenario_controls_frame.py index f919a43..d4f0df7 100644 --- a/target_simulator/gui/scenario_controls_frame.py +++ b/target_simulator/gui/scenario_controls_frame.py @@ -1,32 +1,113 @@ # target_simulator/gui/scenario_controls_frame.py """ -A frame containing widgets for managing scenarios (loading, saving, deleting). +A frame containing widgets for managing scenarios. """ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, simpledialog, messagebox +from typing import Callable, List class ScenarioControlsFrame(ttk.LabelFrame): """Frame for scenario management controls.""" - def __init__(self, master): - super().__init__(master, text="Scenario Management") + def __init__(self, master, *, + load_scenario_command: Callable[[str], None], + save_as_command: Callable[[str], None], + update_command: Callable[[str], None], + delete_command: Callable[[str], None]): + super().__init__(master, text="Scenario Controls") - self.columnconfigure(1, weight=1) + self._load_scenario_command = load_scenario_command + self._save_as_command = save_as_command + self._update_command = update_command + self._delete_command = delete_command + + self.current_scenario = tk.StringVar() # --- Widgets --- - ttk.Label(self, text="Scenario:").grid(row=0, column=0, padx=(5, 0), pady=5, sticky=tk.W) + top_frame = ttk.Frame(self) + top_frame.pack(fill=tk.X, padx=5, pady=(5, 2)) - self.scenario_combobox = ttk.Combobox(self, state="readonly") - self.scenario_combobox.grid(row=0, column=1, padx=5, pady=5, sticky=tk.EW) + scenario_label = ttk.Label(top_frame, text="Scenario:") + scenario_label.pack(side=tk.LEFT, padx=(0, 5)) + + self.scenario_combobox = ttk.Combobox( + top_frame, + textvariable=self.current_scenario, + state="readonly" + ) + self.scenario_combobox.pack(side=tk.LEFT, expand=True, fill=tk.X) + self.scenario_combobox.bind("<>", self._on_scenario_select) - # --- Buttons --- button_frame = ttk.Frame(self) - button_frame.grid(row=1, column=0, columnspan=2, pady=5, sticky=tk.W) + button_frame.pack(fill=tk.X, padx=5, pady=5) - self.save_button = ttk.Button(button_frame, text="Save Current...") - self.save_button.pack(side=tk.LEFT, padx=5) + self.save_as_button = ttk.Button( + button_frame, text="Save As...", command=self._on_save_as + ) + self.save_as_button.pack(side=tk.LEFT, padx=(0, 5)) - self.delete_button = ttk.Button(button_frame, text="Delete Selected") - self.delete_button.pack(side=tk.LEFT, padx=5) \ No newline at end of file + self.update_button = ttk.Button( + button_frame, text="Update", command=self._on_update + ) + self.update_button.pack(side=tk.LEFT, padx=5) + + self.delete_button = ttk.Button( + button_frame, text="Delete", command=self._on_delete + ) + self.delete_button.pack(side=tk.LEFT, padx=5) + + def _on_scenario_select(self, event): + """Callback for when a scenario is selected from the combobox.""" + scenario_name = self.current_scenario.get() + if scenario_name: + self._load_scenario_command(scenario_name) + + def _on_save_as(self): + """Handle the 'Save As' button click.""" + scenario_name = simpledialog.askstring( + "Save Scenario As", + "Enter a name for the new scenario:", + parent=self + ) + if scenario_name: + self._save_as_command(scenario_name) + + def _on_update(self): + """Handle the 'Update' button click.""" + scenario_name = self.current_scenario.get() + if not scenario_name: + messagebox.showwarning("No Scenario Selected", "Please select a scenario to update.", parent=self) + return + self._update_command(scenario_name) + + def _on_delete(self): + """Handle the 'Delete' button click.""" + scenario_name = self.current_scenario.get() + if not scenario_name: + messagebox.showwarning("No Scenario Selected", "Please select a scenario to delete.", parent=self) + return + + if messagebox.askyesno( + "Confirm Delete", + f"Are you sure you want to delete the scenario '{scenario_name}'?", + parent=self + ): + self._delete_command(scenario_name) + + def update_scenario_list(self, scenarios: List[str], select_scenario: str = None): + """ + Updates the list of scenarios in the combobox. + + Args: + scenarios: A list of scenario names. + select_scenario: The name of the scenario to select after updating, if any. + """ + self.scenario_combobox['values'] = scenarios + if select_scenario and select_scenario in scenarios: + self.current_scenario.set(select_scenario) + elif scenarios: + self.current_scenario.set(scenarios[0]) # Select the first one + else: + self.current_scenario.set("") diff --git a/target_simulator/utils/config_manager.py b/target_simulator/utils/config_manager.py new file mode 100644 index 0000000..4f13b71 --- /dev/null +++ b/target_simulator/utils/config_manager.py @@ -0,0 +1,99 @@ +# target_simulator/utils/config_manager.py + +""" +Manages loading and saving of application settings and scenarios to a single JSON file. +""" + +import json +import os +import sys +from typing import Dict, Any, Optional, List + +class ConfigManager: + """Handles reading and writing application settings and scenarios from a JSON file.""" + + def __init__(self, filename: str = "settings.json"): + """ + Initializes the ConfigManager. + + Args: + filename: The name of the settings file. It will be stored in + the project root directory. + """ + if getattr(sys, 'frozen', False): + application_path = os.path.dirname(sys.executable) + else: + application_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) + + self.filepath = os.path.join(application_path, filename) + self._settings = self._load_or_initialize_settings() + + def _load_or_initialize_settings(self) -> Dict[str, Any]: + """Loads settings from the JSON file or initializes with a default structure.""" + if not os.path.exists(self.filepath): + return {"general": {}, "scenarios": {}} + try: + with open(self.filepath, 'r', encoding='utf-8') as f: + settings = json.load(f) + if not isinstance(settings, dict) or "general" not in settings or "scenarios" not in settings: + return {"general": settings, "scenarios": {}} + return settings + except (json.JSONDecodeError, IOError): + return {"general": {}, "scenarios": {}} + + def _save_settings(self): + """Saves the current settings to the JSON file.""" + try: + with open(self.filepath, 'w', encoding='utf-8') as f: + json.dump(self._settings, f, indent=4) + except IOError as e: + print(f"Error saving settings to {self.filepath}: {e}") + + def get_general_settings(self) -> Dict[str, Any]: + """Returns the general settings.""" + return self._settings.get("general", {}) + + def save_general_settings(self, data: Dict[str, Any]): + """Saves the general settings.""" + self._settings["general"] = data + self._save_settings() + + def get_scenario_names(self) -> List[str]: + """Returns a list of all scenario names.""" + return list(self._settings.get("scenarios", {}).keys()) + + def get_scenario(self, name: str) -> Optional[Dict[str, Any]]: + """ + Retrieves a specific scenario by name. + + Args: + name: The name of the scenario to retrieve. + + Returns: + A dictionary with the scenario data, or None if not found. + """ + return self._settings.get("scenarios", {}).get(name) + + def save_scenario(self, name: str, data: Dict[str, Any]): + """ + Saves or updates a scenario. + + Args: + name: The name of the scenario to save. + data: The dictionary of scenario data to save. + """ + if "scenarios" not in self._settings: + self._settings["scenarios"] = {} + self._settings["scenarios"][name] = data + self._save_settings() + + def delete_scenario(self, name: str): + """ + Deletes a scenario by name. + + Args: + name: The name of the scenario to delete. + """ + if "scenarios" in self._settings and name in self._settings["scenarios"]: + del self._settings["scenarios"][name] + self._save_settings()