# FlightMonitor/controller/app_controller.py from queue import Queue, Empty as QueueEmpty import threading import tkinter as tk import time from ..data.opensky_live_adapter import ( OpenSkyLiveAdapter, AdapterMessage, MSG_TYPE_FLIGHT_DATA, MSG_TYPE_ADAPTER_STATUS, STATUS_STARTING, STATUS_FETCHING, STATUS_RECOVERED, STATUS_RATE_LIMITED, STATUS_API_ERROR_TEMPORARY, STATUS_PERMANENT_FAILURE, STATUS_STOPPED, ) from ..data import config from ..utils.logger import get_logger from ..data.storage import DataStorage from ..data.common_models import CanonicalFlightState from typing import List, Optional, Dict, Any, TYPE_CHECKING # Avoid circular import for type hinting if not strictly necessary at runtime if TYPE_CHECKING: from ..gui.main_window import MainWindow from ..map.map_canvas_manager import MapCanvasManager # Module-level logger module_logger = get_logger(__name__) GUI_QUEUE_CHECK_INTERVAL_MS = 150 ADAPTER_JOIN_TIMEOUT_SECONDS = 5.0 GUI_STATUS_OK = "OK" GUI_STATUS_WARNING = "WARNING" GUI_STATUS_ERROR = "ERROR" GUI_STATUS_FETCHING = "FETCHING" GUI_STATUS_UNKNOWN = "UNKNOWN" # Default area size (in km) for setting a monitoring box from a map click DEFAULT_CLICK_AREA_SIZE_KM = 50.0 class AppController: def __init__(self): """ Initializes the AppController. The main_window instance is set separately via set_main_window. """ # Use type hinting with TYPE_CHECKING to avoid circular dependency at runtime self.main_window: Optional["MainWindow"] = None self.live_adapter_thread: Optional[OpenSkyLiveAdapter] = None self.is_live_monitoring_active: bool = False self.flight_data_queue: Optional[Queue[AdapterMessage]] = None self._gui_after_id: Optional[str] = None self._active_bounding_box: Optional[Dict[str, float]] = None self.data_storage: Optional[DataStorage] = None try: self.data_storage = DataStorage() module_logger.info("DataStorage initialized successfully by AppController.") except Exception as e: module_logger.critical( f"CRITICAL: Failed to initialize DataStorage in AppController: {e}", exc_info=True, ) self.data_storage = None module_logger.info("AppController initialized.") def set_main_window( self, main_window_instance: "MainWindow" ): """Sets the main window instance and shows initial status or errors.""" self.main_window = main_window_instance module_logger.debug( f"Main window instance ({type(main_window_instance)}) set in AppController." ) if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() and hasattr(self.main_window, "update_semaphore_and_status") ): if not self.data_storage: err_msg = ( "Data storage init failed. Data will not be saved. Check logs." ) self.main_window.update_semaphore_and_status(GUI_STATUS_ERROR, err_msg) elif hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status(GUI_STATUS_OK, "System Initialized. Ready.") else: module_logger.error( "Main window not set or lacks update_semaphore_and_status during set_main_window." ) def _process_flight_data_queue(self): """ Processes messages from the OpenSkyLiveAdapter's output queue. Runs on the main Tkinter thread via root.after(). """ if not self.flight_data_queue: module_logger.warning( "_process_flight_data_queue: flight_data_queue is None." ) return if not ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): self._gui_after_id = None return try: while not self.flight_data_queue.empty(): message: Optional[AdapterMessage] = None try: message = self.flight_data_queue.get(block=False, timeout=0.01) except QueueEmpty: break except Exception as e_get: module_logger.error(f"Error getting message from queue: {e_get}. Continuing processing...", exc_info=False) continue try: message_type = message.get("type") if message_type == MSG_TYPE_FLIGHT_DATA: flight_states_payload: Optional[List[CanonicalFlightState]] = ( message.get("payload") ) if flight_states_payload is not None: module_logger.debug( f"Received flight data with {len(flight_states_payload)} states. Processing..." ) if self.data_storage: saved_count = 0 module_logger.debug(f"Attempting to save {len(flight_states_payload)} flight states to storage.") for state in flight_states_payload: if not isinstance(state, CanonicalFlightState): module_logger.warning(f"Received non-CanonicalFlightState object in data payload: {type(state)}. Skipping storage.") continue try: flight_id = ( self.data_storage.add_or_update_flight_daily( icao24=state.icao24, callsign=state.callsign, origin_country=state.origin_country, detection_timestamp=state.timestamp, ) ) if flight_id: pos_id = self.data_storage.add_position_daily( flight_id, state ) if pos_id: saved_count += 1 except Exception as e_storage: module_logger.error(f"Error saving flight state {state.icao24} to storage: {e_storage}", exc_info=False) if saved_count > 0: module_logger.info( f"Saved {saved_count} position updates to DB." ) module_logger.debug("Controller: Checking conditions to call display_flights_on_canvas...") if ( hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance is not None and hasattr(self.main_window.map_manager_instance, "update_flights_on_map") and hasattr(self.main_window, "display_flights_on_canvas") and self.is_live_monitoring_active and self._active_bounding_box ): module_logger.debug(f"Controller: Conditions met. Calling display_flights_on_canvas with {len(flight_states_payload)} states and BBox context.") try: self.main_window.display_flights_on_canvas( flight_states_payload, self._active_bounding_box ) except Exception as e_display: module_logger.error(f"Error calling display_flights_on_canvas: {e_display}", exc_info=False) else: map_manager_ready = hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance is not None mw_method_exists = hasattr(self.main_window, "display_flights_on_canvas") monitoring_active = self.is_live_monitoring_active bbox_exists = self._active_bounding_box is not None map_manager_method_exists = map_manager_ready and hasattr(self.main_window.map_manager_instance, "update_flights_on_map") module_logger.debug( f"Skipping map display update: map_manager_ready={map_manager_ready}, " f"mw_method_exists={mw_method_exists}, " f"monitoring_active={monitoring_active}, " f"bbox_exists={bbox_exists}, " f"map_manager_method_exists={map_manager_method_exists}." ) gui_message = ( f"Live data: {len(flight_states_payload)} aircraft tracked." if flight_states_payload else "Live data: No aircraft in area." ) if ( hasattr(self.main_window, "update_semaphore_and_status") ): try: self.main_window.update_semaphore_and_status( GUI_STATUS_OK, gui_message ) except tk.TclError as e_tcl_status: module_logger.warning(f"TclError updating status bar (OK): {e_tcl_status}. GUI likely closing.") self._gui_after_id = None return except Exception as e_status: module_logger.error(f"Error updating status bar (OK): {e_status}", exc_info=False) else: module_logger.warning( "Received flight_data message with None payload." ) if ( hasattr(self.main_window, "update_semaphore_and_status") ): try: self.main_window.update_semaphore_and_status( GUI_STATUS_WARNING, "Received empty data payload." ) except tk.TclError as e_tcl_status: module_logger.warning(f"TclError updating status bar (WARN): {e_tcl_status}. GUI likely closing.") self._gui_after_id = None return except Exception as e_status: module_logger.error(f"Error updating status bar (WARN): {e_status}", exc_info=False) elif message_type == MSG_TYPE_ADAPTER_STATUS: status_code = message.get("status_code") gui_message = message.get( "message", f"Adapter status: {status_code}" ) module_logger.info( f"Processing Adapter Status: Code='{status_code}', Message='{gui_message}'" ) gui_status_level = GUI_STATUS_UNKNOWN action_required = None if status_code == STATUS_STARTING: gui_status_level = GUI_STATUS_FETCHING elif status_code == STATUS_FETCHING: gui_status_level = GUI_STATUS_FETCHING elif status_code == STATUS_RECOVERED: gui_status_level = GUI_STATUS_OK elif status_code == STATUS_RATE_LIMITED: gui_status_level = GUI_STATUS_WARNING details = message.get("details", {}) delay = details.get("delay", "N/A") gui_message = ( f"API Rate Limit. Retrying in {float(delay):.0f}s." if isinstance(delay, (int, float)) else f"API Rate Limit. Retry delay: {delay}." ) elif status_code == STATUS_API_ERROR_TEMPORARY: gui_status_level = GUI_STATUS_WARNING details = message.get("details", {}) orig_err_code = details.get( "status_code", "N/A" ) delay = details.get("delay", "N/A") gui_message = ( f"Temp API Error ({orig_err_code}). Retry in {float(delay):.0f}s." if isinstance(delay, (int, float)) else f"Temp API Error ({orig_err_code}). Retry delay: {delay}." ) elif status_code == STATUS_PERMANENT_FAILURE: gui_status_level = GUI_STATUS_ERROR gui_message = message.get("message", "Permanent adapter failure.") action_required = "STOP_MONITORING" elif status_code == STATUS_STOPPED: gui_status_level = GUI_STATUS_OK gui_message = message.get("message", "Monitoring stopped.") pass if ( hasattr(self.main_window, "update_semaphore_and_status") ): try: self.main_window.update_semaphore_and_status( gui_status_level, gui_message ) except tk.TclError as e_tcl_status: module_logger.warning(f"TclError updating status bar ({gui_status_level}): {e_tcl_status}. GUI likely closing.") self._gui_after_id = None return except Exception as e_status: module_logger.error(f"Error updating status bar ({gui_status_level}): {e_status}", exc_info=False) if action_required == "STOP_MONITORING": module_logger.critical( "Permanent failure status received from adapter. Triggering controller stop sequence." ) self.stop_live_monitoring(from_error=True) break else: module_logger.warning( f"Unknown message type from adapter: '{message_type}'. Message: {message}" ) if ( hasattr(self.main_window, "update_semaphore_and_status") ): try: self.main_window.update_semaphore_and_status( GUI_STATUS_WARNING, f"Unknown adapter message type: {message_type}", ) except tk.TclError as e_tcl_status: module_logger.warning(f"TclError updating status bar (UNKNOWN MSG): {e_tcl_status}. GUI likely closing.") self._gui_after_id = None return except Exception as e_status: module_logger.error(f"Error updating status bar (UNKNOWN MSG): {e_status}", exc_info=False) except Exception as e_message_processing: module_logger.error( f"Error processing adapter message (Type: {message.get('type')}): {e_message_processing}", exc_info=True, ) finally: try: self.flight_data_queue.task_done() except Exception as e_task_done: module_logger.error(f"Error calling task_done on queue: {e_task_done}", exc_info=False) except tk.TclError as e_tcl_outer: module_logger.warning(f"TclError during adapter queue processing: {e_tcl_outer}. Aborting queue processing.", exc_info=False) self._gui_after_id = None return except Exception as e_outer: module_logger.error( f"Unexpected critical error processing adapter message queue: {e_outer}", exc_info=True ) if ( hasattr(self.main_window, "update_semaphore_and_status") ): try: self.main_window.update_semaphore_and_status( GUI_STATUS_ERROR, "Critical error processing data. See logs." ) except tk.TclError: pass except Exception: pass finally: if ( self.is_live_monitoring_active and self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: self._gui_after_id = self.main_window.root.after( GUI_QUEUE_CHECK_INTERVAL_MS, self._process_flight_data_queue, ) except tk.TclError: module_logger.warning( "TclError scheduling next queue check, window might be gone." ) self._gui_after_id = None except Exception as e_after: module_logger.error( f"Error scheduling next queue check: {e_after}", exc_info=True ) self._gui_after_id = None def start_live_monitoring(self, bounding_box: Dict[str, float]): if not self.main_window: module_logger.error( "Controller: Main window not set. Cannot start live monitoring." ) return if not bounding_box: err_msg = "Controller: Bounding box is required to start live monitoring but was not provided." module_logger.error(err_msg) if hasattr(self.main_window, "_reset_gui_to_stopped_state"): self.main_window._reset_gui_to_stopped_state("Start failed: Bounding box missing.") return if not self.data_storage: err_msg = "DataStorage not initialized. Live monitoring cannot start." module_logger.error(f"Controller: {err_msg}") if hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status( GUI_STATUS_ERROR, err_msg + " Check logs." ) if hasattr(self.main_window, "_reset_gui_to_stopped_state"): self.main_window._reset_gui_to_stopped_state(err_msg) return if self.is_live_monitoring_active: module_logger.warning( "Controller: Live monitoring already active. Start request ignored." ) return module_logger.info( f"Controller: Starting live monitoring for bbox: {bounding_box}" ) self._active_bounding_box = bounding_box if ( hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance is not None and hasattr(self.main_window.map_manager_instance, "set_target_bbox") ): try: module_logger.debug( f"Controller instructing map manager to set view for BBox: {bounding_box}" ) # Call set_target_bbox on the map manager instance. # This will also cause the blue box to be drawn on the map view. self.main_window.map_manager_instance.set_target_bbox(bounding_box) except Exception as e_map_set_bbox: module_logger.error( f"Error instructing map manager to set BBox {bounding_box}: {e_map_set_bbox}", exc_info=True, ) if hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status( GUI_STATUS_WARNING, "Map update error on start. See logs." ) else: if hasattr(self.main_window, "clear_all_views_data"): try: self.main_window.clear_all_views_data() except Exception as e_clear: module_logger.error(f"Error calling clear_all_views_data on start when map manager is missing: {e_clear}", exc_info=False) if self.flight_data_queue is None: self.flight_data_queue = Queue(maxsize=100) module_logger.debug("Created new flight data queue.") else: while not self.flight_data_queue.empty(): try: message = self.flight_data_queue.get_nowait() self.flight_data_queue.task_done() module_logger.debug( f"Discarded old message from queue before new adapter start: {message.get('type', 'Unknown Type')}" ) except QueueEmpty: break except Exception as e_q_clear: module_logger.warning( f"Error clearing old message from queue before new adapter start: {e_q_clear}" ) break adapter_thread_to_stop = self.live_adapter_thread if adapter_thread_to_stop and adapter_thread_to_stop.is_alive(): module_logger.warning( "Controller: Old LiveAdapter thread still alive. Attempting to stop and join it first." ) try: adapter_thread_to_stop.stop() if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: self.main_window.root.update_idletasks() except Exception: pass module_logger.debug( f"Controller: Waiting for LiveAdapter thread ({adapter_thread_to_stop.name}) to join..." ) adapter_thread_to_stop.join(timeout=ADAPTER_JOIN_TIMEOUT_SECONDS) if adapter_thread_to_stop.is_alive(): module_logger.error( f"Controller: Old LiveAdapter thread ({adapter_thread_to_stop.name}) did NOT join in time ({ADAPTER_JOIN_TIMEOUT_SECONDS}s) after stop signal! This is a problem." ) else: module_logger.info( f"Controller: Old LiveAdapter thread joined successfully." ) except Exception as e_stop_join: module_logger.error(f"Error during adapter stop/join sequence: {e_stop_join}", exc_info=True) finally: self.live_adapter_thread = None else: module_logger.debug( "Controller: No active LiveAdapter thread to stop or already stopped." ) self.live_adapter_thread = OpenSkyLiveAdapter( output_queue=self.flight_data_queue, bounding_box=self._active_bounding_box, ) self.is_live_monitoring_active = ( True ) self.live_adapter_thread.start() module_logger.info("Controller: New live adapter thread started.") if self._gui_after_id: if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: self.main_window.root.after_cancel(self._gui_after_id) module_logger.debug( "Controller: Cancelled previous GUI queue check callback before starting new monitoring." ) except Exception: module_logger.debug("Controller: Error cancelling previous GUI queue check, it might not have been active.") pass self._gui_after_id = None if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): self._gui_after_id = self.main_window.root.after( 100, self._process_flight_data_queue, ) module_logger.info( "Controller: GUI queue polling scheduled." ) else: module_logger.warning( "Controller: Cannot schedule GUI queue polling: MainWindow or root does not exist." ) module_logger.error("Controller: Aborting live monitoring start due to missing GUI root.") self.is_live_monitoring_active = False if self.live_adapter_thread and self.live_adapter_thread.is_alive(): try: self.live_adapter_thread.stop() pass except Exception as e_stop_fail: module_logger.error(f"Error trying to stop adapter after GUI root missing: {e_stop_fail}") def stop_live_monitoring(self, from_error: bool = False): if not self.is_live_monitoring_active and not (from_error and self.live_adapter_thread is not None and self.live_adapter_thread.is_alive()): module_logger.debug(f"Controller: Stop requested but live monitoring is not active (from_error={from_error}). Ignoring.") return self.is_live_monitoring_active = False if self._gui_after_id: if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: self.main_window.root.after_cancel(self._gui_after_id) module_logger.debug( "Controller: Cancelled GUI queue check callback." ) except Exception: module_logger.debug("Controller: Error cancelling GUI queue check.") pass self._gui_after_id = None adapter_thread_to_stop = self.live_adapter_thread if adapter_thread_to_stop and adapter_thread_to_stop.is_alive(): module_logger.debug( f"Controller: Signaling LiveAdapter thread ({adapter_thread_to_stop.name}) to stop." ) try: adapter_thread_to_stop.stop() if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: self.main_window.root.update_idletasks() except Exception: pass module_logger.debug( f"Controller: Waiting for LiveAdapter thread ({adapter_thread_to_stop.name}) to join..." ) adapter_thread_to_stop.join(timeout=ADAPTER_JOIN_TIMEOUT_SECONDS) if adapter_thread_to_stop.is_alive(): module_logger.error( f"Controller: LiveAdapter thread ({adapter_thread_to_stop.name}) did NOT join in time ({ADAPTER_JOIN_TIMEOUT_SECONDS}s) after stop signal! This is a problem." ) else: module_logger.info( f"Controller: LiveAdapter thread ({adapter_thread_to_stop.name}) joined successfully." ) except Exception as e_stop_join: module_logger.error(f"Error during adapter stop/join sequence: {e_stop_join}", exc_info=True) finally: self.live_adapter_thread = None else: module_logger.debug( "Controller: No active LiveAdapter thread to stop or already stopped." ) if self.flight_data_queue: module_logger.debug( "Controller: Processing any final messages from adapter queue post-join..." ) if ( self.main_window and hasattr(self.main_window, "root") and self.main_window.root.winfo_exists() ): try: while not self.flight_data_queue.empty(): try: message = self.flight_data_queue.get_nowait() self.flight_data_queue.task_done() msg_type = message.get("type") if msg_type == MSG_TYPE_ADAPTER_STATUS: status_code = message.get("status_code") gui_message = message.get("message", "Adapter status received.") module_logger.info(f"Controller: Processing final adapter status: {status_code} - {gui_message}") gui_status_level = GUI_STATUS_UNKNOWN if status_code == STATUS_STOPPED: gui_status_level = GUI_STATUS_OK elif status_code == STATUS_PERMANENT_FAILURE: gui_status_level = GUI_STATUS_ERROR if hasattr(self.main_window, "update_semaphore_and_status"): try: self.main_window.update_semaphore_and_status(gui_status_level, gui_message) except tk.TclError: pass except Exception as e_status_final: module_logger.error(f"Error updating status bar with final status {status_code}: {e_status_final}", exc_info=False) except QueueEmpty: break except Exception as e_final_msg: module_logger.error(f"Controller: Error processing final message from queue: {e_final_msg}", exc_info=False) except Exception as e_final_loop: module_logger.error(f"Controller: Unexpected error in final queue processing loop: {e_final_loop}", exc_info=True) module_logger.debug( "Controller: Finished processing any final adapter queue messages." ) else: module_logger.debug("Controller: No flight data queue to process after stop.") if hasattr(self.main_window, "clear_all_views_data"): try: self.main_window.clear_all_views_data() except Exception as e_clear_views: module_logger.error(f"Error calling clear_all_views_data after stop: {e_clear_views}", exc_info=False) # Reset GUI controls to the stopped state if hasattr(self.main_window, "_reset_gui_to_stopped_state"): stop_status_msg = "Monitoring stopped." if from_error: stop_status_msg = "Monitoring stopped due to an error." try: self.main_window._reset_gui_to_stopped_state(stop_status_msg) except tk.TclError: module_logger.warning("TclError resetting GUI state after stop. GUI likely gone.") except Exception as e_reset_gui: module_logger.error(f"Error resetting GUI state after stop: {e_reset_gui}", exc_info=True) module_logger.info( "Controller: Live monitoring shutdown sequence fully completed." ) def on_application_exit(self): """ Performs cleanup when the application is exiting. Called by MainWindow's _on_closing method. """ module_logger.info( "Controller: Application exit requested. Cleaning up resources." ) is_adapter_considered_running = ( self.live_adapter_thread is not None and self.live_adapter_thread.is_alive() ) or self.is_live_monitoring_active if is_adapter_considered_running: module_logger.debug( "Controller: Live monitoring/adapter seems active during app exit, stopping it." ) self.stop_live_monitoring( from_error=False ) else: module_logger.debug( "Controller: Live monitoring/adapter was not active or already stopped during app exit. No adapter cleanup needed." ) if self.data_storage: module_logger.debug( "Controller: Closing DataStorage connection during app exit." ) try: self.data_storage.close_connection() except Exception as e_db_close: module_logger.error(f"Error closing DataStorage connection: {e_db_close}", exc_info=True) finally: self.data_storage = None module_logger.info("Controller: Cleanup on application exit finished.") # --- History Mode (Placeholders) --- def start_history_monitoring(self): if not self.main_window: module_logger.error("Main window not set for history.") return if not self.data_storage: err_msg = "DataStorage not initialized. Cannot use history features." module_logger.error(f"Controller: {err_msg}") if hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status( GUI_STATUS_ERROR, err_msg ) if hasattr(self.main_window, "_reset_gui_to_stopped_state"): self.main_window._reset_gui_to_stopped_state(f"History start failed: {err_msg}") return module_logger.info("Controller: History monitoring started (placeholder).") if hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status( GUI_STATUS_OK, "History mode active (placeholder)." ) def stop_history_monitoring(self): if not self.main_window: return module_logger.info("Controller: History monitoring stopped (placeholder).") if hasattr(self.main_window, "update_semaphore_and_status"): self.main_window.update_semaphore_and_status( GUI_STATUS_OK, "History monitoring stopped." ) # --- MAP INTERACTION METHODS --- # These are called by the MapCanvasManager (which runs on the GUI thread) # and therefore can safely interact with GUI elements via MainWindow. def on_map_left_click(self, latitude: float, longitude: float, screen_x: int, screen_y: int): """ Handles a left-click event on the map canvas. Updates the clicked location in the map info panel. Called by MapCanvasManager on the GUI thread. """ module_logger.debug( f"Controller: Map left-clicked at Geo ({latitude:.5f}, {longitude:.5f})" ) if self.main_window: # Update the clicked location info in the GUI lat_dms_str = "N/A" lon_dms_str = "N/A" try: from ..map.map_utils import deg_to_dms_string lat_dms_str = deg_to_dms_string(latitude, "lat") if latitude is not None else "N/A" lon_dms_str = deg_to_dms_string(longitude, "lon") if longitude is not None else "N/A" except ImportError: module_logger.warning("map_utils.deg_to_dms_string not available for left click info panel.") except Exception as e_dms_calc: module_logger.warning(f"Error calculating DMS for left click ({latitude}, {longitude}): {e_dms_calc}", exc_info=False) lat_dms_str = "N/A (Calc Error)" lon_dms_str = "N/A (Calc Error)" if hasattr(self.main_window, "update_clicked_map_info"): try: self.main_window.update_clicked_map_info( lat_deg=latitude, lon_deg=longitude, lat_dms=lat_dms_str, lon_dms=lon_dms_str ) except tk.TclError as e_tcl_update: module_logger.warning(f"TclError updating map clicked info panel: {e_tcl_update}. GUI likely closing.") except Exception as e_update: module_logger.error(f"Error updating map clicked info panel: {e_update}", exc_info=False) else: module_logger.warning("Main window not available to update clicked map info.") def on_map_right_click( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): """ Handles a right-click event on the map canvas. Delegates showing the context menu to MainWindow. Updates the clicked location in the map info panel. Called by MapCanvasManager on the GUI thread. """ module_logger.debug( f"Controller: Map right-clicked at Geo ({latitude:.5f}, {longitude:.5f})" ) if self.main_window: # Update the clicked location info in the GUI lat_dms_str = "N/A" lon_dms_str = "N/A" try: from ..map.map_utils import deg_to_dms_string lat_dms_str = deg_to_dms_string(latitude, "lat") if latitude is not None else "N/A" lon_dms_str = deg_to_dms_string(longitude, "lon") if longitude is not None else "N/A" except ImportError: module_logger.warning("map_utils.deg_to_dms_string not available for right click info panel.") except Exception as e_dms_calc: module_logger.warning(f"Error calculating DMS for right click ({latitude}, {longitude}): {e_dms_calc}", exc_info=False) lat_dms_str = "N/A (Calc Error)" lon_dms_str = "N/A (Calc Error)" if hasattr(self.main_window, "update_clicked_map_info"): try: self.main_window.update_clicked_map_info( lat_deg=latitude, lon_deg=longitude, lat_dms=lat_dms_str, lon_dms=lon_dms_str ) except tk.TclError as e_tcl_update: module_logger.warning(f"TclError updating map clicked info panel: {e_tcl_update}. GUI likely closing.") except Exception as e_update: module_logger.error(f"Error updating map clicked info panel: {e_update}", exc_info=False) # Request MainWindow to show the context menu if hasattr(self.main_window, "show_map_context_menu"): try: # Pass coordinates and screen position for the menu self.main_window.show_map_context_menu(latitude, longitude, screen_x, screen_y) except tk.TclError as e_tcl_menu: module_logger.warning(f"TclError showing map context menu: {e_tcl_menu}. GUI likely closing.") except Exception as e_menu: module_logger.error(f"Error showing map context menu: {e_menu}", exc_info=False) else: module_logger.warning("Main window not available for right click handling.") def on_map_context_menu_request( self, latitude: float, longitude: float, screen_x: int, screen_y: int ): """ Receives context menu request from MapCanvasManager and delegates to MainWindow. This is the method called by MapCanvasManager. """ module_logger.debug(f"Controller received context menu request for ({latitude:.5f}, {longitude:.5f}) at screen ({screen_x}, {screen_y}).") # Now delegate showing the menu back to MainWindow if self.main_window and hasattr(self.main_window, "show_map_context_menu"): try: self.main_window.show_map_context_menu(latitude, longitude, screen_x, screen_y) except tk.TclError as e_tcl_menu: module_logger.warning(f"TclError delegating show_map_context_menu: {e_tcl_menu}. GUI likely closing.") except Exception as e_menu: module_logger.error(f"Error delegating show_map_context_menu: {e_menu}", exc_info=False) else: module_logger.warning("Main window not available to show context menu.") def recenter_map_at_coords(self, lat: float, lon: float): """ Requests MapCanvasManager to recenter the map view. Called by MainWindow's context menu action. """ module_logger.info(f"Controller received request to recenter map at ({lat:.5f}, {lon:.5f}).") if self.main_window and hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance: map_manager: "MapCanvasManager" = self.main_window.map_manager_instance if hasattr(map_manager, "recenter_map_at_coords"): # Ensure method exists try: map_manager.recenter_map_at_coords(lat, lon) except Exception as e_recenter: module_logger.error(f"Error calling map_manager.recenter_map_at_coords: {e_recenter}", exc_info=False) if hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Failed to recenter map.") else: module_logger.warning("MapCanvasManager instance is missing 'recenter_map_at_coords' method.") if hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Map display component is outdated or incomplete.") else: module_logger.warning("Main window or MapCanvasManager not available to recenter map.") if self.main_window and hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Map display component not available.") def set_bbox_around_coords(self, center_lat: float, center_lon: float, area_size_km: float = DEFAULT_CLICK_AREA_SIZE_KM): """ Requests MapCanvasManager to set a bounding box around the given coordinates. Called by MainWindow's context menu action. """ module_logger.info(f"Controller received request to set BBox ({area_size_km:.1f}km) around ({center_lat:.5f}, {center_lon:.5f}).") if self.main_window and hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance: map_manager: "MapCanvasManager" = self.main_window.map_manager_instance if hasattr(map_manager, "set_bbox_around_coords"): # Ensure method exists try: # MapCanvasManager will calculate the BBox and call set_target_bbox map_manager.set_bbox_around_coords(center_lat, center_lon, area_size_km) # The map manager will also call update_bbox_gui_fields via this controller except Exception as e_set_bbox: module_logger.error(f"Error calling map_manager.set_bbox_around_coords: {e_set_bbox}", exc_info=False) if hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Failed to set monitoring area.") else: module_logger.warning("MapCanvasManager instance is missing 'set_bbox_around_coords' method.") if hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Map display component is outdated or incomplete.") else: module_logger.warning("Main window or MapCanvasManager not available to set BBox.") if self.main_window and hasattr(self.main_window, "show_error_message"): self.main_window.show_error_message("Map Error", "Map display component not available.") def update_bbox_gui_fields(self, bbox_dict: Dict[str, float]): """ Updates the BBox input fields in the GUI with the given dictionary. Called by MapCanvasManager after setting a new target BBox. """ module_logger.debug(f"Controller received request to update BBox GUI fields with: {bbox_dict}") if self.main_window and hasattr(self.main_window, "update_bbox_gui_fields"): try: self.main_window.update_bbox_gui_fields(bbox_dict) except tk.TclError as e_tcl_update: module_logger.warning(f"TclError updating BBox GUI fields: {e_tcl_update}. GUI likely closing.") except Exception as e_update: module_logger.error(f"Error updating BBox GUI fields: {e_update}", exc_info=False) else: module_logger.warning("Main window not available to update BBox GUI fields.") def update_general_map_info(self): """ Called by MapCanvasManager (e.g., after pan/zoom/resize or redraw) to update general map information displayed in the GUI (like zoom, map size, bounds, flight count). Runs on the GUI thread. """ module_logger.debug("Controller received request to update general map info.") # Ensure main window and map manager are available to get info FROM if ( self.main_window and hasattr(self.main_window, "map_manager_instance") and self.main_window.map_manager_instance is not None ): map_manager: "MapCanvasManager" = self.main_window.map_manager_instance if hasattr(map_manager, "get_current_map_info"): # Ensure method exists map_info = {} try: map_info = map_manager.get_current_map_info() module_logger.debug(f"Fetched map info from manager: {map_info}") # Extract and format information zoom = map_info.get("zoom") map_geo_bounds = map_info.get("map_geo_bounds") target_bbox_input = map_info.get("target_bbox_input") map_size_km_w = map_info.get("map_size_km_w") map_size_km_h = map_info.get("map_size_km_h") flight_count = map_info.get("flight_count") # Format map size string map_size_str = "N/A" if map_size_km_w is not None and map_size_km_h is not None: try: # Use configured decimal places for display map_size_str = f"{map_size_km_w:.{config.MAP_SIZE_KM_DECIMAL_PLACES}f}km x {map_size_km_h:.{config.MAP_SIZE_KM_DECIMAL_PLACES}f}km" except Exception as e_format_size: module_logger.warning(f"Error formatting map size: {e_format_size}. Using N/A.", exc_info=False) map_size_str = "N/A (Format Error)" # Note: Clicked Lat/Lon/DMS are NOT updated by this method. # They are updated only by click handlers (on_map_left_click, on_map_right_click). # Pass information to MainWindow to update display if hasattr(self.main_window, "update_general_map_info_display"): try: self.main_window.update_general_map_info_display( zoom=zoom, map_size_str=map_size_str, map_geo_bounds=map_geo_bounds, target_bbox_input=target_bbox_input, flight_count=flight_count ) module_logger.debug("Called MainWindow.update_general_map_info_display.") except tk.TclError as e_tcl_update: module_logger.warning(f"TclError updating general map info panel: {e_tcl_update}. GUI likely closing.") except Exception as e_update: module_logger.error(f"Error updating general map info panel: {e_update}", exc_info=False) else: module_logger.warning("MainWindow instance is missing 'update_general_map_info_display' method.") except Exception as e_get_info: module_logger.error(f"Error getting map info from map manager for general update: {e_get_info}", exc_info=False) # Even on error getting info, attempt to update panel with N/A if method exists if hasattr(self.main_window, "update_general_map_info_display"): try: self.main_window.update_general_map_info_display( zoom=None, map_size_str="N/A", map_geo_bounds=None, target_bbox_input=None, flight_count=None ) except tk.TclError: pass except Exception: pass # Ignore errors during fallback update else: module_logger.warning("MapCanvasManager instance is missing 'get_current_map_info' method.") # If the method is missing, update panel with N/A if the update method exists if self.main_window and hasattr(self.main_window, "update_general_map_info_display"): try: self.main_window.update_general_map_info_display( zoom=None, map_size_str="N/A", map_geo_bounds=None, target_bbox_input=None, flight_count=None ) except tk.TclError: pass except Exception: pass else: module_logger.debug( "Skipping general map info update: MainWindow or map manager not ready.") # If MainWindow itself is not ready, can't update anything.