mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
feat(drone): merge Drone Intelligence module
Multi-vector UAV detection mode: Remote ID (WiFi/BLE ASTM F3411), RTL-SDR 433/868MHz control-link detection, HackRF 2.4/5.8GHz wideband. Workers feed a shared observation queue; DroneCorrelator merges into DroneContact objects with TTL store, risk scoring, and SSE streaming. Frontend: two-panel sidebar + Leaflet map with contact cards and trails. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""Drone intelligence utilities — multi-vector UAV detection."""
|
||||
|
||||
from .models import DroneContact, RemoteIDObservation, RFObservation, RFSignal
|
||||
|
||||
__all__ = ["DroneContact", "RemoteIDObservation", "RFObservation", "RFSignal"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# utils/drone/correlator.py
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import queue
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
|
||||
from .models import DroneContact, RemoteIDObservation, RFObservation, RFSignal
|
||||
|
||||
_CONTACT_TTL = 120.0
|
||||
_MAX_POSITION_HISTORY = 500
|
||||
|
||||
|
||||
def _contact_id_from_serial(serial: str) -> str:
|
||||
return hashlib.sha1(f"serial:{serial}".encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _contact_id_from_rf(freq_hz: int, protocol: str) -> str:
|
||||
return hashlib.sha1(f"rf:{freq_hz}:{protocol}".encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def _compute_risk(contact: DroneContact) -> str:
|
||||
if not contact.compliant:
|
||||
return "high"
|
||||
if len(contact.detection_vectors) > 1:
|
||||
return "medium"
|
||||
if len(contact.rf_signals) >= 2:
|
||||
recent = sorted(contact.rf_signals, key=lambda s: s.timestamp)[-5:]
|
||||
if abs(recent[-1].rssi - recent[0].rssi) > 15:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
class DroneCorrelator:
|
||||
def __init__(self, output_queue: queue.Queue) -> None:
|
||||
self._store: DataStore = DataStore(max_age_seconds=_CONTACT_TTL, name="drone_contacts")
|
||||
self._output_queue = output_queue
|
||||
cleanup_manager.register(self._store)
|
||||
|
||||
def process(self, obs: RemoteIDObservation | RFObservation) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if isinstance(obs, RemoteIDObservation):
|
||||
contact_id = _contact_id_from_serial(obs.serial_number)
|
||||
contact: DroneContact = self._store.get(contact_id) or DroneContact(
|
||||
id=contact_id, first_seen=now, last_seen=now
|
||||
)
|
||||
contact.last_seen = now
|
||||
contact.serial_number = obs.serial_number
|
||||
contact.operator_id = obs.operator_id
|
||||
contact.position = (obs.lat, obs.lon)
|
||||
contact.altitude_m = obs.altitude_m
|
||||
contact.speed_ms = obs.speed_ms
|
||||
contact.heading = obs.heading
|
||||
contact.compliant = True
|
||||
contact.detection_vectors.add(f"REMOTE_ID_{obs.source}")
|
||||
contact.position_history.append((obs.lat, obs.lon, now))
|
||||
if len(contact.position_history) > _MAX_POSITION_HISTORY:
|
||||
contact.position_history = contact.position_history[-_MAX_POSITION_HISTORY:]
|
||||
else:
|
||||
contact_id = _contact_id_from_rf(obs.frequency_hz, obs.protocol)
|
||||
contact = self._store.get(contact_id) or DroneContact(id=contact_id, first_seen=now, last_seen=now)
|
||||
contact.last_seen = now
|
||||
contact.compliant = False
|
||||
contact.detection_vectors.add(obs.hardware)
|
||||
contact.rf_signals.append(
|
||||
RFSignal(
|
||||
frequency_hz=obs.frequency_hz,
|
||||
protocol=obs.protocol,
|
||||
rssi=obs.rssi,
|
||||
hardware=obs.hardware,
|
||||
timestamp=now,
|
||||
)
|
||||
)
|
||||
|
||||
contact.confidence = min(len(contact.detection_vectors) / 4.0, 1.0)
|
||||
contact.risk_level = _compute_risk(contact)
|
||||
self._store.set(contact_id, contact)
|
||||
|
||||
with contextlib.suppress(queue.Full):
|
||||
self._output_queue.put_nowait({"type": "contact", "data": contact.to_dict()})
|
||||
|
||||
def get_all(self) -> list[dict]:
|
||||
return [c.to_dict() for c in self._store.values()]
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
_MAX_HISTORY_IN_DICT = 50
|
||||
_MAX_RF_IN_DICT = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class RFSignal:
|
||||
frequency_hz: int
|
||||
protocol: str
|
||||
rssi: float
|
||||
hardware: str # "RTL433" | "HACKRF"
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteIDObservation:
|
||||
source: str # "WIFI" | "BLE"
|
||||
serial_number: str
|
||||
operator_id: str
|
||||
lat: float
|
||||
lon: float
|
||||
altitude_m: float
|
||||
speed_ms: float
|
||||
heading: float
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RFObservation:
|
||||
frequency_hz: int
|
||||
protocol: str
|
||||
rssi: float
|
||||
hardware: str # "RTL433" | "HACKRF"
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class DroneContact:
|
||||
id: str
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
serial_number: str | None = None
|
||||
operator_id: str | None = None
|
||||
position: tuple[float, float] | None = None
|
||||
altitude_m: float | None = None
|
||||
speed_ms: float | None = None
|
||||
heading: float | None = None
|
||||
position_history: list[tuple[float, float, datetime]] = field(default_factory=list)
|
||||
rf_signals: list[RFSignal] = field(default_factory=list)
|
||||
compliant: bool = False
|
||||
detection_vectors: set[str] = field(default_factory=set)
|
||||
confidence: float = 0.0
|
||||
risk_level: str = "low"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"serial_number": self.serial_number,
|
||||
"operator_id": self.operator_id,
|
||||
"position": list(self.position) if self.position else None,
|
||||
"altitude_m": self.altitude_m,
|
||||
"speed_ms": self.speed_ms,
|
||||
"heading": self.heading,
|
||||
"position_history": [
|
||||
{"lat": p[0], "lon": p[1], "ts": p[2].isoformat()}
|
||||
for p in self.position_history[-_MAX_HISTORY_IN_DICT:]
|
||||
],
|
||||
"rf_signals": [
|
||||
{
|
||||
"frequency_hz": s.frequency_hz,
|
||||
"protocol": s.protocol,
|
||||
"rssi": s.rssi,
|
||||
"hardware": s.hardware,
|
||||
}
|
||||
for s in self.rf_signals[-_MAX_RF_IN_DICT:]
|
||||
],
|
||||
"compliant": self.compliant,
|
||||
"detection_vectors": sorted(self.detection_vectors),
|
||||
"confidence": round(self.confidence, 2),
|
||||
"risk_level": self.risk_level,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# utils/drone/remote_id.py
|
||||
"""Remote ID scanner — WiFi beacon + BLE advertisement parsing (ASTM F3411)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import queue
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import RemoteIDObservation
|
||||
|
||||
logger = logging.getLogger("intercept.drone.remote_id")
|
||||
|
||||
_REMOTE_ID_UUID_LE = b"\xfa\xff"
|
||||
_LOCATION_MSG_TYPE = 0x01
|
||||
_MIN_LOCATION_PAYLOAD = 15
|
||||
|
||||
try:
|
||||
from scapy.all import AsyncSniffer, Dot11Beacon, Dot11Elt
|
||||
|
||||
SCAPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
SCAPY_AVAILABLE = False
|
||||
AsyncSniffer = None
|
||||
Dot11Beacon = Dot11Elt = None
|
||||
|
||||
|
||||
def _parse_ble_remote_id(adv_data: bytes) -> RemoteIDObservation | None:
|
||||
"""Parse a BLE advertisement containing an ASTM F3411 Remote ID payload."""
|
||||
idx = adv_data.find(_REMOTE_ID_UUID_LE)
|
||||
if idx < 0:
|
||||
return None
|
||||
payload = adv_data[idx + 2 :]
|
||||
return _parse_wifi_remote_id(payload, source="BLE")
|
||||
|
||||
|
||||
def _parse_wifi_remote_id(payload: bytes, source: str = "WIFI") -> RemoteIDObservation | None:
|
||||
"""Parse raw ASTM F3411 Location payload bytes into a RemoteIDObservation."""
|
||||
if not payload or len(payload) < 2:
|
||||
return None
|
||||
msg_type = payload[0] & 0x0F
|
||||
if msg_type != _LOCATION_MSG_TYPE:
|
||||
return None
|
||||
if len(payload) < _MIN_LOCATION_PAYLOAD:
|
||||
return None
|
||||
try:
|
||||
lat_enc, lon_enc = struct.unpack_from("<ii", payload, 2)
|
||||
alt_enc = struct.unpack_from("<H", payload, 10)[0]
|
||||
speed_enc = struct.unpack_from("<B", payload, 12)[0]
|
||||
heading_enc = struct.unpack_from("<H", payload, 13)[0]
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
lat = lat_enc * 1e-7
|
||||
lon = lon_enc * 1e-7
|
||||
alt = alt_enc * 0.5 - 1000.0
|
||||
speed = speed_enc * 0.25
|
||||
heading = heading_enc * 0.01
|
||||
|
||||
if not (-90.0 <= lat <= 90.0) or not (-180.0 <= lon <= 180.0):
|
||||
return None
|
||||
|
||||
return RemoteIDObservation(
|
||||
source=source,
|
||||
serial_number="",
|
||||
operator_id="",
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
altitude_m=alt,
|
||||
speed_ms=speed,
|
||||
heading=heading,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class RemoteIDScanner:
|
||||
def __init__(self, output_queue: queue.Queue) -> None:
|
||||
self._queue = output_queue
|
||||
self._sniffer = None
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def _on_wifi_packet(self, pkt) -> None:
|
||||
if not (Dot11Beacon and pkt.haslayer(Dot11Beacon)):
|
||||
return
|
||||
elt = pkt.getlayer(Dot11Elt)
|
||||
while elt:
|
||||
if elt.ID == 221 and elt.info:
|
||||
obs = _parse_wifi_remote_id(elt.info)
|
||||
if obs:
|
||||
with contextlib.suppress(queue.Full):
|
||||
self._queue.put_nowait(obs)
|
||||
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:
|
||||
sniffer = AsyncSniffer(
|
||||
iface=wifi_iface,
|
||||
filter="type mgt subtype beacon",
|
||||
prn=self._on_wifi_packet,
|
||||
store=False,
|
||||
)
|
||||
sniffer.start()
|
||||
self._sniffer = sniffer
|
||||
logger.info("WiFi Remote ID sniffer started on %s", wifi_iface)
|
||||
except Exception as exc:
|
||||
logger.warning("WiFi Remote ID sniffer failed to start: %s", exc)
|
||||
else:
|
||||
logger.info("WiFi Remote ID unavailable (scapy=%s, iface=%s)", SCAPY_AVAILABLE, wifi_iface)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._sniffer:
|
||||
with contextlib.suppress(Exception):
|
||||
self._sniffer.stop()
|
||||
self._sniffer = None
|
||||
@@ -0,0 +1,161 @@
|
||||
"""RF control-link detector — rtl_433 (433/868MHz) + hackrf_sweep (2.4/5.8GHz)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.process import register_process, safe_terminate
|
||||
|
||||
from .models import RFObservation
|
||||
from .signatures import match_signature
|
||||
|
||||
logger = logging.getLogger("intercept.drone.rf_detector")
|
||||
|
||||
_HACKRF_THRESHOLD_DBM = -90.0
|
||||
_DRONE_FREQ_RANGES_HZ = [
|
||||
(433_000_000, 435_000_000),
|
||||
(868_000_000, 869_000_000),
|
||||
(2_400_000_000, 2_484_000_000),
|
||||
(5_725_000_000, 5_875_000_000),
|
||||
]
|
||||
|
||||
|
||||
def _in_drone_band(freq_hz: int) -> bool:
|
||||
return any(lo <= freq_hz <= hi for lo, hi in _DRONE_FREQ_RANGES_HZ)
|
||||
|
||||
|
||||
class RFDetector:
|
||||
def __init__(self, output_queue: queue.Queue) -> None:
|
||||
self._queue = output_queue
|
||||
self._stop_event = threading.Event()
|
||||
self._stop_event.set() # starts in stopped state
|
||||
self._proc_lock = threading.Lock()
|
||||
self._rtl_proc: subprocess.Popen | None = None
|
||||
self._hackrf_proc: subprocess.Popen | None = None
|
||||
self._threads: list[threading.Thread] = []
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return not self._stop_event.is_set()
|
||||
|
||||
def _handle_rtl433_line(self, line: str) -> None:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return
|
||||
freq = data.get("freq")
|
||||
rssi = data.get("rssi")
|
||||
if freq is None or rssi is None:
|
||||
return
|
||||
freq_hz = int(float(freq))
|
||||
if not _in_drone_band(freq_hz):
|
||||
return
|
||||
protocol = match_signature(freq_hz)
|
||||
with contextlib.suppress(queue.Full):
|
||||
self._queue.put_nowait(
|
||||
RFObservation(
|
||||
frequency_hz=freq_hz,
|
||||
protocol=protocol,
|
||||
rssi=float(rssi),
|
||||
hardware="RTL433",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
def _handle_hackrf_line(self, line: str) -> None:
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
if len(parts) < 7:
|
||||
return
|
||||
try:
|
||||
hz_low = int(parts[2])
|
||||
hz_high = int(parts[3])
|
||||
db_values = [float(p) for p in parts[6:] if p]
|
||||
except (ValueError, IndexError):
|
||||
return
|
||||
if not db_values:
|
||||
return
|
||||
avg_db = sum(db_values) / len(db_values)
|
||||
if avg_db < _HACKRF_THRESHOLD_DBM:
|
||||
return
|
||||
freq_hz = (hz_low + hz_high) // 2
|
||||
if not _in_drone_band(freq_hz):
|
||||
return
|
||||
protocol = match_signature(freq_hz)
|
||||
with contextlib.suppress(queue.Full):
|
||||
self._queue.put_nowait(
|
||||
RFObservation(
|
||||
frequency_hz=freq_hz,
|
||||
protocol=protocol,
|
||||
rssi=avg_db,
|
||||
hardware="HACKRF",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
|
||||
def _run_rtl433(self, device_index: int) -> None:
|
||||
rtl_bin = shutil.which("rtl_433")
|
||||
if not rtl_bin:
|
||||
logger.warning("rtl_433 not found — RTL-SDR RF detection disabled")
|
||||
return
|
||||
cmd = [rtl_bin, "-d", str(device_index), "-F", "json", "-f", "433920000", "-f", "868300000"]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
register_process(proc)
|
||||
with self._proc_lock:
|
||||
self._rtl_proc = proc
|
||||
for raw_line in iter(proc.stdout.readline, b""):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self._handle_rtl433_line(raw_line.decode("utf-8", errors="replace").strip())
|
||||
safe_terminate(proc)
|
||||
except Exception as exc:
|
||||
logger.warning("rtl_433 error: %s", exc)
|
||||
|
||||
def _run_hackrf(self) -> None:
|
||||
hackrf_bin = shutil.which("hackrf_sweep")
|
||||
if not hackrf_bin:
|
||||
logger.warning("hackrf_sweep not found — HackRF RF detection disabled")
|
||||
return
|
||||
cmd = [hackrf_bin, "-f", "2400:2484", "-f", "5725:5875", "-w", "1000000"]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
register_process(proc)
|
||||
with self._proc_lock:
|
||||
self._hackrf_proc = proc
|
||||
for raw_line in iter(proc.stdout.readline, b""):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self._handle_hackrf_line(raw_line.decode("utf-8", errors="replace").strip())
|
||||
safe_terminate(proc)
|
||||
except Exception as exc:
|
||||
logger.warning("hackrf_sweep error: %s", exc)
|
||||
|
||||
def start(self, rtl_sdr_index: int = 0, use_hackrf: bool = True) -> None:
|
||||
if self.running:
|
||||
return
|
||||
self._stop_event.clear()
|
||||
t1 = threading.Thread(target=self._run_rtl433, args=(rtl_sdr_index,), daemon=True)
|
||||
t1.start()
|
||||
self._threads.append(t1)
|
||||
if use_hackrf:
|
||||
t2 = threading.Thread(target=self._run_hackrf, daemon=True)
|
||||
t2.start()
|
||||
self._threads.append(t2)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop_event.set()
|
||||
with self._proc_lock:
|
||||
rtl_proc = self._rtl_proc
|
||||
hackrf_proc = self._hackrf_proc
|
||||
self._rtl_proc = None
|
||||
self._hackrf_proc = None
|
||||
safe_terminate(rtl_proc)
|
||||
safe_terminate(hackrf_proc)
|
||||
self._threads.clear()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Drone RF protocol signature table and frequency matcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_SIGNATURES = [
|
||||
{
|
||||
"name": "FRSKY",
|
||||
"freq_min_hz": 433_050_000,
|
||||
"freq_max_hz": 434_790_000,
|
||||
},
|
||||
{
|
||||
"name": "FRSKY_868",
|
||||
"freq_min_hz": 868_000_000,
|
||||
"freq_max_hz": 868_600_000,
|
||||
},
|
||||
{
|
||||
"name": "DJI_OCUSYNC",
|
||||
"freq_min_hz": 2_400_000_000,
|
||||
"freq_max_hz": 2_483_500_000,
|
||||
},
|
||||
{
|
||||
"name": "FPV_VIDEO",
|
||||
"freq_min_hz": 5_725_000_000,
|
||||
"freq_max_hz": 5_875_000_000,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def match_signature(frequency_hz: int) -> str:
|
||||
"""Return the protocol name for a detected frequency, or 'UNKNOWN'."""
|
||||
for sig in _SIGNATURES:
|
||||
if sig["freq_min_hz"] <= frequency_hz <= sig["freq_max_hz"]:
|
||||
return sig["name"]
|
||||
return "UNKNOWN"
|
||||
Reference in New Issue
Block a user