# FlightMonitor/controller/app_controller.py from queue import Queue, Empty as QueueEmpty import threading import tkinter as tk # Only for tk.TclError if absolutely needed by GUI interaction import time # Potentially for timestamps if not from adapter # Relative imports 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 # Module-level logger module_logger = get_logger(__name__) # flightmonitor.controller.app_controller # Constants GUI_QUEUE_CHECK_INTERVAL_MS = 150 # Check queue a bit less frequently ADAPTER_JOIN_TIMEOUT_SECONDS = 3.5 # Timeout for waiting for the adapter thread to join # Define GUI status levels that MainWindow.update_semaphore_and_status expects GUI_STATUS_OK = "OK" GUI_STATUS_WARNING = "WARNING" GUI_STATUS_ERROR = "ERROR" GUI_STATUS_FETCHING = "FETCHING" GUI_STATUS_UNKNOWN = "UNKNOWN" class AppController: def __init__(self, main_window: Optional[tk.Tk]=None): # main_window type can be more specific self.main_window = main_window # Will be set by set_main_window 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 # ID for root.after() job for queue processing self._active_bounding_box: Optional[Dict[str, float]] = None # BBox for API requests 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 # GUI error message will be shown in set_main_window if main_window is available module_logger.info("AppController initialized.") def set_main_window(self, main_window_instance: Any): # Type hint can be 'MainWindow' if imported """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, '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) # else: MainWindow should set its own initial "Ready" status. 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.""" if not self.flight_data_queue: module_logger.warning("_process_flight_data_queue: flight_data_queue is None.") return try: while not self.flight_data_queue.empty(): # Process all available messages message: AdapterMessage = self.flight_data_queue.get_nowait() # We must call task_done for every get_nowait() that succeeds self.flight_data_queue.task_done() 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.") if self.data_storage: saved_count = 0 for state in flight_states_payload: if not isinstance(state, CanonicalFlightState): continue 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 if saved_count > 0: module_logger.info(f"Saved {saved_count} position updates to DB.") # Update the map via MainWindow, which delegates to MapCanvasManager if self.main_window and \ hasattr(self.main_window, 'display_flights_on_canvas') and \ self.is_live_monitoring_active and \ self._active_bounding_box: # _active_bounding_box is for context self.main_window.display_flights_on_canvas(flight_states_payload, self._active_bounding_box) gui_message = f"Live data: {len(flight_states_payload)} aircraft tracked." if flight_states_payload else "Live data: No aircraft in area." if self.main_window and hasattr(self.main_window, 'update_semaphore_and_status'): self.main_window.update_semaphore_and_status(GUI_STATUS_OK, gui_message) else: module_logger.warning("Received flight_data message with None payload.") if self.main_window and hasattr(self.main_window, 'update_semaphore_and_status'): self.main_window.update_semaphore_and_status(GUI_STATUS_WARNING, "Received empty data payload.") 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"Adapter status: Code='{status_code}', Message='{gui_message}'") gui_status_level = GUI_STATUS_UNKNOWN if status_code == STATUS_STARTING: gui_status_level = GUI_STATUS_WARNING 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 delay = message.get("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 orig_err_code = message.get("details", {}).get("status_code", "N/A") delay = message.get("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 elif status_code == STATUS_STOPPED: gui_status_level = GUI_STATUS_OK if self.main_window and self.main_window.root.winfo_exists(): self.main_window.update_semaphore_and_status(gui_status_level, gui_message) if status_code == STATUS_PERMANENT_FAILURE: module_logger.critical("Permanent failure from adapter. Stopping live monitoring via controller.") self.stop_live_monitoring(from_error=True) else: module_logger.warning(f"Unknown message type from adapter: '{message_type}'") if self.main_window and self.main_window.root.winfo_exists(): self.main_window.update_semaphore_and_status(GUI_STATUS_WARNING, f"Unknown adapter message: {message_type}") except QueueEmpty: pass # Normal if queue is empty, no task_done needed except Exception as e: module_logger.error(f"CRITICAL Error processing adapter message queue: {e}", exc_info=True) if self.main_window and self.main_window.root.winfo_exists(): self.main_window.update_semaphore_and_status(GUI_STATUS_ERROR, "Critical error processing data. See logs.") finally: if self.is_live_monitoring_active and \ self.main_window and 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: # Window might be destroyed module_logger.warning("TclError scheduling next queue check, window might be gone.") self._gui_after_id = None except Exception as e_after: # Other unexpected errors 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 self.data_storage: err_msg = "DataStorage not initialized. Live monitoring cannot start." module_logger.error(f"Controller: {err_msg}") if self.main_window.root.winfo_exists(): 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 # --- Comunica il BBox al MapCanvasManager tramite MainWindow --- if self.main_window and \ hasattr(self.main_window, 'map_manager_instance') and \ self.main_window.map_manager_instance is not None: try: module_logger.debug(f"Controller instructing map manager to set view for BBox: {bounding_box}") 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: {e_map_set_bbox}", exc_info=True) else: module_logger.warning("Map manager not available in MainWindow to set initial BBox for map display.") # --- Fine comunicazione BBox alla mappa --- if self.flight_data_queue is None: self.flight_data_queue = Queue() # Default is unbounded # Svuota la coda da messaggi vecchi (se presenti) while not self.flight_data_queue.empty(): try: self.flight_data_queue.get_nowait() self.flight_data_queue.task_done() # Importante! except QueueEmpty: break except Exception as e_q_clear: module_logger.warning(f"Error clearing old message from queue: {e_q_clear}") break # Esci se c'è un problema con la coda if self.live_adapter_thread and self.live_adapter_thread.is_alive(): module_logger.warning("Controller: Old LiveAdapter thread still alive. Attempting to stop and join it first.") self.live_adapter_thread.stop() self.live_adapter_thread.join(timeout=ADAPTER_JOIN_TIMEOUT_SECONDS) if self.live_adapter_thread.is_alive(): module_logger.error("Controller: Old LiveAdapter thread did NOT join in time! May cause issues. Discarding reference.") self.live_adapter_thread = None self.live_adapter_thread = OpenSkyLiveAdapter( output_queue=self.flight_data_queue, bounding_box=self._active_bounding_box, ) self.is_live_monitoring_active = True # Imposta PRIMA di avviare il thread e il polling self.live_adapter_thread.start() if self._gui_after_id: # Cancella il job 'after' precedente, se esiste if self.main_window.root.winfo_exists(): try: self.main_window.root.after_cancel(self._gui_after_id) except Exception: pass # Ignora errori qui self._gui_after_id = None if self.main_window.root.winfo_exists(): # Schedula nuovo polling della coda self._gui_after_id = self.main_window.root.after( 100, # Controlla la coda per i primi messaggi di stato dall'adapter self._process_flight_data_queue ) module_logger.info("Controller: Live monitoring adapter thread started and queue polling scheduled.") def stop_live_monitoring(self, from_error: bool = False): module_logger.info(f"Controller: Attempting to stop live monitoring. (Triggered by error: {from_error})") adapter_thread_to_stop = self.live_adapter_thread self.is_live_monitoring_active = False if self._gui_after_id: if self.main_window and self.main_window.root and self.main_window.root.winfo_exists(): try: self.main_window.root.after_cancel(self._gui_after_id) except Exception: pass self._gui_after_id = None module_logger.debug("Controller: Cancelled GUI queue check callback.") 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.") adapter_thread_to_stop.stop() if self.main_window and self.main_window.root and self.main_window.root.winfo_exists(): try: self.main_window.root.update_idletasks() # Non usare time.sleep() nel thread GUI qui, potrebbe bloccare il processamento # di messaggi di stato dall'adapter. La gestione della coda post-join è meglio. 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 after stop signal! This is a problem.") else: module_logger.info(f"Controller: LiveAdapter thread ({adapter_thread_to_stop.name}) joined successfully.") else: module_logger.debug("Controller: No active LiveAdapter thread to stop or already stopped.") if self.live_adapter_thread == adapter_thread_to_stop: self.live_adapter_thread = None final_adapter_status_processed = False if self.flight_data_queue: module_logger.debug("Controller: Processing any final messages from adapter queue post-join...") # Processa i messaggi rimanenti dopo che l'adapter è stato fermato e joinato # per catturare STATUS_STOPPED o STATUS_PERMANENT_FAILURE 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") status_code = message.get("status_code") if msg_type == MSG_TYPE_ADAPTER_STATUS: module_logger.info(f"Controller: Processing final adapter status from queue: {status_code} - {message.get('message')}") if self.main_window and self.main_window.root.winfo_exists(): gui_status_level = GUI_STATUS_OK if status_code == STATUS_PERMANENT_FAILURE: gui_status_level = GUI_STATUS_ERROR elif status_code == STATUS_STOPPED: gui_status_level = GUI_STATUS_OK # OK per uno stop pulito # Altri stati potrebbero non essere rilevanti qui o già gestiti # Aggiorna la GUI solo se lo stato è significativo per la fase di stop if status_code in [STATUS_STOPPED, STATUS_PERMANENT_FAILURE]: self.main_window.update_semaphore_and_status(gui_status_level, message.get('message', 'Adapter stopped.')) final_adapter_status_processed = True # Indica che la GUI è stata aggiornata dall'adapter except QueueEmpty: break except Exception as e: module_logger.error(f"Controller: Error processing/discarding message from queue after stop: {e}") break module_logger.debug("Controller: Finished processing/discarding final adapter queue messages.") # Aggiorna la GUI se non è stato fatto da un messaggio finale dell'adapter # e non siamo in una condizione di errore (gestita separatamente da _process_flight_data_queue) if not from_error and not final_adapter_status_processed: if self.main_window and self.main_window.root.winfo_exists(): if hasattr(self.main_window, '_reset_gui_to_stopped_state'): # Questo metodo in MainWindow dovrebbe impostare uno stato "Stopped" self.main_window._reset_gui_to_stopped_state("Monitoring stopped.") else: # Fallback se _reset_gui_to_stopped_state non esiste self.main_window.update_semaphore_and_status(GUI_STATUS_OK, "Monitoring stopped.") elif from_error and not final_adapter_status_processed: # Se è stato fermato per errore ma nessun messaggio di errore è stato processato dalla coda, # assicurati che la GUI rifletta uno stato di errore. if self.main_window and self.main_window.root.winfo_exists(): if hasattr(self.main_window, '_reset_gui_to_stopped_state'): self.main_window._reset_gui_to_stopped_state("Monitoring stopped due to an error.") else: self.main_window.update_semaphore_and_status(GUI_STATUS_ERROR, "Monitoring stopped due to an error.") module_logger.info("Controller: Live monitoring shutdown sequence fully completed.") def on_application_exit(self): module_logger.info("Controller: Application exit requested. Cleaning up resources.") # Determina se l'adapter è effettivamente in esecuzione is_adapter_considered_running = (self.live_adapter_thread 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) # Esegui la sequenza di stop completa else: module_logger.debug("Controller: Live monitoring/adapter was not active or already stopped during app exit.") if self.data_storage: module_logger.debug("Controller: Closing DataStorage connection during app exit.") self.data_storage.close_connection() self.data_storage = None # Rilascia riferimento 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 self.main_window.root.winfo_exists(): self.main_window.update_semaphore_and_status(GUI_STATUS_ERROR, err_msg) return module_logger.info("Controller: History monitoring started (placeholder).") if self.main_window.root.winfo_exists(): 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 self.main_window.root.winfo_exists(): self.main_window.update_semaphore_and_status(GUI_STATUS_OK, "History monitoring stopped.") # --- NUOVO METODO PER INTERAZIONE MAPPA --- def on_map_right_click(self, latitude: float, longitude: float, screen_x: int, screen_y: int): module_logger.info(f"Controller: Map right-clicked at Geo ({latitude:.5f}, {longitude:.5f})") if self.main_window and hasattr(self.main_window, 'update_map_info_panel'): # Ottieni le info generali della mappa dal map_manager_instance per lo zoom e la dimensione map_info = {} if hasattr(self.main_window, 'map_manager_instance') and self.main_window.map_manager_instance: map_info = self.main_window.map_manager_instance.get_current_map_info() # Converti lat/lon in DMS (questa logica potrebbe essere centralizzata in map_utils) # o MapCanvasManager potrebbe già fornire stringhe DMS # Per ora, assumiamo che MapCanvasManager fornisca già le stringhe DMS corrette # o che una funzione di utilità le generi. # Se MapCanvasManager invia direttamente un dict con 'lat_dms_str' e 'lon_dms_str', usiamo quelli. # Qui simuliamo la conversione se non fornita direttamente: from ..map.map_utils import deg_to_dms_string # Assicurati che sia importabile 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" self.main_window.update_map_info_panel( lat_deg=latitude, lon_deg=longitude, lat_dms=lat_dms_str, lon_dms=lon_dms_str, zoom=map_info.get("zoom"), map_size_str=f"{map_info.get('map_size_km_w', 'N/A'):.1f}km x {map_info.get('map_size_km_h', 'N/A'):.1f}km" if map_info.get("map_size_km_w") is not None else "N/A" ) # Considera se mostrare un menu contestuale qui (come nel placeholder precedente) def update_general_map_info(self): """Chiamato da MapCanvasManager (o periodicamente) per aggiornare info come zoom e dimensione mappa.""" if self.main_window and \ hasattr(self.main_window, 'map_manager_instance') and \ self.main_window.map_manager_instance is not None and \ hasattr(self.main_window, 'update_map_info_panel'): map_info = self.main_window.map_manager_instance.get_current_map_info() # Per le info generali, non aggiorniamo le coordinate del click (quelle sono per on_map_right_click) # Quindi passiamo None o i valori correnti se li vogliamo mostrare permanentemente # Qui, aggiorniamo solo zoom e dimensione mappa. # Otteniamo le coordinate del centro mappa se disponibili center_lat = map_info.get("center_lat") center_lon = map_info.get("center_lon") lat_dms_str = "N/A" lon_dms_str = "N/A" if center_lat is not None and center_lon is not None: from ..map.map_utils import deg_to_dms_string lat_dms_str = deg_to_dms_string(center_lat, 'lat') lon_dms_str = deg_to_dms_string(center_lon, 'lon') self.main_window.update_map_info_panel( lat_deg=center_lat, # Mostra il centro della mappa lon_deg=center_lon, lat_dms=lat_dms_str, lon_dms=lon_dms_str, zoom=map_info.get("zoom"), map_size_str=f"{map_info.get('map_size_km_w', 'N/A'):.1f}km x {map_info.get('map_size_km_h', 'N/A'):.1f}km" if map_info.get("map_size_km_w") is not None and map_info.get("map_size_km_h") is not None else "N/A" )