# 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 # Make sure STATUS_OK is not from here if not defined ) from ..data import config from ..utils.logger import get_logger from ..data.storage import DataStorage from ..data.common_models import CanonicalFlightState # For type hinting List[CanonicalFlightState] from typing import List, Optional, Dict, Any # For type hints module_logger = get_logger(__name__) # flightmonitor.controller.app_controller GUI_QUEUE_CHECK_INTERVAL_MS = 150 # Check queue a bit less frequently # 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" # For the new semaphore color GUI_STATUS_UNKNOWN = "UNKNOWN" class AppController: def __init__(self, main_window: Optional[tk.Tk]=None): # main_window type can be more specific if MainWindow class is imported 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 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 # 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): # Parameter renamed for clarity """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) # Optionally, also show a messagebox here if it's critical for app function # self.main_window.show_error_message("Critical Storage Error", err_msg) else: # If DataStorage is fine, MainWindow should have already set its initial "Ready" status. # We don't need to override it from here unless there's a specific controller state. pass 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(): message: AdapterMessage = self.flight_data_queue.get_nowait() self.flight_data_queue.task_done() # Important for queue management message_type = message.get("type") gui_message: str = message.get("message", "Processing...") # Default message if message_type == MSG_TYPE_FLIGHT_DATA: flight_states_payload: Optional[List[CanonicalFlightState]] = message.get("payload") if flight_states_payload is not None: # Payload can be an empty list 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.") if self.main_window and self.is_live_monitoring_active and self._active_bounding_box: 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." self.main_window.update_semaphore_and_status(GUI_STATUS_OK, gui_message) else: module_logger.warning("Received flight_data message with None payload.") 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}") # Use adapter's message 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 # Or FETCHING gui_message = message.get("message", "Adapter starting...") elif status_code == STATUS_FETCHING: gui_status_level = GUI_STATUS_FETCHING gui_message = message.get("message", "Fetching new data...") elif status_code == STATUS_RECOVERED: gui_status_level = GUI_STATUS_OK gui_message = message.get("message", "Connection recovered, fetching data.") 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 {delay:.0f}s." 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"Temporary API Error ({orig_err_code}). Retrying in {delay:.0f}s." elif status_code == STATUS_PERMANENT_FAILURE: gui_status_level = GUI_STATUS_ERROR gui_message = message.get("message", "Too many API errors. Live updates stopped.") elif status_code == STATUS_STOPPED: gui_status_level = GUI_STATUS_OK # Or UNKNOWN if we prefer neutral for stopped state gui_message = message.get("message", "Live data adapter stopped.") 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) # Resets GUI via _reset_gui_to_stopped_state 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 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: module_logger.warning("TclError scheduling next queue check, window might be gone.") 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 # Should not happen if set_main_window was called 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.") # self.main_window.update_semaphore_and_status(GUI_STATUS_WARNING, "Monitoring is already running.") return module_logger.info(f"Controller: Starting live monitoring for bbox: {bounding_box}") self._active_bounding_box = bounding_box # MainWindow's _start_monitoring has already set a "WARNING" state like "Attempting to start..." # The adapter will soon send a STATUS_STARTING or STATUS_FETCHING message. if self.flight_data_queue is None: self.flight_data_queue = Queue() while not self.flight_data_queue.empty(): # Clear old messages try: self.flight_data_queue.get_nowait() except QueueEmpty: break if self.live_adapter_thread and self.live_adapter_thread.is_alive(): module_logger.warning("Controller: Old LiveAdapter thread still alive. Stopping it first.") self.live_adapter_thread.stop() self.live_adapter_thread.join(timeout=1.0) # Wait a bit for it to stop if self.live_adapter_thread.is_alive(): module_logger.error("Controller: Old LiveAdapter thread did not stop in time! May cause issues.") self.live_adapter_thread = None # Try to discard it anyway self.live_adapter_thread = OpenSkyLiveAdapter( output_queue=self.flight_data_queue, bounding_box=self._active_bounding_box, # Other params use defaults from config via adapter's __init__ ) self.live_adapter_thread.start() # This will trigger adapter to send initial status messages self.is_live_monitoring_active = True # The GUI status will be updated by messages processed in _process_flight_data_queue if self._gui_after_id: # Cancel any pre-existing queue polling if self.main_window.root.winfo_exists(): try: self.main_window.root.after_cancel(self._gui_after_id) except tk.TclError: pass self._gui_after_id = None if self.main_window.root.winfo_exists(): # Schedule new queue polling self._gui_after_id = self.main_window.root.after( 10, # Check very soon for the first status message from 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})") if self._gui_after_id: # Stop GUI polling the queue 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 tk.TclError: pass self._gui_after_id = None module_logger.debug("Controller: Cancelled GUI queue check callback.") adapter_to_stop = self.live_adapter_thread if adapter_to_stop and adapter_to_stop.is_alive(): module_logger.debug(f"Controller: Signalling LiveAdapter thread ({adapter_to_stop.name}) to stop.") adapter_to_stop.stop() # Adapter will send STATUS_STOPPING then STATUS_STOPPED else: module_logger.debug("Controller: No active LiveAdapter thread to signal stop, or already stopped.") self.live_adapter_thread = None # Clear reference self.is_live_monitoring_active = False # Crucial to stop _process_flight_data_queue rescheduling # self._active_bounding_box = None # Optionally clear this # The final GUI status (semaphore and text) will be set by: # 1. The STATUS_STOPPED message from the adapter if it stops cleanly. # 2. The STATUS_PERMANENT_FAILURE message if that was the cause. # 3. MainWindow's _reset_gui_to_stopped_state if called directly by user stop button. # If `from_error` is true, it means a PERMANENT_FAILURE likely occurred and already # triggered the GUI reset. if not from_error and self.main_window and self.main_window.root.winfo_exists(): # This provides an immediate feedback if stop was clean from controller side, # but might be overwritten by adapter's final STATUS_STOPPED. # Let MainWindow._reset_gui_to_stopped_state handle this when user clicks stop. # If called programmatically (not from error), a "stopping" status is okay. # self.main_window.update_semaphore_and_status(GUI_STATUS_OK, "Live monitoring stopping...") pass module_logger.info("Controller: Live monitoring process requested to stop. Adapter signalled.") def on_application_exit(self): module_logger.info("Controller: Application exit requested. Cleaning up resources.") if self.is_live_monitoring_active or (self.live_adapter_thread and self.live_adapter_thread.is_alive()): module_logger.debug("Controller: Live monitoring/adapter active during app exit, stopping it.") self.stop_live_monitoring(from_error=False) # Treat as a normal, non-error stop else: module_logger.debug("Controller: Live monitoring/adapter was not active during app exit.") if self.data_storage: module_logger.debug("Controller: Closing DataStorage connection during app exit.") self.data_storage.close_connection() 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.")