403 lines
23 KiB
Python
403 lines
23 KiB
Python
# 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):
|
|
"""
|
|
Called by MapCanvasManager when the map is right-clicked.
|
|
Placeholder for future functionality like showing a context menu or details.
|
|
"""
|
|
module_logger.info(f"Controller: Map right-clicked at Geo ({latitude:.5f}, {longitude:.5f}), Screen ({screen_x}, {screen_y})")
|
|
# Qui potresti, ad esempio, preparare dati per un menu contestuale
|
|
# o chiedere a MainWindow di visualizzare un popup di informazioni.
|
|
if self.main_window and hasattr(self.main_window, 'show_map_context_menu'):
|
|
# self.main_window.show_map_context_menu(latitude, longitude, screen_x, screen_y)
|
|
pass # Implementa in futuro
|
|
elif self.main_window: # Fallback: aggiorna lo status bar con le coordinate
|
|
self.main_window.update_semaphore_and_status(
|
|
GUI_STATUS_OK,
|
|
f"Map right-click: Lat {latitude:.4f}, Lon {longitude:.4f}"
|
|
) |