From e8b94b6efcbb44e6493116e52836eb72b7467f7a Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 3 May 2026 12:52:03 +0100 Subject: [PATCH] feat(drone): add RFDetector for rtl_433 and hackrf_sweep control-link detection Implements RFDetector class that wraps rtl_433 (433/868MHz) and hackrf_sweep (2.4/5.8GHz) subprocesses, emitting RFObservation objects onto a shared queue. Includes signature matching, frequency band validation, and power thresholding. - _handle_rtl433_line(): Parse JSON output, filter drone bands, emit observations - _handle_hackrf_line(): Parse CSV output, average power levels, threshold at -90dBm - start()/stop(): Manage subprocess threads for concurrent RF detection - Graceful handling of missing tools (rtl_433, hackrf_sweep) Co-Authored-By: Claude Sonnet 4.6 --- tests/test_drone_rf_detector.py | 83 ++++++++++++++++++ utils/drone/rf_detector.py | 146 ++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/test_drone_rf_detector.py create mode 100644 utils/drone/rf_detector.py diff --git a/tests/test_drone_rf_detector.py b/tests/test_drone_rf_detector.py new file mode 100644 index 0000000..3cb6887 --- /dev/null +++ b/tests/test_drone_rf_detector.py @@ -0,0 +1,83 @@ +"""Tests for RFDetector (rtl_433 + hackrf_sweep control-link detection).""" + +from __future__ import annotations + +import json +import queue +from unittest.mock import MagicMock, patch + +import pytest + +from utils.drone.models import RFObservation +from utils.drone.rf_detector import RFDetector + + +@pytest.fixture +def detector(): + q = queue.Queue() + return RFDetector(output_queue=q), q + + +def test_detector_not_running_initially(detector): + det, q = detector + assert not det.running + + +def test_rtl433_json_line_emits_observation(detector): + det, q = detector + rtl433_line = json.dumps( + { + "freq": 433920000, + "rssi": -68.5, + "protocol": "FrSky", + } + ) + det._handle_rtl433_line(rtl433_line) + obs = q.get_nowait() + assert isinstance(obs, RFObservation) + assert obs.frequency_hz == 433_920_000 + assert obs.hardware == "RTL433" + assert obs.rssi == -68.5 + + +def test_rtl433_non_json_line_ignored(detector): + det, q = detector + det._handle_rtl433_line("not json at all") + assert q.empty() + + +def test_hackrf_sweep_line_emits_observation(detector): + det, q = detector + # hackrf_sweep CSV: date, time, hz_low, hz_high, hz_bin_width, num_samples, db, db, ... + hz_low = 2_440_000_000 + hz_high = 2_441_000_000 + sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -45.2, -46.1, -44.8" + det._handle_hackrf_line(sweep_line) + obs = q.get_nowait() + assert isinstance(obs, RFObservation) + assert obs.hardware == "HACKRF" + assert obs.frequency_hz == hz_low + assert obs.rssi < 0 + + +def test_hackrf_sweep_below_threshold_ignored(detector): + det, q = detector + hz_low = 2_440_000_000 + hz_high = 2_441_000_000 + # Very low power — should be ignored (below -90 dBm threshold) + sweep_line = f"2026-05-03, 12:00:00, {hz_low}, {hz_high}, 1000000, 10, -95.0, -96.0, -95.5" + det._handle_hackrf_line(sweep_line) + assert q.empty() + + +def test_start_stop(detector): + det, q = detector + mock_proc = MagicMock() + mock_proc.stdout = MagicMock() + mock_proc.stdout.readline = MagicMock(side_effect=[b""]) + with patch("subprocess.Popen", return_value=mock_proc): + with patch("shutil.which", return_value="/usr/bin/rtl_433"): + det.start(rtl_sdr_index=0) + assert det.running + det.stop() + assert not det.running diff --git a/utils/drone/rf_detector.py b/utils/drone/rf_detector.py new file mode 100644 index 0000000..adbd818 --- /dev/null +++ b/utils/drone/rf_detector.py @@ -0,0 +1,146 @@ +"""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 .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._running = False + 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 self._running + + 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]) + 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 + if not _in_drone_band(hz_low): + return + protocol = match_signature(hz_low) + with contextlib.suppress(queue.Full): + self._queue.put_nowait( + RFObservation( + frequency_hz=hz_low, + 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) + self._rtl_proc = proc + for raw_line in iter(proc.stdout.readline, b""): + if not self._running: + break + self._handle_rtl433_line(raw_line.decode("utf-8", errors="replace").strip()) + 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) + self._hackrf_proc = proc + for raw_line in iter(proc.stdout.readline, b""): + if not self._running: + break + self._handle_hackrf_line(raw_line.decode("utf-8", errors="replace").strip()) + except Exception as exc: + logger.warning("hackrf_sweep error: %s", exc) + + def start(self, rtl_sdr_index: int = 0, use_hackrf: bool = True) -> None: + self._running = True + 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._running = False + for proc in (self._rtl_proc, self._hackrf_proc): + if proc: + with contextlib.suppress(Exception): + proc.terminate() + self._rtl_proc = None + self._hackrf_proc = None + self._threads.clear()