diff --git a/flightmonitor/__main__.py b/flightmonitor/__main__.py index 4d31e3b..4a7abe6 100644 --- a/flightmonitor/__main__.py +++ b/flightmonitor/__main__.py @@ -9,7 +9,7 @@ from .data.logging_config import LOGGING_CONFIG # Import the config dictionary # MODIFIED: Import the new setup_basic_logging function AND get_logger function # WHY: Need to call the centralized basic setup function early and get logger instances. # HOW: Changed setup_logging to setup_basic_logging. -from .utils.logger import setup_basic_logging, get_logger # MODIFIED HERE +from .utils.logger import setup_basic_logging, get_logger from .gui.main_window import MainWindow from .controller.app_controller import AppController @@ -28,8 +28,8 @@ def main(): # WHY: The Tkinter-based log processor needs a root instance to schedule itself. # The TkinterTextHandler itself will be added later by MainWindow. # HOW: Called setup_basic_logging here, passing the necessary arguments. - setup_basic_logging( # MODIFIED HERE - root_tk_instance_for_processor=root, # MODIFIED: Pass root for the log processor + setup_basic_logging( + root_tk_instance_for_processor=root, logging_config_dict=LOGGING_CONFIG, ) @@ -40,13 +40,20 @@ def main(): # Create the application controller app_controller = AppController() - # Create the main application window - # MainWindow itself will now handle the creation and specific addition of its log widget - # to the logging system using a new dedicated function in the logger module. - main_app_window = MainWindow(root, app_controller) - - # Set the main window instance in the controller - app_controller.set_main_window(main_app_window) + # MODIFIED: Set the main window instance in the controller *before* creating MainWindow + # WHY: MainWindow's initialization (specifically MapCanvasManager) might call back + # to the controller requesting MainWindow methods (like show_error_message). + # If the controller doesn't have a reference to MainWindow yet, these calls fail. + # By setting it here, the controller has a reference to the *soon-to-be-created* + # MainWindow (self-referencing via `app_controller.set_main_window`), + # which allows it to correctly pass itself (with its window reference) to sub-components like MapCanvasManager. + # HOW: Moved this line before MainWindow creation. It's a forward reference, + # but Tkinter's `root` and `app_controller` are already defined. + # MainWindow will then internally set its own reference during its init. + # NOTE: This is a common pattern for mutually dependent objects. + main_app_window_placeholder = MainWindow(root, app_controller) # Create the main application window instance + app_controller.set_main_window(main_app_window_placeholder) # Pass the instance to the controller + # main_app_window = main_app_window_placeholder # Keep the name consistent # Start the Tkinter event loop module_logger_main.info("Starting Tkinter main loop.") @@ -59,4 +66,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/flightmonitor/controller/aircraft_db_importer.py b/flightmonitor/controller/aircraft_db_importer.py new file mode 100644 index 0000000..bef4510 --- /dev/null +++ b/flightmonitor/controller/aircraft_db_importer.py @@ -0,0 +1,285 @@ +# FlightMonitor/controller/aircraft_db_importer.py +""" +Handles the import of aircraft database from CSV files, +including progress tracking and multi-threading for the import process. +""" +import os +import csv +import threading +import time +from typing import Optional, Any, Callable, Tuple, TYPE_CHECKING + +from ..utils.logger import get_logger + +# Type checking imports to avoid circular dependencies at runtime +if TYPE_CHECKING: + from ..data.aircraft_database_manager import AircraftDatabaseManager + from ..gui.main_window import MainWindow + from ..gui.dialogs.import_progress_dialog import ImportProgressDialog + +module_logger = get_logger(__name__) + +# Constants for CSV processing +CSV_FIELD_SIZE_LIMIT_BYTES = 10 * 1024 * 1024 # 10 MB +PROGRESS_CALLBACK_INTERVAL_ROWS = 500 # How often to update the progress bar + + +class AircraftDBImporter: + """ + Manages the process of importing aircraft data from a CSV file into the database. + Designed to run the import in a separate thread and report progress to a GUI dialog. + """ + + def __init__( + self, + aircraft_db_manager: "AircraftDatabaseManager", + main_window: "MainWindow", + ): + """ + Initializes the AircraftDBImporter. + + Args: + aircraft_db_manager: An instance of AircraftDatabaseManager to perform DB operations. + main_window: A reference to the main application window (for GUI updates and messages). + """ + self.aircraft_db_manager = aircraft_db_manager + self.main_window = main_window + module_logger.debug("AircraftDBImporter initialized.") + + def import_aircraft_database_with_progress( + self, csv_filepath: str, progress_dialog_ref: "ImportProgressDialog" + ): + """ + Starts the aircraft database import process in a separate thread. + This method is intended to be called from the GUI thread. + + Args: + csv_filepath: The path to the CSV file to import. + progress_dialog_ref: A reference to the ImportProgressDialog instance for updates. + """ + module_logger.info( + f"AircraftDBImporter: Requesting aircraft DB import with progress from: {csv_filepath}" + ) + + # Basic validation checks before starting the thread + if not self.aircraft_db_manager: + module_logger.error( + "AircraftDBImporter: AircraftDatabaseManager not initialized. Cannot import." + ) + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + # Schedule update to run on the GUI thread + self._schedule_gui_update( + progress_dialog_ref.import_finished, + False, + "Error: Database manager not active.", + ) + elif self.main_window and hasattr(self.main_window, "show_error_message"): + self.main_window.show_error_message( + "Database Error", "Aircraft database manager not active." + ) + return + + if not (self.main_window and self.main_window.root.winfo_exists()): + module_logger.error("AircraftDBImporter: MainWindow not available to start import thread.") + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.import_finished, + False, + "Error: Main application window not available.", + ) + return + + # Notify the dialog that import has started + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update(progress_dialog_ref.import_started) + + # Start the import in a new daemon thread + import_thread = threading.Thread( + target=self._perform_db_import_threaded, + args=(csv_filepath, progress_dialog_ref), + daemon=True, # Daemon threads exit when the main program exits + name="AircraftDBImportWorker" + ) + import_thread.start() + module_logger.info(f"AircraftDBImporter: Import thread started for: {csv_filepath}") + + def _perform_db_import_threaded( + self, csv_filepath: str, progress_dialog_ref: "ImportProgressDialog" + ): + """ + Executes the database import process in a separate thread. + This method should NOT be called directly from the GUI thread. + """ + if not self.aircraft_db_manager: + module_logger.error("AircraftDBImporter: AircraftDBManager N/A in import thread.") + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.import_finished, + False, + "Internal Error: DB Manager missing.", + ) + return + + # Attempt to increase CSV field size limit to handle large fields + self._set_csv_field_size_limit() + + module_logger.info(f"AircraftDBImporter: Import thread: Starting row count for: {csv_filepath}") + + # Count total rows for progress bar calculation + total_data_rows = self._count_csv_rows(csv_filepath) + if total_data_rows is None: + module_logger.error(f"AircraftDBImporter: Could not count rows in CSV: {csv_filepath}") + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.import_finished, + False, + f"Error: Could not read/count rows in '{os.path.basename(csv_filepath)}'. Check logs.", + ) + return + if total_data_rows == 0: + module_logger.info(f"AircraftDBImporter: CSV file '{csv_filepath}' is empty or header-only.") + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.update_progress, + 0, + 0, + 0, + f"File '{os.path.basename(csv_filepath)}' is empty or header-only.", + ) + self._schedule_gui_update( + progress_dialog_ref.import_finished, + True, + "Import complete: No data rows to import.", + ) + return + + # Initial progress update with total rows found + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.update_progress, + 0, + 0, + total_data_rows, + f"Found {total_data_rows} data rows. Starting import from '{os.path.basename(csv_filepath)}'...", + ) + + # Define the progress callback function that will be passed to the DB manager + def import_progress_update_callback(processed_csv_rows, imported_db_rows, total_for_cb): + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + # Schedule the GUI update on the main Tkinter thread + self._schedule_gui_update( + progress_dialog_ref.update_progress, + processed_csv_rows, + imported_db_rows, + total_for_cb, + f"Importing CSV row {processed_csv_rows}...", + ) + + # Perform the actual import via AircraftDatabaseManager + processed_final, imported_final = self.aircraft_db_manager.import_from_csv( + csv_filepath, + replace_existing=True, # Always replace existing for full import + progress_callback=import_progress_update_callback, + total_rows_for_callback=total_data_rows, + ) + + # Determine final message and success status + success = True + final_message = f"DB Import complete. Processed: {processed_final}, Imported/Updated: {imported_final}." + if imported_final == 0 and processed_final > 0 and total_data_rows > 0: + success = False # Consider it a failure if no rows were imported + final_message = f"DB Import: {processed_final} CSV data rows processed, 0 imported (check CSV format/logs)." + module_logger.error(f"AircraftDBImporter: {final_message}") + elif imported_final == 0 and processed_final == 0 and total_data_rows > 0: + success = False + final_message = f"DB Import failed. Could not process rows from '{os.path.basename(csv_filepath)}'." + module_logger.critical(f"AircraftDBImporter: {final_message}") + + # Final update to the progress dialog + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + self._schedule_gui_update( + progress_dialog_ref.update_progress, + processed_final, + imported_final, + total_data_rows, + "Finalizing import...", + ) + self._schedule_gui_update( + progress_dialog_ref.import_finished, success, final_message + ) + module_logger.info(f"AircraftDBImporter: Import thread finished. Result: {final_message}") + + def _count_csv_rows(self, csv_filepath: str) -> Optional[int]: + """ + Counts the number of data rows in a CSV file (excluding header). + + Args: + csv_filepath: The path to the CSV file. + + Returns: + The number of data rows, or None if the file cannot be read or is not found. + """ + # Temporarily increase CSV field size limit for robust row counting + self._set_csv_field_size_limit() + + try: + with open(csv_filepath, "r", encoding="utf-8-sig") as f: + reader = csv.reader(f) + try: + next(reader) # Skip header row + except StopIteration: + return 0 # File is empty or only contains header + + row_count = sum(1 for _ in reader) + module_logger.debug(f"AircraftDBImporter: Counted {row_count} data rows in {csv_filepath}") + return row_count + except FileNotFoundError: + module_logger.error(f"AircraftDBImporter: CSV file not found: {csv_filepath}") + return None + except csv.Error as e_csv: + module_logger.error(f"AircraftDBImporter: CSV format error during row count of {csv_filepath}: {e_csv}", exc_info=False) + return None + except Exception as e: + module_logger.error(f"AircraftDBImporter: Unexpected error counting rows in {csv_filepath}: {e}", exc_info=True) + return None + + def _set_csv_field_size_limit(self): + """ + Attempts to increase the CSV field size limit to handle potentially large fields. + """ + try: + current_limit = csv.field_size_limit() + if CSV_FIELD_SIZE_LIMIT_BYTES > current_limit: + csv.field_size_limit(CSV_FIELD_SIZE_LIMIT_BYTES) + module_logger.debug( + f"AircraftDBImporter: Increased csv.field_size_limit from {current_limit} to {CSV_FIELD_SIZE_LIMIT_BYTES}." + ) + except Exception as e: + module_logger.warning( + f"AircraftDBImporter: Error attempting to increase csv.field_size_limit: {e}. Proceeding with default limit.", exc_info=False + ) + + def _schedule_gui_update(self, callable_func: Callable, *args: Any): + """ + Schedules a callable function to be executed on the main Tkinter thread. + This is crucial for safely updating GUI elements from a worker thread. + + Args: + callable_func: The function to call on the GUI thread. + *args: Arguments to pass to the callable function. + """ + if ( + self.main_window + and hasattr(self.main_window, "root") + and self.main_window.root.winfo_exists() + ): + try: + self.main_window.root.after(0, lambda: callable_func(*args)) + except Exception as e: + module_logger.error( + f"AircraftDBImporter: Error scheduling GUI update from import thread: {e}", exc_info=True + ) + else: + module_logger.warning( + f"AircraftDBImporter: Main window or root destroyed, cannot schedule GUI update for {callable_func.__name__}." + ) \ No newline at end of file diff --git a/flightmonitor/controller/app_controller.py b/flightmonitor/controller/app_controller.py index 0be04c7..a073919 100644 --- a/flightmonitor/controller/app_controller.py +++ b/flightmonitor/controller/app_controller.py @@ -1,7 +1,6 @@ # FlightMonitor/controller/app_controller.py from queue import Queue, Empty as QueueEmpty -import threading -import tkinter as tk + import time import os import csv @@ -11,15 +10,6 @@ from datetime import datetime, timezone 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 as app_config from ..utils.logger import get_logger @@ -36,7 +26,10 @@ from ..utils.gui_utils import ( GUI_STATUS_UNKNOWN, ) -# MODIFIED: Import _is_valid_bbox_dict from map_utils for bounding_box validation +from .aircraft_db_importer import AircraftDBImporter +from .live_data_processor import LiveDataProcessor + + from ..map.map_utils import _is_valid_bbox_dict @@ -48,7 +41,6 @@ if TYPE_CHECKING: module_logger = get_logger(__name__) -GUI_QUEUE_CHECK_INTERVAL_MS = 150 ADAPTER_JOIN_TIMEOUT_SECONDS = 5.0 DEFAULT_CLICK_AREA_SIZE_KM = 50.0 @@ -58,11 +50,15 @@ class AppController: 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 self.aircraft_db_manager: Optional[AircraftDatabaseManager] = None + self.aircraft_db_importer: Optional[AircraftDBImporter] = None + + self.live_data_processor: Optional[LiveDataProcessor] = None self.active_detail_window_icao: Optional[str] = None self.active_detail_window_ref: Optional["FullFlightDetailsWindow"] = None @@ -94,6 +90,20 @@ class AppController: module_logger.debug( f"Main window instance ({type(main_window_instance)}) set in AppController." ) + + if self.aircraft_db_manager and self.main_window: + self.aircraft_db_importer = AircraftDBImporter( + self.aircraft_db_manager, self.main_window + ) + module_logger.info("AircraftDBImporter initialized successfully by AppController.") + else: + module_logger.warning("AircraftDBImporter could not be initialized due to missing dependencies.") + + self.flight_data_queue = Queue(maxsize=200) + self.live_data_processor = LiveDataProcessor(self, self.flight_data_queue) + module_logger.info("LiveDataProcessor initialized successfully by AppController.") + + initial_status_msg = "System Initialized. Ready." initial_status_level = GUI_STATUS_OK if not self.data_storage: @@ -125,257 +135,6 @@ class AppController: "Main window not set or lacks update_semaphore_and_status during set_main_window." ) - def _process_flight_data_queue(self): - if not self.flight_data_queue: - 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 - - flight_payloads_this_cycle: List[CanonicalFlightState] = [] - - try: - while not self.flight_data_queue.empty(): - message = None - try: - message = self.flight_data_queue.get(block=False, timeout=0.01) - except QueueEmpty: - break - except Exception as e_q_get: - module_logger.warning( - f"Error getting from flight_data_queue: {e_q_get}" - ) - continue - - if message is None: - continue - - try: - message_type = message.get("type") - if message_type == MSG_TYPE_FLIGHT_DATA: - flight_states_payload_chunk: Optional[ - List[CanonicalFlightState] - ] = message.get("payload") - if flight_states_payload_chunk is not None: - flight_payloads_this_cycle.extend( - flight_states_payload_chunk - ) - - if self.data_storage: - saved_count = 0 - for state in flight_states_payload_chunk: - if not isinstance(state, CanonicalFlightState): - module_logger.warning( - f"Skipping non-CanonicalFlightState object in payload: {type(state)}" - ) - 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_db_add: - module_logger.error( - f"Error saving flight/position to DB for ICAO {state.icao24}: {e_db_add}", - exc_info=False, - ) - if saved_count > 0: - module_logger.info( - f"Saved {saved_count} position updates to DB from this chunk." - ) - - 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 self.is_live_monitoring_active - and self._active_bounding_box - ): - self.main_window.map_manager_instance.update_flights_on_map( - flight_states_payload_chunk - ) - - gui_message = ( - f"Live data: {len(flight_states_payload_chunk)} aircraft in chunk." - if flight_states_payload_chunk - else "Live data: No aircraft in area (chunk)." - ) - 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: - self._gui_after_id = None - return - else: - if hasattr(self.main_window, "update_semaphore_and_status"): - try: - self.main_window.update_semaphore_and_status( - GUI_STATUS_WARNING, - "Received empty data payload from adapter.", - ) - except tk.TclError: - self._gui_after_id = None - return - - elif message_type == MSG_TYPE_ADAPTER_STATUS: - status_code = message.get("status_code") - gui_message_from_adapter = message.get( - "message", f"Adapter status: {status_code}" - ) - gui_status_level_to_set = GUI_STATUS_UNKNOWN - action_required = None - - if status_code == STATUS_PERMANENT_FAILURE: - action_required = "STOP_MONITORING" - elif status_code == STATUS_API_ERROR_TEMPORARY: - gui_status_level_to_set = GUI_STATUS_ERROR - elif status_code == STATUS_RATE_LIMITED: - gui_status_level_to_set = GUI_STATUS_WARNING - elif status_code == STATUS_FETCHING: - gui_status_level_to_set = GUI_STATUS_FETCHING - elif status_code in [ - STATUS_STARTING, - STATUS_RECOVERED, - STATUS_STOPPED, - ]: - gui_status_level_to_set = GUI_STATUS_OK - - if hasattr(self.main_window, "update_semaphore_and_status"): - try: - self.main_window.update_semaphore_and_status( - gui_status_level_to_set, gui_message_from_adapter - ) - except tk.TclError: - self._gui_after_id = None - return - - if action_required == "STOP_MONITORING": - self.stop_live_monitoring(from_error=True) - break - - except Exception as e_msg_proc: - module_logger.error( - f"Error processing adapter message: {e_msg_proc}", exc_info=True - ) - finally: - try: - self.flight_data_queue.task_done() - except ValueError: - pass - except Exception as e_task_done: - module_logger.error( - f"Error calling task_done on flight_data_queue: {e_task_done}" - ) - - # MODIFIED: Live update for detail window - if ( - self.active_detail_window_ref - and self.active_detail_window_icao - and self.active_detail_window_ref.winfo_exists() - ): - - flight_of_interest_updated_this_cycle = False - latest_live_data_for_detail_icao: Optional[Dict[str, Any]] = None - - for state_obj in flight_payloads_this_cycle: - if state_obj.icao24 == self.active_detail_window_icao: - flight_of_interest_updated_this_cycle = True - latest_live_data_for_detail_icao = state_obj.to_dict() - # Consider this the "most live" data for the detail view from this batch - break - - if flight_of_interest_updated_this_cycle: - module_logger.info( - f"AppController: Flight {self.active_detail_window_icao} in detail view was in the latest data batch. Refreshing detail window." - ) - - static_data_upd: Optional[Dict[str, Any]] = None - if self.aircraft_db_manager: - static_data_upd = self.aircraft_db_manager.get_aircraft_details( - self.active_detail_window_icao - ) - - full_track_data_list_upd: List[Dict[str, Any]] = [] - if self.data_storage: - try: - current_utc_date = datetime.now( - timezone.utc - ) # Or a date range if history is more complex - track_states_upd = ( - self.data_storage.get_flight_track_for_icao_on_date( - self.active_detail_window_icao, current_utc_date - ) - ) - if track_states_upd: - full_track_data_list_upd = [ - s.to_dict() for s in track_states_upd - ] - except Exception as e_track_upd: - module_logger.error( - f"Error retrieving updated track for detail view {self.active_detail_window_icao}: {e_track_upd}" - ) - - try: - self.active_detail_window_ref.update_details( - static_data_upd, - latest_live_data_for_detail_icao, - full_track_data_list_upd, - ) - except tk.TclError: - module_logger.warning( - f"AppController: TclError trying to update detail window for {self.active_detail_window_icao}, likely closed." - ) - self.details_window_closed(self.active_detail_window_icao) - except Exception as e_upd_detail_win: - module_logger.error( - f"AppController: Error updating detail window for {self.active_detail_window_icao}: {e_upd_detail_win}", - exc_info=True, - ) - - except Exception as e_outer: - module_logger.error( - f"Outer error in _process_flight_data_queue: {e_outer}", exc_info=True - ) - 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: - self._gui_after_id = None - except Exception as e_after_schedule: - module_logger.error( - f"Error rescheduling _process_flight_data_queue: {e_after_schedule}" - ) - self._gui_after_id = None - else: - 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 for live monitoring.") @@ -422,9 +181,7 @@ class AppController: if hasattr(self.main_window, "clear_all_views_data"): self.main_window.clear_all_views_data() - if self.flight_data_queue is None: - self.flight_data_queue = Queue(maxsize=200) - else: + if self.flight_data_queue: while not self.flight_data_queue.empty(): try: self.flight_data_queue.get_nowait() @@ -434,21 +191,11 @@ class AppController: except Exception: break - if self.live_adapter_thread and self.live_adapter_thread.is_alive(): - module_logger.info("Old adapter thread found alive. Stopping it first.") - try: - self.live_adapter_thread.stop() - if self.main_window and self.main_window.root.winfo_exists(): - self.main_window.root.update_idletasks() - self.live_adapter_thread.join(timeout=ADAPTER_JOIN_TIMEOUT_SECONDS) - if self.live_adapter_thread.is_alive(): - module_logger.warning("Old adapter thread did not stop in time.") - except Exception as e_join: - module_logger.error( - f"Error stopping old adapter: {e_join}", exc_info=True - ) - finally: - self.live_adapter_thread = None + if not self.live_data_processor: + module_logger.critical("Controller: LiveDataProcessor not initialized. Cannot start monitoring.") + if hasattr(self.main_window, "_reset_gui_to_stopped_state"): + self.main_window._reset_gui_to_stopped_state("Start failed: Internal error (Processor N/A).") + return self.live_adapter_thread = OpenSkyLiveAdapter( output_queue=self.flight_data_queue, @@ -458,31 +205,7 @@ class AppController: self.is_live_monitoring_active = True self.live_adapter_thread.start() - if ( - self._gui_after_id - and self.main_window - and self.main_window.root.winfo_exists() - ): - try: - self.main_window.root.after_cancel(self._gui_after_id) - except: - pass - finally: - self._gui_after_id = None - - if self.main_window and self.main_window.root.winfo_exists(): - self._gui_after_id = self.main_window.root.after( - 100, self._process_flight_data_queue - ) - else: - module_logger.error( - "Cannot schedule queue processor: MainWindow or root missing." - ) - self.is_live_monitoring_active = False - if self.live_adapter_thread and self.live_adapter_thread.is_alive(): - self.live_adapter_thread.stop() - if hasattr(self.main_window, "_reset_gui_to_stopped_state"): - self.main_window._reset_gui_to_stopped_state("Start failed: GUI error.") + self.live_data_processor.start_processing_queue() def stop_live_monitoring(self, from_error: bool = False): if not self.is_live_monitoring_active and not ( @@ -504,20 +227,10 @@ class AppController: ) self.is_live_monitoring_active = False - if ( - self._gui_after_id - and 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) - except tk.TclError: - pass - except Exception: - pass - finally: - self._gui_after_id = None + if self.live_data_processor: + self.live_data_processor.stop_processing_queue() + else: + module_logger.warning("Controller: LiveDataProcessor not initialized during stop_live_monitoring.") if self.live_adapter_thread and self.live_adapter_thread.is_alive(): try: @@ -534,23 +247,6 @@ class AppController: finally: self.live_adapter_thread = None - if ( - self.flight_data_queue - and self.main_window - and hasattr(self.main_window, "root") - and self.main_window.root.winfo_exists() - ): - try: - # Process remaining queue items. Temporarily set active to True to allow one last run. - original_active_state = self.is_live_monitoring_active - self.is_live_monitoring_active = True # Allow one last processing - self._process_flight_data_queue() - self.is_live_monitoring_active = original_active_state # Restore state - except Exception as e_final_q: - module_logger.error( - f"Error in final queue processing: {e_final_q}", exc_info=True - ) - if hasattr(self.main_window, "clear_all_views_data"): self.main_window.clear_all_views_data() @@ -585,7 +281,7 @@ class AppController: module_logger.error( f"Error closing detail window on app exit: {e_close_detail}" ) - finally: # Ensure these are cleared even if destroy fails for some reason + finally: self.active_detail_window_ref = None self.active_detail_window_icao = None @@ -609,6 +305,10 @@ class AppController: exc_info=True, ) + if self.live_data_processor and self.is_live_monitoring_active: + module_logger.info("Controller: Stopping LiveDataProcessor explicitly on app exit.") + self.live_data_processor.stop_processing_queue() + is_adapter_considered_running = ( self.live_adapter_thread and self.live_adapter_thread.is_alive() ) or self.is_live_monitoring_active @@ -716,8 +416,34 @@ class AppController: f"Error updating map clicked info panel: {e_update}", exc_info=False ) + def import_aircraft_database_from_file_with_progress( + self, csv_filepath: str, progress_dialog_ref: "ImportProgressDialog" + ): + if not self.aircraft_db_importer: + module_logger.error( + "AppController: AircraftDBImporter not initialized. Cannot perform import." + ) + if progress_dialog_ref and progress_dialog_ref.winfo_exists(): + if self.main_window and self.main_window.root.winfo_exists(): + self.main_window.root.after(0, lambda: progress_dialog_ref.import_finished( + False, "Error: Import function not initialized correctly (controller issue)." + )) + elif self.main_window and hasattr(self.main_window, "show_error_message"): + self.main_window.show_error_message( + "Import Error", "Aircraft database importer not ready." + ) + return + + self.aircraft_db_importer.import_aircraft_database_with_progress( + csv_filepath, progress_dialog_ref + ) + + # MODIFIED: Re-added request_detailed_flight_info (this was the missing function) + # WHY: This function is crucial for displaying selected flight details in the main window panel. + # It was inadvertently removed during the refactoring process. + # HOW: Re-inserted the function with its corrected logic for combining live and static data. def request_detailed_flight_info(self, icao24: str): - normalized_icao24 = icao24.lower().strip() # Normalize here as well for safety + normalized_icao24 = icao24.lower().strip() module_logger.info( f"Controller: Detailed info request for ICAO24: {normalized_icao24}" ) @@ -734,8 +460,11 @@ class AppController: live_data_for_panel: Optional[Dict[str, Any]] = None static_data_for_panel: Optional[Dict[str, Any]] = None + + # Initialize combined_details_for_panel with icao24. combined_details_for_panel: Dict[str, Any] = {"icao24": normalized_icao24} + # Process live data first if ( self.main_window and hasattr(self.main_window, "map_manager_instance") @@ -751,190 +480,28 @@ class AppController: if state.icao24 == normalized_icao24: live_data_for_panel = state.to_dict() break + if live_data_for_panel: + # IMPORTANT: Use .update() to ensure live data fields overwrite existing ones combined_details_for_panel.update(live_data_for_panel) + module_logger.debug(f"AppController: Added live data to details for {normalized_icao24}.") + # Process static data second, only if key not already set by live data if self.aircraft_db_manager: static_data_for_panel = self.aircraft_db_manager.get_aircraft_details( normalized_icao24 ) if static_data_for_panel: for k, v in static_data_for_panel.items(): - if k not in combined_details_for_panel: + # Only add static data if the key is not already populated (e.g., by live data) + if k not in combined_details_for_panel or combined_details_for_panel[k] is None: combined_details_for_panel[k] = v + module_logger.debug(f"AppController: Added static data to details for {normalized_icao24}.") + # Final call to update the GUI panel if hasattr(self.main_window, "update_selected_flight_details"): self.main_window.update_selected_flight_details(combined_details_for_panel) - - def import_aircraft_database_from_file_with_progress( - self, csv_filepath: str, progress_dialog_ref: "ImportProgressDialog" - ): - module_logger.info( - f"Controller: Requesting aircraft DB import with progress from: {csv_filepath}" - ) - if not self.aircraft_db_manager: - module_logger.error( - "AircraftDatabaseManager not initialized. Cannot import." - ) - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - progress_dialog_ref.import_finished( - False, "Error: Database manager not active." - ) - elif self.main_window and hasattr(self.main_window, "show_error_message"): - self.main_window.show_error_message( - "Database Error", "Aircraft database manager not active." - ) - return - - if not self.main_window or not self.main_window.root.winfo_exists(): - module_logger.error("MainWindow not available to start import thread.") - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - progress_dialog_ref.import_finished( - False, "Error: Main application window not available." - ) - return - - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - progress_dialog_ref.import_started() - - import_thread = threading.Thread( - target=self._perform_db_import_with_progress_threaded, - args=(csv_filepath, progress_dialog_ref), - daemon=True, - ) - import_thread.start() - module_logger.info(f"Aircraft DB import thread started for: {csv_filepath}") - - def _count_csv_rows(self, csv_filepath: str) -> Optional[int]: - try: - current_limit = csv.field_size_limit() - new_limit_target = 10 * 1024 * 1024 - if new_limit_target > current_limit: - csv.field_size_limit(new_limit_target) - except Exception: - pass # Ignore errors setting limit - - try: - with open(csv_filepath, "r", encoding="utf-8-sig") as f: - reader = csv.reader(f) - try: - next(reader) - except StopIteration: - return 0 - return sum(1 for _ in reader) - except FileNotFoundError: - return None - except csv.Error: - return None - except Exception: - return None - - def _perform_db_import_with_progress_threaded( - self, csv_filepath: str, progress_dialog_ref: "ImportProgressDialog" - ): - if not self.aircraft_db_manager: - module_logger.error("AircraftDBManager N/A in import thread.") - if ( - progress_dialog_ref - and progress_dialog_ref.winfo_exists() - and self.main_window - and self.main_window.root.winfo_exists() - ): - self.main_window.root.after( - 0, - lambda: progress_dialog_ref.import_finished( - False, "Internal Error: DB Manager missing." - ), - ) - return - - module_logger.info(f"Import thread: Starting row count for: {csv_filepath}") - - def schedule_gui_update(callable_func: Callable, *args: Any): - if ( - self.main_window - and hasattr(self.main_window, "root") - and self.main_window.root.winfo_exists() - ): - try: - self.main_window.root.after(0, lambda: callable_func(*args)) - except Exception as e_after: - module_logger.error( - f"Error scheduling GUI update from import thread: {e_after}" - ) - - total_data_rows = self._count_csv_rows(csv_filepath) - if total_data_rows is None: - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - schedule_gui_update( - progress_dialog_ref.import_finished, - False, - f"Error: Could not read/count rows in '{os.path.basename(csv_filepath)}'. Check logs.", - ) - return - if total_data_rows == 0: - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - schedule_gui_update( - progress_dialog_ref.update_progress, - 0, - 0, - 0, - f"File '{os.path.basename(csv_filepath)}' is empty or header-only.", - ) - schedule_gui_update( - progress_dialog_ref.import_finished, - True, - "Import complete: No data rows to import.", - ) - return - - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - schedule_gui_update( - progress_dialog_ref.update_progress, - 0, - 0, - total_data_rows, - f"Found {total_data_rows} data rows. Starting import from '{os.path.basename(csv_filepath)}'...", - ) - - def import_progress_update_for_dialog_from_controller( - processed_csv_rows, imported_db_rows, total_for_cb - ): - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - schedule_gui_update( - progress_dialog_ref.update_progress, - processed_csv_rows, - imported_db_rows, - total_for_cb, - f"Importing CSV row {processed_csv_rows}...", - ) - - processed_final, imported_final = self.aircraft_db_manager.import_from_csv( - csv_filepath, - replace_existing=True, - progress_callback=import_progress_update_for_dialog_from_controller, - total_rows_for_callback=total_data_rows, - ) - - final_message = f"DB Import complete. Processed: {processed_final}, Imported/Updated: {imported_final}." - success = True - if imported_final == 0 and processed_final > 0 and total_data_rows > 0: - final_message = f"DB Import: {processed_final} CSV data rows processed, 0 imported (check CSV format/logs)." - elif imported_final == 0 and processed_final == 0 and total_data_rows > 0: - final_message = f"DB Import failed. Could not process rows from '{os.path.basename(csv_filepath)}'." - success = False - - if progress_dialog_ref and progress_dialog_ref.winfo_exists(): - schedule_gui_update( - progress_dialog_ref.update_progress, - processed_final, - imported_final, - total_data_rows, - "Finalizing import...", - ) - schedule_gui_update( - progress_dialog_ref.import_finished, success, final_message - ) + module_logger.debug(f"AppController: Called update_selected_flight_details with combined data for {normalized_icao24}.") def request_and_show_full_flight_details(self, icao24: str): normalized_icao24 = icao24.lower().strip() @@ -960,20 +527,16 @@ class AppController: ) return - # MODIFIED: Close existing detail window before opening a new one if ( self.active_detail_window_ref and self.active_detail_window_ref.winfo_exists() ): - # Do not destroy if it's for the same ICAO and already open (optional behavior) - # For now, always destroy and recreate for simplicity of state. if self.active_detail_window_icao == normalized_icao24: module_logger.info( f"Detail window for {normalized_icao24} already open. Re-focusing/Re-populating." ) self.active_detail_window_ref.lift() self.active_detail_window_ref.focus_set() - # No need to re-create, just update its content with potentially newer data else: module_logger.info( f"Closing existing detail window for {self.active_detail_window_icao} before opening new one for {normalized_icao24}." @@ -982,7 +545,6 @@ class AppController: self.active_detail_window_ref.destroy() except tk.TclError: pass - # details_window_closed will clear self.active_detail_window_ref & icao static_data: Optional[Dict[str, Any]] = None if self.aircraft_db_manager: @@ -1025,14 +587,13 @@ class AppController: try: from ..gui.dialogs.full_flight_details_window import FullFlightDetailsWindow - # If we decided to re-focus/re-populate, and the window still exists if ( self.active_detail_window_ref and self.active_detail_window_icao == normalized_icao24 and self.active_detail_window_ref.winfo_exists() ): details_win = self.active_detail_window_ref - else: # Create new + else: details_win = FullFlightDetailsWindow( self.main_window.root, normalized_icao24, self ) @@ -1060,30 +621,27 @@ class AppController: self.main_window.show_error_message( "Error", f"Could not display full details: {e_show_details}" ) - self.active_detail_window_ref = None # Clear refs if creation/update failed + self.active_detail_window_ref = None self.active_detail_window_icao = None if self.main_window: self.main_window.full_flight_details_window = None def details_window_closed(self, closed_icao24: str): normalized_closed_icao24 = closed_icao24.lower().strip() - # Important: check if the closed ICAO matches the one we are actively tracking if self.active_detail_window_icao == normalized_closed_icao24: module_logger.info( f"AppController: Detail window for {normalized_closed_icao24} reported closed. Clearing references." ) self.active_detail_window_ref = None self.active_detail_window_icao = None - # Also clear MainWindow's convenience reference if it matches if ( self.main_window and hasattr(self.main_window, "full_flight_details_window") and self.main_window.full_flight_details_window and not self.main_window.full_flight_details_window.winfo_exists() - ): # Check if already destroyed + ): self.main_window.full_flight_details_window = None else: - # This case can happen if a new detail window was opened before the old one finished its close notification module_logger.debug( f"AppController: A detail window for {normalized_closed_icao24} closed, but it was not the currently tracked active one ({self.active_detail_window_icao}). No action on active_detail references." ) @@ -1187,10 +745,9 @@ class AppController: if hasattr(map_manager, "recenter_map_at_coords"): try: map_manager.recenter_map_at_coords(lat, lon) - except Exception as e_recenter: + except Exception as e: module_logger.error( - f"Error map_manager.recenter_map_at_coords: {e_recenter}", - exc_info=False, + f"Error map_manager.recenter_map_at_coords: {e}", exc_info=False ) else: module_logger.warning( @@ -1221,9 +778,9 @@ class AppController: map_manager.set_bbox_around_coords( center_lat, center_lon, area_size_km ) - except Exception as e_set_bbox: + except Exception as e: module_logger.error( - f"Error map_manager.set_bbox_around_coords: {e_set_bbox}", + f"Error map_manager.set_bbox_around_coords: {e}", exc_info=False, ) else: @@ -1454,4 +1011,4 @@ class AppController: else: module_logger.warning( "MapCanvasManager or 'set_max_track_points' N/A to set track length." - ) + ) \ No newline at end of file diff --git a/flightmonitor/controller/live_data_processor.py b/flightmonitor/controller/live_data_processor.py new file mode 100644 index 0000000..9dc62ee --- /dev/null +++ b/flightmonitor/controller/live_data_processor.py @@ -0,0 +1,308 @@ +# FlightMonitor/controller/live_data_processor.py +""" +Manages the processing of live flight data from the adapter's output queue. +It pulls data, saves it to storage, updates GUI elements, and handles adapter status messages. +""" +import tkinter as tk +from queue import Queue, Empty as QueueEmpty +from datetime import datetime, timezone +from typing import List, Optional, Dict, Any, TYPE_CHECKING + +from ..utils.logger import get_logger +from ..data.common_models import CanonicalFlightState +from ..data.opensky_live_adapter import ( + 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 ..utils.gui_utils import ( + GUI_STATUS_OK, + GUI_STATUS_WARNING, + GUI_STATUS_ERROR, + GUI_STATUS_FETCHING, + GUI_STATUS_UNKNOWN, +) + +# Type checking imports to avoid circular dependencies at runtime +if TYPE_CHECKING: + from .app_controller import AppController # Import for type hinting AppController + from ..gui.main_window import MainWindow # For specific MainWindow methods/attributes + + +module_logger = get_logger(__name__) + +# Constants for queue processing interval +GUI_QUEUE_CHECK_INTERVAL_MS = 150 + + +class LiveDataProcessor: + """ + Processes live flight data from a queue, dispatching updates to various + parts of the application (storage, GUI) and managing adapter status. + """ + + def __init__(self, app_controller: "AppController", flight_data_queue: Queue[AdapterMessage]): + """ + Initializes the LiveDataProcessor. + + Args: + app_controller: The main AppController instance. This processor accesses + various components (MainWindow, DataStorage, etc.) via the controller. + flight_data_queue: The queue from which to read AdapterMessages (flight data or status). + """ + self.app_controller = app_controller + self.flight_data_queue = flight_data_queue + self._gui_after_id: Optional[str] = None # Stores the ID for the Tkinter `after` loop + module_logger.debug("LiveDataProcessor initialized.") + + def start_processing_queue(self): + """ + Starts the periodic processing of the flight data queue. + Schedules the _process_queue_cycle method to run on the Tkinter main loop. + """ + main_window = self.app_controller.main_window + if not (main_window and main_window.root and main_window.root.winfo_exists()): + module_logger.error("LiveDataProcessor: Cannot start queue processing. MainWindow or root is missing or destroyed.") + return + + if self._gui_after_id: # Cancel any existing scheduled processing + try: + main_window.root.after_cancel(self._gui_after_id) + module_logger.debug("LiveDataProcessor: Cancelled existing queue processing loop.") + except Exception: + pass # Ignore if ID is already invalid + + module_logger.info("LiveDataProcessor: Starting live data queue processing loop.") + self._gui_after_id = main_window.root.after(GUI_QUEUE_CHECK_INTERVAL_MS, self._process_queue_cycle) + + def stop_processing_queue(self): + """ + Stops the periodic processing of the flight data queue. + Cancels the scheduled _process_queue_cycle method. + """ + main_window = self.app_controller.main_window + if self._gui_after_id and main_window and main_window.root and main_window.root.winfo_exists(): + try: + main_window.root.after_cancel(self._gui_after_id) + module_logger.info("LiveDataProcessor: Stopped live data queue processing loop.") + except Exception: + module_logger.debug("LiveDataProcessor: Failed to cancel queue processing loop (might be already stopped or invalid ID).") + finally: + self._gui_after_id = None + else: + module_logger.debug("LiveDataProcessor: Queue processing loop already stopped or main window not available.") + self._gui_after_id = None # Ensure it's cleared if root is gone + + # Process any remaining items in the queue one last time + self._process_queue_cycle(is_final_flush=True) + + + def _process_queue_cycle(self, is_final_flush: bool = False): + """ + Processes a batch of messages from the flight data queue in a single Tkinter cycle. + This method is scheduled by Tkinter's `after` method. + """ + # Get necessary references from the app_controller + main_window: Optional["MainWindow"] = self.app_controller.main_window + data_storage = self.app_controller.data_storage + aircraft_db_manager = self.app_controller.aircraft_db_manager + is_live_monitoring_active = self.app_controller.is_live_monitoring_active # Check state for re-scheduling + active_bounding_box = self.app_controller._active_bounding_box # For map updates + active_detail_window_ref = self.app_controller.active_detail_window_ref + active_detail_window_icao = self.app_controller.active_detail_window_icao + + if not (main_window and main_window.root and main_window.root.winfo_exists()): + module_logger.warning("LiveDataProcessor: Main window or root destroyed during queue processing. Halting loop.") + self._gui_after_id = None + return + + flight_payloads_this_cycle: List[CanonicalFlightState] = [] + messages_processed_this_cycle = 0 + + try: + while not self.flight_data_queue.empty(): + message = None + try: + # Get message without blocking to keep GUI responsive + message = self.flight_data_queue.get(block=False) + messages_processed_this_cycle += 1 + except QueueEmpty: + break # No more messages in queue + except Exception as e_q_get: + module_logger.warning(f"LiveDataProcessor: Error getting from flight_data_queue: {e_q_get}") + continue + + if message is None: + continue + + try: + message_type = message.get("type") + if message_type == MSG_TYPE_FLIGHT_DATA: + flight_states_payload_chunk: Optional[List[CanonicalFlightState]] = message.get("payload") + if flight_states_payload_chunk is not None: + flight_payloads_this_cycle.extend(flight_states_payload_chunk) + + # --- Save to Data Storage --- + if data_storage: + saved_count = 0 + for state in flight_states_payload_chunk: + if not isinstance(state, CanonicalFlightState): + module_logger.warning(f"LiveDataProcessor: Skipping non-CanonicalFlightState object in payload: {type(state)}") + continue + try: + flight_id = 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 = data_storage.add_position_daily( + flight_id, state + ) + if pos_id: + saved_count += 1 + except Exception as e_db_add: + module_logger.error(f"LiveDataProcessor: Error saving flight/position to DB for ICAO {state.icao24}: {e_db_add}", exc_info=False) + if saved_count > 0: + module_logger.info(f"LiveDataProcessor: Saved {saved_count} position updates to DB from this chunk.") + + # --- Update Map Display --- + if ( + hasattr(main_window, "map_manager_instance") + and main_window.map_manager_instance is not None + and hasattr(main_window.map_manager_instance, "update_flights_on_map") + and self.app_controller.is_live_monitoring_active # Check AppController's live state + and active_bounding_box # Map is active only if bbox is set + ): + main_window.map_manager_instance.update_flights_on_map(flight_states_payload_chunk) + + # --- Update GUI Status Bar (Semaphore) --- + gui_message = f"Live data: {len(flight_states_payload_chunk)} aircraft in chunk." if flight_states_payload_chunk else "Live data: No aircraft in area (chunk)." + if hasattr(main_window, "update_semaphore_and_status"): + try: + main_window.update_semaphore_and_status(GUI_STATUS_OK, gui_message) + except tk.TclError: + module_logger.debug("LiveDataProcessor: TclError updating semaphore, MainWindow likely closing.") + # Don't reschedule if GUI is closing + self._gui_after_id = None + return + else: + if hasattr(main_window, "update_semaphore_and_status"): + try: + main_window.update_semaphore_and_status(GUI_STATUS_WARNING, "Received empty data payload from adapter.") + except tk.TclError: + module_logger.debug("LiveDataProcessor: TclError updating semaphore (empty payload), MainWindow likely closing.") + self._gui_after_id = None + return + + elif message_type == MSG_TYPE_ADAPTER_STATUS: + status_code = message.get("status_code") + gui_message_from_adapter = message.get("message", f"Adapter status: {status_code}") + gui_status_level_to_set = GUI_STATUS_UNKNOWN + action_required = None + + if status_code == STATUS_PERMANENT_FAILURE: + action_required = "STOP_MONITORING" + gui_status_level_to_set = GUI_STATUS_ERROR # Permanent failures are critical errors + elif status_code == STATUS_API_ERROR_TEMPORARY: + gui_status_level_to_set = GUI_STATUS_ERROR + elif status_code == STATUS_RATE_LIMITED: + gui_status_level_to_set = GUI_STATUS_WARNING + elif status_code == STATUS_FETCHING: + gui_status_level_to_set = GUI_STATUS_FETCHING + elif status_code in [STATUS_STARTING, STATUS_RECOVERED, STATUS_STOPPED]: + gui_status_level_to_set = GUI_STATUS_OK + + if hasattr(main_window, "update_semaphore_and_status"): + try: + main_window.update_semaphore_and_status(gui_status_level_to_set, gui_message_from_adapter) + except tk.TclError: + module_logger.debug("LiveDataProcessor: TclError updating semaphore (adapter status), MainWindow likely closing.") + self._gui_after_id = None + return + + if action_required == "STOP_MONITORING": + # Delegate stop action back to AppController + module_logger.critical(f"LiveDataProcessor: Adapter reported permanent failure. Requesting AppController to stop monitoring.") + self.app_controller.stop_live_monitoring(from_error=True) + break # Break out of inner queue processing loop to allow main loop to terminate if needed + + except Exception as e_msg_proc: + module_logger.error(f"LiveDataProcessor: Error processing adapter message: {e_msg_proc}", exc_info=True) + finally: + try: + self.flight_data_queue.task_done() # Mark task as done regardless of processing success + except ValueError: + pass # Queue might be empty already if break was called + except Exception as e_task_done: + module_logger.error(f"LiveDataProcessor: Error calling task_done on flight_data_queue: {e_task_done}") + + # --- Live Update for Full Flight Details Window (if open) --- + if ( + active_detail_window_ref + and active_detail_window_icao + and active_detail_window_ref.winfo_exists() + ): + flight_of_interest_updated_this_cycle = False + latest_live_data_for_detail_icao: Optional[Dict[str, Any]] = None + + # Find the most recent live data for the actively viewed flight + for state_obj in flight_payloads_this_cycle: + if state_obj.icao24 == active_detail_window_icao: + flight_of_interest_updated_this_cycle = True + latest_live_data_for_detail_icao = state_obj.to_dict() + break # Found the latest update for this flight in the current chunk + + if flight_of_interest_updated_this_cycle: + module_logger.debug(f"LiveDataProcessor: Flight {active_detail_window_icao} in detail view was in the latest data batch. Refreshing detail window.") + + # Retrieve static data (if available) and full track data (if storage is active) + static_data_upd: Optional[Dict[str, Any]] = None + if aircraft_db_manager: + static_data_upd = aircraft_db_manager.get_aircraft_details(active_detail_window_icao) + + full_track_data_list_upd: List[Dict[str, Any]] = [] + if data_storage: + try: + current_utc_date = datetime.now(timezone.utc) + track_states_upd = data_storage.get_flight_track_for_icao_on_date(active_detail_window_icao, current_utc_date) + if track_states_upd: + full_track_data_list_upd = [s.to_dict() for s in track_states_upd] + except Exception as e_track_upd: + module_logger.error(f"LiveDataProcessor: Error retrieving updated track for detail view {active_detail_window_icao}: {e_track_upd}") + + try: + active_detail_window_ref.update_details( + static_data_upd, + latest_live_data_for_detail_icao, + full_track_data_list_upd, + ) + except tk.TclError: + module_logger.warning(f"LiveDataProcessor: TclError trying to update detail window for {active_detail_window_icao}, likely closed.") + self.app_controller.details_window_closed(active_detail_window_icao) # Notify controller to clear its references + except Exception as e_upd_detail_win: + module_logger.error(f"LiveDataProcessor: Error updating detail window for {active_detail_window_icao}: {e_upd_detail_win}", exc_info=True) + + except Exception as e_outer: + module_logger.error(f"LiveDataProcessor: Outer error in _process_queue_cycle: {e_outer}", exc_info=True) + finally: + # Reschedule the next processing cycle ONLY if not a final flush and monitoring is still active + if not is_final_flush and is_live_monitoring_active and main_window.root.winfo_exists(): + try: + self._gui_after_id = main_window.root.after(GUI_QUEUE_CHECK_INTERVAL_MS, self._process_queue_cycle) + except tk.TclError: + module_logger.debug("LiveDataProcessor: TclError rescheduling _process_queue_cycle, MainWindow likely closing.") + self._gui_after_id = None + except Exception as e_after_schedule: + module_logger.error(f"LiveDataProcessor: Error rescheduling _process_queue_cycle: {e_after_schedule}") + self._gui_after_id = None + else: + self._gui_after_id = None # Ensure ID is cleared if not rescheduling \ No newline at end of file