commit 6e5fcefa42a6fa47a391f1bb0f219a7a5ec37ac7 Author: Mees van der Wijk Date: Thu Jun 4 17:11:29 2026 +0200 Initial commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..da4a62d --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +def classFactory(iface): + from .plugin import BAPEBridgePlugin + return BAPEBridgePlugin(iface) diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..16de37c Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/__pycache__/api_client.cpython-312.pyc b/__pycache__/api_client.cpython-312.pyc new file mode 100644 index 0000000..597ca54 Binary files /dev/null and b/__pycache__/api_client.cpython-312.pyc differ diff --git a/__pycache__/layer_manager.cpython-312.pyc b/__pycache__/layer_manager.cpython-312.pyc new file mode 100644 index 0000000..6badb2f Binary files /dev/null and b/__pycache__/layer_manager.cpython-312.pyc differ diff --git a/__pycache__/notification_server.cpython-312.pyc b/__pycache__/notification_server.cpython-312.pyc new file mode 100644 index 0000000..2a6abe5 Binary files /dev/null and b/__pycache__/notification_server.cpython-312.pyc differ diff --git a/__pycache__/plugin.cpython-312.pyc b/__pycache__/plugin.cpython-312.pyc new file mode 100644 index 0000000..dd66462 Binary files /dev/null and b/__pycache__/plugin.cpython-312.pyc differ diff --git a/api_client.py b/api_client.py new file mode 100644 index 0000000..8f0d829 --- /dev/null +++ b/api_client.py @@ -0,0 +1,52 @@ +import requests +from qgis.core import QgsMessageLog, Qgis + + +class ApiClient: + """Fetches location records from the external REST API.""" + + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + + def fetch_locations(self) -> list[dict]: + """GET /api/locations and return a list of location dicts. + + Each dict contains: id (int), name (str), latitude (float), longitude (float). + + Raises: + requests.RequestException: on any network or HTTP error. + ValueError: if the response body is not valid JSON or missing expected keys. + """ + url = f"{self.base_url}/api/locations" + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + except requests.HTTPError as exc: + QgsMessageLog.logMessage( + f"HTTP error fetching locations from {url}: {exc}", + "BAPEBridge", + Qgis.Warning, + ) + raise + except requests.RequestException as exc: + QgsMessageLog.logMessage( + f"Network error fetching locations from {url}: {exc}", + "BAPEBridge", + Qgis.Warning, + ) + raise + + try: + data = response.json() + except ValueError as exc: + QgsMessageLog.logMessage( + f"Invalid JSON in locations response: {exc}", + "BAPEBridge", + Qgis.Warning, + ) + raise + + if not isinstance(data, list): + raise ValueError(f"Expected a JSON array, got {type(data).__name__}") + + return data diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..e9b19f8 Binary files /dev/null and b/icon.png differ diff --git a/layer_manager.py b/layer_manager.py new file mode 100644 index 0000000..667dc4b --- /dev/null +++ b/layer_manager.py @@ -0,0 +1,166 @@ +import sip +from qgis.core import ( + QgsVectorLayer, + QgsFeature, + QgsGeometry, + QgsPointXY, + QgsField, + QgsProject, + QgsMessageLog, + QgsPalLayerSettings, + QgsVectorLayerSimpleLabeling, + QgsTextFormat, + QgsTextBufferSettings, + QgsMarkerSymbol, + QgsSingleSymbolRenderer, + Qgis, +) +from PyQt5.QtCore import QVariant +from PyQt5.QtGui import QColor + +from .api_client import ApiClient + + +class LocationLayerManager: + """Creates and manages the 'BAPE Bridge' memory vector layer.""" + + _NAME_OK = "BAPE Bridge" + _NAME_ERR = "🔴 BAPE Bridge" + + def __init__(self, api_client: ApiClient): + self._api_client = api_client + self.layer: QgsVectorLayer | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def create_layer(self) -> None: + """Create the memory layer, add it to the project 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). + """ + self.layer = QgsVectorLayer("Point?crs=EPSG:4326", self._NAME_OK, "memory") + if not self.layer.isValid(): + raise RuntimeError("Failed to create memory layer.") + + provider = self.layer.dataProvider() + provider.addAttributes([ + QgsField("id", QVariant.String), + QgsField("name", QVariant.String), + ]) + self.layer.updateFields() + + QgsProject.instance().addMapLayer(self.layer) + self._configure_marker() + self._configure_labels() + + # Initial load — set error name on failure so the layer is visible + # in the panel but clearly marked as not yet connected. + try: + self._do_reload() + except Exception: + self.layer.setName(self._NAME_ERR) + raise + + def reload_layer(self) -> bool: + """Fetch fresh data and replace all features in the layer. + + Returns: + True on success, False on failure (existing features are kept). + """ + if self.layer is None or not self.layer.isValid(): + return False + + try: + self._do_reload() + return True + except Exception as exc: + QgsMessageLog.logMessage( + f"Layer reload failed, keeping existing features: {exc}", + "BAPEBridge", + Qgis.Warning, + ) + self.layer.setName(self._NAME_ERR) + self.layer.emitStyleChanged() + return False + + def clear_layer(self) -> None: + """Remove all features from the layer without fetching new data.""" + if self.layer is None or not self.layer.isValid(): + return + self.layer.dataProvider().truncate() + self.layer.updateExtents() + self.layer.triggerRepaint() + + def remove_layer(self) -> None: + """Remove the layer from the project and release the reference.""" + if self.layer is not None: + if not sip.isdeleted(self.layer): + layer_id = self.layer.id() + if QgsProject.instance().mapLayer(layer_id) is not None: + QgsProject.instance().removeMapLayer(layer_id) + self.layer = None + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _configure_marker(self) -> None: + """Set a green filled triangle marker at size 3.""" + symbol = QgsMarkerSymbol.createSimple({ + "name": "triangle", + "color": "72,123,182,255", + "size": "3", + }) + self.layer.setRenderer(QgsSingleSymbolRenderer(symbol)) + + def _configure_labels(self) -> None: + """Enable simple labels showing the 'name' field next to each point.""" + buffer = QgsTextBufferSettings() + buffer.setEnabled(True) + buffer.setColor(QColor("black")) + buffer.setSize(0.4) + + text_format = QgsTextFormat() + text_format.setColor(QColor("white")) + text_format.setBuffer(buffer) + + pal = QgsPalLayerSettings() + pal.fieldName = "name" + pal.enabled = True + pal.setFormat(text_format) + self.layer.setLabeling(QgsVectorLayerSimpleLabeling(pal)) + self.layer.setLabelsEnabled(True) + + def _do_reload(self) -> None: + """Core fetch-and-replace logic. Raises on any failure.""" + locations = self._api_client.fetch_locations() + + provider = self.layer.dataProvider() + provider.truncate() + + features = [self._build_feature(loc) for loc in locations] + provider.addFeatures(features) + + self.layer.updateExtents() + self.layer.triggerRepaint() + self.layer.setName(self._NAME_OK) + self.layer.emitStyleChanged() + + QgsMessageLog.logMessage( + f"Loaded {len(features)} location(s).", + "BAPEBridge", + Qgis.Info, + ) + + def _build_feature(self, loc: dict) -> QgsFeature: + feature = QgsFeature(self.layer.fields()) + feature.setGeometry( + QgsGeometry.fromPointXY(QgsPointXY(loc["longitude"], loc["latitude"])) + ) + feature["id"] = loc["id"] + feature["name"] = loc["name"] + return feature diff --git a/metadata.txt b/metadata.txt new file mode 100644 index 0000000..80fab97 --- /dev/null +++ b/metadata.txt @@ -0,0 +1,16 @@ +[general] +name=BAPE Bridge +qgisMinimumVersion=3.0 +description=Live feedback from BAPE. +version=0.1.0 +author=BunkerArchive +email= +about=Live feedback from BAPE. +tracker= +repository= +tags=locations,live,api,points,real-time +homepage= +category=Plugins +icon=icon.png +experimental=True +deprecated=False diff --git a/notification_server.py b/notification_server.py new file mode 100644 index 0000000..ac4032c --- /dev/null +++ b/notification_server.py @@ -0,0 +1,112 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Callable + +from PyQt5.QtCore import QObject, pyqtSignal +from qgis.core import QgsMessageLog, Qgis + + +class _Signals(QObject): + """Thin QObject wrapper so we can emit signals from the server thread. + + Qt auto-connection (the default) marshals cross-thread signals via the + event queue, so connected slots always run in the main Qt thread. + """ + reload = pyqtSignal() + clear = pyqtSignal() + + +class NotificationServer: + """Lightweight HTTP server that listens on localhost:22651. + + Exposes: + POST /reload → triggers the reload callback in the main Qt thread + POST /clear → triggers the clear callback in the main Qt thread + + Both respond with {"status": "ok"}. + The server runs in a dedicated daemon thread so it never blocks QGIS. + """ + + def __init__(self, reload_callback: Callable[[], None], clear_callback: Callable[[], None], port: int = 8765): + self._port = port + self._signal = _Signals() + self._signal.reload.connect(reload_callback) + self._signal.clear.connect(clear_callback) + self._server: HTTPServer | None = None + self._thread: threading.Thread | None = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self) -> None: + """Bind to localhost:{port} and begin serving in a background thread. + + Raises: + RuntimeError: if the port is already in use. + """ + signal = self._signal # captured by the handler class below + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path == "/reload": + signal.reload.emit() + self._respond_ok() + elif self.path == "/clear": + signal.clear.emit() + self._respond_ok() + else: + self.send_response(404) + self.end_headers() + + def _respond_ok(self): + body = json.dumps({"status": "ok"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + # Suppress the default access log to keep the QGIS console clean. + def log_message(self, fmt, *args): # noqa: ANN001 + QgsMessageLog.logMessage( + fmt % args, "BAPEBridge", Qgis.Info + ) + + try: + self._server = HTTPServer(("127.0.0.1", self._port), _Handler) + except OSError as exc: + raise RuntimeError( + f"Cannot bind notification server to port {self._port}: {exc}" + ) from exc + + self._thread = threading.Thread( + target=self._server.serve_forever, + name="BAPEBridge-NotifServer", + daemon=True, + ) + self._thread.start() + + QgsMessageLog.logMessage( + f"Notification server started on http://127.0.0.1:{self._port}", + "BAPEBridge", + Qgis.Info, + ) + + def stop(self) -> None: + """Shut down the server and wait for the thread to exit.""" + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + + if self._thread is not None: + self._thread.join(timeout=5) + self._thread = None + + QgsMessageLog.logMessage( + "Notification server stopped.", + "BAPEBridge", + Qgis.Info, + ) diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..2055e4d --- /dev/null +++ b/plugin.py @@ -0,0 +1,167 @@ +from PyQt5.QtCore import QTimer +from PyQt5.QtWidgets import QAction +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 seconds between automatic retries +_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._toggle_action: QAction | None = None + + # Timer drives periodic retry when the initial API call fails. + self._retry_timer = QTimer() + self._retry_timer.setInterval(_RETRY_INTERVAL_MS) + self._retry_timer.timeout.connect(self._retry_load) + + QgsProject.instance().cleared.connect(self._on_project_cleared) + + # ------------------------------------------------------------------ + # QGIS plugin lifecycle + # ------------------------------------------------------------------ + + def initGui(self) -> None: + """Add entry to the Layer menu.""" + self._toggle_action = QAction("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._iface.addPluginToLayerMenu("BAPE Bridge", self._toggle_action) + + def unload(self) -> None: + """Clean up when the plugin is disabled or QGIS closes.""" + self._deactivate() + self._iface.removePluginFromLayerMenu("BAPE Bridge", self._toggle_action) + + # ------------------------------------------------------------------ + # Toggle on / off + # ------------------------------------------------------------------ + + def _on_toggle(self, checked: bool) -> None: + if checked: + self._activate() + else: + self._deactivate() + + 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 + + def _deactivate(self) -> None: + if not self._active: + return + + self._retry_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 + + if self._toggle_action is not None: + self._toggle_action.setChecked(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() + + def _retry_load(self) -> None: + """Periodic retry: stop the timer once features load successfully.""" + if self._layer_manager is None: + self._retry_timer.stop() + return + + if self._layer_manager.reload_layer(): + self._retry_timer.stop() + + 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() + + 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 + + if self._toggle_action is not None: + self._toggle_action.setChecked(False) + + diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..c6a44f7 --- /dev/null +++ b/resources.qrc @@ -0,0 +1,4 @@ + + + +