From 5839a3ce666f212d8181acde68deb116d1c66f67 Mon Sep 17 00:00:00 2001 From: Mees van der Wijk Date: Thu, 4 Jun 2026 17:49:28 +0200 Subject: [PATCH] More fixes --- api_client.py | 2 +- layer_manager.py | 23 +++++-- plugin.py | 157 ++++++++++++++++++++++++++++++++++------------- 3 files changed, 134 insertions(+), 48 deletions(-) diff --git a/api_client.py b/api_client.py index 8f0d829..6ca7b64 100644 --- a/api_client.py +++ b/api_client.py @@ -19,7 +19,7 @@ class ApiClient: """ url = f"{self.base_url}/api/locations" try: - response = requests.get(url, timeout=10) + response = requests.get(url, timeout=1) response.raise_for_status() except requests.HTTPError as exc: QgsMessageLog.logMessage( diff --git a/layer_manager.py b/layer_manager.py index 667dc4b..9da1df6 100644 --- a/layer_manager.py +++ b/layer_manager.py @@ -36,12 +36,26 @@ class LocationLayerManager: # ------------------------------------------------------------------ def create_layer(self) -> None: - """Create the memory layer, add it to the project and load initial features. + """Create or reuse the layer and load initial features. - Raises: - Exception: if the initial API fetch fails (layer is still added to the - project but will be empty until reload_layer() succeeds). + If a layer with the stored ID already exists in the project (e.g. loaded + from a saved project file) it is reused rather than duplicated. """ + # Check if a previously created layer is still alive in the project. + stored_id, _ = QgsProject.instance().readEntry("BAPEBridge", "layer_id", "") + if stored_id: + existing = QgsProject.instance().mapLayer(stored_id) + if existing is not None and not sip.isdeleted(existing): + self.layer = existing + return # layer already present — nothing more to do + + # Guard against duplicate layers by name (e.g. toggled on twice). + for lyr in QgsProject.instance().mapLayers().values(): + if lyr.name() in (self._NAME_OK, self._NAME_ERR): + self.layer = lyr + QgsProject.instance().writeEntry("BAPEBridge", "layer_id", lyr.id()) + return + self.layer = QgsVectorLayer("Point?crs=EPSG:4326", self._NAME_OK, "memory") if not self.layer.isValid(): raise RuntimeError("Failed to create memory layer.") @@ -54,6 +68,7 @@ class LocationLayerManager: self.layer.updateFields() QgsProject.instance().addMapLayer(self.layer) + QgsProject.instance().writeEntry("BAPEBridge", "layer_id", self.layer.id()) self._configure_marker() self._configure_labels() diff --git a/plugin.py b/plugin.py index 6354107..d070fa7 100644 --- a/plugin.py +++ b/plugin.py @@ -1,7 +1,7 @@ import os from PyQt5.QtCore import QTimer from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction +from PyQt5.QtWidgets import QAction, QMessageBox from qgis.core import Qgis, QgsProject from qgis.gui import QgisInterface @@ -9,7 +9,9 @@ from .api_client import ApiClient from .layer_manager import LocationLayerManager from .notification_server import NotificationServer -_RETRY_INTERVAL_MS = 30_000 # 30 seconds between automatic retries +_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 @@ -28,61 +30,79 @@ class BAPEBridgePlugin: self._layer_manager: LocationLayerManager | None = None self._notif_server: NotificationServer | None = None - self._toggle_action: QAction | None = None + self._add_action: QAction | None = None - # Timer drives periodic retry when the initial API call fails. + # 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 to the Layer menu, after 'Add From Layer Definition File'.""" + """Add entry under Layer > Add Layer.""" icon = QIcon(os.path.join(os.path.dirname(__file__), "icon.png")) - self._toggle_action = QAction(icon, "BAPE Bridge", self._iface.mainWindow()) - self._toggle_action.setCheckable(True) - self._toggle_action.setToolTip("Enable / disable the BAPE Bridge layer") - self._toggle_action.triggered.connect(self._on_toggle) + 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) - layer_menu = self._iface.layerMenu() - # Find the 'Add From Layer Definition File' action and insert after it. - after_action = None - for action in layer_menu.actions(): - if "layer definition" in action.text().lower(): - after_action = action - break - - if after_action is not None: - # insertAction inserts *before* the reference; get the next item instead. - actions = layer_menu.actions() - idx = actions.index(after_action) - next_action = actions[idx + 1] if idx + 1 < len(actions) else None - if next_action is not None: - layer_menu.insertAction(next_action, self._toggle_action) - else: - layer_menu.addAction(self._toggle_action) - else: - layer_menu.addAction(self._toggle_action) + # 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.""" - self._deactivate() - self._iface.layerMenu().removeAction(self._toggle_action) + """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) # ------------------------------------------------------------------ - # Toggle on / off + # Add layer # ------------------------------------------------------------------ - def _on_toggle(self, checked: bool) -> None: - if checked: - self._activate() - else: - self._deactivate() + 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: @@ -117,12 +137,15 @@ class BAPEBridgePlugin: ) 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() @@ -134,9 +157,6 @@ class BAPEBridgePlugin: self._active = False - if self._toggle_action is not None: - self._toggle_action.setChecked(False) - # ------------------------------------------------------------------ # Reload handling # ------------------------------------------------------------------ @@ -155,9 +175,11 @@ class BAPEBridgePlugin: 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: - """Periodic retry: stop the timer once features load successfully.""" + """Retry on startup failure: stop once the API responds.""" if self._layer_manager is None: self._retry_timer.stop() return @@ -165,6 +187,12 @@ class BAPEBridgePlugin: 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). @@ -172,6 +200,8 @@ class BAPEBridgePlugin: 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() @@ -183,7 +213,48 @@ class BAPEBridgePlugin: self._active = False - if self._toggle_action is not None: - self._toggle_action.setChecked(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() \ No newline at end of file