More fixes
This commit is contained in:
@@ -19,7 +19,7 @@ class ApiClient:
|
|||||||
"""
|
"""
|
||||||
url = f"{self.base_url}/api/locations"
|
url = f"{self.base_url}/api/locations"
|
||||||
try:
|
try:
|
||||||
response = requests.get(url, timeout=10)
|
response = requests.get(url, timeout=1)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.HTTPError as exc:
|
except requests.HTTPError as exc:
|
||||||
QgsMessageLog.logMessage(
|
QgsMessageLog.logMessage(
|
||||||
|
|||||||
@@ -36,12 +36,26 @@ class LocationLayerManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def create_layer(self) -> None:
|
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:
|
If a layer with the stored ID already exists in the project (e.g. loaded
|
||||||
Exception: if the initial API fetch fails (layer is still added to the
|
from a saved project file) it is reused rather than duplicated.
|
||||||
project but will be empty until reload_layer() succeeds).
|
|
||||||
"""
|
"""
|
||||||
|
# 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")
|
self.layer = QgsVectorLayer("Point?crs=EPSG:4326", self._NAME_OK, "memory")
|
||||||
if not self.layer.isValid():
|
if not self.layer.isValid():
|
||||||
raise RuntimeError("Failed to create memory layer.")
|
raise RuntimeError("Failed to create memory layer.")
|
||||||
@@ -54,6 +68,7 @@ class LocationLayerManager:
|
|||||||
self.layer.updateFields()
|
self.layer.updateFields()
|
||||||
|
|
||||||
QgsProject.instance().addMapLayer(self.layer)
|
QgsProject.instance().addMapLayer(self.layer)
|
||||||
|
QgsProject.instance().writeEntry("BAPEBridge", "layer_id", self.layer.id())
|
||||||
self._configure_marker()
|
self._configure_marker()
|
||||||
self._configure_labels()
|
self._configure_labels()
|
||||||
|
|
||||||
|
|||||||
155
plugin.py
155
plugin.py
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from PyQt5.QtCore import QTimer
|
from PyQt5.QtCore import QTimer
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QIcon
|
||||||
from PyQt5.QtWidgets import QAction
|
from PyQt5.QtWidgets import QAction, QMessageBox
|
||||||
from qgis.core import Qgis, QgsProject
|
from qgis.core import Qgis, QgsProject
|
||||||
from qgis.gui import QgisInterface
|
from qgis.gui import QgisInterface
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ from .api_client import ApiClient
|
|||||||
from .layer_manager import LocationLayerManager
|
from .layer_manager import LocationLayerManager
|
||||||
from .notification_server import NotificationServer
|
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"
|
_API_BASE_URL = "http://localhost:22650"
|
||||||
_NOTIF_PORT = 22651
|
_NOTIF_PORT = 22651
|
||||||
|
|
||||||
@@ -28,61 +30,79 @@ class BAPEBridgePlugin:
|
|||||||
self._layer_manager: LocationLayerManager | None = None
|
self._layer_manager: LocationLayerManager | None = None
|
||||||
self._notif_server: NotificationServer | 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 = QTimer()
|
||||||
self._retry_timer.setInterval(_RETRY_INTERVAL_MS)
|
self._retry_timer.setInterval(_RETRY_INTERVAL_MS)
|
||||||
self._retry_timer.timeout.connect(self._retry_load)
|
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().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
|
# QGIS plugin lifecycle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def initGui(self) -> None:
|
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"))
|
icon = QIcon(os.path.join(os.path.dirname(__file__), "icon.png"))
|
||||||
self._toggle_action = QAction(icon, "BAPE Bridge", self._iface.mainWindow())
|
self._add_action = QAction(icon, "Add BAPE Bridge Layer...", self._iface.mainWindow())
|
||||||
self._toggle_action.setCheckable(True)
|
self._add_action.setToolTip("Add the BAPE Bridge live locations layer to this project")
|
||||||
self._toggle_action.setToolTip("Enable / disable the BAPE Bridge layer")
|
self._add_action.triggered.connect(self._on_add_layer)
|
||||||
self._toggle_action.triggered.connect(self._on_toggle)
|
|
||||||
|
|
||||||
layer_menu = self._iface.layerMenu()
|
# iface.addLayerMenu() is the "Add Layer" submenu inside the Layer menu.
|
||||||
# Find the 'Add From Layer Definition File' action and insert after it.
|
self._iface.addLayerMenu().addAction(self._add_action)
|
||||||
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)
|
|
||||||
|
|
||||||
def unload(self) -> None:
|
def unload(self) -> None:
|
||||||
"""Clean up when the plugin is disabled or QGIS closes."""
|
"""Clean up when the plugin is disabled or QGIS closes.
|
||||||
self._deactivate()
|
|
||||||
self._iface.layerMenu().removeAction(self._toggle_action)
|
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:
|
def _on_add_layer(self) -> None:
|
||||||
if checked:
|
"""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()
|
self._activate()
|
||||||
else:
|
|
||||||
self._deactivate()
|
|
||||||
|
|
||||||
def _activate(self) -> None:
|
def _activate(self) -> None:
|
||||||
if self._active:
|
if self._active:
|
||||||
@@ -117,12 +137,15 @@ class BAPEBridgePlugin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._active = True
|
self._active = True
|
||||||
|
self._poll_timer.start()
|
||||||
|
|
||||||
def _deactivate(self) -> None:
|
def _deactivate(self) -> None:
|
||||||
if not self._active:
|
if not self._active:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._retry_timer.stop()
|
self._retry_timer.stop()
|
||||||
|
self._poll_timer.stop()
|
||||||
|
self._post_clear_timer.stop()
|
||||||
|
|
||||||
if self._notif_server is not None:
|
if self._notif_server is not None:
|
||||||
self._notif_server.stop()
|
self._notif_server.stop()
|
||||||
@@ -134,9 +157,6 @@ class BAPEBridgePlugin:
|
|||||||
|
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|
||||||
if self._toggle_action is not None:
|
|
||||||
self._toggle_action.setChecked(False)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Reload handling
|
# Reload handling
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -155,9 +175,11 @@ class BAPEBridgePlugin:
|
|||||||
if self._layer_manager is None:
|
if self._layer_manager is None:
|
||||||
return
|
return
|
||||||
self._layer_manager.clear_layer()
|
self._layer_manager.clear_layer()
|
||||||
|
# Check 5 s later whether BAPE is still up.
|
||||||
|
self._post_clear_timer.start()
|
||||||
|
|
||||||
def _retry_load(self) -> None:
|
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:
|
if self._layer_manager is None:
|
||||||
self._retry_timer.stop()
|
self._retry_timer.stop()
|
||||||
return
|
return
|
||||||
@@ -165,6 +187,12 @@ class BAPEBridgePlugin:
|
|||||||
if self._layer_manager.reload_layer():
|
if self._layer_manager.reload_layer():
|
||||||
self._retry_timer.stop()
|
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:
|
def _on_project_cleared(self) -> None:
|
||||||
"""Called when QGIS clears the project (open new/different project).
|
"""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.
|
not touch them — just drop references and reset state.
|
||||||
"""
|
"""
|
||||||
self._retry_timer.stop()
|
self._retry_timer.stop()
|
||||||
|
self._poll_timer.stop()
|
||||||
|
self._post_clear_timer.stop()
|
||||||
|
|
||||||
if self._notif_server is not None:
|
if self._notif_server is not None:
|
||||||
self._notif_server.stop()
|
self._notif_server.stop()
|
||||||
@@ -183,7 +213,48 @@ class BAPEBridgePlugin:
|
|||||||
|
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|
||||||
if self._toggle_action is not None:
|
def _try_reconnect(self) -> None:
|
||||||
self._toggle_action.setChecked(False)
|
"""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()
|
||||||
Reference in New Issue
Block a user