Initial commit
This commit is contained in:
3
__init__.py
Normal file
3
__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
def classFactory(iface):
|
||||
from .plugin import BAPEBridgePlugin
|
||||
return BAPEBridgePlugin(iface)
|
||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/api_client.cpython-312.pyc
Normal file
BIN
__pycache__/api_client.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/layer_manager.cpython-312.pyc
Normal file
BIN
__pycache__/layer_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/notification_server.cpython-312.pyc
Normal file
BIN
__pycache__/notification_server.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/plugin.cpython-312.pyc
Normal file
BIN
__pycache__/plugin.cpython-312.pyc
Normal file
Binary file not shown.
52
api_client.py
Normal file
52
api_client.py
Normal 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
|
||||
166
layer_manager.py
Normal file
166
layer_manager.py
Normal 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
16
metadata.txt
Normal 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
112
notification_server.py
Normal 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
167
plugin.py
Normal 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
4
resources.qrc
Normal file
@@ -0,0 +1,4 @@
|
||||
<RCC>
|
||||
<qresource prefix="/plugins/my_location_plugin">
|
||||
</qresource>
|
||||
</RCC>
|
||||
Reference in New Issue
Block a user