More fixes

This commit is contained in:
2026-06-04 17:49:28 +02:00
parent bb51549193
commit 5839a3ce66
3 changed files with 134 additions and 48 deletions

View File

@@ -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(

View File

@@ -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()

157
plugin.py
View File

@@ -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()