diff --git a/scenarios.json b/scenarios.json index f85b898..e4c54e7 100644 --- a/scenarios.json +++ b/scenarios.json @@ -416,6 +416,38 @@ } ], "use_spline": false + }, + { + "target_id": 1, + "active": true, + "traceable": true, + "trajectory": [ + { + "maneuver_type": "Fly to Point", + "duration_s": 10.0, + "target_range_nm": 40.0, + "target_azimuth_deg": -45.0, + "target_altitude_ft": 10000.0, + "target_velocity_fps": 506.343, + "target_heading_deg": 90.0, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 0.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + }, + { + "maneuver_type": "Fly to Point", + "duration_s": 30.0, + "target_range_nm": 40.0, + "target_azimuth_deg": 45.0, + "target_altitude_ft": 10000.0, + "longitudinal_acceleration_g": 0.0, + "lateral_acceleration_g": 0.0, + "vertical_acceleration_g": 0.0, + "turn_direction": "Right" + } + ], + "use_spline": false } ] } diff --git a/settings.json b/settings.json index 1f5c393..4a22b1a 100644 --- a/settings.json +++ b/settings.json @@ -3,7 +3,7 @@ "scan_limit": 60, "max_range": 100, "geometry": "1305x929+587+179", - "last_selected_scenario": "scenario2", + "last_selected_scenario": "corto", "connection": { "target": { "type": "sfp", @@ -17,8 +17,8 @@ }, "sfp": { "ip": "127.0.0.1", - "port": 60003, - "local_port": 60002, + "port": 60013, + "local_port": 60012, "use_json_protocol": true } }, diff --git a/target_simulator/gui/main_view.py b/target_simulator/gui/main_view.py index ce3d2b9..73c1d72 100644 --- a/target_simulator/gui/main_view.py +++ b/target_simulator/gui/main_view.py @@ -346,21 +346,18 @@ class MainView(tk.Tk): spacer = ttk.Frame(engine_frame) spacer.grid(row=0, column=3, sticky="ew") + # Display fixed speed indicator (1x). The real system runs at + # 1x and the speed should not be changed at runtime, so show a + # read-only label instead of an editable combobox. ttk.Label(engine_frame, text="Speed:").grid( row=0, column=4, sticky="e", padx=(10, 2), pady=5 ) self.time_multiplier_var = tk.StringVar(value="1x") - self.multiplier_combo = ttk.Combobox( - engine_frame, - textvariable=self.time_multiplier_var, - values=["1x", "2x", "4x", "10x", "20x"], - state="readonly", - width=4, - ) + # Keep attribute name `multiplier_combo` for backward + # compatibility with other code that references it, but expose + # a non-interactive label instead of a Combobox. + self.multiplier_combo = ttk.Label(engine_frame, text="1x") self.multiplier_combo.grid(row=0, column=5, sticky="w", padx=(0, 5), pady=5) - self.multiplier_combo.bind( - "<>", self._on_time_multiplier_changed - ) ttk.Label(engine_frame, text="Update Time (s):").grid( row=0, column=6, sticky="e", padx=(10, 2), pady=5 @@ -930,20 +927,39 @@ class MainView(tk.Tk): return False def _on_start_simulation(self): - # Delegate to SimulationController if available - try: - if hasattr(self, "simulation_controller") and self.simulation_controller: - return self.simulation_controller.start_simulation(self) - except Exception: + # Prevent duplicate start attempts: use a simple re-entrancy guard. + if getattr(self, "_start_in_progress_main", False): try: - self.logger.exception("SimulationController start failed; falling back to inline start.") + self.logger.info("Start already in progress; ignoring duplicate request.") except Exception: pass - # If controller is not present or failed, attempt no-op fallback + return + + self._start_in_progress_main = True try: - messagebox.showerror("Start Error", "Unable to start simulation (controller unavailable).") - except Exception: - pass + # Delegate to SimulationController if available + try: + if hasattr(self, "simulation_controller") and self.simulation_controller: + return self.simulation_controller.start_simulation(self) + except Exception: + try: + self.logger.exception("SimulationController start failed; falling back to inline start.") + except Exception: + pass + + # If controller is not present or failed, attempt no-op fallback + try: + messagebox.showerror("Start Error", "Unable to start simulation (controller unavailable).") + except Exception: + pass + finally: + # Clear the guard so future explicit retries are allowed. The + # actual button state will be managed by _update_button_states + # based on `is_simulation_running`. + try: + self._start_in_progress_main = False + except Exception: + pass def _on_stop_simulation(self): try: @@ -1102,15 +1118,45 @@ class MainView(tk.Tk): tk.NORMAL if (not is_running and has_data_to_analyze) else tk.DISABLED ) - state = tk.DISABLED if is_running else tk.NORMAL + # If a start is currently in progress (either via the controller + # path or via the SimulationControls immediate handler), keep the + # Start button disabled to avoid duplicate starts even though + # `is_simulation_running` may not yet be True. + start_in_progress_flag = False + try: + if getattr(self, "_start_in_progress_main", False): + start_in_progress_flag = True + except Exception: + start_in_progress_flag = start_in_progress_flag + + try: + sc = getattr(self, "simulation_controls", None) + if sc and getattr(sc, "_start_in_progress", False): + start_in_progress_flag = True + except Exception: + pass + + state = tk.DISABLED if (is_running or start_in_progress_flag) else tk.NORMAL self.reset_radar_button.config(state=state) 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) # Analysis tab has its own controls; nothing to update here. - self.multiplier_combo.config( - state="readonly" if not is_running else tk.DISABLED - ) + # multiplier_combo may be a non-interactive label after the + # speed-combobox removal. Attempt to set state if supported, + # otherwise ignore errors so UI updates remain robust. + try: + if hasattr(self, "multiplier_combo") and self.multiplier_combo is not None: + try: + self.multiplier_combo.config( + state="readonly" if not is_running else tk.DISABLED + ) + except Exception: + # Some widget types (e.g., ttk.Label) don't accept + # a 'state' option; ignore in that case. + pass + except Exception: + pass self.scenario_controls.new_button.config(state=state) self.scenario_controls.save_button.config(state=state) diff --git a/target_simulator/gui/simulation_controls.py b/target_simulator/gui/simulation_controls.py index a2f398f..340093b 100644 --- a/target_simulator/gui/simulation_controls.py +++ b/target_simulator/gui/simulation_controls.py @@ -1,5 +1,5 @@ import tkinter as tk -from tkinter import ttk +from tkinter import ttk, messagebox from typing import Optional @@ -35,21 +35,23 @@ class SimulationControls(ttk.LabelFrame): self.stop_button = ttk.Button(self, text="Stop Live", command=self._stop, state=tk.DISABLED) self.stop_button.grid(row=0, column=1, sticky="w", padx=5, pady=5) + # Guard to prevent re-entrant start attempts while a start is in progress + self._start_in_progress = False # spacer spacer = ttk.Frame(self) spacer.grid(row=0, column=3, sticky="ew") + # The simulation runs at real-time (1x) for live mode; show a + # non-editable indicator instead of an interactive combobox so the + # UI cannot change the runtime multiplier. ttk.Label(self, text="Speed:").grid(row=0, column=4, sticky="e", padx=(10, 2), pady=5) - self.multiplier_combo = ttk.Combobox( - self, - textvariable=self.time_multiplier_var, - values=["1x", "2x", "4x", "10x", "20x"], - state="readonly", - width=4, - ) + # Use a simple (non-interactive) label to indicate fixed 1x speed. + # Keep the attribute name `multiplier_combo` for backward + # compatibility with code that expects this attribute, but expose a + # read-only display instead of a Combobox. + self.multiplier_combo = ttk.Label(self, text="1x") self.multiplier_combo.grid(row=0, column=5, sticky="w", padx=(0, 5), pady=5) - self.multiplier_combo.bind("<>", getattr(main_view, "_on_time_multiplier_changed", lambda e=None: None)) ttk.Label(self, text="Update Time (s):").grid(row=0, column=6, sticky="e", padx=(10, 2), pady=5) self.update_time_entry = ttk.Entry(self, textvariable=self.update_time, width=5) @@ -104,9 +106,59 @@ class SimulationControls(ttk.LabelFrame): # Button handlers that delegate to main_view methods def _start(self): + # 1) Verify connection before attempting to start try: + connected = False + try: + if hasattr(self.main_view, "target_communicator") and self.main_view.target_communicator: + connected = bool(getattr(self.main_view.target_communicator, "is_open", False)) + except Exception: + connected = False + + if not connected: + # Inform the user and do NOT disable the Start button + try: + messagebox.showinfo( + "Not connected", + "Target communicator is not connected. Please connect before starting the simulation.", + parent=self.main_view, + ) + except Exception: + # As a fallback, log to stdout (rare in GUI) and return + print("Target communicator is not connected. Please connect before starting the simulation.") + return + + # 3) If connected, disable Start immediately to prevent duplicates + try: + self.start_button.config(state=tk.DISABLED) + # Force the UI to process this change immediately so the + # button appears disabled before any potentially blocking + # start/reset work begins in MainView. + try: + if hasattr(self.main_view, "update_idletasks"): + self.main_view.update_idletasks() + if hasattr(self.main_view, "update"): + # update() processes pending events and redraws UI + # immediately. Use cautiously but helpful here to + # avoid race where user can click again before the + # widget state is visually updated. + self.main_view.update() + except Exception: + pass + except Exception: + pass + + # Delegate to MainView to start the simulation. If it fails, + # re-enable the Start button so the user can retry. if hasattr(self.main_view, "_on_start_simulation"): - self.main_view._on_start_simulation() + try: + self.main_view._on_start_simulation() + except Exception: + try: + self.start_button.config(state=tk.NORMAL) + except Exception: + pass + raise except Exception: raise diff --git a/target_simulator/simulation/simulation_controller.py b/target_simulator/simulation/simulation_controller.py index 19b17fa..295831a 100644 --- a/target_simulator/simulation/simulation_controller.py +++ b/target_simulator/simulation/simulation_controller.py @@ -427,7 +427,8 @@ class SimulationController: except Exception: pass try: - main_view.after(0, lambda: main_view._update_button_states()) + # Clear start-in-progress flag and update UI on main thread + main_view.after(0, lambda: (setattr(main_view, "_start_in_progress_main", False), main_view._update_button_states())) except Exception: pass return @@ -454,7 +455,7 @@ class SimulationController: except Exception: pass try: - main_view.after(0, lambda: main_view._update_button_states()) + main_view.after(0, lambda: (setattr(main_view, "_start_in_progress_main", False), main_view._update_button_states())) except Exception: pass return @@ -476,7 +477,7 @@ class SimulationController: except Exception: pass try: - main_view.after(0, lambda: main_view._update_button_states()) + main_view.after(0, lambda: (setattr(main_view, "_start_in_progress_main", False), main_view._update_button_states())) except Exception: pass return @@ -510,7 +511,7 @@ class SimulationController: except Exception: pass try: - main_view.after(0, lambda: main_view._update_button_states()) + main_view.after(0, lambda: (setattr(main_view, "_start_in_progress_main", False), main_view._update_button_states())) except Exception: pass return @@ -537,6 +538,11 @@ class SimulationController: except Exception: pass main_view.is_simulation_running.set(True) + # Clear the start-in-progress flag and update UI + try: + main_view._start_in_progress_main = False + except Exception: + pass try: main_view._update_button_states() except Exception: @@ -549,6 +555,13 @@ class SimulationController: except Exception: pass + # Mark that a start is in progress so the UI keeps Start disabled + try: + # Set synchronously so immediate callers see the flag + main_view._start_in_progress_main = True + except Exception: + pass + # UI: show status and disable controls before background work try: main_view.after(0, lambda: main_view.status_bar.show_status_message("Starting simulation...", timeout_ms=0))