import time import threading from typing import Optional from tkinter import messagebox from target_simulator.core.simulation_engine import SimulationEngine from target_simulator.analysis.simulation_archive import SimulationArchive import time import threading from typing import Optional from tkinter import messagebox from target_simulator.core.simulation_engine import SimulationEngine from target_simulator.analysis.simulation_archive import SimulationArchive from target_simulator.core import command_builder class SimulationController: """Orchestrates simulation start/stop/reset logic extracted from MainView. To minimize changes across the codebase, the controller operates by mutating the provided MainView instance during start/stop operations — this keeps attributes like `simulation_engine` and `current_archive` available where the rest of the code expects them. """ def __init__(self, communicator_manager, simulation_hub, config_manager, logger): self.communicator_manager = communicator_manager self.simulation_hub = simulation_hub self.config_manager = config_manager self.logger = logger self.simulation_engine: Optional[SimulationEngine] = None self.current_archive: Optional[SimulationArchive] = None def reset_radar_state(self, main_view) -> bool: """Reset radar via communicator. Mirrors previous MainView behavior. Returns True on success, False otherwise. """ target_comm = getattr(self.communicator_manager, "target_communicator", None) if not target_comm or not getattr(target_comm, "is_open", False): try: self.logger.error("Cannot reset radar state: communicator is not connected.") except Exception: pass try: messagebox.showerror("Connection Error", "Cannot reset radar: Not Connected.") except Exception: pass return False try: use_json = bool(getattr(target_comm, "_use_json_protocol", False)) except Exception: use_json = False def _wait_for_clear(timeout_s: float = 3.0, poll_interval: float = 0.2) -> bool: waited = 0.0 while waited < timeout_s: try: if not self.simulation_hub.has_active_real_targets(): return True except Exception: try: self.logger.debug("Error while querying simulation_hub during reset wait", exc_info=True) except Exception: pass time.sleep(poll_interval) waited += poll_interval return False # 1) Legacy textual command path if not use_json: cmd = "tgtset /-s\n" try: self.logger.info("Sending legacy reset command: %s", cmd.strip()) except Exception: pass try: ok = target_comm.send_commands([cmd]) except Exception: try: self.logger.exception("Failed to send legacy reset command") except Exception: pass ok = False if not ok: try: messagebox.showerror("Reset Error", "Failed to send reset command to the radar.") except Exception: pass return False cleared = _wait_for_clear() if cleared: try: self.logger.info("Legacy reset acknowledged: no active real targets remain.") except Exception: pass return True else: try: self.logger.error("Legacy reset sent but server did not clear active targets in time.") except Exception: pass try: messagebox.showerror("Reset Error", "Radar did not clear targets after reset.") except Exception: pass return False # 2) JSON-capable communicator path try: json_reset = '{"CMD":"reset"}\n' try: self.logger.info("Sending JSON reset command: %s", json_reset.strip()) except Exception: pass try: ok = target_comm.send_commands([json_reset]) except Exception: try: self.logger.exception("Failed to send JSON reset command") except Exception: pass ok = False if ok: cleared = _wait_for_clear() if cleared: try: self.logger.info("JSON reset acknowledged: server cleared active flags.") except Exception: pass return True else: try: self.logger.info("JSON reset sent but server did not clear active flags; will attempt per-target JSON zeroing.") except Exception: pass else: try: self.logger.warning("JSON reset command failed to send; will attempt per-target JSON zeroing.") except Exception: pass except Exception: try: self.logger.exception("Unexpected error while attempting JSON reset") except Exception: pass # 3) Build and send per-target JSON payloads that explicitly clear active flag try: try: self.logger.info("Building per-target JSON reset payloads to clear active flags on all targets.") except Exception: pass json_payloads = command_builder.build_json_reset_ids() except Exception: try: self.logger.exception("Failed to build per-target JSON reset payloads") except Exception: pass try: messagebox.showerror("Reset Error", "Failed to build JSON reset payloads.") except Exception: pass return False all_sent_ok = True for i, payload in enumerate(json_payloads): p = payload if payload.endswith("\n") else payload + "\n" try: self.logger.info("Sending JSON reset payload part %d/%d", i + 1, len(json_payloads)) except Exception: pass try: ok = target_comm.send_commands([p]) except Exception: try: self.logger.exception("Exception while sending JSON reset payload part %d", i + 1) except Exception: pass ok = False if not ok: all_sent_ok = False try: self.logger.error("Failed to send JSON reset payload part %d", i + 1) except Exception: pass break if not all_sent_ok: try: messagebox.showerror("Reset Error", "Failed to send one or more JSON reset payloads to the radar.") except Exception: pass return False cleared = _wait_for_clear() if cleared: try: self.logger.info("Per-target JSON reset succeeded: all active flags cleared.") except Exception: pass return True else: try: self.logger.error("Per-target JSON reset did not clear active flags within timeout.") except Exception: pass try: messagebox.showerror("Reset Error", "Radar did not clear targets after per-target reset.") except Exception: pass return False def _reset_radar_state_no_ui(self, main_view) -> bool: """Same logic as reset_radar_state but avoids any Tk UI calls (messagebox). This variant is safe to call from a background thread; failures are logged and a boolean is returned so the caller can decide how to notify the user on the main thread. """ target_comm = getattr(self.communicator_manager, "target_communicator", None) if not target_comm or not getattr(target_comm, "is_open", False): try: self.logger.error("Cannot reset radar state: communicator is not connected.") except Exception: pass return False try: use_json = bool(getattr(target_comm, "_use_json_protocol", False)) except Exception: use_json = False def _wait_for_clear(timeout_s: float = 3.0, poll_interval: float = 0.2) -> bool: waited = 0.0 while waited < timeout_s: try: if not self.simulation_hub.has_active_real_targets(): return True except Exception: try: self.logger.debug("Error while querying simulation_hub during reset wait", exc_info=True) except Exception: pass time.sleep(poll_interval) waited += poll_interval return False # Legacy textual path if not use_json: cmd = "tgtset /-s\n" try: self.logger.info("Sending legacy reset command: %s", cmd.strip()) except Exception: pass try: ok = target_comm.send_commands([cmd]) except Exception: try: self.logger.exception("Failed to send legacy reset command") except Exception: pass ok = False if not ok: try: self.logger.error("Failed to send reset command to the radar.") except Exception: pass return False cleared = _wait_for_clear() return bool(cleared) # JSON-capable communicator path try: json_reset = '{"CMD":"reset"}\n' try: self.logger.info("Sending JSON reset command: %s", json_reset.strip()) except Exception: pass try: ok = target_comm.send_commands([json_reset]) except Exception: try: self.logger.exception("Failed to send JSON reset command") except Exception: pass ok = False if ok: cleared = _wait_for_clear() if cleared: try: self.logger.info("JSON reset acknowledged: server cleared active flags.") except Exception: pass return True except Exception: try: self.logger.exception("Unexpected error while attempting JSON reset") except Exception: pass # 3) Build and send per-target JSON payloads that explicitly clear active flag try: try: self.logger.info("Building per-target JSON reset payloads to clear active flags on all targets.") except Exception: pass json_payloads = command_builder.build_json_reset_ids() except Exception: try: self.logger.exception("Failed to build per-target JSON reset payloads") except Exception: pass return False all_sent_ok = True for i, payload in enumerate(json_payloads): p = payload if payload.endswith("\n") else payload + "\n" try: self.logger.info("Sending JSON reset payload part %d/%d", i + 1, len(json_payloads)) except Exception: pass try: ok = target_comm.send_commands([p]) except Exception: try: self.logger.exception("Exception while sending JSON reset payload part %d", i + 1) except Exception: pass ok = False if not ok: all_sent_ok = False try: self.logger.error("Failed to send JSON reset payload part %d", i + 1) except Exception: pass break if not all_sent_ok: try: self.logger.error("Failed to send one or more JSON reset payloads to the radar.") except Exception: pass return False cleared = _wait_for_clear() return bool(cleared) def start_simulation(self, main_view): """Start live simulation using data from main_view (keeps compatibility).""" if main_view.is_simulation_running.get(): try: self.logger.info("Simulation is already running.") except Exception: pass return # Require explicit connection before starting live. target_comm = getattr(self.communicator_manager, "target_communicator", None) if not (target_comm and getattr(target_comm, "is_open", False)): try: messagebox.showwarning( "Not Connected", "Please connect to the target (use the Connect button) before starting live simulation.", ) except Exception: pass return if not main_view.scenario or not main_view.scenario.get_all_targets(): try: messagebox.showinfo("Empty Scenario", "Cannot start simulation with an empty scenario.") except Exception: pass return try: update_interval = main_view.update_time.get() if update_interval <= 0: try: messagebox.showwarning("Invalid Input", "Update time must be a positive number.") except Exception: pass return except Exception: try: messagebox.showwarning("Invalid Input", "Update time must be a valid number.") except Exception: pass return # Reset data hub and PPI trails before starting try: self.logger.info("Resetting simulation data hub and PPI trails.") except Exception: pass try: self.simulation_hub.reset() except Exception: pass try: if hasattr(main_view, "ppi_widget") and main_view.ppi_widget: main_view.ppi_widget.clear_trails() except Exception: pass # Do UI updates (disable/notify) and run heavy ops in background def _background_start(): # Reset radar state in background ok = self._reset_radar_state_no_ui(main_view) if not ok: try: self.logger.error("Aborting simulation start due to radar reset failure.") except Exception: pass try: main_view.after(0, lambda: messagebox.showerror("Reset Error", "Failed to reset radar before starting simulation.")) except Exception: pass try: main_view.after(0, lambda: main_view._update_button_states()) except Exception: pass return time.sleep(1) # Send initial scenario try: self.logger.info("Sending initial scenario state before starting live updates...") except Exception: pass try: sent = target_comm.send_scenario(main_view.scenario) except Exception: sent = False if not sent: try: self.logger.error("Failed to send initial scenario state. Aborting live simulation start.") except Exception: pass try: main_view.after(0, lambda: messagebox.showerror("Send Error", "Failed to send the initial scenario configuration. Cannot start live simulation.")) except Exception: pass try: main_view.after(0, lambda: main_view._update_button_states()) except Exception: pass return # Initialize engine try: engine = SimulationEngine(communicator=target_comm, simulation_hub=self.simulation_hub) engine.set_time_multiplier(main_view.time_multiplier) engine.set_update_interval(update_interval) engine.load_scenario(main_view.scenario) self.simulation_engine = engine except Exception: try: self.logger.exception("Failed to initialize SimulationEngine") except Exception: pass try: main_view.after(0, lambda: messagebox.showerror("Engine Error", "Failed to initialize simulation engine.")) except Exception: pass try: main_view.after(0, lambda: main_view._update_button_states()) except Exception: pass return # Compute durations, archive, and start engine thread try: durations = [getattr(t, "_total_duration_s", 0.0) for t in main_view.scenario.get_all_targets()] total_sim_time = max(durations) if durations else 0.0 except Exception: total_sim_time = 0.0 try: current_archive = SimulationArchive(main_view.scenario) engine.archive = current_archive if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(current_archive) except Exception: current_archive = None try: engine.start() except Exception: try: self.logger.exception("Failed to start simulation engine thread") except Exception: pass try: main_view.after(0, lambda: messagebox.showerror("Start Error", "Failed to start simulation engine thread.")) except Exception: pass try: main_view.after(0, lambda: main_view._update_button_states()) except Exception: pass return # Finalize on main thread def _on_started(): try: main_view.simulation_engine = engine self.current_archive = current_archive main_view.current_archive = current_archive main_view.total_sim_time = total_sim_time main_view.sim_elapsed_time = 0.0 try: main_view.sim_slider_var.set(0.0) except Exception: pass try: main_view._update_simulation_progress_display() except Exception: pass try: if hasattr(main_view, "ppi_widget") and main_view.ppi_widget: main_view.ppi_widget.clear_previews() except Exception: pass main_view.is_simulation_running.set(True) try: main_view._update_button_states() except Exception: pass except Exception: pass try: main_view.after(0, _on_started) 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)) except Exception: pass try: main_view.after(0, lambda: main_view._update_button_states()) except Exception: pass t = threading.Thread(target=_background_start, daemon=True) t.start() def stop_simulation(self, main_view): if not main_view.is_simulation_running.get() or not getattr(main_view, "simulation_engine", None): return try: if main_view.current_archive: try: main_view.current_archive.save() except Exception: pass main_view.current_archive = None try: target_comm = getattr(self.communicator_manager, "target_communicator", None) if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(None) except Exception: pass try: main_view._refresh_analysis_list() except Exception: pass try: main_view.simulation_engine.stop() except Exception: pass main_view.simulation_engine = None self.simulation_engine = None except Exception: pass main_view.is_simulation_running.set(False) try: main_view._update_button_states() except Exception: pass self.current_archive = SimulationArchive(main_view.scenario) self.simulation_engine.archive = self.current_archive if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(self.current_archive) except Exception: pass try: self.simulation_engine.start() except Exception: try: self.logger.exception("Failed to start simulation engine thread") except Exception: pass return # Clear scenario previews when live starts try: if hasattr(main_view, "ppi_widget") and main_view.ppi_widget: main_view.ppi_widget.clear_previews() except Exception: pass main_view.current_archive = self.current_archive # Set running state and update UI main_view.is_simulation_running.set(True) try: main_view._update_button_states() except Exception: pass def stop_simulation(self, main_view): if not main_view.is_simulation_running.get() or not getattr(main_view, "simulation_engine", None): return try: if main_view.current_archive: try: main_view.current_archive.save() except Exception: pass main_view.current_archive = None try: target_comm = getattr(self.communicator_manager, "target_communicator", None) if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(None) except Exception: pass try: main_view._refresh_analysis_list() except Exception: pass except Exception: pass try: self.logger.info("Stopping live simulation (user request)...") except Exception: pass try: main_view.simulation_engine.stop() except Exception: try: self.logger.exception("Error while stopping simulation engine") except Exception: pass main_view.simulation_engine = None main_view.is_simulation_running.set(False) try: main_view._update_button_states() except Exception: pass try: messagebox.showinfo( "Simulation Stopped", "Live simulation was stopped. Connection to the target remains active.", ) except Exception: pass def on_simulation_finished(self, main_view): try: self.logger.info("Handling simulation finished (engine signalled completion).") except Exception: pass try: if main_view.current_archive: try: main_view.current_archive.save() except Exception: pass main_view.current_archive = None try: target_comm = getattr(self.communicator_manager, "target_communicator", None) if target_comm and hasattr(target_comm, "router"): router = target_comm.router() if router: router.set_archive(None) except Exception: pass try: main_view._refresh_analysis_list() except Exception: pass except Exception: pass if getattr(main_view, "simulation_engine", None) and main_view.simulation_engine.is_running(): try: main_view.simulation_engine.stop() except Exception: try: self.logger.exception("Error while stopping finished simulation engine") except Exception: pass main_view.simulation_engine = None main_view.is_simulation_running.set(False) try: main_view._update_button_states() except Exception: pass try: if getattr(main_view, "simulation_notice_var", None) is not None: main_view.simulation_notice_var.set( "Simulation finished — live simulation completed. Server data may still arrive and will update the PPI." ) else: try: self.logger.info("Live simulation completed (notice widget unavailable).") except Exception: pass except Exception: try: self.logger.exception("Failed to set non-blocking simulation notice") except Exception: pass