add configuration manager, fix minor display ppi

This commit is contained in:
VALLONGOL 2025-10-01 15:10:55 +02:00
parent 870d79889d
commit 276cae0692
5 changed files with 362 additions and 48 deletions

10
settings.json Normal file
View File

@ -0,0 +1,10 @@
{
"general": {
"scan_limit": 60,
"max_range": 200,
"connection_port": null,
"geometry": "1200x900+338+338",
"last_selected_scenario": null
},
"scenarios": {}
}

View File

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

View File

@ -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()
@ -63,6 +66,20 @@ class PPIDisplay(ttk.Frame):
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("")
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')
self.ax.tick_params(axis='y', 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 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]
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)
# 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()

View File

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

View File

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