diff --git a/app.py b/app.py index acf5a8c..b046556 100644 --- a/app.py +++ b/app.py @@ -317,6 +317,9 @@ deauth_detector = None deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) deauth_detector_lock = threading.Lock() +# Drone Intelligence +drone_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) + # ============================================ # GLOBAL STATE DICTIONARIES # ============================================ diff --git a/requirements.txt b/requirements.txt index 2567927..41232e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,6 +30,7 @@ meshtastic>=2.0.0 # Deauthentication attack detection (optional - for WiFi TSCM) scapy>=2.4.5 +opendroneid>=1.0 # QR code generation for Meshtastic channels (optional) qrcode[pil]>=7.4 diff --git a/routes/__init__.py b/routes/__init__.py index ddb23d4..51a3eb0 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -18,6 +18,7 @@ def register_blueprints(app): from .bt_locate import bt_locate_bp from .controller import controller_bp from .correlation import correlation_bp + from .drone import drone_bp from .dsc import dsc_bp from .gps import gps_bp from .ground_station import ground_station_bp @@ -91,6 +92,7 @@ def register_blueprints(app): app.register_blueprint(system_bp) # System health monitoring app.register_blueprint(ook_bp) # Generic OOK signal decoder app.register_blueprint(ground_station_bp) # Ground station automation + app.register_blueprint(drone_bp) # Drone intelligence / UAV detection # Exempt all API blueprints from CSRF (they use JSON, not form tokens) if _csrf: @@ -99,5 +101,6 @@ def register_blueprints(app): # Initialize TSCM state with queue and lock from app import app as app_module - if hasattr(app_module, 'tscm_queue') and hasattr(app_module, 'tscm_lock'): + + if hasattr(app_module, "tscm_queue") and hasattr(app_module, "tscm_lock"): init_tscm_state(app_module.tscm_queue, app_module.tscm_lock) diff --git a/routes/drone.py b/routes/drone.py new file mode 100644 index 0000000..0d3ea67 --- /dev/null +++ b/routes/drone.py @@ -0,0 +1,99 @@ +"""Drone intelligence routes — multi-vector UAV detection.""" + +from __future__ import annotations + +import logging + +from flask import Blueprint, Response, jsonify, request + +import app as app_module +from utils.constants import SSE_KEEPALIVE_INTERVAL, SSE_QUEUE_TIMEOUT +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 + +logger = logging.getLogger("intercept.drone") + +drone_bp = Blueprint("drone", __name__, url_prefix="/drone") + +_correlator: DroneCorrelator | None = None +_remote_id_scanner: RemoteIDScanner | None = None +_rf_detector: RFDetector | None = None +_drone_running = False + + +def _ensure_workers() -> None: + global _correlator, _remote_id_scanner, _rf_detector + 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) + if _rf_detector is None: + _rf_detector = RFDetector(output_queue=app_module.drone_queue) + + +@drone_bp.route("/status") +def status(): + vectors = [] + if _remote_id_scanner and _remote_id_scanner.running: + vectors.append("REMOTE_ID") + if _rf_detector and _rf_detector.running: + vectors.append("RF") + return jsonify( + { + "running": _drone_running, + "vectors": vectors, + "contact_count": len(_correlator.get_all()) if _correlator else 0, + } + ) + + +@drone_bp.route("/contacts") +def contacts(): + if not _correlator: + return jsonify([]) + return jsonify(_correlator.get_all()) + + +@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)) + + if not _drone_running: + _remote_id_scanner.start(wifi_iface=wifi_iface) + _rf_detector.start(rtl_sdr_index=rtl_index, use_hackrf=use_hackrf) + _drone_running = True + logger.info("Drone detection started") + + return jsonify({"status": "ok", "running": True}) + + +@drone_bp.route("/stop", methods=["POST"]) +def stop(): + global _drone_running + if _remote_id_scanner: + _remote_id_scanner.stop() + if _rf_detector: + _rf_detector.stop() + _drone_running = False + logger.info("Drone detection stopped") + return jsonify({"status": "ok", "running": False}) + + +@drone_bp.route("/stream") +def stream(): + return Response( + sse_stream_fanout( + source_queue=app_module.drone_queue, + channel_key="drone", + timeout=SSE_QUEUE_TIMEOUT, + keepalive_interval=SSE_KEEPALIVE_INTERVAL, + ), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/tests/test_drone_routes.py b/tests/test_drone_routes.py new file mode 100644 index 0000000..a7998f9 --- /dev/null +++ b/tests/test_drone_routes.py @@ -0,0 +1,63 @@ +import json +import queue +from unittest.mock import patch + +import pytest +from flask import Flask + +import app as app_module +from routes.drone import drone_bp + + +@pytest.fixture(autouse=True) +def mock_app_state(mocker): + mocker.patch.object(app_module, "drone_queue", queue.Queue()) + yield + + +@pytest.fixture +def drone_app(): + app = Flask(__name__) + app.register_blueprint(drone_bp) + app.config["TESTING"] = True + return app + + +@pytest.fixture +def client(drone_app): + return drone_app.test_client() + + +def test_status_returns_json(client): + resp = client.get("/drone/status") + assert resp.status_code == 200 + data = json.loads(resp.data) + assert "running" in data + assert "vectors" in data + + +def test_contacts_returns_empty_list_when_idle(client): + resp = client.get("/drone/contacts") + assert resp.status_code == 200 + data = json.loads(resp.data) + assert data == [] or isinstance(data, list) + + +def test_start_returns_ok(client): + with ( + patch("routes.drone._correlator"), + patch("routes.drone._remote_id_scanner"), + patch("routes.drone._rf_detector"), + ): + resp = client.post("/drone/start", json={}) + assert resp.status_code == 200 + + +def test_stop_returns_ok(client): + resp = client.post("/drone/stop") + assert resp.status_code == 200 + + +def test_stream_returns_event_stream(client): + resp = client.get("/drone/stream") + assert resp.content_type.startswith("text/event-stream")