"""AUTARCH SDR / RF Tools Software-defined radio integration for spectrum analysis, signal capture/replay, ADS-B tracking, FM/AM demodulation, and GPS spoofing detection. Supports HackRF, RTL-SDR, and compatible devices. """ DESCRIPTION = "SDR/RF — spectrum analysis, signal capture & replay" AUTHOR = "darkHal" VERSION = "1.0" CATEGORY = "analyze" import os import re import json import time import shutil import struct import subprocess import threading from pathlib import Path from datetime import datetime, timezone from typing import Dict, List, Optional, Any try: from core.paths import find_tool, get_data_dir except ImportError: def find_tool(name): return shutil.which(name) def get_data_dir(): return str(Path(__file__).parent.parent / 'data') # ── Common Frequencies Reference ───────────────────────────────────────────── COMMON_FREQUENCIES = { 'FM Broadcast': { 'range': '87.5-108 MHz', 'entries': [ {'freq': 87500000, 'name': 'FM Band Start'}, {'freq': 92100000, 'name': 'FM Example (92.1)'}, {'freq': 97500000, 'name': 'FM Example (97.5)'}, {'freq': 108000000, 'name': 'FM Band End'}, ], }, 'Aviation': { 'range': '108-137 MHz', 'entries': [ {'freq': 108000000, 'name': 'VOR/ILS Start'}, {'freq': 118000000, 'name': 'Air Traffic Control Start'}, {'freq': 121500000, 'name': 'Emergency / Guard'}, {'freq': 123450000, 'name': 'Air-to-Air (Unicom)'}, {'freq': 128825000, 'name': 'Eurocontrol UAC'}, {'freq': 132000000, 'name': 'Approach Control'}, {'freq': 136975000, 'name': 'ACARS'}, ], }, 'Marine VHF': { 'range': '156-162 MHz', 'entries': [ {'freq': 156000000, 'name': 'Ch 0 — Coast Guard'}, {'freq': 156300000, 'name': 'Ch 6 — Intership Safety'}, {'freq': 156525000, 'name': 'Ch 70 — DSC Distress'}, {'freq': 156800000, 'name': 'Ch 16 — Distress / Calling'}, {'freq': 161975000, 'name': 'AIS 1'}, {'freq': 162025000, 'name': 'AIS 2'}, ], }, 'Weather': { 'range': '162.4-162.55 MHz', 'entries': [ {'freq': 162400000, 'name': 'NOAA WX1'}, {'freq': 162425000, 'name': 'NOAA WX2'}, {'freq': 162450000, 'name': 'NOAA WX3'}, {'freq': 162475000, 'name': 'NOAA WX4'}, {'freq': 162500000, 'name': 'NOAA WX5'}, {'freq': 162525000, 'name': 'NOAA WX6'}, {'freq': 162550000, 'name': 'NOAA WX7'}, ], }, 'ISM 433': { 'range': '433-434 MHz', 'notes': 'Garage doors, key fobs, weather stations, tire pressure sensors', 'entries': [ {'freq': 433050000, 'name': 'ISM 433.05 — Key Fobs'}, {'freq': 433420000, 'name': 'ISM 433.42 — TPMS'}, {'freq': 433920000, 'name': 'ISM 433.92 — Common Remote'}, {'freq': 434000000, 'name': 'ISM Band End'}, ], }, 'ISM 915': { 'range': '902-928 MHz', 'notes': 'LoRa, smart meters, Z-Wave, RFID', 'entries': [ {'freq': 902000000, 'name': 'ISM 902 Band Start'}, {'freq': 903900000, 'name': 'LoRa Uplink Start'}, {'freq': 915000000, 'name': 'ISM Center'}, {'freq': 923300000, 'name': 'LoRa Downlink Start'}, {'freq': 928000000, 'name': 'ISM 928 Band End'}, ], }, 'Pager': { 'range': '929-932 MHz', 'entries': [ {'freq': 929000000, 'name': 'Pager Band Start'}, {'freq': 931000000, 'name': 'Common Pager Freq'}, {'freq': 931862500, 'name': 'FLEX Pager'}, ], }, 'ADS-B': { 'range': '1090 MHz', 'entries': [ {'freq': 978000000, 'name': 'UAT (978 MHz) — GA'}, {'freq': 1090000000, 'name': 'Mode S Extended Squitter'}, ], }, 'GPS L1': { 'range': '1575.42 MHz', 'entries': [ {'freq': 1575420000, 'name': 'GPS L1 C/A'}, {'freq': 1176450000, 'name': 'GPS L5'}, {'freq': 1227600000, 'name': 'GPS L2'}, {'freq': 1602000000, 'name': 'GLONASS L1'}, ], }, 'WiFi 2.4': { 'range': '2.4-2.5 GHz', 'entries': [ {'freq': 2412000000, 'name': 'Channel 1'}, {'freq': 2437000000, 'name': 'Channel 6'}, {'freq': 2462000000, 'name': 'Channel 11'}, ], }, 'Public Safety': { 'range': '150-174 / 450-470 MHz', 'entries': [ {'freq': 155475000, 'name': 'Police Mutual Aid'}, {'freq': 155520000, 'name': 'Fire Mutual Aid'}, {'freq': 156750000, 'name': 'Search & Rescue'}, {'freq': 460025000, 'name': 'Police UHF Common'}, {'freq': 462562500, 'name': 'FRS Channel 1'}, {'freq': 462675000, 'name': 'GMRS Repeater'}, ], }, 'Amateur': { 'range': 'Various bands', 'entries': [ {'freq': 144000000, 'name': '2m Band Start'}, {'freq': 146520000, 'name': '2m Calling Freq'}, {'freq': 146940000, 'name': '2m Repeater'}, {'freq': 440000000, 'name': '70cm Band Start'}, {'freq': 446000000, 'name': '70cm Calling Freq'}, ], }, } # ── Drone RF Frequency Reference ───────────────────────────────────────────── DRONE_FREQUENCIES = { 'dji_control_2g': {'center': 2437000000, 'bandwidth': 40000000, 'desc': 'DJI OcuSync 2.4 GHz Control'}, 'dji_control_5g': {'center': 5787000000, 'bandwidth': 80000000, 'desc': 'DJI OcuSync 5.8 GHz Control'}, 'fpv_video_5g': {'center': 5800000000, 'bandwidth': 200000000, 'desc': 'Analog FPV 5.8 GHz Video'}, 'crossfire_900': {'center': 915000000, 'bandwidth': 26000000, 'desc': 'TBS Crossfire 900 MHz'}, 'elrs_2g': {'center': 2440000000, 'bandwidth': 80000000, 'desc': 'ExpressLRS 2.4 GHz'}, 'elrs_900': {'center': 915000000, 'bandwidth': 26000000, 'desc': 'ExpressLRS 900 MHz'}, 'analog_video_12g': {'center': 1280000000, 'bandwidth': 100000000, 'desc': '1.2 GHz Analog Video'}, 'telemetry_433': {'center': 433000000, 'bandwidth': 2000000, 'desc': '433 MHz Telemetry'}, } FPV_5G_CHANNELS = { 'R1': 5658, 'R2': 5695, 'R3': 5732, 'R4': 5769, 'R5': 5806, 'R6': 5843, 'R7': 5880, 'R8': 5917, 'F1': 5740, 'F2': 5760, 'F3': 5780, 'F4': 5800, 'F5': 5820, 'F6': 5840, 'F7': 5860, 'F8': 5880, 'E1': 5705, 'E2': 5685, 'E3': 5665, 'E4': 5645, 'E5': 5885, 'E6': 5905, 'E7': 5925, 'E8': 5945, 'A1': 5865, 'A2': 5845, 'A3': 5825, 'A4': 5805, 'A5': 5785, 'A6': 5765, 'A7': 5745, 'A8': 5725, } # ── SDR Tools Class ────────────────────────────────────────────────────────── class SDRTools: """Software-defined radio integration for the AUTARCH platform.""" _instance = None def __init__(self): self._sdr_dir = Path(str(get_data_dir())) / 'sdr' self._sdr_dir.mkdir(parents=True, exist_ok=True) self._recordings_dir = self._sdr_dir / 'recordings' self._recordings_dir.mkdir(parents=True, exist_ok=True) self._metadata_file = self._sdr_dir / 'recordings_meta.json' self._capture_process: Optional[subprocess.Popen] = None self._capture_lock = threading.Lock() self._capture_info: Dict[str, Any] = {} self._adsb_process: Optional[subprocess.Popen] = None self._adsb_thread: Optional[threading.Thread] = None self._adsb_running = False self._adsb_aircraft: Dict[str, Dict[str, Any]] = {} self._adsb_lock = threading.Lock() # Drone detection state self._drone_process: Optional[subprocess.Popen] = None self._drone_thread: Optional[threading.Thread] = None self._drone_running = False self._drone_detections: List[Dict[str, Any]] = [] self._drone_lock = threading.Lock() self._drone_detections_file = self._sdr_dir / 'drone_detections.json' self._load_drone_detections() self._load_metadata() def _load_metadata(self): """Load recording metadata from disk.""" try: if self._metadata_file.exists(): with open(self._metadata_file, 'r') as f: self._metadata = json.load(f) else: self._metadata = [] except Exception: self._metadata = [] def _save_metadata(self): """Persist recording metadata to disk.""" try: with open(self._metadata_file, 'w') as f: json.dump(self._metadata, f, indent=2) except Exception: pass def _run_cmd(self, cmd: str, timeout: int = 30) -> tuple: """Run a shell command and return (success, stdout).""" try: result = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=timeout ) return result.returncode == 0, result.stdout.strip() except subprocess.TimeoutExpired: return False, 'Command timed out' except Exception as e: return False, str(e) # ── Device Detection ───────────────────────────────────────────────────── def detect_devices(self) -> List[Dict[str, Any]]: """Detect connected SDR devices (RTL-SDR, HackRF).""" devices = [] # Check RTL-SDR rtl_test = find_tool('rtl_test') if rtl_test: try: result = subprocess.run( [rtl_test, '-t'], capture_output=True, text=True, timeout=8 ) output = result.stdout + result.stderr # Look for "Found N device(s)" pattern match = re.search(r'Found\s+(\d+)\s+device', output) if match: count = int(match.group(1)) if count > 0: # Parse each device for m in re.finditer( r'(\d+):\s+(.+?)(?:,\s*(.+?))?\s*(?:SN:\s*(\S+))?', output ): devices.append({ 'type': 'rtl-sdr', 'index': int(m.group(1)), 'name': m.group(2).strip(), 'serial': m.group(4) or 'N/A', 'status': 'available', 'capabilities': ['rx'], }) # If regex didn't match specifics, add generic entry if not devices: for i in range(count): devices.append({ 'type': 'rtl-sdr', 'index': i, 'name': 'RTL-SDR Device', 'serial': 'N/A', 'status': 'available', 'capabilities': ['rx'], }) elif 'No supported devices' not in output: # rtl_test ran but gave unexpected output pass except subprocess.TimeoutExpired: pass except Exception: pass else: devices.append({ 'type': 'rtl-sdr', 'name': 'RTL-SDR', 'serial': 'N/A', 'status': 'tool_missing', 'note': 'rtl_test not found — install rtl-sdr package', 'capabilities': [], }) # Check HackRF hackrf_info = find_tool('hackrf_info') if hackrf_info: try: result = subprocess.run( [hackrf_info], capture_output=True, text=True, timeout=8 ) output = result.stdout + result.stderr if 'Serial number' in output: serials = re.findall(r'Serial number:\s*(\S+)', output) fw_versions = re.findall(r'Firmware Version:\s*(.+)', output) for idx, serial in enumerate(serials): devices.append({ 'type': 'hackrf', 'index': idx, 'name': 'HackRF One', 'serial': serial, 'firmware': fw_versions[idx].strip() if idx < len(fw_versions) else 'Unknown', 'status': 'available', 'capabilities': ['rx', 'tx'], }) elif 'No HackRF' in output or result.returncode != 0: pass except subprocess.TimeoutExpired: pass except Exception: pass else: devices.append({ 'type': 'hackrf', 'name': 'HackRF', 'serial': 'N/A', 'status': 'tool_missing', 'note': 'hackrf_info not found — install hackrf package', 'capabilities': [], }) return devices # ── Spectrum Scanning ──────────────────────────────────────────────────── def scan_spectrum(self, device: str = 'rtl', freq_start: int = 88000000, freq_end: int = 108000000, step: Optional[int] = None, gain: Optional[int] = None, duration: int = 5) -> Dict[str, Any]: """Sweep a frequency range and collect signal strength at each step. Returns a dict with 'data' (list of {freq, power_db}) and scan metadata. """ if step is None: # Auto-calculate step based on range span = freq_end - freq_start if span <= 1000000: step = 10000 # 10 kHz steps for narrow scans elif span <= 10000000: step = 100000 # 100 kHz steps elif span <= 100000000: step = 250000 # 250 kHz steps else: step = 1000000 # 1 MHz steps for wide scans results = {'data': [], 'device': device, 'freq_start': freq_start, 'freq_end': freq_end, 'step': step, 'timestamp': datetime.now(timezone.utc).isoformat()} if device == 'hackrf': return self._scan_hackrf(freq_start, freq_end, step, gain, duration, results) else: return self._scan_rtl(freq_start, freq_end, step, gain, duration, results) def _scan_rtl(self, freq_start, freq_end, step, gain, duration, results): """Spectrum scan using rtl_power.""" rtl_power = find_tool('rtl_power') if not rtl_power: results['error'] = 'rtl_power not found — install rtl-sdr package' return results # rtl_power output file outfile = self._sdr_dir / 'spectrum_scan.csv' if outfile.exists(): outfile.unlink() # Build command: rtl_power -f :: -g -i -1 cmd = [rtl_power, '-f', f'{freq_start}:{freq_end}:{step}', '-i', str(duration), '-1'] # single sweep if gain is not None: cmd.extend(['-g', str(gain)]) cmd.append(str(outfile)) try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30) if not outfile.exists(): results['error'] = 'No output from rtl_power: ' + (proc.stderr or proc.stdout) return results # Parse CSV: date,time,Hz_low,Hz_high,Hz_step,samples,dB,dB,... with open(outfile, 'r') as f: for line in f: line = line.strip() if not line: continue parts = line.split(',') if len(parts) < 7: continue try: hz_low = float(parts[2]) hz_step = float(parts[4]) db_values = [float(x) for x in parts[6:] if x.strip()] for i, db in enumerate(db_values): freq = hz_low + (i * hz_step) results['data'].append({ 'freq': int(freq), 'power_db': round(db, 2) }) except (ValueError, IndexError): continue results['points'] = len(results['data']) except subprocess.TimeoutExpired: results['error'] = 'Spectrum scan timed out' except Exception as e: results['error'] = str(e) return results def _scan_hackrf(self, freq_start, freq_end, step, gain, duration, results): """Spectrum scan using hackrf_sweep.""" hackrf_sweep = find_tool('hackrf_sweep') if not hackrf_sweep: results['error'] = 'hackrf_sweep not found — install hackrf package' return results # Convert Hz to MHz for hackrf_sweep f_start_mhz = freq_start // 1000000 f_end_mhz = max(freq_end // 1000000, f_start_mhz + 1) cmd = [hackrf_sweep, '-f', f'{f_start_mhz}:{f_end_mhz}', '-n', '8192', # FFT bin width '-w', str(step)] if gain is not None: cmd.extend(['-l', str(gain)]) # LNA gain try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=duration + 30) output = proc.stdout # Parse hackrf_sweep output: date,time,Hz_low,Hz_high,Hz_bin_width,num_samples,dB... for line in output.splitlines(): line = line.strip() if not line or line.startswith('#'): continue parts = line.split(',') if len(parts) < 7: continue try: hz_low = float(parts[2].strip()) hz_bin_width = float(parts[4].strip()) db_values = [float(x.strip()) for x in parts[6:] if x.strip()] for i, db in enumerate(db_values): freq = hz_low + (i * hz_bin_width) if freq_start <= freq <= freq_end: results['data'].append({ 'freq': int(freq), 'power_db': round(db, 2) }) except (ValueError, IndexError): continue results['points'] = len(results['data']) except subprocess.TimeoutExpired: results['error'] = 'HackRF sweep timed out' except Exception as e: results['error'] = str(e) return results # ── Signal Capture ─────────────────────────────────────────────────────── def start_capture(self, device: str = 'rtl', frequency: int = 100000000, sample_rate: int = 2048000, gain: str = 'auto', duration: int = 10, output: Optional[str] = None) -> Dict[str, Any]: """Capture raw IQ samples to a file.""" with self._capture_lock: if self._capture_process is not None and self._capture_process.poll() is None: return {'error': 'Capture already in progress', 'capturing': True} ts = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') freq_mhz = frequency / 1000000 filename = output or f'capture_{freq_mhz:.3f}MHz_{ts}.raw' filepath = self._recordings_dir / filename if device == 'hackrf': tool = find_tool('hackrf_transfer') if not tool: return {'error': 'hackrf_transfer not found — install hackrf package'} cmd = [tool, '-r', str(filepath), '-f', str(frequency), '-s', str(sample_rate), '-n', str(sample_rate * duration)] if gain != 'auto': cmd.extend(['-l', str(gain)]) else: tool = find_tool('rtl_sdr') if not tool: return {'error': 'rtl_sdr not found — install rtl-sdr package'} cmd = [tool, '-f', str(frequency), '-s', str(sample_rate), '-n', str(sample_rate * duration)] if gain != 'auto': cmd.extend(['-g', str(gain)]) cmd.append(str(filepath)) try: self._capture_process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) self._capture_info = { 'file': str(filepath), 'filename': filename, 'device': device, 'frequency': frequency, 'sample_rate': sample_rate, 'gain': gain, 'duration': duration, 'started': datetime.now(timezone.utc).isoformat(), 'pid': self._capture_process.pid, } # Auto-stop thread def _auto_stop(): try: self._capture_process.wait(timeout=duration + 5) except subprocess.TimeoutExpired: self._capture_process.terminate() finally: self._finalize_capture() t = threading.Thread(target=_auto_stop, daemon=True) t.start() return { 'status': 'capturing', 'file': filename, 'frequency': frequency, 'sample_rate': sample_rate, 'duration': duration, 'device': device, } except Exception as e: self._capture_process = None return {'error': f'Failed to start capture: {e}'} def _finalize_capture(self): """Save metadata for a completed capture.""" with self._capture_lock: info = self._capture_info.copy() filepath = Path(info.get('file', '')) if filepath.exists(): size = filepath.stat().st_size info['size'] = size info['size_human'] = self._human_size(size) # Calculate actual duration from file size sr = info.get('sample_rate', 2048000) # IQ samples: 2 bytes per sample (8-bit I + 8-bit Q) for RTL-SDR bytes_per_sample = 2 actual_samples = size / bytes_per_sample info['actual_duration'] = round(actual_samples / sr, 2) if sr > 0 else 0 info['completed'] = datetime.now(timezone.utc).isoformat() self._metadata.append(info) self._save_metadata() self._capture_process = None self._capture_info = {} def stop_capture(self) -> Dict[str, Any]: """Stop an active capture.""" with self._capture_lock: if self._capture_process is None or self._capture_process.poll() is not None: return {'status': 'no_capture', 'message': 'No capture is running'} try: self._capture_process.terminate() self._capture_process.wait(timeout=5) except subprocess.TimeoutExpired: self._capture_process.kill() except Exception: pass self._finalize_capture() return {'status': 'stopped', 'message': 'Capture stopped'} def is_capturing(self) -> bool: """Check if a capture is currently running.""" with self._capture_lock: return (self._capture_process is not None and self._capture_process.poll() is None) # ── Replay ─────────────────────────────────────────────────────────────── def replay_signal(self, file_path: str, frequency: int = 100000000, sample_rate: int = 2048000, gain: int = 47) -> Dict[str, Any]: """Transmit a captured signal via HackRF (TX only on HackRF).""" hackrf = find_tool('hackrf_transfer') if not hackrf: return {'error': 'hackrf_transfer not found — install hackrf package'} # Resolve file path fpath = Path(file_path) if not fpath.is_absolute(): fpath = self._recordings_dir / file_path if not fpath.exists(): return {'error': f'Recording file not found: {file_path}'} cmd = [hackrf, '-t', str(fpath), '-f', str(frequency), '-s', str(sample_rate), '-x', str(gain)] # -x = TX VGA gain try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: return { 'status': 'completed', 'message': f'Replayed {fpath.name} at {frequency/1e6:.3f} MHz', 'file': fpath.name, 'frequency': frequency, } else: return { 'error': f'Replay failed: {result.stderr or result.stdout}', 'returncode': result.returncode, } except subprocess.TimeoutExpired: return {'error': 'Replay timed out'} except Exception as e: return {'error': str(e)} # ── Recordings Management ──────────────────────────────────────────────── def list_recordings(self) -> List[Dict[str, Any]]: """List all saved recordings with metadata.""" self._load_metadata() recordings = [] # Include metadata-tracked recordings for meta in self._metadata: filepath = Path(meta.get('file', '')) if filepath.exists(): meta_copy = meta.copy() meta_copy['exists'] = True recordings.append(meta_copy) else: meta_copy = meta.copy() meta_copy['exists'] = False recordings.append(meta_copy) # Also check for un-tracked files in the recordings directory tracked_files = {Path(m.get('file', '')).name for m in self._metadata} for f in self._recordings_dir.iterdir(): if f.is_file() and f.suffix in ('.raw', '.iq', '.wav', '.cu8', '.cs8'): if f.name not in tracked_files: stat = f.stat() recordings.append({ 'file': str(f), 'filename': f.name, 'size': stat.st_size, 'size_human': self._human_size(stat.st_size), 'device': 'unknown', 'frequency': 0, 'sample_rate': 0, 'completed': datetime.fromtimestamp( stat.st_mtime, tz=timezone.utc ).isoformat(), 'exists': True, 'untracked': True, }) # Sort by completed time, newest first recordings.sort(key=lambda r: r.get('completed', ''), reverse=True) return recordings def delete_recording(self, recording_id: str) -> Dict[str, Any]: """Delete a recording by filename.""" # Try to match against metadata self._load_metadata() new_meta = [] deleted = False for meta in self._metadata: fname = Path(meta.get('file', '')).name if fname == recording_id or meta.get('filename') == recording_id: filepath = Path(meta.get('file', '')) if filepath.exists(): try: filepath.unlink() except Exception: pass deleted = True else: new_meta.append(meta) if deleted: self._metadata = new_meta self._save_metadata() return {'status': 'deleted', 'file': recording_id} # Try direct file match in recordings directory fpath = self._recordings_dir / recording_id if fpath.exists(): try: fpath.unlink() return {'status': 'deleted', 'file': recording_id} except Exception as e: return {'error': f'Could not delete: {e}'} return {'error': f'Recording not found: {recording_id}'} # ── Demodulation ───────────────────────────────────────────────────────── def demodulate_fm(self, file_path: str, frequency: Optional[int] = None) -> Dict[str, Any]: """FM demodulate captured IQ data to audio.""" fpath = self._resolve_recording(file_path) if not fpath: return {'error': f'Recording file not found: {file_path}'} outfile = fpath.with_suffix('.fm.wav') # Method 1: Use rtl_fm pipeline (if file was captured with rtl_sdr) sox = find_tool('sox') rtl_fm = find_tool('rtl_fm') # We'll use a Python-based approach: read raw IQ, apply FM demod, write WAV try: raw = fpath.read_bytes() if len(raw) < 1024: return {'error': 'File too small to demodulate'} # Assume unsigned 8-bit IQ (RTL-SDR default) samples = [] for i in range(0, len(raw) - 1, 2): i_val = (raw[i] - 127.5) / 127.5 q_val = (raw[i + 1] - 127.5) / 127.5 samples.append(complex(i_val, q_val)) if len(samples) < 2: return {'error': 'Not enough samples for demodulation'} # FM demodulation: phase difference between consecutive samples audio = [] for i in range(1, len(samples)): conj = complex(samples[i - 1].real, -samples[i - 1].imag) product = samples[i] * conj import math phase = math.atan2(product.imag, product.real) audio.append(phase) # Downsample to ~48 kHz audio # Assume 2.048 MHz sample rate → decimate by 42 for ~48.7 kHz decimation = 42 decimated = [audio[i] for i in range(0, len(audio), decimation)] # Normalize to 16-bit PCM if not decimated: return {'error': 'Demodulation produced no audio samples'} max_val = max(abs(s) for s in decimated) or 1.0 pcm = [int((s / max_val) * 32000) for s in decimated] # Write WAV file import wave with wave.open(str(outfile), 'w') as wav: wav.setnchannels(1) wav.setsampwidth(2) wav.setframerate(48000) wav.writeframes(struct.pack(f'<{len(pcm)}h', *pcm)) return { 'status': 'completed', 'output': str(outfile), 'filename': outfile.name, 'samples': len(pcm), 'duration': round(len(pcm) / 48000, 2), 'mode': 'FM', } except Exception as e: return {'error': f'FM demodulation failed: {e}'} def demodulate_am(self, file_path: str, frequency: Optional[int] = None) -> Dict[str, Any]: """AM demodulate captured IQ data to audio.""" fpath = self._resolve_recording(file_path) if not fpath: return {'error': f'Recording file not found: {file_path}'} outfile = fpath.with_suffix('.am.wav') try: raw = fpath.read_bytes() if len(raw) < 1024: return {'error': 'File too small to demodulate'} # AM demodulation: envelope detection (magnitude of IQ samples) audio = [] for i in range(0, len(raw) - 1, 2): i_val = (raw[i] - 127.5) / 127.5 q_val = (raw[i + 1] - 127.5) / 127.5 import math magnitude = math.sqrt(i_val * i_val + q_val * q_val) audio.append(magnitude) if not audio: return {'error': 'Not enough samples for AM demodulation'} # Remove DC offset mean_val = sum(audio) / len(audio) audio = [s - mean_val for s in audio] # Downsample to ~48 kHz decimation = 42 decimated = [audio[i] for i in range(0, len(audio), decimation)] # Normalize to 16-bit PCM if not decimated: return {'error': 'Demodulation produced no audio samples'} max_val = max(abs(s) for s in decimated) or 1.0 pcm = [int((s / max_val) * 32000) for s in decimated] # Write WAV import wave with wave.open(str(outfile), 'w') as wav: wav.setnchannels(1) wav.setsampwidth(2) wav.setframerate(48000) wav.writeframes(struct.pack(f'<{len(pcm)}h', *pcm)) return { 'status': 'completed', 'output': str(outfile), 'filename': outfile.name, 'samples': len(pcm), 'duration': round(len(pcm) / 48000, 2), 'mode': 'AM', } except Exception as e: return {'error': f'AM demodulation failed: {e}'} # ── ADS-B Tracking ─────────────────────────────────────────────────────── def start_adsb(self, device: str = 'rtl') -> Dict[str, Any]: """Start ADS-B aircraft tracking (1090 MHz).""" with self._adsb_lock: if self._adsb_running: return {'status': 'already_running', 'message': 'ADS-B tracking is already active'} # Try dump1090 first, then rtl_adsb dump1090 = find_tool('dump1090') rtl_adsb = find_tool('rtl_adsb') tool = dump1090 or rtl_adsb if not tool: return {'error': 'No ADS-B tool found — install dump1090 or rtl-sdr (rtl_adsb)'} try: if dump1090: cmd = [dump1090, '--raw', '--net-only', '--quiet'] else: cmd = [rtl_adsb] self._adsb_process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) self._adsb_running = True self._adsb_aircraft.clear() # Background thread to parse output self._adsb_thread = threading.Thread( target=self._adsb_reader, daemon=True ) self._adsb_thread.start() return { 'status': 'started', 'tool': Path(tool).name, 'message': f'ADS-B tracking started with {Path(tool).name}', } except Exception as e: self._adsb_running = False return {'error': f'Failed to start ADS-B: {e}'} def _adsb_reader(self): """Background thread to read and parse ADS-B output.""" try: while self._adsb_running and self._adsb_process: line = self._adsb_process.stdout.readline() if not line: if self._adsb_process.poll() is not None: break continue line = line.strip() if not line: continue self._parse_adsb_message(line) except Exception: pass finally: self._adsb_running = False def _parse_adsb_message(self, msg: str): """Parse a raw ADS-B hex message and update aircraft tracking.""" # Clean up message msg = msg.strip().lstrip('*').rstrip(';') if not msg or len(msg) < 14: return try: data = bytes.fromhex(msg) except ValueError: return # Downlink Format (first 5 bits) df = (data[0] >> 3) & 0x1F # We primarily care about DF17 (ADS-B extended squitter) if df == 17 and len(data) >= 7: # ICAO address is bytes 1-3 icao = data[1:4].hex().upper() # Type code is first 5 bits of ME field (byte 4) tc = (data[4] >> 3) & 0x1F now = datetime.now(timezone.utc).isoformat() with self._adsb_lock: if icao not in self._adsb_aircraft: self._adsb_aircraft[icao] = { 'icao': icao, 'callsign': '', 'altitude': None, 'speed': None, 'heading': None, 'lat': None, 'lon': None, 'vertical_rate': None, 'squawk': '', 'first_seen': now, 'last_seen': now, 'messages': 0, } ac = self._adsb_aircraft[icao] ac['last_seen'] = now ac['messages'] += 1 # TC 1-4: Aircraft identification if 1 <= tc <= 4: charset = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######' callsign = '' if len(data) >= 11: bits = int.from_bytes(data[4:11], 'big') for i in range(8): idx = (bits >> (42 - i * 6)) & 0x3F if idx < len(charset): callsign += charset[idx] ac['callsign'] = callsign.strip().strip('#') # TC 9-18: Airborne position elif 9 <= tc <= 18: if len(data) >= 11: alt_code = ((data[5] & 0xFF) << 4) | ((data[6] >> 4) & 0x0F) # Remove Q-bit (bit 4) q_bit = (alt_code >> 4) & 1 if q_bit: n = ((alt_code >> 5) << 4) | (alt_code & 0x0F) ac['altitude'] = n * 25 - 1000 # TC 19: Airborne velocity elif tc == 19: if len(data) >= 11: sub = data[4] & 0x07 if sub in (1, 2): ew_dir = (data[5] >> 2) & 1 ew_vel = ((data[5] & 0x03) << 8) | data[6] ns_dir = (data[7] >> 7) & 1 ns_vel = ((data[7] & 0x7F) << 3) | ((data[8] >> 5) & 0x07) ew_vel = (ew_vel - 1) * (-1 if ew_dir else 1) ns_vel = (ns_vel - 1) * (-1 if ns_dir else 1) import math ac['speed'] = round(math.sqrt(ew_vel**2 + ns_vel**2)) ac['heading'] = round(math.degrees(math.atan2(ew_vel, ns_vel)) % 360) def stop_adsb(self) -> Dict[str, Any]: """Stop ADS-B tracking.""" with self._adsb_lock: if not self._adsb_running: return {'status': 'not_running', 'message': 'ADS-B tracking is not active'} self._adsb_running = False if self._adsb_process: try: self._adsb_process.terminate() self._adsb_process.wait(timeout=5) except Exception: try: self._adsb_process.kill() except Exception: pass self._adsb_process = None count = len(self._adsb_aircraft) return { 'status': 'stopped', 'message': f'ADS-B tracking stopped — {count} aircraft tracked', 'aircraft_count': count, } def get_adsb_aircraft(self) -> List[Dict[str, Any]]: """Return current list of tracked aircraft.""" with self._adsb_lock: aircraft = list(self._adsb_aircraft.values()) # Sort by last seen, most recent first aircraft.sort(key=lambda a: a.get('last_seen', ''), reverse=True) return aircraft # ── GPS Spoofing Detection ─────────────────────────────────────────────── def detect_gps_spoofing(self, duration: int = 30) -> Dict[str, Any]: """Monitor GPS L1 frequency for spoofing indicators. Checks for: multiple strong signals, unusual power levels, inconsistent signal patterns that suggest spoofing. """ gps_freq = 1575420000 # GPS L1 C/A: 1575.42 MHz bandwidth = 2048000 # 2 MHz bandwidth around center rtl_power = find_tool('rtl_power') rtl_sdr = find_tool('rtl_sdr') if not rtl_power and not rtl_sdr: return {'error': 'No RTL-SDR tools found — install rtl-sdr package'} results = { 'frequency': gps_freq, 'duration': duration, 'timestamp': datetime.now(timezone.utc).isoformat(), 'analysis': {}, 'spoofing_indicators': [], 'risk_level': 'unknown', } # Capture a short sample at GPS L1 frequency if rtl_power: outfile = self._sdr_dir / 'gps_check.csv' if outfile.exists(): outfile.unlink() freq_lo = gps_freq - 1000000 freq_hi = gps_freq + 1000000 cmd = [rtl_power, '-f', f'{freq_lo}:{freq_hi}:10000', '-i', str(min(duration, 10)), '-1', str(outfile)] try: subprocess.run(cmd, capture_output=True, timeout=duration + 15) if outfile.exists(): powers = [] with open(outfile, 'r') as f: for line in f: parts = line.strip().split(',') if len(parts) >= 7: try: db_values = [float(x) for x in parts[6:] if x.strip()] powers.extend(db_values) except ValueError: continue if powers: avg_power = sum(powers) / len(powers) max_power = max(powers) min_power = min(powers) # Count strong signals (above average + 10dB) threshold = avg_power + 10 strong_signals = sum(1 for p in powers if p > threshold) results['analysis'] = { 'avg_power_db': round(avg_power, 2), 'max_power_db': round(max_power, 2), 'min_power_db': round(min_power, 2), 'power_range_db': round(max_power - min_power, 2), 'strong_signals': strong_signals, 'total_bins': len(powers), } # Spoofing indicators if max_power > -20: results['spoofing_indicators'].append({ 'indicator': 'Unusually strong GPS signal', 'detail': f'Max power: {max_power:.1f} dBm (normal GPS: -130 to -120 dBm at ground)', 'severity': 'high', }) if strong_signals > len(powers) * 0.3: results['spoofing_indicators'].append({ 'indicator': 'Multiple strong carriers detected', 'detail': f'{strong_signals} strong signals out of {len(powers)} bins', 'severity': 'high', }) if max_power - min_power < 5 and max_power > -60: results['spoofing_indicators'].append({ 'indicator': 'Flat power distribution', 'detail': f'Power range only {max_power - min_power:.1f} dB — consistent with artificial signal', 'severity': 'medium', }) if max_power > -80: results['spoofing_indicators'].append({ 'indicator': 'Signal strength above expected GPS level', 'detail': f'Max {max_power:.1f} dBm is well above typical GPS signal levels', 'severity': 'medium', }) # Overall risk high = sum(1 for i in results['spoofing_indicators'] if i['severity'] == 'high') med = sum(1 for i in results['spoofing_indicators'] if i['severity'] == 'medium') if high >= 2: results['risk_level'] = 'high' elif high >= 1 or med >= 2: results['risk_level'] = 'medium' elif med >= 1: results['risk_level'] = 'low' else: results['risk_level'] = 'none' else: results['analysis']['note'] = 'No power data collected — antenna may not receive GPS L1' results['risk_level'] = 'unknown' except subprocess.TimeoutExpired: results['error'] = 'GPS monitoring timed out' except Exception as e: results['error'] = str(e) else: results['error'] = 'rtl_power not found (required for GPS analysis)' return results # ── Drone RF Detection ───────────────────────────────────────────────── def _load_drone_detections(self): """Load saved drone detections from disk.""" try: if self._drone_detections_file.exists(): with open(self._drone_detections_file, 'r') as f: self._drone_detections = json.load(f) else: self._drone_detections = [] except Exception: self._drone_detections = [] def _save_drone_detections(self): """Persist drone detections to disk.""" try: with open(self._drone_detections_file, 'w') as f: json.dump(self._drone_detections, f, indent=2) except Exception: pass def start_drone_detection(self, device: str = 'rtl', duration: int = 0) -> Dict[str, Any]: """Start continuous drone RF detection. Monitors known drone control frequencies: - 2.4 GHz ISM band (DJI, common FPV) - 5.8 GHz (DJI FPV, video downlinks) - 900 MHz (long-range control links) - 1.2 GHz (analog video) - 433 MHz (some telemetry) DJI drones use OcuSync/Lightbridge on 2.4/5.8 GHz with frequency hopping. FPV drones typically use fixed channels on 5.8 GHz for video. Args: device: 'rtl' or 'hackrf' duration: seconds to run (0 = until stopped) Returns detection results including: - Frequency hopping patterns (characteristic of drone control) - Signal strength and bearing estimation - Protocol identification (DJI OcuSync, analog FPV, Crossfire, ELRS) - Drone type estimation """ with self._drone_lock: if self._drone_running: return {'status': 'already_running', 'message': 'Drone detection is already active'} # Verify we have the required tools if device == 'hackrf': tool = find_tool('hackrf_sweep') tool_name = 'hackrf_sweep' if not tool: return {'error': 'hackrf_sweep not found -- install hackrf package'} else: tool = find_tool('rtl_power') tool_name = 'rtl_power' if not tool: return {'error': 'rtl_power not found -- install rtl-sdr package'} with self._drone_lock: self._drone_running = True # Start background monitoring thread self._drone_thread = threading.Thread( target=self._drone_scan_loop, args=(device, tool, duration), daemon=True ) self._drone_thread.start() return { 'status': 'started', 'device': device, 'tool': tool_name, 'duration': duration if duration > 0 else 'continuous', 'message': f'Drone detection started with {tool_name}', 'bands': [v['desc'] for v in DRONE_FREQUENCIES.values()], } def _drone_scan_loop(self, device: str, tool: str, duration: int): """Background loop that sweeps drone frequency bands repeatedly.""" import math start_time = time.time() # Define scan bands -- we focus on 2.4 GHz and 5.8 GHz as primary, # plus 900 MHz and 433 MHz as secondary bands scan_bands = [ { 'name': '2.4 GHz ISM', 'freq_start': 2400000000, 'freq_end': 2500000000, 'protocols': ['dji_control_2g', 'elrs_2g'], }, { 'name': '5.8 GHz', 'freq_start': 5640000000, 'freq_end': 5950000000, 'protocols': ['dji_control_5g', 'fpv_video_5g'], }, { 'name': '900 MHz', 'freq_start': 900000000, 'freq_end': 930000000, 'protocols': ['crossfire_900', 'elrs_900'], }, { 'name': '433 MHz', 'freq_start': 432000000, 'freq_end': 435000000, 'protocols': ['telemetry_433'], }, ] # History of power readings per band for hopping detection band_history: Dict[str, List[Dict[str, Any]]] = {b['name']: [] for b in scan_bands} try: while self._drone_running: # Check duration limit if duration > 0 and (time.time() - start_time) >= duration: break for band in scan_bands: if not self._drone_running: break spectrum_data = self._drone_sweep_band( device, tool, band['freq_start'], band['freq_end'] ) if not spectrum_data: continue # Analyze the spectrum for drone signatures detections = self._analyze_drone_spectrum( spectrum_data, band, band_history[band['name']] ) # Store sweep in history (keep last 10 sweeps per band) band_history[band['name']].append({ 'time': time.time(), 'data': spectrum_data, }) if len(band_history[band['name']]) > 10: band_history[band['name']].pop(0) # Add any new detections if detections: with self._drone_lock: for det in detections: self._drone_detections.append(det) self._save_drone_detections() # Brief pause between full scan cycles if self._drone_running: time.sleep(1) except Exception: pass finally: with self._drone_lock: self._drone_running = False def _drone_sweep_band(self, device: str, tool: str, freq_start: int, freq_end: int) -> List[Dict[str, Any]]: """Perform a single spectrum sweep of a frequency band. Returns list of {freq, power_db} dicts. """ data = [] if device == 'hackrf': # hackrf_sweep: output in CSV format f_start_mhz = freq_start // 1000000 f_end_mhz = max(freq_end // 1000000, f_start_mhz + 1) cmd = [tool, '-f', f'{f_start_mhz}:{f_end_mhz}', '-n', '8192', '-w', '1000000'] try: proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) for line in proc.stdout.splitlines(): line = line.strip() if not line or line.startswith('#'): continue parts = line.split(',') if len(parts) < 7: continue try: hz_low = float(parts[2].strip()) hz_bin_width = float(parts[4].strip()) db_values = [float(x.strip()) for x in parts[6:] if x.strip()] for i, db in enumerate(db_values): freq = hz_low + (i * hz_bin_width) if freq_start <= freq <= freq_end: data.append({'freq': int(freq), 'power_db': round(db, 2)}) except (ValueError, IndexError): continue except (subprocess.TimeoutExpired, Exception): pass else: # rtl_power outfile = self._sdr_dir / 'drone_sweep.csv' if outfile.exists(): outfile.unlink() # RTL-SDR tops out around 1766 MHz, so for 2.4/5.8 GHz bands # we need HackRF. But we still try -- rtl_power will just fail # gracefully if frequency is out of range. step = 250000 # 250 kHz steps for drone detection cmd = [tool, '-f', f'{freq_start}:{freq_end}:{step}', '-i', '2', '-1', str(outfile)] try: subprocess.run(cmd, capture_output=True, text=True, timeout=15) if outfile.exists(): with open(outfile, 'r') as f: for line in f: parts = line.strip().split(',') if len(parts) < 7: continue try: hz_low = float(parts[2]) hz_step = float(parts[4]) db_values = [float(x) for x in parts[6:] if x.strip()] for i, db in enumerate(db_values): freq = hz_low + (i * hz_step) data.append({'freq': int(freq), 'power_db': round(db, 2)}) except (ValueError, IndexError): continue except (subprocess.TimeoutExpired, Exception): pass return data def _analyze_drone_spectrum(self, spectrum_data: List[Dict[str, Any]], band: Dict[str, Any], history: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Analyze spectrum sweep data for drone RF signatures. Looks for: - Strong signals above the noise floor - FHSS patterns (power appearing/disappearing at different frequencies) - Characteristic bandwidths matching known drone protocols - Fixed carriers on known FPV video channels """ import math detections = [] if not spectrum_data: return detections now = datetime.now(timezone.utc).isoformat() powers = [d['power_db'] for d in spectrum_data] if not powers: return detections avg_power = sum(powers) / len(powers) max_power = max(powers) # Noise floor estimate: median of lowest 50% of readings sorted_powers = sorted(powers) noise_floor = sorted_powers[len(sorted_powers) // 4] if sorted_powers else avg_power # Detection threshold: noise floor + 15 dB threshold = noise_floor + 15 # Find strong signal clusters above threshold strong_bins = [d for d in spectrum_data if d['power_db'] > threshold] if not strong_bins: return detections # Group adjacent strong bins into clusters clusters = self._cluster_signals(strong_bins) for cluster in clusters: if len(cluster) < 2: continue cluster_freqs = [d['freq'] for d in cluster] cluster_powers = [d['power_db'] for d in cluster] center_freq = (min(cluster_freqs) + max(cluster_freqs)) // 2 bandwidth_hz = max(cluster_freqs) - min(cluster_freqs) peak_power = max(cluster_powers) avg_cluster_power = sum(cluster_powers) / len(cluster_powers) # Identify the likely protocol protocol = self.identify_drone_protocol({ 'center_freq': center_freq, 'bandwidth_hz': bandwidth_hz, 'peak_power': peak_power, 'avg_power': avg_cluster_power, 'noise_floor': noise_floor, 'num_bins': len(cluster), 'band_name': band['name'], 'history': history, }) if protocol['protocol'] == 'unknown': continue # Calculate confidence based on signal characteristics confidence = protocol.get('confidence', 0) # Check history for frequency hopping patterns hopping_detected = False if len(history) >= 3: hopping_detected = self._detect_fhss_pattern( center_freq, bandwidth_hz, history ) if hopping_detected: confidence = min(confidence + 20, 100) detection = { 'time': now, 'frequency': center_freq, 'frequency_mhz': round(center_freq / 1e6, 3), 'bandwidth_mhz': round(bandwidth_hz / 1e6, 3), 'signal_strength_db': round(peak_power, 1), 'noise_floor_db': round(noise_floor, 1), 'snr_db': round(peak_power - noise_floor, 1), 'protocol': protocol['protocol'], 'protocol_detail': protocol.get('detail', ''), 'drone_type': protocol.get('drone_type', 'Unknown'), 'confidence': confidence, 'band': band['name'], 'fhss_detected': hopping_detected, 'duration_s': 0, } # Update duration if we have seen this signal before with self._drone_lock: for prev in reversed(self._drone_detections): if (prev.get('protocol') == detection['protocol'] and abs(prev.get('frequency', 0) - center_freq) < 5000000): try: prev_time = datetime.fromisoformat(prev['time']) now_time = datetime.fromisoformat(now) delta = (now_time - prev_time).total_seconds() if delta < 60: detection['duration_s'] = round( prev.get('duration_s', 0) + delta, 1 ) except Exception: pass break detections.append(detection) return detections def _cluster_signals(self, strong_bins: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: """Group adjacent frequency bins into signal clusters. Bins within 2 MHz of each other are considered part of the same signal. """ if not strong_bins: return [] sorted_bins = sorted(strong_bins, key=lambda d: d['freq']) clusters: List[List[Dict[str, Any]]] = [[sorted_bins[0]]] for b in sorted_bins[1:]: # Adjacent if within 2 MHz of last bin in current cluster if b['freq'] - clusters[-1][-1]['freq'] <= 2000000: clusters[-1].append(b) else: clusters.append([b]) return clusters def _detect_fhss_pattern(self, center_freq: int, bandwidth_hz: int, history: List[Dict[str, Any]]) -> bool: """Detect frequency hopping spread spectrum patterns by comparing sequential sweeps for signals that appear/disappear at different frequencies within the same band. FHSS signature: power peaks shift between sweeps while maintaining similar amplitude, consistent with drone control hopping patterns. """ if len(history) < 3: return False # Look at the last few sweeps for peak frequency shifts peak_freqs = [] for sweep in history[-5:]: data = sweep.get('data', []) if not data: continue # Find the peak frequency in this sweep within the band band_data = [d for d in data if abs(d['freq'] - center_freq) < bandwidth_hz] if band_data: peak = max(band_data, key=lambda d: d['power_db']) peak_freqs.append(peak['freq']) if len(peak_freqs) < 3: return False # FHSS: peak frequency changes between sweeps by more than 1 MHz # but stays within the same band freq_shifts = [] for i in range(1, len(peak_freqs)): shift = abs(peak_freqs[i] - peak_freqs[i - 1]) freq_shifts.append(shift) # At least 2 significant frequency shifts = likely FHSS significant_shifts = sum(1 for s in freq_shifts if s > 1000000) return significant_shifts >= 2 def identify_drone_protocol(self, spectrum_data: Dict[str, Any]) -> Dict[str, Any]: """Analyze spectrum sweep data and return likely drone protocol based on bandwidth, frequency, and signal characteristics. Args: spectrum_data: dict with keys: center_freq, bandwidth_hz, peak_power, avg_power, noise_floor, num_bins, band_name, history Returns: dict with protocol, detail, drone_type, confidence """ center = spectrum_data.get('center_freq', 0) bw = spectrum_data.get('bandwidth_hz', 0) peak = spectrum_data.get('peak_power', -100) noise = spectrum_data.get('noise_floor', -80) snr = peak - noise band = spectrum_data.get('band_name', '') result = { 'protocol': 'unknown', 'detail': '', 'drone_type': 'Unknown', 'confidence': 0, } # Minimum SNR for a valid detection if snr < 10: return result # ── 2.4 GHz band analysis ── if band == '2.4 GHz ISM' or 2400000000 <= center <= 2500000000: # DJI OcuSync 2.x/3.0: ~10-40 MHz wide FHSS on 2.4 GHz if 8000000 <= bw <= 45000000: result['protocol'] = 'DJI OcuSync' result['detail'] = f'{bw/1e6:.0f} MHz wide FHSS on 2.4 GHz' result['drone_type'] = 'DJI (Mavic/Air/Mini series)' result['confidence'] = min(40 + int(snr), 85) # ExpressLRS 2.4 GHz: narrower, ~1-5 MHz elif 500000 <= bw <= 6000000: result['protocol'] = 'ExpressLRS 2.4G' result['detail'] = f'{bw/1e6:.1f} MHz narrow band on 2.4 GHz' result['drone_type'] = 'FPV Racing/Freestyle Drone' result['confidence'] = min(30 + int(snr), 70) # Generic 2.4 GHz control -- could be WiFi drone elif bw <= 25000000: result['protocol'] = 'WiFi/2.4G Control' result['detail'] = f'{bw/1e6:.1f} MHz signal on 2.4 GHz' result['drone_type'] = 'WiFi-based drone or controller' result['confidence'] = min(20 + int(snr * 0.5), 50) # ── 5.8 GHz band analysis ── elif band == '5.8 GHz' or 5640000000 <= center <= 5950000000: # Check against known FPV analog video channels center_mhz = center / 1e6 matched_channel = None for ch_name, ch_mhz in FPV_5G_CHANNELS.items(): if abs(center_mhz - ch_mhz) < 10: matched_channel = ch_name break if matched_channel and bw <= 15000000: # Analog FPV video: constant carrier, ~10-12 MHz bandwidth result['protocol'] = 'Analog FPV Video' result['detail'] = f'Channel {matched_channel} ({center_mhz:.0f} MHz)' result['drone_type'] = 'FPV Drone (analog video)' result['confidence'] = min(50 + int(snr), 90) elif 10000000 <= bw <= 80000000: # DJI FPV / OcuSync on 5.8 GHz result['protocol'] = 'DJI OcuSync 5.8G' result['detail'] = f'{bw/1e6:.0f} MHz wide on 5.8 GHz' result['drone_type'] = 'DJI FPV / Digital Link' result['confidence'] = min(35 + int(snr), 80) elif bw <= 10000000: # Could be digital FPV (HDZero, Walksnail) result['protocol'] = 'Digital FPV Video' result['detail'] = f'{bw/1e6:.1f} MHz on 5.8 GHz' result['drone_type'] = 'FPV Drone (digital video)' result['confidence'] = min(25 + int(snr * 0.7), 65) # ── 900 MHz band analysis ── elif band == '900 MHz' or 900000000 <= center <= 930000000: if bw <= 2000000: # Crossfire or ELRS 900 MHz -- narrow, hopping result['protocol'] = 'Crossfire/ELRS 900' result['detail'] = f'{bw/1e3:.0f} kHz on 900 MHz ISM' result['drone_type'] = 'Long-range FPV/RC Drone' result['confidence'] = min(30 + int(snr), 70) elif 2000000 < bw <= 26000000: result['protocol'] = 'Crossfire 900' result['detail'] = f'{bw/1e6:.1f} MHz wideband 900 MHz' result['drone_type'] = 'Long-range FPV Drone' result['confidence'] = min(25 + int(snr * 0.7), 65) # ── 433 MHz band analysis ── elif band == '433 MHz' or 432000000 <= center <= 435000000: if bw <= 1000000: result['protocol'] = '433 MHz Telemetry' result['detail'] = f'{bw/1e3:.0f} kHz telemetry link' result['drone_type'] = 'Drone with 433 telemetry' result['confidence'] = min(20 + int(snr * 0.5), 50) return result def stop_drone_detection(self) -> Dict[str, Any]: """Stop the drone detection background scan.""" with self._drone_lock: if not self._drone_running: return {'status': 'not_running', 'message': 'Drone detection is not active'} self._drone_running = False # Wait briefly for the thread to finish if self._drone_thread and self._drone_thread.is_alive(): self._drone_thread.join(timeout=5) self._drone_thread = None with self._drone_lock: count = len(self._drone_detections) return { 'status': 'stopped', 'message': f'Drone detection stopped -- {count} detections recorded', 'detection_count': count, } def get_drone_detections(self) -> List[Dict[str, Any]]: """Return current list of drone detections, newest first.""" with self._drone_lock: dets = list(self._drone_detections) dets.sort(key=lambda d: d.get('time', ''), reverse=True) return dets def clear_drone_detections(self): """Clear all stored drone detections.""" with self._drone_lock: self._drone_detections = [] self._save_drone_detections() def is_drone_detecting(self) -> bool: """Check if drone detection is currently running.""" with self._drone_lock: return self._drone_running # ── Signal Analysis ────────────────────────────────────────────────────── def analyze_signal(self, file_path: str) -> Dict[str, Any]: """Basic signal analysis on a captured IQ file.""" fpath = self._resolve_recording(file_path) if not fpath: return {'error': f'Recording file not found: {file_path}'} try: raw = fpath.read_bytes() size = len(raw) if size < 64: return {'error': 'File too small for analysis'} # Parse as unsigned 8-bit IQ (RTL-SDR format) i_samples = [] q_samples = [] magnitudes = [] import math for idx in range(0, min(size, 2048000) - 1, 2): i_val = (raw[idx] - 127.5) / 127.5 q_val = (raw[idx + 1] - 127.5) / 127.5 i_samples.append(i_val) q_samples.append(q_val) magnitudes.append(math.sqrt(i_val * i_val + q_val * q_val)) if not magnitudes: return {'error': 'No valid samples found'} avg_mag = sum(magnitudes) / len(magnitudes) max_mag = max(magnitudes) min_mag = min(magnitudes) # Estimate power in dB (relative to full scale) avg_power_db = round(20 * math.log10(avg_mag + 1e-10), 2) peak_power_db = round(20 * math.log10(max_mag + 1e-10), 2) # Simple duty cycle: percentage of time signal is above 50% of max threshold = max_mag * 0.5 above = sum(1 for m in magnitudes if m > threshold) duty_cycle = round(above / len(magnitudes) * 100, 1) # Estimate bandwidth using power spectral density # Simple FFT-based approach n = min(len(i_samples), 4096) fft_input = [complex(i_samples[k], q_samples[k]) for k in range(n)] # Manual DFT for small N, or use simple approximation bandwidth_estimate = 'N/A (requires numpy for FFT)' # Try modulation type guess based on signal characteristics # AM: magnitude varies, phase relatively stable # FM: magnitude relatively stable, phase varies mag_variance = sum((m - avg_mag) ** 2 for m in magnitudes) / len(magnitudes) mag_std = math.sqrt(mag_variance) mag_cv = mag_std / (avg_mag + 1e-10) # coefficient of variation if mag_cv < 0.15: mod_guess = 'FM (constant envelope)' elif mag_cv > 0.5: mod_guess = 'AM or OOK (high amplitude variation)' else: mod_guess = 'Mixed / Unknown' # Recording metadata from our store meta = {} for m in self._metadata: if Path(m.get('file', '')).name == fpath.name: meta = m break return { 'file': fpath.name, 'file_size': size, 'file_size_human': self._human_size(size), 'total_samples': size // 2, 'analyzed_samples': len(magnitudes), 'power': { 'average_db': avg_power_db, 'peak_db': peak_power_db, 'dynamic_range_db': round(peak_power_db - avg_power_db, 2), }, 'magnitude': { 'average': round(avg_mag, 4), 'max': round(max_mag, 4), 'min': round(min_mag, 4), 'std_dev': round(mag_std, 4), }, 'duty_cycle_pct': duty_cycle, 'modulation_guess': mod_guess, 'bandwidth_estimate': bandwidth_estimate, 'frequency': meta.get('frequency', 'Unknown'), 'sample_rate': meta.get('sample_rate', 'Unknown'), 'device': meta.get('device', 'Unknown'), } except Exception as e: return {'error': f'Analysis failed: {e}'} # ── Common Frequencies ─────────────────────────────────────────────────── def get_common_frequencies(self) -> Dict[str, Any]: """Return the common frequencies reference dictionary.""" return COMMON_FREQUENCIES # ── Status ─────────────────────────────────────────────────────────────── def get_status(self) -> Dict[str, Any]: """Get current SDR status: device info, active capture, ADS-B state, drone detection.""" capturing = self.is_capturing() adsb_running = self._adsb_running status = { 'capturing': capturing, 'capture_info': self._capture_info if capturing else None, 'adsb_running': adsb_running, 'adsb_aircraft_count': len(self._adsb_aircraft), 'drone_detecting': self.is_drone_detecting(), 'drone_detection_count': len(self._drone_detections), 'recordings_count': len(self.list_recordings()), 'recordings_dir': str(self._recordings_dir), } return status # ── Helpers ────────────────────────────────────────────────────────────── def _resolve_recording(self, file_path: str) -> Optional[Path]: """Resolve a recording file path, checking recordings dir.""" fpath = Path(file_path) if fpath.exists(): return fpath # Try in recordings directory fpath = self._recordings_dir / file_path if fpath.exists(): return fpath # Try just filename fpath = self._recordings_dir / Path(file_path).name if fpath.exists(): return fpath return None @staticmethod def _human_size(nbytes: int) -> str: """Convert bytes to human-readable size string.""" for unit in ('B', 'KB', 'MB', 'GB'): if abs(nbytes) < 1024: return f'{nbytes:.1f} {unit}' nbytes /= 1024 return f'{nbytes:.1f} TB' # ── Singleton ──────────────────────────────────────────────────────────────── _instance = None def get_sdr_tools() -> SDRTools: global _instance if _instance is None: _instance = SDRTools() return _instance # ── CLI Interface ──────────────────────────────────────────────────────────── def run(): """CLI entry point for SDR/RF Tools module.""" import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from core.banner import Colors, clear_screen, display_banner sdr = get_sdr_tools() while True: clear_screen() display_banner() print(f"\n{Colors.CYAN}=== SDR / RF Tools ==={Colors.RESET}\n") print(f" {Colors.GREEN}1{Colors.RESET}) Detect Devices") print(f" {Colors.GREEN}2{Colors.RESET}) Spectrum Scan") print(f" {Colors.GREEN}3{Colors.RESET}) Capture Signal") print(f" {Colors.GREEN}4{Colors.RESET}) Replay Signal") print(f" {Colors.GREEN}5{Colors.RESET}) ADS-B Track") print(f" {Colors.GREEN}6{Colors.RESET}) FM Demod") print(f" {Colors.GREEN}7{Colors.RESET}) AM Demod") print(f" {Colors.GREEN}8{Colors.RESET}) List Recordings") print(f" {Colors.GREEN}9{Colors.RESET}) Analyze Signal") print(f" {Colors.RED}0{Colors.RESET}) Back\n") choice = input(f"{Colors.CYAN}Select> {Colors.RESET}").strip() if choice == '0': break elif choice == '1': print(f"\n{Colors.CYAN}[*] Detecting SDR devices...{Colors.RESET}") devices = sdr.detect_devices() if not devices: print(f"{Colors.YELLOW}[!] No SDR devices found{Colors.RESET}") else: for d in devices: status_color = Colors.GREEN if d['status'] == 'available' else Colors.YELLOW print(f" {status_color}[{d['status']}]{Colors.RESET} {d['type']}: {d.get('name', 'Unknown')} (SN: {d.get('serial', 'N/A')})") if d.get('capabilities'): print(f" Capabilities: {', '.join(d['capabilities'])}") if d.get('note'): print(f" {Colors.YELLOW}{d['note']}{Colors.RESET}") elif choice == '2': try: dev = input(" Device (rtl/hackrf) [rtl]: ").strip() or 'rtl' f_start = input(" Start frequency MHz [88]: ").strip() or '88' f_end = input(" End frequency MHz [108]: ").strip() or '108' dur = input(" Duration seconds [5]: ").strip() or '5' print(f"\n{Colors.CYAN}[*] Scanning spectrum {f_start}-{f_end} MHz...{Colors.RESET}") result = sdr.scan_spectrum( device=dev, freq_start=int(float(f_start) * 1000000), freq_end=int(float(f_end) * 1000000), duration=int(dur) ) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: points = result.get('data', []) print(f"{Colors.GREEN}[+] Collected {len(points)} data points{Colors.RESET}") # Show top 10 strongest signals top = sorted(points, key=lambda p: p['power_db'], reverse=True)[:10] if top: print(f"\n {'Frequency':>15s} {'Power (dB)':>10s}") print(f" {'-'*15} {'-'*10}") for p in top: freq_str = f"{p['freq']/1e6:.3f} MHz" print(f" {freq_str:>15s} {p['power_db']:>10.1f}") except (ValueError, KeyboardInterrupt): print(f"\n{Colors.YELLOW}[!] Cancelled{Colors.RESET}") elif choice == '3': try: dev = input(" Device (rtl/hackrf) [rtl]: ").strip() or 'rtl' freq = input(" Frequency MHz [100.0]: ").strip() or '100.0' dur = input(" Duration seconds [10]: ").strip() or '10' print(f"\n{Colors.CYAN}[*] Capturing at {freq} MHz for {dur}s...{Colors.RESET}") result = sdr.start_capture( device=dev, frequency=int(float(freq) * 1000000), duration=int(dur) ) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"{Colors.GREEN}[+] Capturing to: {result.get('file')}{Colors.RESET}") print(f" Press Enter to wait for completion...") input() except (ValueError, KeyboardInterrupt): sdr.stop_capture() print(f"\n{Colors.YELLOW}[!] Capture stopped{Colors.RESET}") elif choice == '4': recordings = sdr.list_recordings() if not recordings: print(f"\n{Colors.YELLOW}[!] No recordings found{Colors.RESET}") else: print(f"\n Recordings:") for i, r in enumerate(recordings): print(f" {i+1}) {r.get('filename', 'unknown')} ({r.get('size_human', '?')})") try: idx = int(input(f"\n Select recording [1-{len(recordings)}]: ").strip()) - 1 rec = recordings[idx] freq = input(f" TX Frequency MHz [{rec.get('frequency', 100000000)/1e6:.3f}]: ").strip() if not freq: freq = str(rec.get('frequency', 100000000) / 1e6) print(f"\n{Colors.CYAN}[*] Replaying {rec.get('filename')} at {freq} MHz...{Colors.RESET}") result = sdr.replay_signal( rec.get('file', rec.get('filename', '')), frequency=int(float(freq) * 1000000) ) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"{Colors.GREEN}[+] {result.get('message', 'Done')}{Colors.RESET}") except (ValueError, IndexError, KeyboardInterrupt): print(f"\n{Colors.YELLOW}[!] Cancelled{Colors.RESET}") elif choice == '5': if sdr._adsb_running: print(f"\n{Colors.CYAN}[*] ADS-B is running. Showing aircraft...{Colors.RESET}") aircraft = sdr.get_adsb_aircraft() if not aircraft: print(f"{Colors.YELLOW} No aircraft detected yet{Colors.RESET}") else: print(f"\n {'ICAO':>8s} {'Callsign':>10s} {'Alt(ft)':>8s} {'Spd(kn)':>8s} {'Hdg':>5s} {'Msgs':>5s}") print(f" {'-'*8} {'-'*10} {'-'*8} {'-'*8} {'-'*5} {'-'*5}") for ac in aircraft[:20]: alt = str(ac.get('altitude', '')) if ac.get('altitude') is not None else '--' spd = str(ac.get('speed', '')) if ac.get('speed') is not None else '--' hdg = str(ac.get('heading', '')) if ac.get('heading') is not None else '--' print(f" {ac['icao']:>8s} {ac.get('callsign', ''):>10s} {alt:>8s} {spd:>8s} {hdg:>5s} {ac.get('messages', 0):>5d}") stop = input(f"\n Stop tracking? [y/N]: ").strip().lower() if stop == 'y': result = sdr.stop_adsb() print(f"{Colors.GREEN}[+] {result.get('message', 'Stopped')}{Colors.RESET}") else: dev = input(" Device (rtl) [rtl]: ").strip() or 'rtl' print(f"\n{Colors.CYAN}[*] Starting ADS-B tracking...{Colors.RESET}") result = sdr.start_adsb(device=dev) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"{Colors.GREEN}[+] {result.get('message', 'Started')}{Colors.RESET}") elif choice == '6': recordings = sdr.list_recordings() if not recordings: print(f"\n{Colors.YELLOW}[!] No recordings found{Colors.RESET}") else: print(f"\n Recordings:") for i, r in enumerate(recordings): print(f" {i+1}) {r.get('filename', 'unknown')} ({r.get('size_human', '?')})") try: idx = int(input(f"\n Select recording [1-{len(recordings)}]: ").strip()) - 1 rec = recordings[idx] print(f"\n{Colors.CYAN}[*] FM demodulating {rec.get('filename')}...{Colors.RESET}") result = sdr.demodulate_fm(rec.get('file', rec.get('filename', ''))) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"{Colors.GREEN}[+] Output: {result.get('filename')}{Colors.RESET}") print(f" Duration: {result.get('duration', 0):.2f}s, Samples: {result.get('samples', 0)}") except (ValueError, IndexError, KeyboardInterrupt): print(f"\n{Colors.YELLOW}[!] Cancelled{Colors.RESET}") elif choice == '7': recordings = sdr.list_recordings() if not recordings: print(f"\n{Colors.YELLOW}[!] No recordings found{Colors.RESET}") else: print(f"\n Recordings:") for i, r in enumerate(recordings): print(f" {i+1}) {r.get('filename', 'unknown')} ({r.get('size_human', '?')})") try: idx = int(input(f"\n Select recording [1-{len(recordings)}]: ").strip()) - 1 rec = recordings[idx] print(f"\n{Colors.CYAN}[*] AM demodulating {rec.get('filename')}...{Colors.RESET}") result = sdr.demodulate_am(rec.get('file', rec.get('filename', ''))) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"{Colors.GREEN}[+] Output: {result.get('filename')}{Colors.RESET}") print(f" Duration: {result.get('duration', 0):.2f}s, Samples: {result.get('samples', 0)}") except (ValueError, IndexError, KeyboardInterrupt): print(f"\n{Colors.YELLOW}[!] Cancelled{Colors.RESET}") elif choice == '8': recordings = sdr.list_recordings() if not recordings: print(f"\n{Colors.YELLOW}[!] No recordings found{Colors.RESET}") else: print(f"\n {'#':>3s} {'Filename':>30s} {'Freq':>12s} {'Size':>10s} {'Device':>8s} {'Date':>20s}") print(f" {'-'*3} {'-'*30} {'-'*12} {'-'*10} {'-'*8} {'-'*20}") for i, r in enumerate(recordings): freq = r.get('frequency', 0) freq_str = f"{freq/1e6:.3f} MHz" if freq else 'N/A' date_str = r.get('completed', '')[:19] if r.get('completed') else 'N/A' print(f" {i+1:>3d} {r.get('filename', 'unknown'):>30s} {freq_str:>12s} {r.get('size_human', '?'):>10s} {r.get('device', '?'):>8s} {date_str:>20s}") elif choice == '9': recordings = sdr.list_recordings() if not recordings: print(f"\n{Colors.YELLOW}[!] No recordings found{Colors.RESET}") else: print(f"\n Recordings:") for i, r in enumerate(recordings): print(f" {i+1}) {r.get('filename', 'unknown')} ({r.get('size_human', '?')})") try: idx = int(input(f"\n Select recording [1-{len(recordings)}]: ").strip()) - 1 rec = recordings[idx] print(f"\n{Colors.CYAN}[*] Analyzing {rec.get('filename')}...{Colors.RESET}") result = sdr.analyze_signal(rec.get('file', rec.get('filename', ''))) if result.get('error'): print(f"{Colors.RED}[X] {result['error']}{Colors.RESET}") else: print(f"\n {Colors.GREEN}Signal Analysis:{Colors.RESET}") print(f" File: {result.get('file', 'unknown')}") print(f" Size: {result.get('file_size_human', '?')}") print(f" Samples: {result.get('total_samples', 0):,}") pwr = result.get('power', {}) print(f" Avg Power: {pwr.get('average_db', '?')} dB") print(f" Peak Power: {pwr.get('peak_db', '?')} dB") print(f" Dynamic Range: {pwr.get('dynamic_range_db', '?')} dB") print(f" Duty Cycle: {result.get('duty_cycle_pct', '?')}%") print(f" Modulation: {result.get('modulation_guess', '?')}") except (ValueError, IndexError, KeyboardInterrupt): print(f"\n{Colors.YELLOW}[!] Cancelled{Colors.RESET}") input(f"\n{Colors.DIM}Press Enter to continue...{Colors.RESET}")