Initial commit

This commit is contained in:
2026-06-04 17:11:29 +02:00
commit 6e5fcefa42
13 changed files with 520 additions and 0 deletions

3
__init__.py Normal file
View File

@@ -0,0 +1,3 @@
def classFactory(iface):
from .plugin import BAPEBridgePlugin
return BAPEBridgePlugin(iface)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

52
api_client.py Normal file
View File

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

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 B

166
layer_manager.py Normal file
View File

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

16
metadata.txt Normal file
View File

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

112
notification_server.py Normal file
View File

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

167
plugin.py Normal file
View File

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

4
resources.qrc Normal file
View File

@@ -0,0 +1,4 @@
<RCC>
<qresource prefix="/plugins/my_location_plugin">
</qresource>
</RCC>