From 8632e31c011c4deb3972ca39e6f6b707dc7dbb0c Mon Sep 17 00:00:00 2001
From: James Smith
Date: Sun, 3 May 2026 21:47:12 +0100
Subject: [PATCH] fix(drone): resolve critical pipeline, frontend, and input
validation issues
Data pipeline (critical): scanners/detectors now write to a separate _obs_queue;
a relay thread reads observations and calls correlator.process(), which emits
processed DroneContact dicts to drone_queue for SSE. Without this the SSE stream
received raw unserializable dataclass objects causing JSON errors.
Frontend (critical):
- Add droneContactList container to drone.html so contact cards render
- Add droneMap container and initialize Leaflet in drone.js init()
- Define dsc-distress-pulse keyframes in drone.css (was referenced but missing)
- Fix SSE reconnect: null _sse before setTimeout to prevent _connectSSE no-op loop
Other fixes:
- Validate rtl_sdr_index with validate_device_index(), return 400 on bad input
- Move _ensure_workers() inside _drone_lock to prevent double-initialization race
- Add double-call guard to RemoteIDScanner.start()
Co-Authored-By: Claude Sonnet 4.6
---
routes/drone.py | 41 ++++++++++++++++++++++++-----
static/css/modes/drone.css | 12 +++++++++
static/js/modes/drone.js | 14 ++++++++++
templates/partials/modes/drone.html | 7 +++++
utils/drone/remote_id.py | 2 ++
5 files changed, 69 insertions(+), 7 deletions(-)
diff --git a/routes/drone.py b/routes/drone.py
index 43c41bf..8303738 100644
--- a/routes/drone.py
+++ b/routes/drone.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
+import queue
import threading
from flask import Blueprint, Response, jsonify, request
@@ -13,6 +14,7 @@ from utils.drone.correlator import DroneCorrelator
from utils.drone.remote_id import RemoteIDScanner
from utils.drone.rf_detector import RFDetector
from utils.sse import sse_stream_fanout
+from utils.validation import validate_device_index
logger = logging.getLogger("intercept.drone")
@@ -21,18 +23,37 @@ drone_bp = Blueprint("drone", __name__, url_prefix="/drone")
_correlator: DroneCorrelator | None = None
_remote_id_scanner: RemoteIDScanner | None = None
_rf_detector: RFDetector | None = None
+_obs_queue: queue.Queue | None = None # raw observations from scanners/detectors
+_relay_thread: threading.Thread | None = None
_drone_running = False
_drone_lock = threading.Lock()
+_SENTINEL = object()
+
+
+def _relay_observations() -> None:
+ """Read raw observations from _obs_queue and feed them into the correlator."""
+ while True:
+ obs = _obs_queue.get()
+ if obs is _SENTINEL:
+ break
+ if _correlator is not None:
+ _correlator.process(obs)
+
def _ensure_workers() -> None:
- global _correlator, _remote_id_scanner, _rf_detector
+ global _correlator, _remote_id_scanner, _rf_detector, _obs_queue, _relay_thread
+ if _obs_queue is None:
+ _obs_queue = queue.Queue(maxsize=512)
if _correlator is None:
_correlator = DroneCorrelator(output_queue=app_module.drone_queue)
if _remote_id_scanner is None:
- _remote_id_scanner = RemoteIDScanner(output_queue=app_module.drone_queue)
+ _remote_id_scanner = RemoteIDScanner(output_queue=_obs_queue)
if _rf_detector is None:
- _rf_detector = RFDetector(output_queue=app_module.drone_queue)
+ _rf_detector = RFDetector(output_queue=_obs_queue)
+ if _relay_thread is None or not _relay_thread.is_alive():
+ _relay_thread = threading.Thread(target=_relay_observations, daemon=True)
+ _relay_thread.start()
@drone_bp.route("/status")
@@ -61,12 +82,16 @@ def contacts():
@drone_bp.route("/start", methods=["POST"])
def start():
global _drone_running
- _ensure_workers()
- wifi_iface = request.json.get("wifi_iface") if request.json else None
- rtl_index = int((request.json or {}).get("rtl_sdr_index", 0))
- use_hackrf = bool((request.json or {}).get("use_hackrf", True))
+ body = request.json or {}
+ wifi_iface = body.get("wifi_iface") or None
+ try:
+ rtl_index = validate_device_index(body.get("rtl_sdr_index", 0))
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ use_hackrf = bool(body.get("use_hackrf", True))
with _drone_lock:
+ _ensure_workers()
if not _drone_running:
if _remote_id_scanner:
_remote_id_scanner.start(wifi_iface=wifi_iface)
@@ -86,6 +111,8 @@ def stop():
_remote_id_scanner.stop()
if _rf_detector:
_rf_detector.stop()
+ if _obs_queue is not None:
+ _obs_queue.put_nowait(_SENTINEL)
_drone_running = False
logger.info("Drone detection stopped")
return jsonify({"status": "ok", "running": False})
diff --git a/static/css/modes/drone.css b/static/css/modes/drone.css
index 6453a92..1e57e71 100644
--- a/static/css/modes/drone.css
+++ b/static/css/modes/drone.css
@@ -69,6 +69,18 @@
color: var(--accent-red);
}
+.drone-map {
+ height: 280px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ margin: 0 12px 12px;
+}
+
.drone-marker-high-risk {
animation: dsc-distress-pulse 1.5s infinite;
}
+
+@keyframes dsc-distress-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(1.4); }
+}
diff --git a/static/js/modes/drone.js b/static/js/modes/drone.js
index 55af9d8..d7b7777 100644
--- a/static/js/modes/drone.js
+++ b/static/js/modes/drone.js
@@ -10,10 +10,22 @@
function init() {
document.getElementById('droneStartBtn')?.addEventListener('click', _start);
document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
+ _initMap();
_connectSSE();
_refreshStatus();
}
+ function _initMap() {
+ if (_map) return;
+ const mapEl = document.getElementById('droneMap');
+ if (!mapEl || typeof L === 'undefined') return;
+ _map = L.map('droneMap', { zoomControl: true }).setView([20, 0], 2);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap',
+ maxZoom: 18,
+ }).addTo(_map);
+ }
+
function destroy() {
_disconnectSSE();
if (_map) {
@@ -34,6 +46,8 @@
} catch (_) {}
});
_sse.onerror = function () {
+ _sse.close();
+ _sse = null;
setTimeout(_connectSSE, 3000);
};
}
diff --git a/templates/partials/modes/drone.html b/templates/partials/modes/drone.html
index e63a860..c39babf 100644
--- a/templates/partials/modes/drone.html
+++ b/templates/partials/modes/drone.html
@@ -39,4 +39,11 @@
Non-compliant: 0
+
+
+
+
diff --git a/utils/drone/remote_id.py b/utils/drone/remote_id.py
index a4143db..778e0de 100644
--- a/utils/drone/remote_id.py
+++ b/utils/drone/remote_id.py
@@ -98,6 +98,8 @@ class RemoteIDScanner:
elt = elt.payload if hasattr(elt, "payload") and isinstance(elt.payload, Dot11Elt) else None
def start(self, wifi_iface: str | None = None) -> None:
+ if self._running:
+ return
self._running = True
if SCAPY_AVAILABLE and wifi_iface:
try: