SXXXXXXX_FlightMonitor/flightmonitor/controller/app_controller.py

1063 lines
51 KiB
Python

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