Files
QGIS_BAPEBridge/plugin.py
2026-06-04 17:49:28 +02:00

260 lines
9.1 KiB
Python

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