mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Merge pull request #213 from smittix/fix/adsb-photos-and-drone-docs
fix(adsb): fix aircraft photo display and add Drone Intelligence docs
This commit is contained in:
@@ -69,3 +69,6 @@ data/subghz/captures/
|
||||
reset-sdr.*
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -55,6 +55,7 @@ Support the developer of this open-source project
|
||||
- **Spy Stations** - Number stations and diplomatic HF network database
|
||||
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
|
||||
- **Offline Mode** - Bundled assets for air-gapped/field deployments
|
||||
- **Drone Intelligence** - Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
# ============================================
|
||||
|
||||
@@ -354,6 +354,42 @@ Technical Surveillance Countermeasures (TSCM) screening for detecting wireless s
|
||||
- No cryptographic de-randomization
|
||||
- Passive screening only (no active probing by default)
|
||||
|
||||
## Drone Intelligence
|
||||
|
||||
Multi-vector UAV detection and identification system combining three complementary detection methods into unified contact tracking.
|
||||
|
||||
### Detection Vectors
|
||||
|
||||
- **Remote ID (WiFi/BLE)** — Parses ASTM F3411-22a broadcast frames from WiFi Beacon and BLE Advertisement packets. Extracts drone ID, operator ID, drone type, GPS position, altitude, speed, and emergency status. Mandatory for all drones >250g in the US/EU since 2023.
|
||||
- **RTL-SDR RF (433/868 MHz)** — Monitors ISM bands for control link and telemetry signals characteristic of consumer and FPV drones. Detects DJI OcuSync, FrSky, FlySky, and generic FSK/GFSK drone control protocols.
|
||||
- **HackRF (2.4/5.8 GHz)** — Wide-scan of video downlink and telemetry bands used by most consumer drones. Detects power above noise floor across 2.400–2.483 GHz and 5.725–5.875 GHz ISM bands.
|
||||
|
||||
### Contact Correlation
|
||||
|
||||
The `DroneCorrelator` merges raw observations from all three vectors into unified `DroneContact` objects:
|
||||
- **TTL-based store** — contacts expire after 120 seconds of no activity
|
||||
- **Multi-vector fusion** — a single contact can be seen on 1–3 vectors simultaneously
|
||||
- **Deduplication** — observations from the same vector within 5 seconds are collapsed
|
||||
|
||||
### Risk Scoring
|
||||
|
||||
| Level | Criteria |
|
||||
|-------|----------|
|
||||
| High | No Remote ID broadcast (non-compliant) or ASTM non-conformant frame |
|
||||
| Medium | Multiple detection vectors active, or RSSI delta >15 dB between vectors |
|
||||
| Low | Compliant Remote ID present, single detection vector |
|
||||
|
||||
### Live Map
|
||||
|
||||
Remote ID contacts with GPS position data are plotted on a Leaflet map. Markers show drone ID and last known coordinates. Map updates in real time via SSE.
|
||||
|
||||
### Requirements
|
||||
|
||||
- WiFi adapter capable of monitor mode (for BLE/WiFi Remote ID)
|
||||
- RTL-SDR dongle (for 433/868 MHz RF detection)
|
||||
- HackRF One (optional, for 2.4/5.8 GHz detection)
|
||||
- Python package: `opendroneid>=1.0`
|
||||
|
||||
## Meshtastic Mesh Networks
|
||||
|
||||
Integration with Meshtastic LoRa mesh networking devices for decentralized communication.
|
||||
|
||||
@@ -446,6 +446,35 @@ Digital Selective Calling monitoring runs alongside AIS:
|
||||
- Full functionality requires WiFi adapter, Bluetooth adapter, and SDR hardware
|
||||
- Threat detection uses a database of 47K+ known tracker fingerprints
|
||||
|
||||
## Drone Intelligence
|
||||
|
||||
1. **Open Mode** - Select "Drone Intel" from the Intel group in the navigation bar
|
||||
2. **Configure Interfaces** - Enter your WiFi interface name (must support monitor mode) for Remote ID detection
|
||||
3. **Set RTL-SDR Index** - If you have multiple RTL-SDR devices, enter the device index (default: 0)
|
||||
4. **Start** - Click "Start Scan" to activate all available detection vectors simultaneously
|
||||
5. **Monitor Contacts** - Detected drone contacts appear in the contact list with ID, vectors, risk level, and last seen time
|
||||
6. **View Map** - Contacts with GPS data from Remote ID are plotted on the live map
|
||||
|
||||
### Detection Vectors
|
||||
|
||||
- **Remote ID (WiFi/BLE)** — Passive sniff of 802.11 beacon frames and BLE advertisements. Decodes ASTM F3411 payloads: drone GPS, operator ID, drone type, speed, altitude, and emergency status
|
||||
- **433/868 MHz RF** — RTL-SDR scans ISM bands for drone control link and telemetry RF signatures
|
||||
- **2.4/5.8 GHz** — HackRF (if present) sweeps video downlink bands for active drone transmissions
|
||||
|
||||
### Risk Levels
|
||||
|
||||
- **High** — Drone operating without Remote ID (non-compliant) or malformed ASTM frame. Warrants immediate attention.
|
||||
- **Medium** — Contact detected on multiple RF vectors, or significant RSSI difference between vectors (>15 dB). May indicate evasion or multi-radio platform.
|
||||
- **Low** — Compliant Remote ID broadcast, single detection vector. Standard consumer drone.
|
||||
|
||||
### Tips
|
||||
|
||||
- Remote ID is mandatory for drones >250g in the US (FAA) and EU (EU 2019/945) — absence of Remote ID is itself a significant indicator
|
||||
- WiFi adapter must support monitor mode; run `airmon-ng check kill` if other processes interfere
|
||||
- The contact map only shows drones that broadcast GPS coordinates via Remote ID
|
||||
- Contacts expire after 120 seconds of inactivity — the list shows only currently active drones
|
||||
- HackRF detection is passive (receive-only); no transmission occurs
|
||||
|
||||
## Spy Stations
|
||||
|
||||
1. **Browse Database** - View the full list of documented number stations and diplomatic networks
|
||||
|
||||
+6
-1
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">34</span>
|
||||
<span class="stat-value">35</span>
|
||||
<span class="stat-label">Modes</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
@@ -202,6 +202,11 @@
|
||||
<h3>TSCM</h3>
|
||||
<p>Counter-surveillance with baseline recording, threat detection, device correlation, and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="intel">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg></div>
|
||||
<h3>Drone Intelligence</h3>
|
||||
<p>Multi-vector UAV detection via ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, and HackRF 2.4/5.8 GHz scanning with live contact map and risk scoring.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
||||
<h3>Meshtastic</h3>
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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)
|
||||
|
||||
+545
-454
File diff suppressed because it is too large
Load Diff
+132
@@ -0,0 +1,132 @@
|
||||
"""Drone intelligence routes — multi-vector UAV detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
|
||||
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
|
||||
from utils.validation import validate_device_index
|
||||
|
||||
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
|
||||
_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, _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=_obs_queue)
|
||||
if _rf_detector is None:
|
||||
_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")
|
||||
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
|
||||
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)
|
||||
if _rf_detector:
|
||||
_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
|
||||
with _drone_lock:
|
||||
if _remote_id_scanner:
|
||||
_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})
|
||||
|
||||
|
||||
@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"},
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
/* Drone Intelligence Styles */
|
||||
|
||||
.drone-vector-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.drone-vector-pill {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-dim);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.drone-vector-pill.active {
|
||||
background: color-mix(in srgb, var(--accent-cyan) 15%, transparent);
|
||||
color: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.drone-contact-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.drone-contact-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.drone-contact-card.high-risk {
|
||||
border-left: 3px solid var(--accent-red);
|
||||
}
|
||||
|
||||
.drone-contact-card.medium-risk {
|
||||
border-left: 3px solid var(--accent-yellow);
|
||||
}
|
||||
|
||||
.drone-contact-card.low-risk {
|
||||
border-left: 3px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.drone-compliance-badge {
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.drone-compliance-badge.compliant {
|
||||
background: color-mix(in srgb, var(--accent-green) 20%, transparent);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.drone-compliance-badge.non-compliant {
|
||||
background: color-mix(in srgb, var(--accent-red) 20%, transparent);
|
||||
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); }
|
||||
}
|
||||
@@ -17,12 +17,13 @@ const CheatSheets = (function () {
|
||||
sstv: { title: 'ISS SSTV', icon: '🖼️', hardware: 'RTL-SDR + 145MHz antenna', description: 'Receives ISS SSTV images via slowrx.', whatToExpect: 'Color images during ISS SSTV events (PD180 mode).', tips: ['ISS SSTV: 145.800 MHz', 'Check ARISS for active event dates', 'ISS must be overhead — check pass times'] },
|
||||
weathersat: { title: 'Weather Satellites', icon: '🌤️', hardware: 'RTL-SDR + 137MHz turnstile/QFH antenna', description: 'Decodes NOAA APT and Meteor LRPT weather imagery via SatDump.', whatToExpect: 'Infrared/visible cloud imagery.', tips: ['NOAA 15/18/19: 137.1–137.9 MHz APT', 'Meteor M2-3: 137.9 MHz LRPT', 'Use circular polarized antenna (QFH or turnstile)'] },
|
||||
sstv_general:{ title: 'HF SSTV', icon: '📷', hardware: 'RTL-SDR + HF upconverter', description: 'Receives HF SSTV transmissions.', whatToExpect: 'Amateur radio images on 14.230 MHz (USB mode).', tips: ['14.230 MHz USB is primary HF SSTV frequency', 'Scottie 1 and Martin 1 most common', 'Best during daylight hours'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
gps: { title: 'GPS Receiver', icon: '🗺️', hardware: 'USB GPS receiver (NMEA)', description: 'Streams GPS position and feeds location to other modes.', whatToExpect: 'Lat/lon, altitude, speed, heading, satellite count.', tips: ['BT Locate uses GPS for trail logging', 'Set observer location for satellite prediction', 'Verify a 3D fix before relying on altitude'] },
|
||||
spaceweather:{ title: 'Space Weather', icon: '☀️', hardware: 'None (NOAA/SpaceWeatherLive data)', description: 'Monitors solar activity and geomagnetic storm indices.', whatToExpect: 'Kp index, solar flux, X-ray flare alerts, CME tracking.', tips: ['High Kp (≥5) = geomagnetic storm', 'X-class flares cause HF radio blackouts', 'Check before HF or satellite operations'] },
|
||||
controller_monitor: { title: 'Controller Monitor', icon: '🖧', hardware: 'Optional remote agents', description: 'Aggregated controller view across connected agents and local sources.', whatToExpect: 'Combined device activity, logs, and agent health in one place.', tips: ['Use it to compare what each agent is seeing', 'Check agent status before remote starts', 'Open Manage to add or troubleshoot agents'] },
|
||||
tscm: { title: 'TSCM Counter-Surveillance', icon: '🔍', hardware: 'WiFi + Bluetooth adapters', description: 'Technical Surveillance Countermeasures — detects hidden devices.', whatToExpect: 'RF baseline comparison, rogue device alerts, tracker detection.', tips: ['Take baseline in a known-clean environment', 'New strong signals = potential bug', 'Correlate WiFi + Bluetooth observations'] },
|
||||
spystations: { title: 'Spy Stations', icon: '🕵️', hardware: 'RTL-SDR + HF antenna', description: 'Database of known number stations, military, and diplomatic HF signals.', whatToExpect: 'Scheduled broadcasts, frequency database, tune-to links.', tips: ['Numbers stations often broadcast on the hour', 'Use Spectrum Waterfall to tune directly', 'STANAG and HF mil signals are common'] },
|
||||
websdr: { title: 'WebSDR', icon: '🌐', hardware: 'None (uses remote SDR servers)', description: 'Access remote WebSDR receivers worldwide for HF shortwave listening.', whatToExpect: 'Live audio from global HF receivers, waterfall display.', tips: ['websdr.org lists available servers', 'Good for HF when local antenna is lacking', 'Use in-app player for seamless experience'] },
|
||||
drone: { title: 'Drone Intelligence', icon: '🚁', hardware: 'WiFi adapter (monitor mode) + RTL-SDR + optional HackRF', description: 'Multi-vector UAV detection: ASTM F3411 Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF fingerprinting, HackRF 2.4/5.8 GHz.', whatToExpect: 'Drone contacts with ID, operator, GPS position (if broadcast), detection vectors, and risk level.', tips: ['Remote ID is mandatory in the US/EU since 2023 — absence flags high risk', 'RTL-SDR catches DJI/FPV video links on 2.4 GHz if HackRF unavailable', 'Risk HIGH = no Remote ID or non-compliant; MEDIUM = multi-vector or RSSI anomaly', 'Map markers appear only for contacts with GPS coordinates from Remote ID'] },
|
||||
subghz: { title: 'SubGHz Transceiver', icon: '📡', hardware: 'HackRF One', description: 'Transmit and receive sub-GHz RF signals for IoT and industrial protocols.', whatToExpect: 'Raw signal capture, replay, and protocol analysis.', tips: ['Only use on licensed frequencies', 'Capture mode records raw IQ for replay', 'Common: garage doors, keyfobs, 315/433/868/915 MHz'] },
|
||||
rtlamr: { title: 'Utility Meter Reader', icon: '⚡', hardware: 'RTL-SDR dongle', description: 'Reads AMI/AMR smart utility meter broadcasts via rtlamr.', whatToExpect: 'Meter IDs, consumption readings, interval data.', tips: ['Most meters broadcast on 915 MHz', 'MSG types 5, 7, 13, 21 most common', 'Consumption data is read-only public broadcast'] },
|
||||
waterfall: { title: 'Spectrum Waterfall', icon: '🌊', hardware: 'RTL-SDR or HackRF (WebSocket)', description: 'Full-screen real-time FFT spectrum waterfall display.', whatToExpect: 'Color-coded signal intensity scrolling over time.', tips: ['Turbo palette has best contrast for weak signals', 'Peak hold shows max power in red', 'Hover over waterfall to see frequency'] },
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
(function DroneMode() {
|
||||
'use strict';
|
||||
|
||||
let _sse = null;
|
||||
let _map = null;
|
||||
let _markers = {};
|
||||
let _trails = {};
|
||||
let _running = false;
|
||||
|
||||
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) {
|
||||
_map.remove();
|
||||
_map = null;
|
||||
}
|
||||
_markers = {};
|
||||
_trails = {};
|
||||
}
|
||||
|
||||
function _connectSSE() {
|
||||
if (_sse) return;
|
||||
_sse = new EventSource('/drone/stream');
|
||||
_sse.addEventListener('message', function (e) {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'contact') _handleContact(msg.data);
|
||||
} catch (_) {}
|
||||
});
|
||||
_sse.onerror = function () {
|
||||
_sse.close();
|
||||
_sse = null;
|
||||
setTimeout(_connectSSE, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function _disconnectSSE() {
|
||||
if (_sse) { _sse.close(); _sse = null; }
|
||||
}
|
||||
|
||||
function _handleContact(contact) {
|
||||
_upsertCard(contact);
|
||||
if (contact.position) _upsertMapMarker(contact);
|
||||
_updateStats();
|
||||
}
|
||||
|
||||
function _upsertCard(contact) {
|
||||
const listEl = document.getElementById('droneContactList');
|
||||
if (!listEl) return;
|
||||
let card = document.getElementById('drone-card-' + contact.id);
|
||||
if (!card) {
|
||||
card = document.createElement('div');
|
||||
card.id = 'drone-card-' + contact.id;
|
||||
card.className = 'drone-contact-card';
|
||||
card.addEventListener('click', function () { _focusContact(contact.id); });
|
||||
listEl.prepend(card);
|
||||
}
|
||||
card.className = 'drone-contact-card ' + contact.risk_level + '-risk';
|
||||
const complianceLabel = contact.compliant
|
||||
? '<span class="drone-compliance-badge compliant">Remote ID</span>'
|
||||
: '<span class="drone-compliance-badge non-compliant">No Remote ID</span>';
|
||||
const vectors = (contact.detection_vectors || []).map(function (v) {
|
||||
return '<span class="drone-vector-pill active">' + v + '</span>';
|
||||
}).join('');
|
||||
const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + 'm' : '—';
|
||||
const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + 'm/s' : '—';
|
||||
card.innerHTML = [
|
||||
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">',
|
||||
' <span style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan);">' + (contact.serial_number || contact.id) + '</span>',
|
||||
' ' + complianceLabel,
|
||||
'</div>',
|
||||
'<div class="drone-vector-pills" style="margin-bottom:6px;">' + vectors + '</div>',
|
||||
'<div style="font-size:10px; color:var(--text-dim);">Alt: ' + alt + ' Speed: ' + spd + '</div>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
function _upsertMapMarker(contact) {
|
||||
if (!_map) return;
|
||||
const lat = contact.position[0];
|
||||
const lon = contact.position[1];
|
||||
if (_markers[contact.id]) {
|
||||
_markers[contact.id].setLatLng([lat, lon]);
|
||||
} else {
|
||||
const color = contact.risk_level === 'high' ? 'var(--accent-red)' :
|
||||
contact.risk_level === 'medium' ? 'var(--accent-yellow)' :
|
||||
'var(--accent-cyan)';
|
||||
const icon = L.divIcon({
|
||||
className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''),
|
||||
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid #fff;"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
});
|
||||
_markers[contact.id] = L.marker([lat, lon], { icon: icon })
|
||||
.addTo(_map)
|
||||
.bindPopup('<b>' + (contact.serial_number || contact.id) + '</b><br>Risk: ' + contact.risk_level);
|
||||
}
|
||||
const trailPoints = (contact.position_history || []).map(function (p) {
|
||||
return [p.lat, p.lon];
|
||||
});
|
||||
if (_trails[contact.id]) {
|
||||
_trails[contact.id].setLatLngs(trailPoints);
|
||||
} else if (trailPoints.length > 1) {
|
||||
_trails[contact.id] = L.polyline(trailPoints, {
|
||||
color: contact.risk_level === 'high' ? '#ff4444' : '#00ccff',
|
||||
weight: 1.5,
|
||||
opacity: 0.6,
|
||||
}).addTo(_map);
|
||||
}
|
||||
}
|
||||
|
||||
function _focusContact(contactId) {
|
||||
if (_map && _markers[contactId]) {
|
||||
_map.panTo(_markers[contactId].getLatLng());
|
||||
_markers[contactId].openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
function _updateStats() {
|
||||
fetch('/drone/contacts')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (contacts) {
|
||||
const nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
|
||||
const countEl = document.getElementById('droneContactCount');
|
||||
const ncEl = document.getElementById('droneNonCompliantCount');
|
||||
if (countEl) countEl.textContent = contacts.length;
|
||||
if (ncEl) ncEl.textContent = nonCompliant;
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _refreshStatus() {
|
||||
fetch('/drone/status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_running = data.running;
|
||||
_setRunningUI(data.running);
|
||||
_updateVectorPills(data.vectors || []);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _start() {
|
||||
const iface = document.getElementById('droneWifiIface')?.value.trim() || null;
|
||||
fetch('/drone/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wifi_iface: iface }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () { _setRunningUI(true); _refreshStatus(); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _stop() {
|
||||
fetch('/drone/stop', { method: 'POST' })
|
||||
.then(function () { _setRunningUI(false); _refreshStatus(); })
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function _setRunningUI(running) {
|
||||
const startBtn = document.getElementById('droneStartBtn');
|
||||
const stopBtn = document.getElementById('droneStopBtn');
|
||||
const statusEl = document.getElementById('droneStatusText');
|
||||
if (startBtn) startBtn.disabled = running;
|
||||
if (stopBtn) stopBtn.disabled = !running;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = running ? 'Active' : 'Standby';
|
||||
statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)';
|
||||
}
|
||||
}
|
||||
|
||||
function _updateVectorPills(activeVectors) {
|
||||
const pillMap = {
|
||||
'REMOTE_ID': 'dronePillRemoteId',
|
||||
'RTL433': 'dronePill433',
|
||||
'HACKRF': 'dronePillHackrf',
|
||||
};
|
||||
Object.entries(pillMap).forEach(function ([key, id]) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); }));
|
||||
});
|
||||
}
|
||||
|
||||
window.DroneMode = { init: init, destroy: destroy };
|
||||
})();
|
||||
@@ -3555,17 +3555,15 @@ sudo make install</code>
|
||||
const photoCache = {};
|
||||
|
||||
async function fetchAircraftPhoto(registration) {
|
||||
const container = document.getElementById('aircraftPhotoContainer');
|
||||
const img = document.getElementById('aircraftPhoto');
|
||||
const link = document.getElementById('aircraftPhotoLink');
|
||||
const credit = document.getElementById('aircraftPhotoCredit');
|
||||
|
||||
if (!container || !img) return;
|
||||
|
||||
// Check cache first
|
||||
// Check cache first (synchronous path — DOM refs are always current here)
|
||||
if (photoCache[registration]) {
|
||||
const cached = photoCache[registration];
|
||||
if (cached.thumbnail) {
|
||||
const container = document.getElementById('aircraftPhotoContainer');
|
||||
const img = document.getElementById('aircraftPhoto');
|
||||
const link = document.getElementById('aircraftPhotoLink');
|
||||
const credit = document.getElementById('aircraftPhotoCredit');
|
||||
if (!container || !img) return;
|
||||
img.src = cached.thumbnail;
|
||||
link.href = cached.link || '#';
|
||||
credit.textContent = cached.photographer ? `Photo: ${cached.photographer}` : '';
|
||||
@@ -3574,13 +3572,24 @@ sudo make install</code>
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: bail early if the panel doesn't exist yet
|
||||
if (!document.getElementById('aircraftPhotoContainer')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Cache the result
|
||||
// Cache before touching DOM — subsequent synchronous calls will hit this
|
||||
photoCache[registration] = data;
|
||||
|
||||
// Re-query after the await: showAircraftDetails rebuilds innerHTML on every
|
||||
// RAF update, so refs captured before the await may point to detached nodes.
|
||||
const container = document.getElementById('aircraftPhotoContainer');
|
||||
const img = document.getElementById('aircraftPhoto');
|
||||
const link = document.getElementById('aircraftPhotoLink');
|
||||
const credit = document.getElementById('aircraftPhotoCredit');
|
||||
if (!container || !img) return;
|
||||
|
||||
if (data.success && data.thumbnail) {
|
||||
img.src = data.thumbnail;
|
||||
link.href = data.link || '#';
|
||||
@@ -3591,7 +3600,8 @@ sudo make install</code>
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Failed to fetch aircraft photo:', err);
|
||||
container.style.display = 'none';
|
||||
const container = document.getElementById('aircraftPhotoContainer');
|
||||
if (container) container.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -102,7 +102,8 @@
|
||||
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
|
||||
meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}",
|
||||
system: "{{ url_for('static', filename='css/modes/system.css') }}",
|
||||
ook: "{{ url_for('static', filename='css/modes/ook.css') }}"
|
||||
ook: "{{ url_for('static', filename='css/modes/ook.css') }}",
|
||||
drone: "{{ url_for('static', filename='css/modes/drone.css') }}"
|
||||
};
|
||||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||||
window.INTERCEPT_MODE_STYLE_PROMISES = {};
|
||||
@@ -186,7 +187,8 @@
|
||||
spaceweather: "{{ url_for('static', filename='js/modes/space-weather.js') }}",
|
||||
system: "{{ url_for('static', filename='js/modes/system.js') }}",
|
||||
meteor: "{{ url_for('static', filename='js/modes/meteor.js') }}",
|
||||
waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"
|
||||
waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21",
|
||||
drone: "{{ url_for('static', filename='js/modes/drone.js') }}"
|
||||
};
|
||||
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
|
||||
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
|
||||
@@ -764,6 +766,8 @@
|
||||
|
||||
{% include 'partials/modes/ais.html' %}
|
||||
|
||||
{% include 'partials/modes/drone.html' %}
|
||||
|
||||
{% include 'partials/modes/radiosonde.html' %}
|
||||
|
||||
{% include 'partials/modes/spy-stations.html' %}
|
||||
@@ -3767,6 +3771,7 @@
|
||||
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', group: 'wireless' },
|
||||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||||
drone: { label: 'Drone Intel', indicator: 'DRONE', outputTitle: 'Drone Intelligence', group: 'intel' },
|
||||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||||
@@ -4403,6 +4408,7 @@
|
||||
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||||
drone: () => typeof DroneMode !== 'undefined' && DroneMode.destroy?.(),
|
||||
};
|
||||
return moduleDestroyMap[mode] || null;
|
||||
}
|
||||
@@ -4713,6 +4719,7 @@
|
||||
document.getElementById('aprsMode')?.classList.toggle('active', mode === 'aprs');
|
||||
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
|
||||
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
|
||||
document.getElementById('droneMode')?.classList.toggle('active', mode === 'drone');
|
||||
document.getElementById('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
|
||||
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
|
||||
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
|
||||
@@ -5011,6 +5018,8 @@
|
||||
SystemHealth.init();
|
||||
} else if (mode === 'ook') {
|
||||
OokMode.init();
|
||||
} else if (mode === 'drone') {
|
||||
if (typeof DroneMode !== 'undefined') DroneMode.init();
|
||||
}
|
||||
if (requestId !== modeSwitchRequestId) return;
|
||||
|
||||
|
||||
@@ -270,6 +270,17 @@
|
||||
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Drone Intelligence Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Detects UAVs via three simultaneous vectors: Remote ID (WiFi/BLE), RTL-SDR 433/868 MHz RF, and HackRF 2.4/5.8 GHz</li>
|
||||
<li>Parses ASTM F3411 Remote ID broadcast frames — captures drone ID, operator ID, and GPS position</li>
|
||||
<li>RF fingerprinting on 433/868 MHz ISM bands and 2.4/5.8 GHz to detect drone control links and video downlinks</li>
|
||||
<li>Correlates observations across all vectors into unified <em>DroneContact</em> entries with risk scoring</li>
|
||||
<li>Risk levels: <strong>High</strong> (non-compliant / no Remote ID), <strong>Medium</strong> (multi-vector or RSSI delta >15 dB), <strong>Low</strong> (compliant, single vector)</li>
|
||||
<li>Live map shows last known position for Remote ID contacts with GPS data</li>
|
||||
<li>Requires: WiFi adapter (monitor mode) for BLE Remote ID, RTL-SDR for 433/868 MHz, HackRF for 2.4/5.8 GHz</li>
|
||||
</ul>
|
||||
|
||||
<h3>Network Monitor</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Aggregates data from multiple remote INTERCEPT agents</li>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- DRONE INTELLIGENCE MODE -->
|
||||
<div id="droneMode" class="mode-content" style="display: none;">
|
||||
<div class="section">
|
||||
<h3>Drone Intelligence</h3>
|
||||
<p class="info-text" style="margin-bottom: 12px;">
|
||||
Multi-vector UAV detection: Remote ID (WiFi/BLE), 433/868 MHz control links, 2.4/5.8 GHz wideband.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Detection Vectors</h3>
|
||||
<div id="droneVectorStatus" class="drone-vector-pills">
|
||||
<span class="drone-vector-pill" id="dronePillRemoteId">Remote ID</span>
|
||||
<span class="drone-vector-pill" id="dronePill433">433 MHz</span>
|
||||
<span class="drone-vector-pill" id="dronePillHackrf">2.4 / 5.8 GHz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>WiFi Interface <span style="font-weight:400; font-size:11px; color:var(--text-dim)">(monitor mode)</span></h3>
|
||||
<input type="text" id="droneWifiIface" placeholder="e.g. wlan0mon" style="width:100%;">
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="droneStartBtn" class="run-btn" style="flex:1;">Start</button>
|
||||
<button id="droneStopBtn" class="stop-btn" style="flex:1;" disabled>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Status</h3>
|
||||
<p class="info-text">
|
||||
Status: <span id="droneStatusText" style="color:var(--accent-yellow);">Standby</span>
|
||||
</p>
|
||||
<p class="info-text">
|
||||
Contacts: <span id="droneContactCount">0</span>
|
||||
|
|
||||
Non-compliant: <span id="droneNonCompliantCount" style="color:var(--accent-red);">0</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Detected Contacts</h3>
|
||||
<div id="droneContactList"></div>
|
||||
</div>
|
||||
|
||||
<div id="droneMap" class="drone-map"></div>
|
||||
</div>
|
||||
@@ -150,6 +150,7 @@
|
||||
{{ mode_item('tscm', 'TSCM', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>') }}
|
||||
{{ mode_item('spystations', 'Spy Stations', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mode_item('websdr', 'WebSDR', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('drone', 'Drone Intel', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><line x1="8" y1="8" x2="9" y2="9"/><line x1="16" y1="8" x2="15" y2="9"/><line x1="8" y1="16" x2="9" y2="15"/><line x1="16" y1="16" x2="15" y2="15"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# tests/test_drone_correlator.py
|
||||
import queue
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.drone.correlator import DroneCorrelator
|
||||
from utils.drone.models import RemoteIDObservation, RFObservation
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _remote_id_obs(serial="SN001", lat=51.5, lon=-0.1):
|
||||
return RemoteIDObservation(
|
||||
source="WIFI",
|
||||
serial_number=serial,
|
||||
operator_id="OP001",
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
altitude_m=50.0,
|
||||
speed_ms=5.0,
|
||||
heading=90.0,
|
||||
timestamp=_now(),
|
||||
)
|
||||
|
||||
|
||||
def _rf_obs(freq=433_920_000, proto="FRSKY", rssi=-70.0):
|
||||
return RFObservation(
|
||||
frequency_hz=freq,
|
||||
protocol=proto,
|
||||
rssi=rssi,
|
||||
hardware="RTL433",
|
||||
timestamp=_now(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def correlator():
|
||||
q = queue.Queue()
|
||||
return DroneCorrelator(output_queue=q), q
|
||||
|
||||
|
||||
def test_remote_id_creates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["compliant"] is True
|
||||
assert contacts[0]["serial_number"] == "SN001"
|
||||
assert contacts[0]["position"] == [51.5, -0.1]
|
||||
|
||||
|
||||
def test_rf_creates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_rf_obs())
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["compliant"] is False
|
||||
|
||||
|
||||
def test_remote_id_emits_sse_event(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
msg = q.get_nowait()
|
||||
assert msg["type"] == "contact"
|
||||
assert msg["data"]["serial_number"] == "SN001"
|
||||
|
||||
|
||||
def test_same_serial_updates_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs(lat=51.5, lon=-0.1))
|
||||
corr.process(_remote_id_obs(lat=51.6, lon=-0.2))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 1
|
||||
assert contacts[0]["position"] == [51.6, -0.2]
|
||||
|
||||
|
||||
def test_different_serials_create_separate_contacts(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs(serial="SN001"))
|
||||
corr.process(_remote_id_obs(serial="SN002"))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts) == 2
|
||||
|
||||
|
||||
def test_position_history_grows(correlator):
|
||||
corr, q = correlator
|
||||
for i in range(5):
|
||||
corr.process(_remote_id_obs(lat=51.0 + i * 0.01, lon=-0.1))
|
||||
contacts = corr.get_all()
|
||||
assert len(contacts[0]["position_history"]) == 5
|
||||
|
||||
|
||||
def test_position_history_capped_at_500(correlator):
|
||||
corr, q = correlator
|
||||
for i in range(510):
|
||||
corr.process(_remote_id_obs(lat=float(i), lon=0.0))
|
||||
store_values = list(corr._store.values())
|
||||
assert len(store_values[0].position_history) == 500
|
||||
|
||||
|
||||
def test_compliant_single_vector_is_low_risk(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = corr.get_all()
|
||||
assert contacts[0]["risk_level"] == "low"
|
||||
|
||||
|
||||
def test_non_compliant_is_high_risk(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_rf_obs())
|
||||
contacts = corr.get_all()
|
||||
assert contacts[0]["risk_level"] == "high"
|
||||
|
||||
|
||||
def test_confidence_increases_with_vectors(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
contacts = {c["id"]: c for c in corr.get_all()}
|
||||
rid_contact = next(c for c in contacts.values() if c["compliant"])
|
||||
assert rid_contact["confidence"] == 0.25 # 1/4
|
||||
|
||||
|
||||
def test_ttl_expiry_removes_contact(correlator):
|
||||
corr, q = correlator
|
||||
corr.process(_remote_id_obs())
|
||||
assert len(corr.get_all()) == 1
|
||||
for key in corr._store.timestamps:
|
||||
corr._store.timestamps[key] = time.time() - 300
|
||||
corr._store.cleanup()
|
||||
assert len(corr.get_all()) == 0
|
||||
@@ -0,0 +1,67 @@
|
||||
# tests/test_drone_models.py
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from utils.drone.models import DroneContact, RFSignal
|
||||
from utils.drone.signatures import match_signature
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_drone_contact_to_dict_minimal():
|
||||
c = DroneContact(id="abc123", first_seen=_now(), last_seen=_now())
|
||||
d = c.to_dict()
|
||||
assert d["id"] == "abc123"
|
||||
assert d["compliant"] is False
|
||||
assert d["risk_level"] == "low"
|
||||
assert d["detection_vectors"] == []
|
||||
assert d["position"] is None
|
||||
|
||||
|
||||
def test_drone_contact_to_dict_with_position():
|
||||
c = DroneContact(id="xyz", first_seen=_now(), last_seen=_now())
|
||||
c.position = (51.5, -0.1)
|
||||
c.serial_number = "SN001"
|
||||
c.compliant = True
|
||||
c.detection_vectors = {"REMOTE_ID_WIFI"}
|
||||
d = c.to_dict()
|
||||
assert d["position"] == [51.5, -0.1]
|
||||
assert d["serial_number"] == "SN001"
|
||||
assert d["detection_vectors"] == ["REMOTE_ID_WIFI"]
|
||||
|
||||
|
||||
def test_drone_contact_position_history_capped():
|
||||
c = DroneContact(id="cap", first_seen=_now(), last_seen=_now())
|
||||
for i in range(510):
|
||||
c.position_history.append((float(i), float(i), _now()))
|
||||
d = c.to_dict()
|
||||
# to_dict sends last 50
|
||||
assert len(d["position_history"]) == 50
|
||||
|
||||
|
||||
def test_rf_signal_fields():
|
||||
s = RFSignal(frequency_hz=433_920_000, protocol="FRSKY", rssi=-65.0, hardware="RTL433", timestamp=_now())
|
||||
assert s.frequency_hz == 433_920_000
|
||||
assert s.protocol == "FRSKY"
|
||||
|
||||
|
||||
def test_match_signature_frsky_433():
|
||||
assert match_signature(433_920_000) == "FRSKY"
|
||||
|
||||
|
||||
def test_match_signature_ocusync_24():
|
||||
assert match_signature(2_440_000_000) == "DJI_OCUSYNC"
|
||||
|
||||
|
||||
def test_match_signature_fpv_58():
|
||||
assert match_signature(5_800_000_000) == "FPV_VIDEO"
|
||||
|
||||
|
||||
def test_match_signature_ocusync_at_2450mhz():
|
||||
# 2,450 MHz is within the DJI_OCUSYNC band
|
||||
assert match_signature(2_450_000_000) == "DJI_OCUSYNC"
|
||||
|
||||
|
||||
def test_match_signature_unrecognised():
|
||||
assert match_signature(100_000_000) == "UNKNOWN"
|
||||
@@ -0,0 +1,92 @@
|
||||
# tests/test_drone_remote_id.py
|
||||
import queue
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from utils.drone.remote_id import RemoteIDScanner, _parse_ble_remote_id, _parse_wifi_remote_id
|
||||
|
||||
|
||||
def _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0) -> bytes:
|
||||
"""Craft a minimal ASTM F3411 Location message (message type 0x01)."""
|
||||
msg_type = 0x01
|
||||
status = 0x00
|
||||
lat_enc = int(lat * 1e7)
|
||||
lon_enc = int(lon * 1e7)
|
||||
alt_enc = int((alt + 1000) / 0.5)
|
||||
speed_enc = int(speed / 0.25)
|
||||
heading_enc = int(heading / 0.01)
|
||||
return struct.pack("<BBiiHBH", msg_type, status, lat_enc, lon_enc, alt_enc, speed_enc, heading_enc)
|
||||
|
||||
|
||||
def _make_basic_id_payload(serial="SN-TESTSERIAL") -> bytes:
|
||||
msg_type = 0x00
|
||||
id_type = 0x01
|
||||
serial_bytes = serial.encode("ascii").ljust(20, b"\x00")[:20]
|
||||
return bytes([msg_type, id_type]) + serial_bytes
|
||||
|
||||
|
||||
def _make_ble_adv_with_remote_id(payload: bytes) -> bytes:
|
||||
uuid_bytes = b"\xfa\xff"
|
||||
service_data_type = 0x16
|
||||
length = len(uuid_bytes) + len(payload) + 1
|
||||
return bytes([length, service_data_type]) + uuid_bytes + payload
|
||||
|
||||
|
||||
def test_parse_ble_location_returns_observation():
|
||||
payload = _make_location_payload(lat=51.5, lon=-0.1, alt=50.0, speed=5.0, heading=90.0)
|
||||
adv = _make_ble_adv_with_remote_id(payload)
|
||||
obs = _parse_ble_remote_id(adv)
|
||||
assert obs is not None
|
||||
assert obs.source == "BLE"
|
||||
assert abs(obs.lat - 51.5) < 0.0001
|
||||
assert abs(obs.lon - (-0.1)) < 0.0001
|
||||
assert abs(obs.altitude_m - 50.0) < 1.0
|
||||
assert abs(obs.speed_ms - 5.0) < 0.5
|
||||
|
||||
|
||||
def test_parse_ble_no_uuid_returns_none():
|
||||
obs = _parse_ble_remote_id(b"\x00\x01\x02\x03")
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_parse_ble_too_short_returns_none():
|
||||
adv = _make_ble_adv_with_remote_id(b"\x01\x00")
|
||||
obs = _parse_ble_remote_id(adv)
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_parse_wifi_remote_id_returns_observation():
|
||||
payload = _make_location_payload(lat=52.0, lon=0.5)
|
||||
obs = _parse_wifi_remote_id(payload)
|
||||
assert obs is not None
|
||||
assert obs.source == "WIFI"
|
||||
assert abs(obs.lat - 52.0) < 0.0001
|
||||
|
||||
|
||||
def test_parse_wifi_non_location_returns_none():
|
||||
payload = _make_basic_id_payload()
|
||||
obs = _parse_wifi_remote_id(payload)
|
||||
assert obs is None
|
||||
|
||||
|
||||
def test_scanner_start_stop():
|
||||
q = queue.Queue()
|
||||
scanner = RemoteIDScanner(output_queue=q)
|
||||
with (
|
||||
patch("utils.drone.remote_id.SCAPY_AVAILABLE", True),
|
||||
patch("utils.drone.remote_id.AsyncSniffer") as mock_sniffer,
|
||||
):
|
||||
mock_sniffer.return_value = MagicMock()
|
||||
scanner.start(wifi_iface="wlan0mon")
|
||||
assert scanner.running
|
||||
scanner.stop()
|
||||
assert not scanner.running
|
||||
|
||||
|
||||
def test_scanner_start_without_scapy_still_works():
|
||||
q = queue.Queue()
|
||||
scanner = RemoteIDScanner(output_queue=q)
|
||||
with patch("utils.drone.remote_id.SCAPY_AVAILABLE", False):
|
||||
scanner.start(wifi_iface=None)
|
||||
assert scanner.running
|
||||
scanner.stop()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""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 + hz_high) // 2
|
||||
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_out_of_band_frequency_ignored(detector):
|
||||
det, q = detector
|
||||
# 915 MHz is not in any drone band
|
||||
line = json.dumps({"freq": 915_000_000, "rssi": -50.0, "protocol": "Generic"})
|
||||
det._handle_rtl433_line(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""])
|
||||
# Patch both shutil.which calls (rtl_433 in _run_rtl433, hackrf_sweep in _run_hackrf)
|
||||
with (
|
||||
patch("subprocess.Popen", return_value=mock_proc),
|
||||
patch("utils.drone.rf_detector.shutil.which", return_value=None),
|
||||
):
|
||||
det.start(rtl_sdr_index=0, use_hackrf=False)
|
||||
assert det.running
|
||||
det.stop()
|
||||
assert not det.running
|
||||
@@ -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")
|
||||
+405
-414
@@ -7,16 +7,16 @@ import shutil
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
logger = logging.getLogger("intercept.dependencies")
|
||||
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian, /usr/local/bin for source builds)
|
||||
EXTRA_TOOL_PATHS = ["/usr/local/bin", "/usr/sbin", "/sbin"]
|
||||
|
||||
# Tools installed to non-standard locations (not on PATH)
|
||||
KNOWN_TOOL_PATHS: dict[str, list[str]] = {
|
||||
'auto_rx.py': [
|
||||
'/opt/radiosonde_auto_rx/auto_rx/auto_rx.py',
|
||||
'/opt/auto_rx/auto_rx.py',
|
||||
"auto_rx.py": [
|
||||
"/opt/radiosonde_auto_rx/auto_rx/auto_rx.py",
|
||||
"/opt/auto_rx/auto_rx.py",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ def get_tool_path(name: str) -> str | None:
|
||||
|
||||
# Prefer native Homebrew binaries on Apple Silicon to avoid mixing Rosetta
|
||||
# /usr/local tools with arm64 Python/runtime.
|
||||
if platform.system() == 'Darwin':
|
||||
if platform.system() == "Darwin":
|
||||
machine = platform.machine().lower()
|
||||
preferred_paths: list[str] = []
|
||||
if machine in {'arm64', 'aarch64'}:
|
||||
preferred_paths.append('/opt/homebrew/bin')
|
||||
preferred_paths.append('/usr/local/bin')
|
||||
if machine in {"arm64", "aarch64"}:
|
||||
preferred_paths.append("/opt/homebrew/bin")
|
||||
preferred_paths.append("/usr/local/bin")
|
||||
|
||||
for base in preferred_paths:
|
||||
full_path = os.path.join(base, name)
|
||||
@@ -78,31 +78,32 @@ def _get_soapy_env() -> dict[str, str]:
|
||||
See: https://github.com/smittix/intercept/issues/77
|
||||
"""
|
||||
import platform
|
||||
|
||||
env = os.environ.copy()
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
if platform.system() == "Darwin":
|
||||
# Homebrew paths for Apple Silicon and Intel Macs
|
||||
homebrew_paths = ['/opt/homebrew', '/usr/local']
|
||||
homebrew_paths = ["/opt/homebrew", "/usr/local"]
|
||||
lib_paths = []
|
||||
module_paths = []
|
||||
|
||||
for base in homebrew_paths:
|
||||
lib_path = f'{base}/lib'
|
||||
lib_path = f"{base}/lib"
|
||||
if os.path.isdir(lib_path):
|
||||
lib_paths.append(lib_path)
|
||||
# SoapySDR modules are in lib/SoapySDR/modules<version>
|
||||
soapy_mod_base = f'{base}/lib/SoapySDR'
|
||||
soapy_mod_base = f"{base}/lib/SoapySDR"
|
||||
if os.path.isdir(soapy_mod_base):
|
||||
module_paths.append(soapy_mod_base)
|
||||
|
||||
if lib_paths:
|
||||
current_dyld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + ([current_dyld] if current_dyld else []))
|
||||
current_dyld = env.get("DYLD_LIBRARY_PATH", "")
|
||||
env["DYLD_LIBRARY_PATH"] = ":".join(lib_paths + ([current_dyld] if current_dyld else []))
|
||||
|
||||
# Set SOAPY_SDR_ROOT if we found Homebrew installation
|
||||
for base in homebrew_paths:
|
||||
if os.path.isdir(f'{base}/lib/SoapySDR'):
|
||||
env['SOAPY_SDR_ROOT'] = base
|
||||
if os.path.isdir(f"{base}/lib/SoapySDR"):
|
||||
env["SOAPY_SDR_ROOT"] = base
|
||||
break
|
||||
|
||||
return env
|
||||
@@ -114,7 +115,7 @@ def check_soapy_factory(factory_name: str) -> bool:
|
||||
# Run SoapySDRUtil --info and look for the factory in 'Available factories'
|
||||
# Use macOS-aware environment to find Homebrew-installed modules
|
||||
env = _get_soapy_env()
|
||||
result = subprocess.run(['SoapySDRUtil', '--info'], capture_output=True, text=True, env=env)
|
||||
result = subprocess.run(["SoapySDRUtil", "--info"], capture_output=True, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
@@ -134,395 +135,390 @@ def check_soapy_factory(factory_name: str) -> bool:
|
||||
|
||||
# Comprehensive tool dependency definitions
|
||||
TOOL_DEPENDENCIES = {
|
||||
'pager': {
|
||||
'name': 'Pager Decoding',
|
||||
'tools': {
|
||||
'rtl_fm': {
|
||||
'required': True,
|
||||
'description': 'RTL-SDR FM demodulator',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
},
|
||||
'multimon-ng': {
|
||||
'required': True,
|
||||
'description': 'Digital transmission decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||
}
|
||||
},
|
||||
'rtl_test': {
|
||||
'required': False,
|
||||
'description': 'RTL-SDR device detection',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'sensor': {
|
||||
'name': '433MHz Sensors',
|
||||
'tools': {
|
||||
'rtl_433': {
|
||||
'required': True,
|
||||
'description': 'ISM band decoder for sensors, weather stations, TPMS',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-433',
|
||||
'brew': 'brew install rtl_433',
|
||||
'manual': 'https://github.com/merbanan/rtl_433'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'wifi': {
|
||||
'name': 'WiFi Reconnaissance',
|
||||
'tools': {
|
||||
'airmon-ng': {
|
||||
'required': True,
|
||||
'description': 'Monitor mode controller',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'airodump-ng': {
|
||||
'required': True,
|
||||
'description': 'WiFi network scanner',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'aireplay-ng': {
|
||||
'required': False,
|
||||
'description': 'Deauthentication / packet injection',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'aircrack-ng': {
|
||||
'required': False,
|
||||
'description': 'Handshake verification',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'brew install aircrack-ng',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'hcxdumptool': {
|
||||
'required': False,
|
||||
'description': 'PMKID capture tool',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hcxdumptool',
|
||||
'brew': 'brew install hcxtools',
|
||||
'manual': 'https://github.com/ZerBea/hcxdumptool'
|
||||
}
|
||||
},
|
||||
'hcxpcapngtool': {
|
||||
'required': False,
|
||||
'description': 'PMKID hash extractor',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hcxtools',
|
||||
'brew': 'brew install hcxtools',
|
||||
'manual': 'https://github.com/ZerBea/hcxtools'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'bluetooth': {
|
||||
'name': 'Bluetooth Scanning',
|
||||
'tools': {
|
||||
'hcitool': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth HCI tool (legacy)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
},
|
||||
'bluetoothctl': {
|
||||
'required': True,
|
||||
'description': 'Modern Bluetooth controller',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
},
|
||||
'hciconfig': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth adapter configuration',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'aircraft': {
|
||||
'name': 'Aircraft Tracking (ADS-B)',
|
||||
'tools': {
|
||||
'dump1090': {
|
||||
'required': False,
|
||||
'description': 'Mode S / ADS-B decoder (preferred)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install dump1090-mutability (or build dump1090-fa from source)',
|
||||
'brew': 'brew install dump1090-mutability',
|
||||
'manual': 'https://github.com/flightaware/dump1090'
|
||||
"pager": {
|
||||
"name": "Pager Decoding",
|
||||
"tools": {
|
||||
"rtl_fm": {
|
||||
"required": True,
|
||||
"description": "RTL-SDR FM demodulator",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-sdr",
|
||||
"brew": "brew install librtlsdr",
|
||||
"manual": "https://osmocom.org/projects/rtl-sdr/wiki",
|
||||
},
|
||||
'alternatives': ['dump1090-mutability', 'dump1090-fa']
|
||||
},
|
||||
'rtl_adsb': {
|
||||
'required': False,
|
||||
'description': 'Simple ADS-B decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'acars': {
|
||||
'name': 'Aircraft Messaging (ACARS)',
|
||||
'tools': {
|
||||
'acarsdec': {
|
||||
'required': True,
|
||||
'description': 'ACARS VHF decoder',
|
||||
'install': {
|
||||
'apt': 'Run ./setup.sh (builds from source)',
|
||||
'brew': 'Run ./setup.sh (builds from source)',
|
||||
'manual': 'https://github.com/TLeconte/acarsdec'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'ais': {
|
||||
'name': 'Vessel Tracking (AIS)',
|
||||
'tools': {
|
||||
'AIS-catcher': {
|
||||
'required': True,
|
||||
'description': 'AIS receiver and decoder',
|
||||
'install': {
|
||||
'apt': 'Download .deb from https://github.com/jvde-github/AIS-catcher/releases',
|
||||
'brew': 'brew install aiscatcher',
|
||||
'manual': 'https://github.com/jvde-github/AIS-catcher/releases'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'aprs': {
|
||||
'name': 'APRS Tracking',
|
||||
'tools': {
|
||||
'direwolf': {
|
||||
'required': False,
|
||||
'description': 'APRS/packet radio decoder (preferred)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install direwolf',
|
||||
'brew': 'brew install direwolf',
|
||||
'manual': 'https://github.com/wb2osz/direwolf'
|
||||
}
|
||||
},
|
||||
'multimon-ng': {
|
||||
'required': False,
|
||||
'description': 'Alternative AFSK1200 decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOenal/multimon-ng'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'satellite': {
|
||||
'name': 'Satellite Tracking',
|
||||
'tools': {
|
||||
'skyfield': {
|
||||
'required': True,
|
||||
'description': 'Python orbital mechanics library',
|
||||
'install': {
|
||||
'pip': 'pip install skyfield',
|
||||
'manual': 'https://rhodesmill.org/skyfield/'
|
||||
"multimon-ng": {
|
||||
"required": True,
|
||||
"description": "Digital transmission decoder",
|
||||
"install": {
|
||||
"apt": "sudo apt install multimon-ng",
|
||||
"brew": "brew install multimon-ng",
|
||||
"manual": "https://github.com/EliasOenal/multimon-ng",
|
||||
},
|
||||
'python_module': True
|
||||
}
|
||||
}
|
||||
},
|
||||
"rtl_test": {
|
||||
"required": False,
|
||||
"description": "RTL-SDR device detection",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-sdr",
|
||||
"brew": "brew install librtlsdr",
|
||||
"manual": "https://osmocom.org/projects/rtl-sdr/wiki",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'sdr_hardware': {
|
||||
'name': 'SDR Hardware Support',
|
||||
'tools': {
|
||||
'SoapySDRUtil': {
|
||||
'required': False,
|
||||
'description': 'Universal SDR abstraction (required for LimeSDR, HackRF)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-tools',
|
||||
'brew': 'brew install soapysdr',
|
||||
'manual': 'https://github.com/pothosware/SoapySDR'
|
||||
}
|
||||
},
|
||||
'rx_fm': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR FM receiver (for non-RTL hardware)',
|
||||
'install': {
|
||||
'manual': 'Part of SoapySDR utilities or build from source'
|
||||
}
|
||||
},
|
||||
'LimeUtil': {
|
||||
'required': False,
|
||||
'description': 'LimeSDR native utilities',
|
||||
'install': {
|
||||
'apt': 'sudo apt install limesuite',
|
||||
'brew': 'brew install limesuite',
|
||||
'manual': 'https://github.com/myriadrf/LimeSuite'
|
||||
}
|
||||
},
|
||||
'SoapyLMS7': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR plugin for LimeSDR',
|
||||
'soapy_factory': 'lime',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-module-lms7',
|
||||
'brew': 'brew install soapylms7',
|
||||
'manual': 'https://github.com/myriadrf/LimeSuite'
|
||||
}
|
||||
},
|
||||
'hackrf_info': {
|
||||
'required': False,
|
||||
'description': 'HackRF native utilities',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hackrf',
|
||||
'brew': 'brew install hackrf',
|
||||
'manual': 'https://github.com/greatscottgadgets/hackrf'
|
||||
}
|
||||
},
|
||||
'SoapyHackRF': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR plugin for HackRF',
|
||||
'soapy_factory': 'hackrf',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-module-hackrf',
|
||||
'brew': 'brew install soapyhackrf',
|
||||
'manual': 'https://github.com/pothosware/SoapyHackRF'
|
||||
}
|
||||
},
|
||||
'readsb': {
|
||||
'required': False,
|
||||
'description': 'ADS-B decoder with SoapySDR support',
|
||||
'install': {
|
||||
'apt': 'Build from source with SoapySDR support',
|
||||
'brew': 'Build from source with SoapySDR support',
|
||||
'manual': 'https://github.com/wiedehopf/readsb'
|
||||
}
|
||||
"sensor": {
|
||||
"name": "433MHz Sensors",
|
||||
"tools": {
|
||||
"rtl_433": {
|
||||
"required": True,
|
||||
"description": "ISM band decoder for sensors, weather stations, TPMS",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-433",
|
||||
"brew": "brew install rtl_433",
|
||||
"manual": "https://github.com/merbanan/rtl_433",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
'subghz': {
|
||||
'name': 'SubGHz Transceiver',
|
||||
'tools': {
|
||||
'hackrf_transfer': {
|
||||
'required': True,
|
||||
'description': 'HackRF IQ capture and replay',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hackrf',
|
||||
'brew': 'brew install hackrf',
|
||||
'manual': 'https://github.com/greatscottgadgets/hackrf'
|
||||
}
|
||||
"wifi": {
|
||||
"name": "WiFi Reconnaissance",
|
||||
"tools": {
|
||||
"airmon-ng": {
|
||||
"required": True,
|
||||
"description": "Monitor mode controller",
|
||||
"install": {
|
||||
"apt": "sudo apt install aircrack-ng",
|
||||
"brew": "Not available on macOS",
|
||||
"manual": "https://aircrack-ng.org",
|
||||
},
|
||||
},
|
||||
'hackrf_sweep': {
|
||||
'required': False,
|
||||
'description': 'HackRF wideband spectrum sweep',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hackrf',
|
||||
'brew': 'brew install hackrf',
|
||||
'manual': 'https://github.com/greatscottgadgets/hackrf'
|
||||
}
|
||||
"airodump-ng": {
|
||||
"required": True,
|
||||
"description": "WiFi network scanner",
|
||||
"install": {
|
||||
"apt": "sudo apt install aircrack-ng",
|
||||
"brew": "Not available on macOS",
|
||||
"manual": "https://aircrack-ng.org",
|
||||
},
|
||||
},
|
||||
'rtl_433': {
|
||||
'required': False,
|
||||
'description': 'Protocol decoder for SubGHz signals',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-433',
|
||||
'brew': 'brew install rtl_433',
|
||||
'manual': 'https://github.com/merbanan/rtl_433'
|
||||
}
|
||||
}
|
||||
}
|
||||
"aireplay-ng": {
|
||||
"required": False,
|
||||
"description": "Deauthentication / packet injection",
|
||||
"install": {
|
||||
"apt": "sudo apt install aircrack-ng",
|
||||
"brew": "Not available on macOS",
|
||||
"manual": "https://aircrack-ng.org",
|
||||
},
|
||||
},
|
||||
"aircrack-ng": {
|
||||
"required": False,
|
||||
"description": "Handshake verification",
|
||||
"install": {
|
||||
"apt": "sudo apt install aircrack-ng",
|
||||
"brew": "brew install aircrack-ng",
|
||||
"manual": "https://aircrack-ng.org",
|
||||
},
|
||||
},
|
||||
"hcxdumptool": {
|
||||
"required": False,
|
||||
"description": "PMKID capture tool",
|
||||
"install": {
|
||||
"apt": "sudo apt install hcxdumptool",
|
||||
"brew": "brew install hcxtools",
|
||||
"manual": "https://github.com/ZerBea/hcxdumptool",
|
||||
},
|
||||
},
|
||||
"hcxpcapngtool": {
|
||||
"required": False,
|
||||
"description": "PMKID hash extractor",
|
||||
"install": {
|
||||
"apt": "sudo apt install hcxtools",
|
||||
"brew": "brew install hcxtools",
|
||||
"manual": "https://github.com/ZerBea/hcxtools",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'radiosonde': {
|
||||
'name': 'Radiosonde Tracking',
|
||||
'tools': {
|
||||
'auto_rx.py': {
|
||||
'required': True,
|
||||
'description': 'Radiosonde weather balloon decoder',
|
||||
'install': {
|
||||
'apt': 'Run ./setup.sh (clones from GitHub)',
|
||||
'brew': 'Run ./setup.sh (clones from GitHub)',
|
||||
'manual': 'https://github.com/projecthorus/radiosonde_auto_rx'
|
||||
}
|
||||
}
|
||||
}
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth Scanning",
|
||||
"tools": {
|
||||
"hcitool": {
|
||||
"required": False,
|
||||
"description": "Bluetooth HCI tool (legacy)",
|
||||
"install": {
|
||||
"apt": "sudo apt install bluez",
|
||||
"brew": "Not available on macOS (use native)",
|
||||
"manual": "http://www.bluez.org",
|
||||
},
|
||||
},
|
||||
"bluetoothctl": {
|
||||
"required": True,
|
||||
"description": "Modern Bluetooth controller",
|
||||
"install": {
|
||||
"apt": "sudo apt install bluez",
|
||||
"brew": "Not available on macOS (use native)",
|
||||
"manual": "http://www.bluez.org",
|
||||
},
|
||||
},
|
||||
"hciconfig": {
|
||||
"required": False,
|
||||
"description": "Bluetooth adapter configuration",
|
||||
"install": {
|
||||
"apt": "sudo apt install bluez",
|
||||
"brew": "Not available on macOS",
|
||||
"manual": "http://www.bluez.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'tscm': {
|
||||
'name': 'TSCM Counter-Surveillance',
|
||||
'tools': {
|
||||
'rtl_power': {
|
||||
'required': False,
|
||||
'description': 'Wideband spectrum sweep for RF analysis',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
"aircraft": {
|
||||
"name": "Aircraft Tracking (ADS-B)",
|
||||
"tools": {
|
||||
"dump1090": {
|
||||
"required": False,
|
||||
"description": "Mode S / ADS-B decoder (preferred)",
|
||||
"install": {
|
||||
"apt": "sudo apt install dump1090-mutability (or build dump1090-fa from source)",
|
||||
"brew": "brew install dump1090-mutability",
|
||||
"manual": "https://github.com/flightaware/dump1090",
|
||||
},
|
||||
"alternatives": ["dump1090-mutability", "dump1090-fa"],
|
||||
},
|
||||
'rtl_fm': {
|
||||
'required': True,
|
||||
'description': 'RF signal demodulation',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
"rtl_adsb": {
|
||||
"required": False,
|
||||
"description": "Simple ADS-B decoder",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-sdr",
|
||||
"brew": "brew install librtlsdr",
|
||||
"manual": "https://osmocom.org/projects/rtl-sdr/wiki",
|
||||
},
|
||||
},
|
||||
'rtl_433': {
|
||||
'required': False,
|
||||
'description': 'ISM band device decoding',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-433',
|
||||
'brew': 'brew install rtl_433',
|
||||
'manual': 'https://github.com/merbanan/rtl_433'
|
||||
}
|
||||
},
|
||||
'airmon-ng': {
|
||||
'required': False,
|
||||
'description': 'WiFi monitor mode for network scanning',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'bluetoothctl': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth device scanning',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
},
|
||||
},
|
||||
"acars": {
|
||||
"name": "Aircraft Messaging (ACARS)",
|
||||
"tools": {
|
||||
"acarsdec": {
|
||||
"required": True,
|
||||
"description": "ACARS VHF decoder",
|
||||
"install": {
|
||||
"apt": "Run ./setup.sh (builds from source)",
|
||||
"brew": "Run ./setup.sh (builds from source)",
|
||||
"manual": "https://github.com/TLeconte/acarsdec",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"ais": {
|
||||
"name": "Vessel Tracking (AIS)",
|
||||
"tools": {
|
||||
"AIS-catcher": {
|
||||
"required": True,
|
||||
"description": "AIS receiver and decoder",
|
||||
"install": {
|
||||
"apt": "Download .deb from https://github.com/jvde-github/AIS-catcher/releases",
|
||||
"brew": "brew install aiscatcher",
|
||||
"manual": "https://github.com/jvde-github/AIS-catcher/releases",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"aprs": {
|
||||
"name": "APRS Tracking",
|
||||
"tools": {
|
||||
"direwolf": {
|
||||
"required": False,
|
||||
"description": "APRS/packet radio decoder (preferred)",
|
||||
"install": {
|
||||
"apt": "sudo apt install direwolf",
|
||||
"brew": "brew install direwolf",
|
||||
"manual": "https://github.com/wb2osz/direwolf",
|
||||
},
|
||||
},
|
||||
"multimon-ng": {
|
||||
"required": False,
|
||||
"description": "Alternative AFSK1200 decoder",
|
||||
"install": {
|
||||
"apt": "sudo apt install multimon-ng",
|
||||
"brew": "brew install multimon-ng",
|
||||
"manual": "https://github.com/EliasOenal/multimon-ng",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"satellite": {
|
||||
"name": "Satellite Tracking",
|
||||
"tools": {
|
||||
"skyfield": {
|
||||
"required": True,
|
||||
"description": "Python orbital mechanics library",
|
||||
"install": {"pip": "pip install skyfield", "manual": "https://rhodesmill.org/skyfield/"},
|
||||
"python_module": True,
|
||||
}
|
||||
},
|
||||
},
|
||||
"sdr_hardware": {
|
||||
"name": "SDR Hardware Support",
|
||||
"tools": {
|
||||
"SoapySDRUtil": {
|
||||
"required": False,
|
||||
"description": "Universal SDR abstraction (required for LimeSDR, HackRF)",
|
||||
"install": {
|
||||
"apt": "sudo apt install soapysdr-tools",
|
||||
"brew": "brew install soapysdr",
|
||||
"manual": "https://github.com/pothosware/SoapySDR",
|
||||
},
|
||||
},
|
||||
"rx_fm": {
|
||||
"required": False,
|
||||
"description": "SoapySDR FM receiver (for non-RTL hardware)",
|
||||
"install": {"manual": "Part of SoapySDR utilities or build from source"},
|
||||
},
|
||||
"LimeUtil": {
|
||||
"required": False,
|
||||
"description": "LimeSDR native utilities",
|
||||
"install": {
|
||||
"apt": "sudo apt install limesuite",
|
||||
"brew": "brew install limesuite",
|
||||
"manual": "https://github.com/myriadrf/LimeSuite",
|
||||
},
|
||||
},
|
||||
"SoapyLMS7": {
|
||||
"required": False,
|
||||
"description": "SoapySDR plugin for LimeSDR",
|
||||
"soapy_factory": "lime",
|
||||
"install": {
|
||||
"apt": "sudo apt install soapysdr-module-lms7",
|
||||
"brew": "brew install soapylms7",
|
||||
"manual": "https://github.com/myriadrf/LimeSuite",
|
||||
},
|
||||
},
|
||||
"hackrf_info": {
|
||||
"required": False,
|
||||
"description": "HackRF native utilities",
|
||||
"install": {
|
||||
"apt": "sudo apt install hackrf",
|
||||
"brew": "brew install hackrf",
|
||||
"manual": "https://github.com/greatscottgadgets/hackrf",
|
||||
},
|
||||
},
|
||||
"SoapyHackRF": {
|
||||
"required": False,
|
||||
"description": "SoapySDR plugin for HackRF",
|
||||
"soapy_factory": "hackrf",
|
||||
"install": {
|
||||
"apt": "sudo apt install soapysdr-module-hackrf",
|
||||
"brew": "brew install soapyhackrf",
|
||||
"manual": "https://github.com/pothosware/SoapyHackRF",
|
||||
},
|
||||
},
|
||||
"readsb": {
|
||||
"required": False,
|
||||
"description": "ADS-B decoder with SoapySDR support",
|
||||
"install": {
|
||||
"apt": "Build from source with SoapySDR support",
|
||||
"brew": "Build from source with SoapySDR support",
|
||||
"manual": "https://github.com/wiedehopf/readsb",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"subghz": {
|
||||
"name": "SubGHz Transceiver",
|
||||
"tools": {
|
||||
"hackrf_transfer": {
|
||||
"required": True,
|
||||
"description": "HackRF IQ capture and replay",
|
||||
"install": {
|
||||
"apt": "sudo apt install hackrf",
|
||||
"brew": "brew install hackrf",
|
||||
"manual": "https://github.com/greatscottgadgets/hackrf",
|
||||
},
|
||||
},
|
||||
"hackrf_sweep": {
|
||||
"required": False,
|
||||
"description": "HackRF wideband spectrum sweep",
|
||||
"install": {
|
||||
"apt": "sudo apt install hackrf",
|
||||
"brew": "brew install hackrf",
|
||||
"manual": "https://github.com/greatscottgadgets/hackrf",
|
||||
},
|
||||
},
|
||||
"rtl_433": {
|
||||
"required": False,
|
||||
"description": "Protocol decoder for SubGHz signals",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-433",
|
||||
"brew": "brew install rtl_433",
|
||||
"manual": "https://github.com/merbanan/rtl_433",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"radiosonde": {
|
||||
"name": "Radiosonde Tracking",
|
||||
"tools": {
|
||||
"auto_rx.py": {
|
||||
"required": True,
|
||||
"description": "Radiosonde weather balloon decoder",
|
||||
"install": {
|
||||
"apt": "Run ./setup.sh (clones from GitHub)",
|
||||
"brew": "Run ./setup.sh (clones from GitHub)",
|
||||
"manual": "https://github.com/projecthorus/radiosonde_auto_rx",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"tscm": {
|
||||
"name": "TSCM Counter-Surveillance",
|
||||
"tools": {
|
||||
"rtl_power": {
|
||||
"required": False,
|
||||
"description": "Wideband spectrum sweep for RF analysis",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-sdr",
|
||||
"brew": "brew install librtlsdr",
|
||||
"manual": "https://osmocom.org/projects/rtl-sdr/wiki",
|
||||
},
|
||||
},
|
||||
"rtl_fm": {
|
||||
"required": True,
|
||||
"description": "RF signal demodulation",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-sdr",
|
||||
"brew": "brew install librtlsdr",
|
||||
"manual": "https://osmocom.org/projects/rtl-sdr/wiki",
|
||||
},
|
||||
},
|
||||
"rtl_433": {
|
||||
"required": False,
|
||||
"description": "ISM band device decoding",
|
||||
"install": {
|
||||
"apt": "sudo apt install rtl-433",
|
||||
"brew": "brew install rtl_433",
|
||||
"manual": "https://github.com/merbanan/rtl_433",
|
||||
},
|
||||
},
|
||||
"airmon-ng": {
|
||||
"required": False,
|
||||
"description": "WiFi monitor mode for network scanning",
|
||||
"install": {
|
||||
"apt": "sudo apt install aircrack-ng",
|
||||
"brew": "Not available on macOS",
|
||||
"manual": "https://aircrack-ng.org",
|
||||
},
|
||||
},
|
||||
"bluetoothctl": {
|
||||
"required": False,
|
||||
"description": "Bluetooth device scanning",
|
||||
"install": {
|
||||
"apt": "sudo apt install bluez",
|
||||
"brew": "Not available on macOS (use native)",
|
||||
"manual": "http://www.bluez.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -532,16 +528,11 @@ def check_all_dependencies() -> dict[str, dict[str, Any]]:
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for mode, config in TOOL_DEPENDENCIES.items():
|
||||
mode_result = {
|
||||
'name': config['name'],
|
||||
'tools': {},
|
||||
'ready': True,
|
||||
'missing_required': []
|
||||
}
|
||||
mode_result = {"name": config["name"], "tools": {}, "ready": True, "missing_required": []}
|
||||
|
||||
for tool, tool_config in config['tools'].items():
|
||||
for tool, tool_config in config["tools"].items():
|
||||
# Check if it's a Python module
|
||||
if tool_config.get('python_module'):
|
||||
if tool_config.get("python_module"):
|
||||
try:
|
||||
__import__(tool)
|
||||
installed = True
|
||||
@@ -549,23 +540,23 @@ def check_all_dependencies() -> dict[str, dict[str, Any]]:
|
||||
logger.debug(f"Failed to import {tool}: {type(e).__name__}: {e}")
|
||||
installed = False
|
||||
# Check using SoapySDRUtil if specified
|
||||
elif tool_config.get('soapy_factory'):
|
||||
installed = check_soapy_factory(tool_config['soapy_factory'])
|
||||
elif tool_config.get("soapy_factory"):
|
||||
installed = check_soapy_factory(tool_config["soapy_factory"])
|
||||
else:
|
||||
# Check for alternatives
|
||||
alternatives = tool_config.get('alternatives', [])
|
||||
alternatives = tool_config.get("alternatives", [])
|
||||
installed = check_tool(tool) or any(check_tool(alt) for alt in alternatives)
|
||||
|
||||
mode_result['tools'][tool] = {
|
||||
'installed': installed,
|
||||
'required': tool_config['required'],
|
||||
'description': tool_config['description'],
|
||||
'install': tool_config['install']
|
||||
mode_result["tools"][tool] = {
|
||||
"installed": installed,
|
||||
"required": tool_config["required"],
|
||||
"description": tool_config["description"],
|
||||
"install": tool_config["install"],
|
||||
}
|
||||
|
||||
if tool_config['required'] and not installed:
|
||||
mode_result['ready'] = False
|
||||
mode_result['missing_required'].append(tool)
|
||||
if tool_config["required"] and not installed:
|
||||
mode_result["ready"] = False
|
||||
mode_result["missing_required"].append(tool)
|
||||
|
||||
results[mode] = mode_result
|
||||
|
||||
|
||||
@@ -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"
|
||||
+952
-796
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user