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

+ +
+

Detected Contacts

+
+
+ +
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: