mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user