import os from PyQt5.QtCore import QTimer from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QAction, QMessageBox from qgis.core import Qgis, QgsProject from qgis.gui import QgisInterface from .api_client import ApiClient from .layer_manager import LocationLayerManager from .notification_server import NotificationServer _RETRY_INTERVAL_MS = 30_000 # 30 s retry when API is unreachable on startup _POLL_INTERVAL_MS = 10_000 # 10 s periodic health-check poll _POST_CLEAR_DELAY_MS = 1_000 # 1 s delay before checking liveness after /clear _API_BASE_URL = "http://localhost:22650" _NOTIF_PORT = 22651 class BAPEBridgePlugin: """Main QGIS plugin class. Wires together ApiClient, LocationLayerManager and NotificationServer. Provides a checkable toolbar button to toggle the layer on/off. """ def __init__(self, iface: QgisInterface): self._iface = iface self._active = False self._layer_manager: LocationLayerManager | None = None self._notif_server: NotificationServer | None = None self._add_action: QAction | None = None # Timer: retry when the initial API call fails at startup. self._retry_timer = QTimer() self._retry_timer.setInterval(_RETRY_INTERVAL_MS) self._retry_timer.timeout.connect(self._retry_load) # Timer: periodic health-check / reload while active. self._poll_timer = QTimer() self._poll_timer.setInterval(_POLL_INTERVAL_MS) self._poll_timer.timeout.connect(self._poll_locations) # One-shot timer: re-check liveness 5 s after a /clear command. self._post_clear_timer = QTimer() self._post_clear_timer.setSingleShot(True) self._post_clear_timer.setInterval(_POST_CLEAR_DELAY_MS) self._post_clear_timer.timeout.connect(self._poll_locations) QgsProject.instance().cleared.connect(self._on_project_cleared) QgsProject.instance().readProject.connect(self._on_project_read) # Reconnect immediately if a layer from a previous plugin instance is # already loaded (e.g. after a plugin reload without restarting QGIS). self._try_reconnect() # ------------------------------------------------------------------ # QGIS plugin lifecycle # ------------------------------------------------------------------ def initGui(self) -> None: """Add entry under Layer > Add Layer.""" icon = QIcon(os.path.join(os.path.dirname(__file__), "icon.png")) self._add_action = QAction(icon, "Add BAPE Bridge Layer...", self._iface.mainWindow()) self._add_action.setToolTip("Add the BAPE Bridge live locations layer to this project") self._add_action.triggered.connect(self._on_add_layer) # iface.addLayerMenu() is the "Add Layer" submenu inside the Layer menu. self._iface.addLayerMenu().addAction(self._add_action) def unload(self) -> None: """Clean up when the plugin is disabled or QGIS closes. Intentionally leaves the layer in the project so it survives a plugin reload. Only the server and timers are torn down. """ self._retry_timer.stop() self._poll_timer.stop() self._post_clear_timer.stop() if self._notif_server is not None: self._notif_server.stop() self._notif_server = None # Drop the Python reference but do NOT remove the layer from the project. self._layer_manager = None self._active = False self._iface.addLayerMenu().removeAction(self._add_action) # ------------------------------------------------------------------ # Add layer # ------------------------------------------------------------------ def _on_add_layer(self) -> None: """Called when the user clicks 'Add BAPE Bridge Layer'.""" if self._active: QMessageBox.warning( self._iface.mainWindow(), "BAPE Bridge", "There is already a BAPE Bridge layer inside this project.", ) return self._activate() def _activate(self) -> None: if self._active: return api_client = ApiClient(_API_BASE_URL) self._layer_manager = LocationLayerManager(api_client) # Create the layer structure (always succeeds). Initial feature load # may fail if the API is not yet reachable; we surface a message and # start the retry timer in that case. try: self._layer_manager.create_layer() except Exception: self._retry_timer.start() # Start the notification server regardless; the layer may be empty # but the server should be ready to receive reload calls. self._notif_server = NotificationServer( self._on_reload_requested, self._on_clear_requested, port=_NOTIF_PORT, ) try: self._notif_server.start() except RuntimeError as exc: self._iface.messageBar().pushMessage( "BAPE Bridge", f"Notification server error: {exc}", level=Qgis.Critical, duration=0, ) self._active = True self._poll_timer.start() def _deactivate(self) -> None: if not self._active: return self._retry_timer.stop() self._poll_timer.stop() self._post_clear_timer.stop() if self._notif_server is not None: self._notif_server.stop() self._notif_server = None if self._layer_manager is not None: self._layer_manager.remove_layer() self._layer_manager = None self._active = False # ------------------------------------------------------------------ # Reload handling # ------------------------------------------------------------------ def _on_reload_requested(self) -> None: """Called (in the main Qt thread) when POST /reload arrives.""" if self._layer_manager is None: return success = self._layer_manager.reload_layer() if not success: pass # layer name already shows 🔴 def _on_clear_requested(self) -> None: """Called (in the main Qt thread) when POST /clear arrives.""" if self._layer_manager is None: return self._layer_manager.clear_layer() # Check 5 s later whether BAPE is still up. self._post_clear_timer.start() def _retry_load(self) -> None: """Retry on startup failure: stop once the API responds.""" if self._layer_manager is None: self._retry_timer.stop() return if self._layer_manager.reload_layer(): self._retry_timer.stop() def _poll_locations(self) -> None: """Periodic health-check: reload from API to confirm BAPE is still up.""" if self._layer_manager is None: return self._layer_manager.reload_layer() def _on_project_cleared(self) -> None: """Called when QGIS clears the project (open new/different project). The C++ layer objects are already deleted by this point, so we must not touch them — just drop references and reset state. """ self._retry_timer.stop() self._poll_timer.stop() self._post_clear_timer.stop() if self._notif_server is not None: self._notif_server.stop() self._notif_server = None if self._layer_manager is not None: self._layer_manager.layer = None self._layer_manager = None self._active = False def _try_reconnect(self) -> None: """Reconnect to a BAPE layer that already exists in the current project. Called both from __init__ (plugin reload) and from _on_project_read (project loaded from disk). """ if self._active: return stored_id, _ = QgsProject.instance().readEntry("BAPEBridge", "layer_id", "") if not stored_id: return existing = QgsProject.instance().mapLayer(stored_id) if existing is None: return # Layer is present — restore active state. api_client = ApiClient(_API_BASE_URL) self._layer_manager = LocationLayerManager(api_client) self._layer_manager.layer = existing self._notif_server = NotificationServer( self._on_reload_requested, self._on_clear_requested, port=_NOTIF_PORT, ) try: self._notif_server.start() except RuntimeError as exc: self._iface.messageBar().pushMessage( "BAPE Bridge", f"Notification server error: {exc}", level=Qgis.Critical, duration=0, ) self._active = True self._poll_timer.start() def _on_project_read(self) -> None: """Called when a project is loaded from disk.""" self._try_reconnect() # Reload to get fresh data; failures just set the 🔴 name. if self._layer_manager is not None: self._layer_manager.reload_layer()