1063 lines
51 KiB
Python
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. |