diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ec27b35
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,38 @@
+# Git
+.git
+.gitignore
+
+# Python
+__pycache__
+*.py[cod]
+*$py.class
+*.so
+.Python
+.env
+.venv
+env/
+venv/
+.eggs/
+*.egg-info/
+*.egg
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Test/Dev
+tests/
+.pytest_cache/
+.coverage
+htmlcov/
+.mypy_cache/
+
+# Logs
+*.log
+
+# Captured files (don't include in image)
+*.cap
+*.pcap
+*.csv
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1c88b70
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,40 @@
+# INTERCEPT - Signal Intelligence Platform
+# Docker container for running the web interface
+
+FROM python:3.11-slim
+
+# Set working directory
+WORKDIR /app
+
+# Install system dependencies for RTL-SDR tools
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ # RTL-SDR tools
+ rtl-sdr \
+ # 433MHz decoder
+ rtl-433 \
+ # Pager decoder
+ multimon-ng \
+ # WiFi tools (aircrack-ng suite)
+ aircrack-ng \
+ # Bluetooth tools
+ bluez \
+ # Cleanup
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy requirements first for better caching
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy application code
+COPY . .
+
+# Expose web interface port
+EXPOSE 5050
+
+# Environment variables with defaults
+ENV INTERCEPT_HOST=0.0.0.0 \
+ INTERCEPT_PORT=5050 \
+ INTERCEPT_LOG_LEVEL=INFO
+
+# Run the application
+CMD ["python", "intercept.py"]
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..9aa7a53
--- /dev/null
+++ b/app.py
@@ -0,0 +1,194 @@
+"""
+INTERCEPT - Signal Intelligence Platform
+
+Flask application and shared state.
+"""
+
+from __future__ import annotations
+
+import sys
+import site
+
+# Ensure user site-packages is available (may be disabled when running as root/sudo)
+if not site.ENABLE_USER_SITE:
+ user_site = site.getusersitepackages()
+ if user_site and user_site not in sys.path:
+ sys.path.insert(0, user_site)
+
+import os
+import queue
+import threading
+import platform
+import subprocess
+
+from typing import Any
+
+from flask import Flask, render_template, jsonify, send_file, Response
+
+from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
+from utils.process import detect_devices, cleanup_stale_processes
+
+
+# Create Flask app
+app = Flask(__name__)
+
+# ============================================
+# GLOBAL PROCESS MANAGEMENT
+# ============================================
+
+# Pager decoder
+current_process = None
+output_queue = queue.Queue()
+process_lock = threading.Lock()
+
+# RTL_433 sensor
+sensor_process = None
+sensor_queue = queue.Queue()
+sensor_lock = threading.Lock()
+
+# WiFi
+wifi_process = None
+wifi_queue = queue.Queue()
+wifi_lock = threading.Lock()
+
+# Bluetooth
+bt_process = None
+bt_queue = queue.Queue()
+bt_lock = threading.Lock()
+
+# ADS-B aircraft
+adsb_process = None
+adsb_queue = queue.Queue()
+adsb_lock = threading.Lock()
+
+# Satellite/Iridium
+satellite_process = None
+satellite_queue = queue.Queue()
+satellite_lock = threading.Lock()
+
+# ============================================
+# GLOBAL STATE DICTIONARIES
+# ============================================
+
+# Logging settings
+logging_enabled = False
+log_file_path = 'pager_messages.log'
+
+# WiFi state
+wifi_monitor_interface = None
+wifi_networks = {} # BSSID -> network info
+wifi_clients = {} # Client MAC -> client info
+wifi_handshakes = [] # Captured handshakes
+
+# Bluetooth state
+bt_interface = None
+bt_devices = {} # MAC -> device info
+bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
+bt_services = {} # MAC -> list of services
+
+# Aircraft (ADS-B) state
+adsb_aircraft = {} # ICAO hex -> aircraft info
+
+# Satellite state
+iridium_bursts = [] # List of detected Iridium bursts
+satellite_passes = [] # Predicted satellite passes
+
+
+# ============================================
+# MAIN ROUTES
+# ============================================
+
+@app.route('/')
+def index() -> str:
+ tools = {
+ 'rtl_fm': check_tool('rtl_fm'),
+ 'multimon': check_tool('multimon-ng'),
+ 'rtl_433': check_tool('rtl_433')
+ }
+ devices = detect_devices()
+ return render_template('index.html', tools=tools, devices=devices)
+
+
+@app.route('/favicon.svg')
+def favicon() -> Response:
+ return send_file('favicon.svg', mimetype='image/svg+xml')
+
+
+@app.route('/devices')
+def get_devices() -> Response:
+ return jsonify(detect_devices())
+
+
+@app.route('/dependencies')
+def get_dependencies() -> Response:
+ """Get status of all tool dependencies."""
+ results = check_all_dependencies()
+
+ # Determine OS for install instructions
+ system = platform.system().lower()
+ if system == 'darwin':
+ install_method = 'brew'
+ elif system == 'linux':
+ install_method = 'apt'
+ else:
+ install_method = 'manual'
+
+ return jsonify({
+ 'os': system,
+ 'install_method': install_method,
+ 'modes': results
+ })
+
+
+@app.route('/killall', methods=['POST'])
+def kill_all() -> Response:
+ """Kill all decoder and WiFi processes."""
+ global current_process, sensor_process, wifi_process
+
+ killed = []
+ processes_to_kill = [
+ 'rtl_fm', 'multimon-ng', 'rtl_433',
+ 'airodump-ng', 'aireplay-ng', 'airmon-ng'
+ ]
+
+ for proc in processes_to_kill:
+ try:
+ result = subprocess.run(['pkill', '-f', proc], capture_output=True)
+ if result.returncode == 0:
+ killed.append(proc)
+ except (subprocess.SubprocessError, OSError):
+ pass
+
+ with process_lock:
+ current_process = None
+
+ with sensor_lock:
+ sensor_process = None
+
+ with wifi_lock:
+ wifi_process = None
+
+ return jsonify({'status': 'killed', 'processes': killed})
+
+
+def main() -> None:
+ """Main entry point."""
+ print("=" * 50)
+ print(" INTERCEPT // Signal Intelligence")
+ print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
+ print("=" * 50)
+ print()
+
+ # Clean up any stale processes from previous runs
+ cleanup_stale_processes()
+
+ # Register blueprints
+ from routes import register_blueprints
+ register_blueprints(app)
+
+ print("Open http://localhost:5050 in your browser")
+ print()
+ print("Press Ctrl+C to stop")
+ print()
+
+ app.run(host='0.0.0.0', port=5050, debug=False, threaded=True)
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..cfbc701
--- /dev/null
+++ b/config.py
@@ -0,0 +1,97 @@
+"""Configuration settings for intercept application."""
+
+from __future__ import annotations
+
+import logging
+import os
+import sys
+
+
+def _get_env(key: str, default: str) -> str:
+ """Get environment variable with default."""
+ return os.environ.get(f'INTERCEPT_{key}', default)
+
+
+def _get_env_int(key: str, default: int) -> int:
+ """Get environment variable as integer with default."""
+ try:
+ return int(os.environ.get(f'INTERCEPT_{key}', str(default)))
+ except ValueError:
+ return default
+
+
+def _get_env_float(key: str, default: float) -> float:
+ """Get environment variable as float with default."""
+ try:
+ return float(os.environ.get(f'INTERCEPT_{key}', str(default)))
+ except ValueError:
+ return default
+
+
+def _get_env_bool(key: str, default: bool) -> bool:
+ """Get environment variable as boolean with default."""
+ val = os.environ.get(f'INTERCEPT_{key}', '').lower()
+ if val in ('true', '1', 'yes', 'on'):
+ return True
+ if val in ('false', '0', 'no', 'off'):
+ return False
+ return default
+
+
+# Logging configuration
+_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper()
+LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
+LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s')
+
+# Server settings
+HOST = _get_env('HOST', '0.0.0.0')
+PORT = _get_env_int('PORT', 5050)
+DEBUG = _get_env_bool('DEBUG', False)
+THREADED = _get_env_bool('THREADED', True)
+
+# Default RTL-SDR settings
+DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
+DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
+
+# Pager defaults
+DEFAULT_PAGER_FREQ = _get_env('PAGER_FREQ', '929.6125M')
+
+# Iridium defaults
+DEFAULT_IRIDIUM_FREQ = _get_env('IRIDIUM_FREQ', '1626.0')
+DEFAULT_IRIDIUM_SAMPLE_RATE = _get_env('IRIDIUM_SAMPLE_RATE', '2.048e6')
+
+# Timeouts
+PROCESS_TIMEOUT = _get_env_int('PROCESS_TIMEOUT', 5)
+SOCKET_TIMEOUT = _get_env_int('SOCKET_TIMEOUT', 5)
+SSE_TIMEOUT = _get_env_int('SSE_TIMEOUT', 1)
+
+# WiFi settings
+WIFI_UPDATE_INTERVAL = _get_env_float('WIFI_UPDATE_INTERVAL', 2.0)
+AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
+
+# Bluetooth settings
+BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
+BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
+
+# ADS-B settings
+ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
+ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
+
+# Satellite settings
+SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
+SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
+SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
+
+# Maximum burst count for Iridium monitoring
+IRIDIUM_MAX_BURSTS = _get_env_int('IRIDIUM_MAX_BURSTS', 100)
+
+
+def configure_logging() -> None:
+ """Configure application logging."""
+ logging.basicConfig(
+ level=LOG_LEVEL,
+ format=LOG_FORMAT,
+ stream=sys.stderr
+ )
+ # Suppress Flask development server warning
+ logging.getLogger('werkzeug').setLevel(LOG_LEVEL)
diff --git a/data/__init__.py b/data/__init__.py
new file mode 100644
index 0000000..fe519ae
--- /dev/null
+++ b/data/__init__.py
@@ -0,0 +1,10 @@
+# Data modules for INTERCEPT
+from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
+from .satellites import TLE_SATELLITES
+from .patterns import (
+ AIRTAG_PREFIXES,
+ TILE_PREFIXES,
+ SAMSUNG_TRACKER,
+ DRONE_SSID_PATTERNS,
+ DRONE_OUI_PREFIXES,
+)
diff --git a/data/oui.py b/data/oui.py
new file mode 100644
index 0000000..318df1d
--- /dev/null
+++ b/data/oui.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import logging
+import os
+import json
+
+logger = logging.getLogger('intercept.oui')
+
+
+def load_oui_database() -> dict[str, str] | None:
+ """Load OUI database from external JSON file, with fallback to built-in."""
+ oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
+ try:
+ if os.path.exists(oui_file):
+ with open(oui_file, 'r') as f:
+ data = json.load(f)
+ # Remove comment fields
+ return {k: v for k, v in data.items() if not k.startswith('_')}
+ except Exception as e:
+ logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
+ return None # Will fall back to built-in
+
+
+def get_manufacturer(mac: str) -> str:
+ """Look up manufacturer from MAC address OUI."""
+ prefix = mac[:8].upper()
+ return OUI_DATABASE.get(prefix, 'Unknown')
+
+
+# OUI Database for manufacturer lookup (expanded)
+OUI_DATABASE = {
+ # Apple (extensive list)
+ '00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
+ '14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
+ '40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
+ '5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
+ '78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
+ '90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
+ 'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
+ 'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
+ 'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
+ '04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
+ '14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
+ '28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
+ '3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
+ '50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
+ '64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
+ '74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
+ '84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
+ '9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
+ 'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
+ 'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
+ 'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
+ 'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
+ # Samsung
+ '00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
+ '8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
+ '00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
+ '00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
+ '10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
+ '24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
+ '34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
+ '44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
+ '54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
+ '64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
+ '74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
+ '88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
+ '98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
+ 'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
+ 'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
+ 'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
+ 'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
+ # Google
+ '54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
+ '64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
+ '54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
+ # Sony
+ '00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
+ '08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
+ '40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
+ 'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
+ # Bose
+ '00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
+ '4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
+ # JBL/Harman
+ '00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
+ '50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
+ # Beats (Apple subsidiary)
+ '00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
+ # Jabra/GN Audio
+ '00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
+ '74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
+ # Sennheiser
+ '00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
+ # Xiaomi
+ '04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
+ '20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
+ '44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
+ '74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
+ '8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
+ 'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
+ 'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
+ # Huawei
+ '00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
+ '08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
+ '28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
+ '4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
+ '78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
+ 'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
+ 'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
+ # OnePlus/BBK
+ '64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
+ # Fitbit
+ '2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
+ # Garmin
+ '00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
+ # Microsoft
+ '00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
+ '7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
+ # Intel
+ '00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
+ '08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
+ '48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
+ '8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
+ # Qualcomm/Atheros
+ '00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
+ '1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
+ # Broadcom
+ '00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
+ # Realtek
+ '00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
+ '52:54:00': 'Realtek', '80:EA:96': 'Realtek',
+ # Logitech
+ '00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
+ # Lenovo
+ '00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
+ # Dell
+ '00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
+ '78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
+ # HP
+ '00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
+ '38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
+ # Tile
+ 'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
+ # Raspberry Pi
+ 'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
+ # Amazon
+ '00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
+ '44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
+ 'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
+ # Skullcandy
+ '00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
+ # Bang & Olufsen
+ '00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
+ # Audio-Technica
+ 'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
+ # Plantronics/Poly
+ '00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
+ # Anker
+ 'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
+ # Misc/Generic
+ '00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
+ '00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
+}
+
+# Try to load from external file (easier to update)
+_external_oui = load_oui_database()
+if _external_oui:
+ OUI_DATABASE = _external_oui
+ logger.info(f"Loaded {len(OUI_DATABASE)} entries from oui_database.json")
+else:
+ logger.info(f"Using built-in database with {len(OUI_DATABASE)} entries")
diff --git a/data/patterns.py b/data/patterns.py
new file mode 100644
index 0000000..b427c44
--- /dev/null
+++ b/data/patterns.py
@@ -0,0 +1,39 @@
+# Detection patterns for various device types
+
+# Known beacon prefixes for tracker detection
+AIRTAG_PREFIXES = ['4C:00'] # Apple continuity
+TILE_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A']
+SAMSUNG_TRACKER = ['58:4D', 'A0:75']
+
+# Drone detection patterns (SSID patterns)
+DRONE_SSID_PATTERNS = [
+ # DJI
+ 'DJI-', 'DJI_', 'Mavic', 'Phantom', 'Spark-', 'Mini-', 'Air-', 'Inspire',
+ 'Matrice', 'Avata', 'FPV-', 'Osmo', 'RoboMaster', 'Tello',
+ # Parrot
+ 'Parrot', 'Bebop', 'Anafi', 'Disco-', 'Mambo', 'Swing',
+ # Autel
+ 'Autel', 'EVO-', 'Dragonfish', 'Lite+', 'Nano',
+ # Skydio
+ 'Skydio',
+ # Other brands
+ 'Holy Stone', 'Potensic', 'SYMA', 'Hubsan', 'Eachine', 'FIMI',
+ 'Xiaomi_FIMI', 'Yuneec', 'Typhoon', 'PowerVision', 'PowerEgg',
+ # Generic drone patterns
+ 'Drone', 'UAV-', 'Quadcopter', 'FPV_', 'RC-Drone'
+]
+
+# Drone OUI prefixes (MAC address prefixes for drone manufacturers)
+DRONE_OUI_PREFIXES = {
+ # DJI
+ '60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
+ 'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
+ # Parrot
+ '90:03:B7': 'Parrot', 'A0:14:3D': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
+ # Autel
+ '8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel',
+ # Yuneec
+ '60:60:1F': 'Yuneec',
+ # Skydio
+ 'F8:0F:6F': 'Skydio',
+}
diff --git a/data/satellites.py b/data/satellites.py
new file mode 100644
index 0000000..ca66d20
--- /dev/null
+++ b/data/satellites.py
@@ -0,0 +1,24 @@
+# TLE data for satellite tracking (updated periodically)
+TLE_SATELLITES = {
+ 'ISS': ('ISS (ZARYA)',
+ '1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
+ '2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
+ 'NOAA-15': ('NOAA 15',
+ '1 25338U 98030A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 25338 98.7300 0.0000 0010000 0.0000 0.0000 14.26000000000000'),
+ 'NOAA-18': ('NOAA 18',
+ '1 28654U 05018A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 28654 98.8800 0.0000 0014000 0.0000 0.0000 14.12000000000000'),
+ 'NOAA-19': ('NOAA 19',
+ '1 33591U 09005A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 33591 99.1900 0.0000 0014000 0.0000 0.0000 14.12000000000000'),
+ 'NOAA-20': ('NOAA 20 (JPSS-1)',
+ '1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
+ 'METEOR-M2': ('METEOR-M 2',
+ '1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
+ 'METEOR-M2-3': ('METEOR-M2 3',
+ '1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
+ '2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
+}
diff --git a/intercept.py b/intercept.py
index 6ee5333..81aa304 100755
--- a/intercept.py
+++ b/intercept.py
@@ -22,15435 +22,7 @@ if not site.ENABLE_USER_SITE:
if user_site and user_site not in sys.path:
sys.path.insert(0, user_site)
-import subprocess
-import shutil
-import re
-import threading
-import queue
-import pty
-import os
-import select
-import json
-import time
-from flask import Flask, render_template_string, jsonify, request, Response, send_file
-
-app = Flask(__name__)
-
-
-def load_oui_database():
- """Load OUI database from external JSON file, with fallback to built-in."""
- oui_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'oui_database.json')
- try:
- if os.path.exists(oui_file):
- with open(oui_file, 'r') as f:
- data = json.load(f)
- # Remove comment fields
- return {k: v for k, v in data.items() if not k.startswith('_')}
- except Exception as e:
- print(f"[OUI] Error loading oui_database.json: {e}, using built-in database")
- return None # Will fall back to built-in
-
-# Global process management
-current_process = None
-sensor_process = None
-wifi_process = None
-bt_process = None
-output_queue = queue.Queue()
-sensor_queue = queue.Queue()
-wifi_queue = queue.Queue()
-bt_queue = queue.Queue()
-process_lock = threading.Lock()
-sensor_lock = threading.Lock()
-wifi_lock = threading.Lock()
-bt_lock = threading.Lock()
-
-# Logging settings
-logging_enabled = False
-log_file_path = 'pager_messages.log'
-
-# WiFi state
-wifi_monitor_interface = None
-wifi_networks = {} # BSSID -> network info
-wifi_clients = {} # Client MAC -> client info
-wifi_handshakes = [] # Captured handshakes
-
-# Bluetooth state
-bt_interface = None
-bt_devices = {} # MAC -> device info
-bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
-bt_services = {} # MAC -> list of services
-
-# Aircraft (ADS-B) state
-adsb_process = None
-adsb_queue = queue.Queue()
-adsb_lock = threading.Lock()
-adsb_aircraft = {} # ICAO hex -> aircraft info
-
-# Satellite state
-satellite_process = None
-satellite_queue = queue.Queue()
-satellite_lock = threading.Lock()
-iridium_bursts = [] # List of detected Iridium bursts
-satellite_passes = [] # Predicted satellite passes
-
-# TLE data for satellite tracking (updated periodically)
-TLE_SATELLITES = {
- 'ISS': ('ISS (ZARYA)',
- '1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
- '2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
- 'NOAA-15': ('NOAA 15',
- '1 25338U 98030A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 25338 98.7300 0.0000 0010000 0.0000 0.0000 14.26000000000000'),
- 'NOAA-18': ('NOAA 18',
- '1 28654U 05018A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 28654 98.8800 0.0000 0014000 0.0000 0.0000 14.12000000000000'),
- 'NOAA-19': ('NOAA 19',
- '1 33591U 09005A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 33591 99.1900 0.0000 0014000 0.0000 0.0000 14.12000000000000'),
- 'NOAA-20': ('NOAA 20 (JPSS-1)',
- '1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
- 'METEOR-M2': ('METEOR-M 2',
- '1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
- 'METEOR-M2-3': ('METEOR-M2 3',
- '1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
- '2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
-}
-
-# Known beacon prefixes for detection
-AIRTAG_PREFIXES = ['4C:00'] # Apple continuity
-TILE_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A']
-SAMSUNG_TRACKER = ['58:4D', 'A0:75']
-
-# Drone detection patterns
-DRONE_SSID_PATTERNS = [
- # DJI
- 'DJI-', 'DJI_', 'Mavic', 'Phantom', 'Spark-', 'Mini-', 'Air-', 'Inspire',
- 'Matrice', 'Avata', 'FPV-', 'Osmo', 'RoboMaster', 'Tello',
- # Parrot
- 'Parrot', 'Bebop', 'Anafi', 'Disco-', 'Mambo', 'Swing',
- # Autel
- 'Autel', 'EVO-', 'Dragonfish', 'Lite+', 'Nano',
- # Skydio
- 'Skydio',
- # Other brands
- 'Holy Stone', 'Potensic', 'SYMA', 'Hubsan', 'Eachine', 'FIMI',
- 'Xiaomi_FIMI', 'Yuneec', 'Typhoon', 'PowerVision', 'PowerEgg',
- # Generic drone patterns
- 'Drone', 'UAV-', 'Quadcopter', 'FPV_', 'RC-Drone'
-]
-
-# Drone OUI prefixes (MAC address prefixes for drone manufacturers)
-DRONE_OUI_PREFIXES = {
- # DJI
- '60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
- 'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
- # Parrot
- '90:03:B7': 'Parrot', 'A0:14:3D': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
- # Autel
- '8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel',
- # Yuneec
- '60:60:1F': 'Yuneec',
- # Skydio
- 'F8:0F:6F': 'Skydio',
-}
-
-# OUI Database for manufacturer lookup (expanded)
-OUI_DATABASE = {
- # Apple (extensive list)
- '00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
- '14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
- '40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
- '5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
- '78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
- '90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
- 'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
- 'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
- 'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
- '04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
- '14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
- '28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
- '3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
- '50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
- '64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
- '74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
- '84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
- '9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
- 'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
- 'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
- 'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
- 'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
- # Samsung
- '00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
- '8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
- '00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
- '00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
- '10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
- '24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
- '34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
- '44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
- '54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
- '64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
- '74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
- '88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
- '98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
- 'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
- 'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
- 'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
- 'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
- # Google
- '54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
- '64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
- '54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
- # Sony
- '00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
- '08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
- '40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
- 'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
- # Bose
- '00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
- '4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
- # JBL/Harman
- '00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
- '50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
- # Beats (Apple subsidiary)
- '00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
- # Jabra/GN Audio
- '00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
- '74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
- # Sennheiser
- '00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
- # Xiaomi
- '04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
- '20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
- '44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
- '74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
- '8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
- 'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
- 'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
- # Huawei
- '00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
- '08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
- '28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
- '4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
- '78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
- 'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
- 'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
- # OnePlus/BBK
- '64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
- # Fitbit
- '2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
- # Garmin
- '00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
- # Microsoft
- '00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
- '7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
- # Intel
- '00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
- '08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
- '48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
- '8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
- # Qualcomm/Atheros
- '00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
- '1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
- # Broadcom
- '00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
- # Realtek
- '00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
- '52:54:00': 'Realtek', '80:EA:96': 'Realtek',
- # Logitech
- '00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
- # Lenovo
- '00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
- # Dell
- '00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
- '78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
- # HP
- '00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
- '38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
- # Tile
- 'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
- # Raspberry Pi
- 'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
- # Amazon
- '00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
- '44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
- 'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
- # Skullcandy
- '00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
- # Bang & Olufsen
- '00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
- # Audio-Technica
- 'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
- # Plantronics/Poly
- '00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
- # Anker
- 'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
- # Misc/Generic
- '00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
- '00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
-}
-
-# Try to load from external file (easier to update)
-_external_oui = load_oui_database()
-if _external_oui:
- OUI_DATABASE = _external_oui
- print(f"[OUI] Loaded {len(OUI_DATABASE)} entries from oui_database.json")
-else:
- print(f"[OUI] Using built-in database with {len(OUI_DATABASE)} entries")
-
-
-HTML_TEMPLATE = '''
-
-
-
-
-
- INTERCEPT // Signal Intelligence
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
⚠️
-
DISCLAIMER
-
- INTERCEPT is a signal intelligence tool designed for educational purposes only .
-
-
By using this software, you acknowledge and agree that:
-
- This tool is intended for use by cyber security professionals and researchers only
- You will only use this software in a controlled environment with proper authorization
- Intercepting communications without consent may be illegal in your jurisdiction
- You are solely responsible for ensuring compliance with all applicable laws and regulations
- The developers assume no liability for misuse of this software
-
-
- Only proceed if you understand and accept these terms.
-
-
- I UNDERSTAND & ACCEPT
- DECLINE
-
-
-
-
-
-
-
-
- █████╗ ██████╗ ██████╗███████╗███████╗███████╗
-██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
-███████║██║ ██║ █████╗ ███████╗███████╗
-██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
-██║ ██║╚██████╗╚██████╗███████╗███████║███████║
-╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
-██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
-██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
-██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
-██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
-██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
-╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝
-
-
- root@intercepted: ~# sudo access --grant-permission
- [sudo] password for user: ********
- Error: User is not in the sudoers file.
- This incident will be reported.
-
-
-
- "In a world of locked doors, the man with the key is king.
- And you, my friend, just threw away the key."
-
-
- TRY AGAIN
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Channel Utilization (2.4 GHz)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Channel Utilization (5 GHz)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Target Signal
-
-
No target selected
-
-- dBm
-
-
-
-
-
-
-
-
-
-
-
🕸️ Network Topology
-
-
-
-
-
-
💡 Channel Recommendation
-
- 2.4 GHz: Use channel --
-
-
-
- 5 GHz: Use channel --
-
-
-
-
-
-
🔗 Device Correlation
-
-
Analyzing WiFi/BT device patterns...
-
-
-
-
-
👁️ Hidden SSIDs Revealed
-
-
Monitoring probe requests...
-
-
-
-
-
📡 Client Probe Analysis
-
- Clients: 0
- Unique SSIDs: 0
- Privacy Leaks: 0
-
-
-
Waiting for client probe requests...
-
-
-
-
-
-
-
-
Bluetooth Proximity Radar
-
-
-
-
-
-
-
-
-
-
ADS-B AIRCRAFT TRACKING
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- N = North |
- Center = Overhead (90°) |
- Edge = Horizon (0°)
-
-
-
-
-
-
-
-
- --- Past |
- ● Current |
- ― Future |
- ◉ Observer
-
-
-
-
-
-
-
-
--
-
-
-
Next Pass In
-
--:--:--
-
--
-
-
-
Visibility
-
--:--
-
Duration
-
-
-
Max Elevation
-
--°
-
--
-
-
-
Calculate passes to see countdown
-
-
-
-
-
-
-
-
- Click "Calculate Passes" to predict satellite passes for your location.
-
-
-
-
-
-
-
-
-
-
-
- Iridium bursts will appear here when detected.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- N = North |
- Center = Overhead (90°) |
- Edge = Horizon (0°)
-
-
-
-
-
-
-
--
-
-
-
Next Pass In
-
--:--:--
-
--
-
-
-
Visibility
-
--:--
-
Duration
-
-
-
Max Elevation
-
--°
-
--
-
-
-
Waiting for pass data...
-
-
-
-
-
-
-
-
-
-
-
-
-
- Paste TLE
- Celestrak
-
-
-
- Paste TLE data (3 lines per satellite: name, line 1, line 2)
-
-
-
- Add Satellites from TLE
-
-
-
-
- Select a category to fetch satellites from Celestrak
-
-
- 🚀 Space Stations
- 👁️ Brightest
- 🌤️ Weather
- 📡 NOAA
- 📻 Amateur Radio
- ⭐ Starlink
- 🛰️ GPS
- 📱 Iridium
-
-
-
-
-
-
-
-
-
-
-
- Device intelligence data will appear here as signals are intercepted.
-
-
-
-
-
-
-
-
-
-
- Configure settings and click "Start Decoding" to begin.
-
-
-
-
-
-
- RECON
- 🔊 MUTE
- ⬇ AUTO-SCROLL ON
- 📄 CSV
- 📋 JSON
- 🔍 INTEL
- Clear
-
-
-
-
-
-
-
-
-
-
-
-
×
-
📡 INTERCEPT Help
-
-
- Icons
- Modes
- WiFi
- Tips
-
-
-
-
-
Stats Bar Icons
-
-
📟 POCSAG messages decoded
-
📠 FLEX messages decoded
-
📨 Total messages received
-
🌡️ Unique sensors detected
-
📊 Device types found
-
✈️ Aircraft being tracked
-
🛰️ Satellites monitored
-
📡 WiFi Access Points
-
👤 Connected WiFi clients
-
🤝 Captured handshakes
-
🚁 Detected drones (click for details)
-
⚠️ Rogue APs (click for details)
-
🔵 Bluetooth devices
-
📍 BLE beacons detected
-
-
-
Mode Tab Icons
-
-
📟 Pager - POCSAG/FLEX decoder
-
📡 433MHz - Sensor decoder
-
✈️ Aircraft - ADS-B tracker
-
🛰️ Satellite - Pass prediction
-
📶 WiFi - Network scanner
-
🔵 Bluetooth - BT/BLE scanner
-
-
-
-
-
-
📟 Pager Mode
-
- Decodes POCSAG and FLEX pager signals using RTL-SDR
- Set frequency to local pager frequencies (common: 152-158 MHz)
- Messages are displayed in real-time as they're decoded
- Use presets for common pager frequencies
-
-
-
📡 433MHz Sensor Mode
-
- Decodes wireless sensors on 433.92 MHz ISM band
- Detects temperature, humidity, weather stations, tire pressure monitors
- Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)
- Device intelligence builds profiles of recurring devices
-
-
-
✈️ Aircraft Mode
-
- Tracks aircraft via ADS-B using dump1090 or rtl_adsb
- Interactive map with real OpenStreetMap tiles
- Click aircraft markers to see callsign, altitude, speed, heading
- Map auto-fits to show all tracked aircraft
- Emergency squawk codes highlighted in red
-
-
-
🛰️ Satellite Mode
-
- Track satellites using TLE (Two-Line Element) data
- Add satellites manually or fetch from Celestrak by category
- Categories: Amateur, Weather, ISS, Starlink, GPS, and more
- View next pass predictions with elevation and duration
- Monitor for Iridium satellite bursts
-
-
-
📶 WiFi Mode
-
- Requires a WiFi adapter capable of monitor mode
- Click "Enable Monitor" to put adapter in monitor mode
- Scans all channels or lock to a specific channel
- Detects drones by SSID patterns and manufacturer OUI
- Rogue AP detection flags same SSID on multiple BSSIDs
- Click network rows to target for deauth or handshake capture
-
-
-
🔵 Bluetooth Mode
-
- Scans for classic Bluetooth and BLE devices
- Shows device names, addresses, and signal strength
- Manufacturer lookup from MAC address OUI
- Radar visualization shows device proximity
-
-
-
-
-
-
Monitor Mode
-
- Enable Monitor: Puts WiFi adapter in monitor mode for passive scanning
- Kill Processes: Optional - stops NetworkManager/wpa_supplicant (may drop other connections)
- Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)
-
-
-
Handshake Capture
-
- Click "Capture" on a network to start targeted handshake capture
- Status panel shows capture progress and file location
- Use deauth to force clients to reconnect (only on authorized networks!)
- Handshake files saved to /tmp/intercept_handshake_*.cap
-
-
-
Drone Detection
-
- Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)
- Also detected by manufacturer OUI in MAC address
- Distance estimated from signal strength (approximate)
- Click drone count in stats bar to see all detected drones
-
-
-
Rogue AP Detection
-
- Flags networks where same SSID appears on multiple BSSIDs
- Could indicate evil twin attack or legitimate multi-AP setup
- Click rogue count to see which SSIDs are flagged
-
-
-
Proximity Alerts
-
- Add MAC addresses to watch list for alerts when detected
- Watch list persists in browser localStorage
- Useful for tracking specific devices
-
-
-
Client Probe Analysis
-
- Shows what networks client devices are looking for
- Orange highlights indicate sensitive/private network names
- Reveals user location history (home, work, hotels, airports)
- Useful for security awareness and pen test reports
-
-
-
-
-
-
General Tips
-
- Collapsible sections: Click any section header (▼) to collapse/expand
- Sound alerts: Toggle sound on/off in settings for each mode
- Export data: Use export buttons to save captured data as JSON
- Device Intelligence: Tracks device patterns over time
- Theme toggle: Click 🌙/☀️ button in header to switch dark/light mode
-
-
-
Keyboard Shortcuts
-
- F1 - Open this help page
- ? - Open help (when not typing in a field)
- Escape - Close help and modal dialogs
-
-
-
Requirements
-
- Pager/433MHz: RTL-SDR dongle, rtl_fm, multimon-ng, rtl_433
- Aircraft: RTL-SDR dongle, dump1090 or rtl_adsb
- Satellite: Internet connection for Celestrak (optional)
- WiFi: Monitor-mode capable adapter, aircrack-ng suite
- Bluetooth: Bluetooth adapter, hcitool/bluetoothctl
- Run as root/sudo for full functionality
-
-
-
Legal Notice
-
- Only use on networks and devices you own or have authorization to test
- Passive monitoring may be legal; active attacks require authorization
- Check local laws regarding radio frequency monitoring
-
-
-
-
-
-
-
-
-
×
-
🔧 Tool Dependencies
-
Check which tools are installed for each mode. ● = Installed, ● = Missing
-
-
- Loading dependencies...
-
-
-
-
Quick Install (Debian/Ubuntu)
-
-
sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxtools
-
pip install skyfield flask
-
-
-
-
-
-
-
-
-'''
-
-
-def check_tool(name):
- """Check if a tool is installed."""
- return shutil.which(name) is not None
-
-
-# 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/EliasOewornal/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',
- 'brew': 'brew install dump1090-mutability',
- 'manual': 'https://github.com/flightaware/dump1090'
- },
- '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'
- }
- }
- }
- },
- '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
- }
- }
- },
- 'iridium': {
- 'name': 'Iridium Monitoring',
- 'tools': {
- 'iridium-extractor': {
- 'required': False,
- 'description': 'Iridium burst extractor',
- 'install': {
- 'manual': 'https://github.com/muccc/gr-iridium'
- }
- }
- }
- }
-}
-
-
-def check_all_dependencies():
- """Check all tool dependencies and return status."""
- results = {}
-
- for mode, config in TOOL_DEPENDENCIES.items():
- mode_result = {
- 'name': config['name'],
- 'tools': {},
- 'ready': True,
- 'missing_required': []
- }
-
- for tool, tool_config in config['tools'].items():
- # Check if it's a Python module
- if tool_config.get('python_module'):
- try:
- __import__(tool)
- installed = True
- except Exception as e:
- print(f"[Dependency] Failed to import {tool}: {type(e).__name__}: {e}")
- installed = False
- else:
- # Check for 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']
- }
-
- if tool_config['required'] and not installed:
- mode_result['ready'] = False
- mode_result['missing_required'].append(tool)
-
- results[mode] = mode_result
-
- return results
-
-
-@app.route('/dependencies')
-def get_dependencies():
- """Get status of all tool dependencies."""
- import platform
-
- results = check_all_dependencies()
-
- # Determine OS for install instructions
- system = platform.system().lower()
- if system == 'darwin':
- pkg_manager = 'brew'
- elif system == 'linux':
- pkg_manager = 'apt'
- else:
- pkg_manager = 'manual'
-
- return jsonify({
- 'status': 'success',
- 'os': system,
- 'pkg_manager': pkg_manager,
- 'modes': results
- })
-
-
-def is_valid_mac(mac):
- """Validate MAC address format."""
- import re
- if not mac:
- return False
- return bool(re.match(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', mac))
-
-
-def is_valid_channel(channel):
- """Validate WiFi channel number."""
- try:
- ch = int(channel)
- return 1 <= ch <= 200
- except (ValueError, TypeError):
- return False
-
-
-def detect_devices():
- """Detect RTL-SDR devices."""
- devices = []
-
- if not check_tool('rtl_test'):
- return devices
-
- try:
- result = subprocess.run(
- ['rtl_test', '-t'],
- capture_output=True,
- text=True,
- timeout=5
- )
- output = result.stderr + result.stdout
-
- # Parse device info
- device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
-
- for line in output.split('\n'):
- line = line.strip()
- match = re.match(device_pattern, line)
- if match:
- devices.append({
- 'index': int(match.group(1)),
- 'name': match.group(2).strip().rstrip(','),
- 'serial': match.group(3) or 'N/A'
- })
-
- if not devices:
- found_match = re.search(r'Found (\d+) device', output)
- if found_match:
- count = int(found_match.group(1))
- for i in range(count):
- devices.append({
- 'index': i,
- 'name': f'RTL-SDR Device {i}',
- 'serial': 'Unknown'
- })
-
- except Exception:
- pass
-
- return devices
-
-
-def parse_multimon_output(line):
- """Parse multimon-ng output line."""
- # POCSAG formats:
- # POCSAG512: Address: 1234567 Function: 0 Alpha: Message here
- # POCSAG1200: Address: 1234567 Function: 0 Numeric: 123-456-7890
- # POCSAG2400: Address: 1234567 Function: 0 (no message)
- # FLEX formats:
- # FLEX: NNNN-NN-NN NN:NN:NN NNNN/NN/C NN.NNN [NNNNNNN] ALN Message here
- # FLEX|NNNN-NN-NN|NN:NN:NN|NNNN/NN/C|NN.NNN|NNNNNNN|ALN|Message
-
- line = line.strip()
-
- # POCSAG parsing - with message content
- pocsag_match = re.match(
- r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
- line
- )
- if pocsag_match:
- return {
- 'protocol': pocsag_match.group(1),
- 'address': pocsag_match.group(2),
- 'function': pocsag_match.group(3),
- 'msg_type': pocsag_match.group(4),
- 'message': pocsag_match.group(5).strip() or '[No Message]'
- }
-
- # POCSAG parsing - address only (no message content)
- pocsag_addr_match = re.match(
- r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
- line
- )
- if pocsag_addr_match:
- return {
- 'protocol': pocsag_addr_match.group(1),
- 'address': pocsag_addr_match.group(2),
- 'function': pocsag_addr_match.group(3),
- 'msg_type': 'Tone',
- 'message': '[Tone Only]'
- }
-
- # FLEX parsing (standard format)
- flex_match = re.match(
- r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)',
- line
- )
- if flex_match:
- return {
- 'protocol': 'FLEX',
- 'address': flex_match.group(3),
- 'function': flex_match.group(1),
- 'msg_type': flex_match.group(4),
- 'message': flex_match.group(5).strip() or '[No Message]'
- }
-
- # Simple FLEX format
- flex_simple = re.match(r'FLEX:\s*(.+)', line)
- if flex_simple:
- return {
- 'protocol': 'FLEX',
- 'address': 'Unknown',
- 'function': '',
- 'msg_type': 'Unknown',
- 'message': flex_simple.group(1).strip()
- }
-
- return None
-
-
-def stream_decoder(master_fd, process):
- """Stream decoder output to queue using PTY for unbuffered output."""
- global current_process
-
- try:
- output_queue.put({'type': 'status', 'text': 'started'})
-
- buffer = ""
- while True:
- try:
- ready, _, _ = select.select([master_fd], [], [], 1.0)
- except Exception:
- break
-
- if ready:
- try:
- data = os.read(master_fd, 1024)
- if not data:
- break
- buffer += data.decode('utf-8', errors='replace')
-
- while '\n' in buffer:
- line, buffer = buffer.split('\n', 1)
- line = line.strip()
- if not line:
- continue
-
- parsed = parse_multimon_output(line)
- if parsed:
- from datetime import datetime
- parsed['timestamp'] = datetime.now().strftime('%H:%M:%S')
- output_queue.put({'type': 'message', **parsed})
- log_message(parsed)
- else:
- output_queue.put({'type': 'raw', 'text': line})
- except OSError:
- break
-
- if process.poll() is not None:
- break
-
- except Exception as e:
- output_queue.put({'type': 'error', 'text': str(e)})
- finally:
- try:
- os.close(master_fd)
- except:
- pass
- process.wait()
- output_queue.put({'type': 'status', 'text': 'stopped'})
- with process_lock:
- current_process = None
-
-
-@app.route('/')
-def index():
- tools = {
- 'rtl_fm': check_tool('rtl_fm'),
- 'multimon': check_tool('multimon-ng'),
- 'rtl_433': check_tool('rtl_433')
- }
- devices = detect_devices()
- return render_template_string(HTML_TEMPLATE, tools=tools, devices=devices)
-
-
-@app.route('/favicon.svg')
-def favicon():
- return send_file('favicon.svg', mimetype='image/svg+xml')
-
-
-@app.route('/devices')
-def get_devices():
- return jsonify(detect_devices())
-
-
-@app.route('/start', methods=['POST'])
-def start_decoding():
- global current_process
-
- with process_lock:
- if current_process:
- return jsonify({'status': 'error', 'message': 'Already running'})
-
- data = request.json
- freq = data.get('frequency', '929.6125')
- gain = data.get('gain', '0')
- squelch = data.get('squelch', '0')
- ppm = data.get('ppm', '0')
- device = data.get('device', '0')
- protocols = data.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'])
-
- # Clear queue
- while not output_queue.empty():
- try:
- output_queue.get_nowait()
- except:
- break
-
- # Build multimon-ng decoder arguments
- decoders = []
- for proto in protocols:
- if proto == 'POCSAG512':
- decoders.extend(['-a', 'POCSAG512'])
- elif proto == 'POCSAG1200':
- decoders.extend(['-a', 'POCSAG1200'])
- elif proto == 'POCSAG2400':
- decoders.extend(['-a', 'POCSAG2400'])
- elif proto == 'FLEX':
- decoders.extend(['-a', 'FLEX'])
-
- # Build rtl_fm command
- # rtl_fm -d -f M -M fm -s 22050 -g -p -l - | multimon-ng -t raw -a POCSAG512 -a POCSAG1200 -a FLEX -f alpha -
- rtl_cmd = [
- 'rtl_fm',
- '-d', str(device),
- '-f', f'{freq}M',
- '-M', 'fm',
- '-s', '22050',
- ]
-
- if gain and gain != '0':
- rtl_cmd.extend(['-g', str(gain)])
-
- if ppm and ppm != '0':
- rtl_cmd.extend(['-p', str(ppm)])
-
- if squelch and squelch != '0':
- rtl_cmd.extend(['-l', str(squelch)])
-
- rtl_cmd.append('-')
-
- multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
-
- # Log the command being run
- full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
- print(f"Running: {full_cmd}")
-
- try:
- # Create pipe: rtl_fm | multimon-ng
- # Use PTY for multimon-ng to get unbuffered output
- rtl_process = subprocess.Popen(
- rtl_cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
- )
-
- # Start a thread to monitor rtl_fm stderr for errors
- def monitor_rtl_stderr():
- for line in rtl_process.stderr:
- err_text = line.decode('utf-8', errors='replace').strip()
- if err_text:
- print(f"[RTL_FM] {err_text}", flush=True)
- output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'})
-
- rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
- rtl_stderr_thread.daemon = True
- rtl_stderr_thread.start()
-
- # Create a pseudo-terminal for multimon-ng output
- # This tricks it into thinking it's connected to a terminal,
- # which disables output buffering
- master_fd, slave_fd = pty.openpty()
-
- multimon_process = subprocess.Popen(
- multimon_cmd,
- stdin=rtl_process.stdout,
- stdout=slave_fd,
- stderr=slave_fd,
- close_fds=True
- )
-
- os.close(slave_fd) # Close slave fd in parent process
- rtl_process.stdout.close() # Allow rtl_process to receive SIGPIPE
-
- current_process = multimon_process
- current_process._rtl_process = rtl_process # Store reference to kill later
- current_process._master_fd = master_fd # Store for cleanup
-
- # Start output thread with PTY master fd
- thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
- thread.daemon = True
- thread.start()
-
- # Send the command info to the client
- output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
-
- return jsonify({'status': 'started', 'command': full_cmd})
-
- except FileNotFoundError as e:
- return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/stop', methods=['POST'])
-def stop_decoding():
- global current_process
-
- with process_lock:
- if current_process:
- # Kill rtl_fm process first
- if hasattr(current_process, '_rtl_process'):
- try:
- current_process._rtl_process.terminate()
- current_process._rtl_process.wait(timeout=2)
- except:
- try:
- current_process._rtl_process.kill()
- except:
- pass
-
- # Close PTY master fd
- if hasattr(current_process, '_master_fd'):
- try:
- os.close(current_process._master_fd)
- except:
- pass
-
- # Kill multimon-ng
- current_process.terminate()
- try:
- current_process.wait(timeout=2)
- except subprocess.TimeoutExpired:
- current_process.kill()
-
- current_process = None
- return jsonify({'status': 'stopped'})
-
- return jsonify({'status': 'not_running'})
-
-
-@app.route('/status')
-def get_status():
- """Check if decoder is currently running."""
- with process_lock:
- if current_process and current_process.poll() is None:
- return jsonify({'running': True, 'logging': logging_enabled, 'log_file': log_file_path})
- return jsonify({'running': False, 'logging': logging_enabled, 'log_file': log_file_path})
-
-
-@app.route('/logging', methods=['POST'])
-def toggle_logging():
- """Toggle message logging."""
- global logging_enabled, log_file_path
- data = request.json
- if 'enabled' in data:
- logging_enabled = data['enabled']
- if 'log_file' in data and data['log_file']:
- log_file_path = data['log_file']
- return jsonify({'logging': logging_enabled, 'log_file': log_file_path})
-
-
-def log_message(msg):
- """Log a message to file if logging is enabled."""
- if not logging_enabled:
- return
- try:
- with open(log_file_path, 'a') as f:
- from datetime import datetime
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
- except Exception as e:
- print(f"[ERROR] Failed to log message: {e}", flush=True)
-
-
-@app.route('/killall', methods=['POST'])
-def kill_all():
- """Kill all decoder and WiFi processes."""
- global current_process, sensor_process, wifi_process
-
- killed = []
- processes_to_kill = [
- 'rtl_fm', 'multimon-ng', 'rtl_433',
- 'airodump-ng', 'aireplay-ng', 'airmon-ng'
- ]
-
- for proc in processes_to_kill:
- try:
- result = subprocess.run(['pkill', '-f', proc], capture_output=True)
- if result.returncode == 0:
- killed.append(proc)
- except:
- pass
-
- with process_lock:
- current_process = None
-
- with sensor_lock:
- sensor_process = None
-
- with wifi_lock:
- wifi_process = None
-
- return jsonify({'status': 'killed', 'processes': killed})
-
-
-@app.route('/stream')
-def stream():
- def generate():
- import json
- while True:
- try:
- msg = output_queue.get(timeout=1)
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- response.headers['Connection'] = 'keep-alive'
- return response
-
-
-# ============== RTL_433 SENSOR ROUTES ==============
-
-def stream_sensor_output(process):
- """Stream rtl_433 JSON output to queue."""
- global sensor_process
- import json as json_module
-
- try:
- sensor_queue.put({'type': 'status', 'text': 'started'})
-
- for line in iter(process.stdout.readline, b''):
- line = line.decode('utf-8', errors='replace').strip()
- if not line:
- continue
-
- try:
- # rtl_433 outputs JSON objects, one per line
- data = json_module.loads(line)
- data['type'] = 'sensor'
- sensor_queue.put(data)
-
- # Log if enabled
- if logging_enabled:
- try:
- with open(log_file_path, 'a') as f:
- from datetime import datetime
- timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
- f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json_module.dumps(data)}\n")
- except Exception:
- pass
- except json_module.JSONDecodeError:
- # Not JSON, send as raw
- sensor_queue.put({'type': 'raw', 'text': line})
-
- except Exception as e:
- sensor_queue.put({'type': 'error', 'text': str(e)})
- finally:
- process.wait()
- sensor_queue.put({'type': 'status', 'text': 'stopped'})
- with sensor_lock:
- sensor_process = None
-
-
-@app.route('/start_sensor', methods=['POST'])
-def start_sensor():
- global sensor_process
-
- with sensor_lock:
- if sensor_process:
- return jsonify({'status': 'error', 'message': 'Sensor already running'})
-
- data = request.json
- freq = data.get('frequency', '433.92')
- gain = data.get('gain', '0')
- ppm = data.get('ppm', '0')
- device = data.get('device', '0')
-
- # Clear queue
- while not sensor_queue.empty():
- try:
- sensor_queue.get_nowait()
- except:
- break
-
- # Build rtl_433 command
- # rtl_433 -d -f M -g -p -F json
- cmd = [
- 'rtl_433',
- '-d', str(device),
- '-f', f'{freq}M',
- '-F', 'json'
- ]
-
- if gain and gain != '0':
- cmd.extend(['-g', str(gain)])
-
- if ppm and ppm != '0':
- cmd.extend(['-p', str(ppm)])
-
- full_cmd = ' '.join(cmd)
- print(f"Running: {full_cmd}")
-
- try:
- sensor_process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- bufsize=1
- )
-
- # Start output thread
- thread = threading.Thread(target=stream_sensor_output, args=(sensor_process,))
- thread.daemon = True
- thread.start()
-
- # Monitor stderr
- def monitor_stderr():
- for line in sensor_process.stderr:
- err = line.decode('utf-8', errors='replace').strip()
- if err:
- print(f"[rtl_433] {err}")
- sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
-
- stderr_thread = threading.Thread(target=monitor_stderr)
- stderr_thread.daemon = True
- stderr_thread.start()
-
- sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
-
- return jsonify({'status': 'started', 'command': full_cmd})
-
- except FileNotFoundError:
- return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/stop_sensor', methods=['POST'])
-def stop_sensor():
- global sensor_process
-
- with sensor_lock:
- if sensor_process:
- sensor_process.terminate()
- try:
- sensor_process.wait(timeout=2)
- except subprocess.TimeoutExpired:
- sensor_process.kill()
- sensor_process = None
- return jsonify({'status': 'stopped'})
-
- return jsonify({'status': 'not_running'})
-
-
-@app.route('/stream_sensor')
-def stream_sensor():
- def generate():
- import json
- while True:
- try:
- msg = sensor_queue.get(timeout=1)
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- response.headers['Connection'] = 'keep-alive'
- return response
-
-
-# ============== WIFI RECONNAISSANCE ROUTES ==============
-
-def detect_wifi_interfaces():
- """Detect available WiFi interfaces."""
- interfaces = []
- import platform
-
- if platform.system() == 'Darwin': # macOS
- try:
- # Get list of network interfaces
- result = subprocess.run(['networksetup', '-listallhardwareports'],
- capture_output=True, text=True, timeout=5)
- lines = result.stdout.split('\n')
- current_device = None
- for i, line in enumerate(lines):
- if 'Wi-Fi' in line or 'AirPort' in line:
- # Next line should have the device
- for j in range(i+1, min(i+3, len(lines))):
- if 'Device:' in lines[j]:
- device = lines[j].split('Device:')[1].strip()
- interfaces.append({
- 'name': device,
- 'type': 'internal',
- 'monitor_capable': False, # macOS internal usually can't
- 'status': 'up'
- })
- break
- except Exception as e:
- print(f"[WiFi] Error detecting macOS interfaces: {e}")
-
- # Check for USB WiFi adapters
- try:
- result = subprocess.run(['system_profiler', 'SPUSBDataType'],
- capture_output=True, text=True, timeout=10)
- if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
- interfaces.append({
- 'name': 'USB WiFi Adapter',
- 'type': 'usb',
- 'monitor_capable': True,
- 'status': 'detected'
- })
- except Exception:
- pass
-
- else: # Linux
- try:
- # Use iw to list wireless interfaces
- result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
- current_iface = None
- for line in result.stdout.split('\n'):
- line = line.strip()
- if line.startswith('Interface'):
- current_iface = line.split()[1]
- elif current_iface and 'type' in line:
- iface_type = line.split()[-1]
- interfaces.append({
- 'name': current_iface,
- 'type': iface_type,
- 'monitor_capable': True,
- 'status': 'up'
- })
- current_iface = None
- except FileNotFoundError:
- # Try iwconfig instead
- try:
- result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
- for line in result.stdout.split('\n'):
- if 'IEEE 802.11' in line:
- iface = line.split()[0]
- interfaces.append({
- 'name': iface,
- 'type': 'managed',
- 'monitor_capable': True,
- 'status': 'up'
- })
- except Exception:
- pass
- except Exception as e:
- print(f"[WiFi] Error detecting Linux interfaces: {e}")
-
- return interfaces
-
-
-@app.route('/wifi/interfaces')
-def get_wifi_interfaces():
- """Get available WiFi interfaces."""
- interfaces = detect_wifi_interfaces()
- tools = {
- 'airmon': check_tool('airmon-ng'),
- 'airodump': check_tool('airodump-ng'),
- 'aireplay': check_tool('aireplay-ng'),
- 'iw': check_tool('iw')
- }
- return jsonify({'interfaces': interfaces, 'tools': tools, 'monitor_interface': wifi_monitor_interface})
-
-
-@app.route('/wifi/monitor', methods=['POST'])
-def toggle_monitor_mode():
- """Enable or disable monitor mode on an interface."""
- global wifi_monitor_interface
-
- data = request.json
- interface = data.get('interface')
- action = data.get('action', 'start') # 'start' or 'stop'
-
- if not interface:
- return jsonify({'status': 'error', 'message': 'No interface specified'})
-
- if action == 'start':
- # Try airmon-ng first
- if check_tool('airmon-ng'):
- try:
- import re
-
- # Get list of wireless interfaces BEFORE enabling monitor mode
- def get_wireless_interfaces():
- """Get all wireless interface names."""
- interfaces = set()
- try:
- # Try iwconfig first (shows wireless interfaces)
- result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
- for line in result.stdout.split('\n'):
- if line and not line.startswith(' ') and 'no wireless' not in line.lower():
- iface = line.split()[0] if line.split() else None
- if iface:
- interfaces.add(iface)
- except:
- pass
-
- try:
- # Also check /sys/class/net for interfaces with wireless dir
- import os
- for iface in os.listdir('/sys/class/net'):
- if os.path.exists(f'/sys/class/net/{iface}/wireless'):
- interfaces.add(iface)
- except:
- pass
-
- try:
- # Also try ip link to find any interface
- result = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
- for match in re.finditer(r'^\d+:\s+(\S+):', result.stdout, re.MULTILINE):
- iface = match.group(1).rstrip(':')
- # Include interfaces that look like wireless (wl*, wlan*, etc)
- if iface.startswith('wl') or 'mon' in iface:
- interfaces.add(iface)
- except:
- pass
-
- return interfaces
-
- interfaces_before = get_wireless_interfaces()
- print(f"[WiFi] Interfaces before monitor mode: {interfaces_before}", flush=True)
-
- # Optionally kill interfering processes (can drop other connections)
- kill_processes = data.get('kill_processes', False)
- if kill_processes:
- print("[WiFi] Killing interfering processes...", flush=True)
- subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
- else:
- print("[WiFi] Skipping process kill (other connections preserved)", flush=True)
-
- # Start monitor mode
- result = subprocess.run(['airmon-ng', 'start', interface],
- capture_output=True, text=True, timeout=15)
-
- output = result.stdout + result.stderr
- print(f"[WiFi] airmon-ng output:\n{output}", flush=True)
-
- # Get interfaces AFTER enabling monitor mode
- import time
- time.sleep(1) # Give system time to register new interface
- interfaces_after = get_wireless_interfaces()
- print(f"[WiFi] Interfaces after monitor mode: {interfaces_after}", flush=True)
-
- # Find the new interface (the monitor mode one)
- new_interfaces = interfaces_after - interfaces_before
- print(f"[WiFi] New interfaces detected: {new_interfaces}", flush=True)
-
- # Determine monitor interface
- monitor_iface = None
-
- # Method 1: New interface appeared
- if new_interfaces:
- # Prefer interface with 'mon' in name
- for iface in new_interfaces:
- if 'mon' in iface:
- monitor_iface = iface
- break
- if not monitor_iface:
- monitor_iface = list(new_interfaces)[0]
-
- # Method 2: Parse airmon-ng output
- if not monitor_iface:
- # Look for various patterns in airmon-ng output
- patterns = [
- r'monitor mode.*enabled.*on\s+(\S+)', # "monitor mode enabled on wlan0mon"
- r'\(monitor mode.*enabled.*?(\S+mon)\)', # "(monitor mode enabled on wlan0mon)"
- r'created\s+(\S+mon)', # "created wlan0mon"
- r'\bon\s+(\S+mon)\b', # "on wlan0mon"
- r'\b(\S+mon)\b.*monitor', # "wlan0mon in monitor"
- r'\b(' + re.escape(interface) + r'mon)\b', # exact match: interfacemon
- ]
- for pattern in patterns:
- match = re.search(pattern, output, re.IGNORECASE)
- if match:
- monitor_iface = match.group(1)
- print(f"[WiFi] Found monitor interface via pattern '{pattern}': {monitor_iface}", flush=True)
- break
-
- # Method 3: Check if original interface now in monitor mode
- if not monitor_iface:
- # Check if original interface is now in monitor mode
- try:
- result = subprocess.run(['iwconfig', interface], capture_output=True, text=True, timeout=5)
- if 'Mode:Monitor' in result.stdout:
- monitor_iface = interface
- print(f"[WiFi] Original interface {interface} is now in monitor mode", flush=True)
- except:
- pass
-
- # Method 4: Check interface + 'mon'
- if not monitor_iface:
- potential = interface + 'mon'
- if potential in interfaces_after:
- monitor_iface = potential
-
- # Method 5: Last resort - assume interface + 'mon'
- if not monitor_iface:
- monitor_iface = interface + 'mon'
- print(f"[WiFi] Assuming monitor interface: {monitor_iface}", flush=True)
-
- # Verify the interface actually exists
- try:
- result = subprocess.run(['ip', 'link', 'show', monitor_iface], capture_output=True, text=True, timeout=5)
- if result.returncode != 0:
- # Interface doesn't exist - try to find any mon interface
- for iface in interfaces_after:
- if 'mon' in iface or iface.startswith('wl'):
- # Check if it's in monitor mode
- try:
- check = subprocess.run(['iwconfig', iface], capture_output=True, text=True, timeout=5)
- if 'Mode:Monitor' in check.stdout:
- monitor_iface = iface
- print(f"[WiFi] Found working monitor interface: {monitor_iface}", flush=True)
- break
- except:
- pass
- except:
- pass
-
- wifi_monitor_interface = monitor_iface
- wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {wifi_monitor_interface}'})
- return jsonify({'status': 'success', 'monitor_interface': wifi_monitor_interface})
-
- except Exception as e:
- import traceback
- print(f"[WiFi] Error enabling monitor mode: {e}\n{traceback.format_exc()}", flush=True)
- return jsonify({'status': 'error', 'message': str(e)})
-
- # Fallback to iw (Linux)
- elif check_tool('iw'):
- try:
- subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
- subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
- subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
- wifi_monitor_interface = interface
- return jsonify({'status': 'success', 'monitor_interface': interface})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
- else:
- return jsonify({'status': 'error', 'message': 'No monitor mode tools available. Install aircrack-ng (brew install aircrack-ng) or iw.'})
-
- else: # stop
- if check_tool('airmon-ng'):
- try:
- result = subprocess.run(['airmon-ng', 'stop', wifi_monitor_interface or interface],
- capture_output=True, text=True, timeout=15)
- wifi_monitor_interface = None
- return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
- elif check_tool('iw'):
- try:
- subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
- subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
- subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
- wifi_monitor_interface = None
- return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
- return jsonify({'status': 'error', 'message': 'Unknown action'})
-
-
-def parse_airodump_csv(csv_path):
- """Parse airodump-ng CSV output file."""
- networks = {}
- clients = {}
-
- try:
- with open(csv_path, 'r', errors='replace') as f:
- content = f.read()
-
- # Split into networks and clients sections
- sections = content.split('\n\n')
-
- for section in sections:
- lines = section.strip().split('\n')
- if not lines:
- continue
-
- header = lines[0] if lines else ''
-
- if 'BSSID' in header and 'ESSID' in header:
- # Networks section
- for line in lines[1:]:
- parts = [p.strip() for p in line.split(',')]
- if len(parts) >= 14:
- bssid = parts[0]
- if bssid and ':' in bssid:
- networks[bssid] = {
- 'bssid': bssid,
- 'first_seen': parts[1],
- 'last_seen': parts[2],
- 'channel': parts[3],
- 'speed': parts[4],
- 'privacy': parts[5],
- 'cipher': parts[6],
- 'auth': parts[7],
- 'power': parts[8],
- 'beacons': parts[9],
- 'ivs': parts[10],
- 'lan_ip': parts[11],
- 'essid': parts[13] or 'Hidden'
- }
-
- elif 'Station MAC' in header:
- # Clients section
- for line in lines[1:]:
- parts = [p.strip() for p in line.split(',')]
- if len(parts) >= 6:
- station = parts[0]
- if station and ':' in station:
- # Lookup vendor from OUI database
- vendor = get_manufacturer(station)
- clients[station] = {
- 'mac': station,
- 'first_seen': parts[1],
- 'last_seen': parts[2],
- 'power': parts[3],
- 'packets': parts[4],
- 'bssid': parts[5],
- 'probes': parts[6] if len(parts) > 6 else '',
- 'vendor': vendor
- }
- except Exception as e:
- print(f"[WiFi] Error parsing CSV: {e}")
-
- return networks, clients
-
-
-def stream_airodump_output(process, csv_path):
- """Stream airodump-ng output to queue."""
- global wifi_process, wifi_networks, wifi_clients
- import time
- import select
-
- try:
- wifi_queue.put({'type': 'status', 'text': 'started'})
- last_parse = 0
- start_time = time.time()
- csv_found = False
-
- while process.poll() is None:
- # Check for stderr output (non-blocking)
- try:
- import fcntl
- # Make stderr non-blocking
- fd = process.stderr.fileno()
- fl = fcntl.fcntl(fd, fcntl.F_GETFL)
- fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
-
- stderr_data = process.stderr.read()
- if stderr_data:
- stderr_text = stderr_data.decode('utf-8', errors='replace').strip()
- if stderr_text:
- # Filter out progress updates, report actual errors
- for line in stderr_text.split('\n'):
- line = line.strip()
- if line and not line.startswith('CH') and not line.startswith('Elapsed'):
- wifi_queue.put({'type': 'error', 'text': f'airodump-ng: {line}'})
- except Exception:
- pass
-
- # Parse CSV file periodically
- current_time = time.time()
- if current_time - last_parse >= 2: # Parse every 2 seconds
- csv_file = csv_path + '-01.csv'
- if os.path.exists(csv_file):
- csv_found = True
- networks, clients = parse_airodump_csv(csv_file)
-
- # Detect new networks
- for bssid, net in networks.items():
- if bssid not in wifi_networks:
- wifi_queue.put({
- 'type': 'network',
- 'action': 'new',
- **net
- })
- else:
- # Update existing
- wifi_queue.put({
- 'type': 'network',
- 'action': 'update',
- **net
- })
-
- # Detect new clients
- for mac, client in clients.items():
- if mac not in wifi_clients:
- wifi_queue.put({
- 'type': 'client',
- 'action': 'new',
- **client
- })
-
- wifi_networks = networks
- wifi_clients = clients
- last_parse = current_time
-
- if current_time - start_time > 5 and not csv_found:
- # No CSV after 5 seconds - likely a problem
- wifi_queue.put({'type': 'error', 'text': 'No scan data after 5 seconds. Check if monitor mode is properly enabled.'})
- start_time = current_time + 30 # Don't spam this message
-
- time.sleep(0.5)
-
- # Process exited - capture any remaining stderr
- try:
- remaining_stderr = process.stderr.read()
- if remaining_stderr:
- stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip()
- if stderr_text:
- wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited: {stderr_text}'})
- except Exception:
- pass
-
- # Check exit code
- exit_code = process.returncode
- if exit_code != 0 and exit_code is not None:
- wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited with code {exit_code}'})
-
- except Exception as e:
- wifi_queue.put({'type': 'error', 'text': str(e)})
- finally:
- process.wait()
- wifi_queue.put({'type': 'status', 'text': 'stopped'})
- with wifi_lock:
- wifi_process = None
-
-
-@app.route('/wifi/scan/start', methods=['POST'])
-def start_wifi_scan():
- """Start WiFi scanning with airodump-ng."""
- global wifi_process, wifi_networks, wifi_clients
-
- with wifi_lock:
- if wifi_process:
- return jsonify({'status': 'error', 'message': 'Scan already running'})
-
- data = request.json
- interface = data.get('interface') or wifi_monitor_interface
- channel = data.get('channel') # None = channel hopping
- band = data.get('band', 'abg') # 'a' = 5GHz, 'bg' = 2.4GHz, 'abg' = both
-
- if not interface:
- return jsonify({'status': 'error', 'message': 'No monitor interface available. Enable monitor mode first.'})
-
- # Clear previous data
- wifi_networks = {}
- wifi_clients = {}
-
- # Clear queue
- while not wifi_queue.empty():
- try:
- wifi_queue.get_nowait()
- except:
- break
-
- # Build airodump-ng command
- csv_path = '/tmp/intercept_wifi'
-
- # Remove old files
- for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
- try:
- os.remove(f)
- except:
- pass
-
- cmd = [
- 'airodump-ng',
- '-w', csv_path,
- '--output-format', 'csv,pcap',
- '--band', band,
- interface
- ]
-
- if channel:
- cmd.extend(['-c', str(channel)])
-
- print(f"[WiFi] Running: {' '.join(cmd)}")
-
- try:
- wifi_process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
- )
-
- # Wait briefly to check if process fails immediately
- import time
- time.sleep(0.5)
-
- if wifi_process.poll() is not None:
- # Process already exited - capture error
- stderr_output = wifi_process.stderr.read().decode('utf-8', errors='replace').strip()
- stdout_output = wifi_process.stdout.read().decode('utf-8', errors='replace').strip()
- exit_code = wifi_process.returncode
- wifi_process = None
-
- error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}'
-
- # Strip ANSI escape codes
- import re
- error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
-
- # Common error explanations
- if 'No such device' in error_msg or 'No such interface' in error_msg:
- error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.'
- elif 'Operation not permitted' in error_msg:
- error_msg = 'Permission denied. Try running with sudo.'
- elif 'monitor mode' in error_msg.lower():
- error_msg = f'Interface "{interface}" is not in monitor mode. Enable monitor mode first.'
- elif 'Failed initialising' in error_msg:
- error_msg = f'Failed to initialize "{interface}". The adapter may have been disconnected or monitor mode is not active. Try disabling and re-enabling monitor mode.'
-
- return jsonify({'status': 'error', 'message': error_msg})
-
- # Start parsing thread
- thread = threading.Thread(target=stream_airodump_output, args=(wifi_process, csv_path))
- thread.daemon = True
- thread.start()
-
- wifi_queue.put({'type': 'info', 'text': f'Started scanning on {interface}'})
-
- return jsonify({'status': 'started', 'interface': interface})
-
- except FileNotFoundError:
- return jsonify({'status': 'error', 'message': 'airodump-ng not found. Install aircrack-ng suite (brew install aircrack-ng).'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/wifi/scan/stop', methods=['POST'])
-def stop_wifi_scan():
- """Stop WiFi scanning."""
- global wifi_process
-
- with wifi_lock:
- if wifi_process:
- wifi_process.terminate()
- try:
- wifi_process.wait(timeout=3)
- except subprocess.TimeoutExpired:
- wifi_process.kill()
- wifi_process = None
- return jsonify({'status': 'stopped'})
- return jsonify({'status': 'not_running'})
-
-
-@app.route('/wifi/deauth', methods=['POST'])
-def send_deauth():
- """Send deauthentication packets to force handshake capture."""
- data = request.json
- target_bssid = data.get('bssid')
- target_client = data.get('client', 'FF:FF:FF:FF:FF:FF') # Broadcast by default
- count = data.get('count', 5)
- interface = data.get('interface') or wifi_monitor_interface
-
- if not target_bssid:
- return jsonify({'status': 'error', 'message': 'Target BSSID required'})
-
- # Validate MAC addresses to prevent command injection
- if not is_valid_mac(target_bssid):
- return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
-
- if not is_valid_mac(target_client):
- return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
-
- # Validate count to prevent abuse
- try:
- count = int(count)
- if count < 1 or count > 100:
- count = 5
- except (ValueError, TypeError):
- count = 5
-
- if not interface:
- return jsonify({'status': 'error', 'message': 'No monitor interface'})
-
- if not check_tool('aireplay-ng'):
- return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
-
- try:
- # aireplay-ng --deauth -a -c
- cmd = [
- 'aireplay-ng',
- '--deauth', str(count),
- '-a', target_bssid,
- '-c', target_client,
- interface
- ]
-
- wifi_queue.put({'type': 'info', 'text': f'Sending {count} deauth packets to {target_bssid}'})
-
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
-
- if result.returncode == 0:
- return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
- else:
- return jsonify({'status': 'error', 'message': result.stderr})
-
- except subprocess.TimeoutExpired:
- return jsonify({'status': 'success', 'message': 'Deauth sent (timed out waiting for completion)'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/wifi/handshake/capture', methods=['POST'])
-def capture_handshake():
- """Start targeted handshake capture."""
- global wifi_process
-
- data = request.json
- target_bssid = data.get('bssid')
- channel = data.get('channel')
- interface = data.get('interface') or wifi_monitor_interface
-
- if not target_bssid or not channel:
- return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
-
- # Validate inputs to prevent command injection
- if not is_valid_mac(target_bssid):
- return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
-
- if not is_valid_channel(channel):
- return jsonify({'status': 'error', 'message': 'Invalid channel'})
-
- with wifi_lock:
- if wifi_process:
- return jsonify({'status': 'error', 'message': 'Scan already running. Stop it first.'})
-
- # Safe to use in path after validation
- capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
-
- cmd = [
- 'airodump-ng',
- '-c', str(channel),
- '--bssid', target_bssid,
- '-w', capture_path,
- '--output-format', 'pcap',
- interface
- ]
-
- try:
- wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid} on channel {channel}'})
- return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/wifi/handshake/status', methods=['POST'])
-def check_handshake_status():
- """Check if a handshake has been captured in the specified file."""
- import os
-
- data = request.json
- capture_file = data.get('file', '')
- target_bssid = data.get('bssid', '')
-
- # Security: ensure the file path is in /tmp and looks like our capture files
- if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
- return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
-
- # Check if file exists
- if not os.path.exists(capture_file):
- # Check if capture is still running
- with wifi_lock:
- if wifi_process and wifi_process.poll() is None:
- return jsonify({
- 'status': 'running',
- 'file_exists': False,
- 'handshake_found': False
- })
- else:
- return jsonify({
- 'status': 'stopped',
- 'file_exists': False,
- 'handshake_found': False
- })
-
- # File exists - get size
- file_size = os.path.getsize(capture_file)
-
- # Use aircrack-ng to check if handshake is present
- # aircrack-ng -a 2 -b will show if EAPOL handshake exists
- handshake_found = False
- try:
- if target_bssid and is_valid_mac(target_bssid):
- result = subprocess.run(
- ['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
- capture_output=True,
- text=True,
- timeout=10
- )
- # Check output for handshake indicators
- # aircrack-ng shows "1 handshake" if found, or "0 handshake" if not
- output = result.stdout + result.stderr
- if '1 handshake' in output or 'handshake' in output.lower() and 'wpa' in output.lower():
- # Also check it's not "0 handshake"
- if '0 handshake' not in output:
- handshake_found = True
- except subprocess.TimeoutExpired:
- pass # aircrack-ng timed out, assume no handshake yet
- except Exception as e:
- print(f"[WiFi] Error checking handshake: {e}", flush=True)
-
- return jsonify({
- 'status': 'running' if wifi_process and wifi_process.poll() is None else 'stopped',
- 'file_exists': True,
- 'file_size': file_size,
- 'file': capture_file,
- 'handshake_found': handshake_found
- })
-
-
-# PMKID Capture using hcxdumptool
-pmkid_process = None
-pmkid_lock = threading.Lock()
-
-@app.route('/wifi/pmkid/capture', methods=['POST'])
-def capture_pmkid():
- """Start PMKID capture using hcxdumptool."""
- global pmkid_process
-
- data = request.json
- target_bssid = data.get('bssid')
- channel = data.get('channel')
- interface = data.get('interface') or wifi_monitor_interface
-
- if not target_bssid:
- return jsonify({'status': 'error', 'message': 'BSSID required'})
-
- if not is_valid_mac(target_bssid):
- return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
-
- with pmkid_lock:
- if pmkid_process and pmkid_process.poll() is None:
- return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
-
- capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
-
- # Create filter file for target BSSID
- filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
- with open(filter_file, 'w') as f:
- f.write(target_bssid.replace(':', '').lower())
-
- # hcxdumptool command
- cmd = [
- 'hcxdumptool',
- '-i', interface,
- '-o', capture_path,
- '--filterlist_ap', filter_file,
- '--filtermode', '2', # whitelist mode
- '--enable_status', '1'
- ]
-
- if channel:
- cmd.extend(['-c', str(channel)])
-
- try:
- pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- return jsonify({'status': 'started', 'file': capture_path})
- except FileNotFoundError:
- return jsonify({'status': 'error', 'message': 'hcxdumptool not found. Install with: apt install hcxdumptool'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/wifi/pmkid/status', methods=['POST'])
-def check_pmkid_status():
- """Check if PMKID has been captured."""
- import os
-
- data = request.json
- capture_file = data.get('file', '')
-
- if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
- return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
-
- if not os.path.exists(capture_file):
- return jsonify({'pmkid_found': False, 'file_exists': False})
-
- file_size = os.path.getsize(capture_file)
-
- # Use hcxpcapngtool to check for PMKID
- pmkid_found = False
- try:
- hash_file = capture_file.replace('.pcapng', '.22000')
- result = subprocess.run(
- ['hcxpcapngtool', '-o', hash_file, capture_file],
- capture_output=True,
- text=True,
- timeout=10
- )
-
- # Check if hash file was created and has content
- if os.path.exists(hash_file) and os.path.getsize(hash_file) > 0:
- pmkid_found = True
- except FileNotFoundError:
- # hcxpcapngtool not installed, check file size as fallback
- pmkid_found = file_size > 1000 # Rough heuristic
- except Exception:
- pass
-
- return jsonify({
- 'pmkid_found': pmkid_found,
- 'file_exists': True,
- 'file_size': file_size,
- 'file': capture_file
- })
-
-
-@app.route('/wifi/pmkid/stop', methods=['POST'])
-def stop_pmkid():
- """Stop PMKID capture."""
- global pmkid_process
-
- with pmkid_lock:
- if pmkid_process:
- pmkid_process.terminate()
- try:
- pmkid_process.wait(timeout=5)
- except:
- pmkid_process.kill()
- pmkid_process = None
-
- return jsonify({'status': 'stopped'})
-
-
-@app.route('/wifi/networks')
-def get_wifi_networks():
- """Get current list of discovered networks."""
- return jsonify({
- 'networks': list(wifi_networks.values()),
- 'clients': list(wifi_clients.values()),
- 'handshakes': wifi_handshakes,
- 'monitor_interface': wifi_monitor_interface
- })
-
-
-@app.route('/wifi/stream')
-def stream_wifi():
- """SSE stream for WiFi events."""
- def generate():
- import json
- while True:
- try:
- msg = wifi_queue.get(timeout=1)
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- response.headers['Connection'] = 'keep-alive'
- return response
-
-
-# ============== BLUETOOTH RECONNAISSANCE ROUTES ==============
-
-def get_manufacturer(mac):
- """Look up manufacturer from MAC address OUI."""
- prefix = mac[:8].upper()
- result = OUI_DATABASE.get(prefix, 'Unknown')
- return result
-
-
-def classify_bt_device(name, device_class, services, manufacturer=None):
- """Classify Bluetooth device type based on available info."""
- name_lower = (name or '').lower()
- mfr_lower = (manufacturer or '').lower()
-
- # Audio devices - extensive patterns
- audio_patterns = [
- 'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
- 'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
- 'earphone', 'pod', 'soundbar', 'subwoofer', 'amp', 'dac', 'hifi', 'stereo',
- 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen', 'harman', 'akg', 'shure',
- 'audio-technica', 'plantronics', 'poly', 'soundlink', 'soundsport', 'quietcomfort',
- 'freebuds', 'galaxy buds', 'wf-', 'wh-', 'linkbuds', 'momentum', 'px7', 'px8',
- 'liberty', 'life', 'enco', 'oppo enco', 'nothing ear', 'ear (', 'studio buds',
- 'powerbeats', 'solo', 'flex', 'tour', 'tune', 'reflect', 'endurance', 'soundpeats'
- ]
- if any(x in name_lower for x in audio_patterns):
- return 'audio'
-
- # Wearables - watches, bands, fitness
- wearable_patterns = [
- 'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit', 'huawei band',
- 'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'vivosmart',
- 'vivoactive', 'venu', 'forerunner', 'fenix', 'instinct', 'polar', 'suunto',
- 'whoop', 'oura', 'ring', 'wristband', 'fitness', 'tracker', 'activity',
- 'apple watch', 'iwatch', 'samsung watch', 'ticwatch', 'fossil', 'withings'
- ]
- if any(x in name_lower for x in wearable_patterns):
- return 'wearable'
-
- # Phones - mobile devices
- phone_patterns = [
- 'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
- 'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'moto', 'nokia', 'lg',
- 'sony xperia', 'xperia', 'asus', 'rog phone', 'zenfone', 'nothing phone',
- 'samsung sm-', 'sm-g', 'sm-a', 'sm-s', 'sm-n', 'sm-f'
- ]
- if any(x in name_lower for x in phone_patterns):
- return 'phone'
-
- # Trackers - location devices
- tracker_patterns = [
- 'airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy', 'locator',
- 'gps', 'pet tracker', 'key finder', 'nut', 'trackr', 'pebblebee', 'cube'
- ]
- if any(x in name_lower for x in tracker_patterns):
- return 'tracker'
-
- # Input devices - keyboards, mice, controllers
- input_patterns = [
- 'keyboard', 'mouse', 'controller', 'gamepad', 'joystick', 'remote', 'trackpad',
- 'magic mouse', 'magic keyboard', 'mx master', 'mx keys', 'logitech', 'razer',
- 'dualshock', 'dualsense', 'xbox', 'switch pro', 'joycon', 'joy-con', '8bitdo',
- 'steelseries', 'corsair', 'hyperx'
- ]
- if any(x in name_lower for x in input_patterns):
- return 'input'
-
- # Media devices - TVs, streaming
- media_patterns = [
- 'tv', 'roku', 'chromecast', 'firestick', 'fire tv', 'appletv', 'apple tv',
- 'nvidia shield', 'android tv', 'smart tv', 'lg tv', 'samsung tv', 'sony tv',
- 'tcl', 'hisense', 'vizio', 'projector', 'beam', 'soundbase'
- ]
- if any(x in name_lower for x in media_patterns):
- return 'media'
-
- # Computers - laptops, desktops
- computer_patterns = [
- 'macbook', 'imac', 'mac mini', 'mac pro', 'thinkpad', 'latitude', 'xps',
- 'pavilion', 'envy', 'spectre', 'surface', 'chromebook', 'ideapad', 'legion',
- 'predator', 'rog', 'alienware', 'desktop', 'laptop', 'notebook', 'pc'
- ]
- if any(x in name_lower for x in computer_patterns):
- return 'computer'
-
- # Use manufacturer to infer type
- if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats', 'bang & olufsen', 'audio-technica', 'plantronics', 'skullcandy', 'anker']:
- return 'audio'
- if mfr_lower in ['fitbit', 'garmin']:
- return 'wearable'
- if mfr_lower == 'tile':
- return 'tracker'
- if mfr_lower == 'logitech':
- return 'input'
-
- # Check device class if available
- if device_class:
- major_class = (device_class >> 8) & 0x1F
- if major_class == 1: # Computer
- return 'computer'
- elif major_class == 2: # Phone
- return 'phone'
- elif major_class == 4: # Audio/Video
- return 'audio'
- elif major_class == 5: # Peripheral
- return 'input'
- elif major_class == 6: # Imaging
- return 'imaging'
- elif major_class == 7: # Wearable
- return 'wearable'
-
- return 'other'
-
-
-def detect_tracker(mac, name, manufacturer_data=None):
- """Detect if device is a known tracker (AirTag, Tile, etc)."""
- mac_prefix = mac[:5].upper()
-
- # AirTag detection (Apple Find My)
- if any(mac_prefix.startswith(p) for p in AIRTAG_PREFIXES):
- if manufacturer_data and b'\\x4c\\x00' in manufacturer_data:
- return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
-
- # Tile detection
- if any(mac_prefix.startswith(p) for p in TILE_PREFIXES):
- return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
-
- # Samsung SmartTag
- if any(mac_prefix.startswith(p) for p in SAMSUNG_TRACKER):
- return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'}
-
- # Name-based detection
- name_lower = (name or '').lower()
- if 'airtag' in name_lower:
- return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
- if 'tile' in name_lower:
- return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
- if 'smarttag' in name_lower:
- return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'}
- if 'chipolo' in name_lower:
- return {'type': 'chipolo', 'name': 'Chipolo Tracker', 'risk': 'medium'}
-
- return None
-
-
-def detect_bt_interfaces():
- """Detect available Bluetooth interfaces."""
- interfaces = []
- import platform
-
- if platform.system() == 'Linux':
- try:
- # Use hciconfig to list interfaces
- result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
- output = result.stdout
-
- # Parse hciconfig output - "UP RUNNING" appears on a separate line
- import re
- # Split by interface blocks
- blocks = re.split(r'(?=^hci\d+:)', output, flags=re.MULTILINE)
- for block in blocks:
- if block.strip():
- # Get interface name from first line
- first_line = block.split('\n')[0]
- match = re.match(r'(hci\d+):', first_line)
- if match:
- iface_name = match.group(1)
- # Check if UP appears anywhere in the block
- is_up = 'UP RUNNING' in block or '\tUP ' in block
- interfaces.append({
- 'name': iface_name,
- 'type': 'hci',
- 'status': 'up' if is_up else 'down'
- })
- except FileNotFoundError:
- pass
- except Exception as e:
- print(f"[BT] Error detecting interfaces: {e}")
-
- elif platform.system() == 'Darwin': # macOS
- # macOS uses different Bluetooth stack
- interfaces.append({
- 'name': 'default',
- 'type': 'macos',
- 'status': 'available'
- })
-
- return interfaces
-
-
-@app.route('/bt/reload-oui', methods=['POST'])
-def reload_oui_database():
- """Reload OUI database from external file."""
- global OUI_DATABASE
- new_db = load_oui_database()
- if new_db:
- OUI_DATABASE = new_db
- return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
- return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
-
-
-@app.route('/bt/interfaces')
-def get_bt_interfaces():
- """Get available Bluetooth interfaces and tools."""
- interfaces = detect_bt_interfaces()
- tools = {
- 'hcitool': check_tool('hcitool'),
- 'bluetoothctl': check_tool('bluetoothctl'),
- 'hciconfig': check_tool('hciconfig'),
- 'l2ping': check_tool('l2ping'),
- 'sdptool': check_tool('sdptool')
- }
- return jsonify({
- 'interfaces': interfaces,
- 'tools': tools,
- 'current_interface': bt_interface
- })
-
-
-def parse_hcitool_output(line):
- """Parse hcitool scan output line."""
- # Format: "AA:BB:CC:DD:EE:FF Device Name"
- parts = line.strip().split('\t')
- if len(parts) >= 2:
- mac = parts[0].strip()
- name = parts[1].strip() if len(parts) > 1 else ''
- if ':' in mac and len(mac) == 17:
- return {'mac': mac, 'name': name}
- return None
-
-
-def stream_bt_scan(process, scan_mode):
- """Stream Bluetooth scan output to queue."""
- global bt_process, bt_devices
- import time
-
- try:
- bt_queue.put({'type': 'status', 'text': 'started'})
- start_time = time.time()
- device_found = False
-
- # Set up non-blocking stderr reading
- try:
- import fcntl
- fd = process.stderr.fileno()
- fl = fcntl.fcntl(fd, fcntl.F_GETFL)
- fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
- except Exception:
- pass
-
- if scan_mode == 'hcitool':
- # hcitool lescan output
- for line in iter(process.stdout.readline, b''):
- line = line.decode('utf-8', errors='replace').strip()
- if not line or 'LE Scan' in line:
- continue
-
- # Parse BLE device
- parts = line.split()
- if len(parts) >= 1 and ':' in parts[0]:
- mac = parts[0]
- name = ' '.join(parts[1:]) if len(parts) > 1 else ''
-
- manufacturer = get_manufacturer(mac)
- device = {
- 'mac': mac,
- 'name': name or '[Unknown]',
- 'manufacturer': manufacturer,
- 'type': classify_bt_device(name, None, None, manufacturer),
- 'rssi': None,
- 'last_seen': time.time()
- }
-
- # Check for tracker
- tracker = detect_tracker(mac, name)
- if tracker:
- device['tracker'] = tracker
-
- is_new = mac not in bt_devices
- bt_devices[mac] = device
-
- queue_data = {
- **device,
- 'type': 'device', # Must come after **device to not be overwritten
- 'device_type': device.get('type', 'other'),
- 'action': 'new' if is_new else 'update',
- }
- bt_queue.put(queue_data)
-
- elif scan_mode == 'bluetoothctl':
- # bluetoothctl scan output - read from pty
- import os
- import select
- import time
- import re
-
- master_fd = getattr(process, '_master_fd', None)
- if not master_fd:
- bt_queue.put({'type': 'error', 'text': 'bluetoothctl pty not available'})
- return
-
- buffer = ''
- while process.poll() is None:
- # Check if data available
- readable, _, _ = select.select([master_fd], [], [], 1.0)
- if readable:
- try:
- data = os.read(master_fd, 4096)
- if not data:
- break
- buffer += data.decode('utf-8', errors='replace')
-
- # Process complete lines
- while '\n' in buffer:
- line, buffer = buffer.split('\n', 1)
- line = line.strip()
-
- # Remove ANSI escape codes
- line = re.sub(r'\x1b\[[0-9;]*m', '', line)
- line = re.sub(r'\x1b\[\?.*?[a-zA-Z]', '', line)
- line = re.sub(r'\x1b\[K', '', line) # Clear line escape
- line = re.sub(r'\r', '', line) # Remove carriage returns
-
- # Debug: print what we're receiving
- if line and 'Device' in line:
- print(f"[BT] bluetoothctl: {line}")
-
- # Parse [NEW] Device or [CHG] Device lines
- # Format: [NEW] Device AA:BB:CC:DD:EE:FF DeviceName
- if 'Device' in line:
- match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
- if match:
- mac = match.group(1).upper()
- name = match.group(2).strip()
-
- manufacturer = get_manufacturer(mac)
- device = {
- 'mac': mac,
- 'name': name or '[Unknown]',
- 'manufacturer': manufacturer,
- 'type': classify_bt_device(name, None, None, manufacturer),
- 'rssi': None,
- 'last_seen': time.time()
- }
-
- tracker = detect_tracker(mac, name)
- if tracker:
- device['tracker'] = tracker
-
- is_new = mac not in bt_devices
- bt_devices[mac] = device
-
- queue_data = {
- **device,
- 'type': 'device', # Must come after **device to not be overwritten
- 'device_type': device.get('type', 'other'),
- 'action': 'new' if is_new else 'update',
- }
- print(f"[BT] Queuing device: {mac} - {name}")
- bt_queue.put(queue_data)
- except OSError:
- break
-
- # Close master_fd
- try:
- os.close(master_fd)
- except:
- pass
-
- except Exception as e:
- bt_queue.put({'type': 'error', 'text': str(e)})
- finally:
- # Capture any remaining stderr
- try:
- remaining_stderr = process.stderr.read()
- if remaining_stderr:
- stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip()
- if stderr_text:
- bt_queue.put({'type': 'error', 'text': f'Bluetooth scan: {stderr_text}'})
- except Exception:
- pass
-
- # Check exit code
- process.wait()
- exit_code = process.returncode
- if exit_code != 0 and exit_code is not None:
- bt_queue.put({'type': 'error', 'text': f'Bluetooth scan exited with code {exit_code}'})
-
- bt_queue.put({'type': 'status', 'text': 'stopped'})
- with bt_lock:
- bt_process = None
-
-
-@app.route('/bt/scan/start', methods=['POST'])
-def start_bt_scan():
- """Start Bluetooth scanning."""
- global bt_process, bt_devices, bt_interface
-
- with bt_lock:
- # Check if process is actually still running (not just set)
- if bt_process:
- if bt_process.poll() is None:
- # Process is actually running
- return jsonify({'status': 'error', 'message': 'Scan already running'})
- else:
- # Process died, clear the state
- bt_process = None
-
- data = request.json
- scan_mode = data.get('mode', 'hcitool')
- interface = data.get('interface', 'hci0')
- duration = data.get('duration', 30)
- scan_ble = data.get('scan_ble', True)
- scan_classic = data.get('scan_classic', True)
-
- bt_interface = interface
- bt_devices = {}
-
- # Clear queue
- while not bt_queue.empty():
- try:
- bt_queue.get_nowait()
- except:
- break
-
- try:
- if scan_mode == 'hcitool':
- if scan_ble:
- cmd = ['hcitool', '-i', interface, 'lescan', '--duplicates']
- else:
- cmd = ['hcitool', '-i', interface, 'scan']
-
- bt_process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
- )
-
- elif scan_mode == 'bluetoothctl':
- # Use bluetoothctl for BLE scanning with pty for proper output
- import pty
- import os
- import time
-
- master_fd, slave_fd = pty.openpty()
- bt_process = subprocess.Popen(
- ['bluetoothctl'],
- stdin=slave_fd,
- stdout=slave_fd,
- stderr=slave_fd,
- close_fds=True
- )
- os.close(slave_fd)
-
- # Store master_fd for reading
- bt_process._master_fd = master_fd
-
- # Wait for bluetoothctl to initialize
- time.sleep(0.5)
-
- # Power on and start LE scan (compatible with Bluetooth 5.x)
- os.write(master_fd, b'power on\n')
- time.sleep(0.3)
- os.write(master_fd, b'scan on\n')
-
- else:
- return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
-
- # Wait briefly to check if process fails immediately
- import time
- time.sleep(0.5)
-
- if bt_process.poll() is not None:
- # Process already exited - capture error
- stderr_output = bt_process.stderr.read().decode('utf-8', errors='replace').strip()
- stdout_output = bt_process.stdout.read().decode('utf-8', errors='replace').strip()
- exit_code = bt_process.returncode
- bt_process = None
-
- error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}'
-
- # Common error explanations and auto-recovery
- if 'No such device' in error_msg or 'hci0' in error_msg.lower():
- error_msg = f'Bluetooth interface "{interface}" not found or not available.'
- elif 'Operation not permitted' in error_msg or 'Permission denied' in error_msg:
- error_msg = 'Permission denied. Try running with sudo or add user to bluetooth group.'
- elif 'busy' in error_msg.lower():
- error_msg = f'Bluetooth interface "{interface}" is busy. Stop other Bluetooth operations first.'
- elif 'set scan parameters failed' in error_msg.lower() or 'input/output error' in error_msg.lower():
- # Try to auto-reset the adapter
- try:
- subprocess.run(['hciconfig', interface, 'down'], capture_output=True, timeout=5)
- subprocess.run(['hciconfig', interface, 'up'], capture_output=True, timeout=5)
- error_msg = f'Adapter error - attempted auto-reset. Click "Reset Adapter" and try again.'
- except:
- error_msg = 'Bluetooth adapter I/O error. Click "Reset Adapter" to reset the adapter and try again.'
-
- return jsonify({'status': 'error', 'message': error_msg})
-
- # Start streaming thread
- thread = threading.Thread(target=stream_bt_scan, args=(bt_process, scan_mode))
- thread.daemon = True
- thread.start()
-
- bt_queue.put({'type': 'info', 'text': f'Started {scan_mode} scan on {interface}'})
- return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
-
- except FileNotFoundError as e:
- tool_name = e.filename or scan_mode
- return jsonify({'status': 'error', 'message': f'Tool "{tool_name}" not found. Install required Bluetooth tools.'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/bt/scan/stop', methods=['POST'])
-def stop_bt_scan():
- """Stop Bluetooth scanning."""
- global bt_process
-
- with bt_lock:
- if bt_process:
- bt_process.terminate()
- try:
- bt_process.wait(timeout=3)
- except subprocess.TimeoutExpired:
- bt_process.kill()
- bt_process = None
- return jsonify({'status': 'stopped'})
- return jsonify({'status': 'not_running'})
-
-
-@app.route('/bt/reset', methods=['POST'])
-def reset_bt_adapter():
- """Reset Bluetooth adapter and clear scan state."""
- global bt_process
-
- data = request.json
- interface = data.get('interface', 'hci0')
-
- with bt_lock:
- # Force clear the process state
- if bt_process:
- try:
- bt_process.terminate()
- bt_process.wait(timeout=2)
- except:
- try:
- bt_process.kill()
- except:
- pass
- bt_process = None
-
- # Reset the adapter
- try:
- import time
- import os
-
- # Kill any processes that might be using the adapter
- subprocess.run(['pkill', '-f', 'hcitool'], capture_output=True, timeout=2)
- subprocess.run(['pkill', '-f', 'bluetoothctl'], capture_output=True, timeout=2)
- time.sleep(0.5)
-
- # Check if running as root
- is_root = os.geteuid() == 0
-
- # Try rfkill unblock first
- subprocess.run(['rfkill', 'unblock', 'bluetooth'], capture_output=True, timeout=5)
-
- # Reset the adapter with a delay between down and up
- if is_root:
- down_result = subprocess.run(['hciconfig', interface, 'down'], capture_output=True, text=True, timeout=5)
- time.sleep(1)
- up_result = subprocess.run(['hciconfig', interface, 'up'], capture_output=True, text=True, timeout=5)
- else:
- # Try with sudo
- down_result = subprocess.run(['sudo', '-n', 'hciconfig', interface, 'down'], capture_output=True, text=True, timeout=5)
- time.sleep(1)
- up_result = subprocess.run(['sudo', '-n', 'hciconfig', interface, 'up'], capture_output=True, text=True, timeout=5)
-
- time.sleep(0.5)
-
- # Check if adapter is up
- result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5)
- is_up = 'UP RUNNING' in result.stdout
-
- # If still not up, try bluetoothctl
- if not is_up:
- subprocess.run(['bluetoothctl', 'power', 'off'], capture_output=True, timeout=5)
- time.sleep(1)
- subprocess.run(['bluetoothctl', 'power', 'on'], capture_output=True, timeout=5)
- time.sleep(0.5)
- result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5)
- is_up = 'UP RUNNING' in result.stdout
-
- if is_up:
- bt_queue.put({'type': 'info', 'text': f'Bluetooth adapter {interface} reset successfully'})
- else:
- bt_queue.put({'type': 'error', 'text': f'Adapter {interface} may need manual reset. Try: sudo hciconfig {interface} up'})
-
- return jsonify({
- 'status': 'success' if is_up else 'warning',
- 'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter still down. Run: sudo hciconfig {interface} up',
- 'is_up': is_up
- })
-
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/bt/enum', methods=['POST'])
-def enum_bt_services():
- """Enumerate services on a Bluetooth device."""
- data = request.json
- target_mac = data.get('mac')
-
- if not target_mac:
- return jsonify({'status': 'error', 'message': 'Target MAC required'})
-
- try:
- # Try sdptool for classic BT
- result = subprocess.run(
- ['sdptool', 'browse', target_mac],
- capture_output=True, text=True, timeout=30
- )
-
- services = []
- current_service = {}
-
- for line in result.stdout.split('\n'):
- line = line.strip()
- if line.startswith('Service Name:'):
- if current_service:
- services.append(current_service)
- current_service = {'name': line.split(':', 1)[1].strip()}
- elif line.startswith('Service Description:'):
- current_service['description'] = line.split(':', 1)[1].strip()
- elif line.startswith('Service Provider:'):
- current_service['provider'] = line.split(':', 1)[1].strip()
- elif 'Protocol Descriptor' in line:
- current_service['protocol'] = line
-
- if current_service:
- services.append(current_service)
-
- bt_services[target_mac] = services
-
- return jsonify({
- 'status': 'success',
- 'mac': target_mac,
- 'services': services
- })
-
- except subprocess.TimeoutExpired:
- return jsonify({'status': 'error', 'message': 'Connection timed out'})
- except FileNotFoundError:
- return jsonify({'status': 'error', 'message': 'sdptool not found'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/bt/devices')
-def get_bt_devices():
- """Get current list of discovered Bluetooth devices."""
- return jsonify({
- 'devices': list(bt_devices.values()),
- 'beacons': list(bt_beacons.values()),
- 'interface': bt_interface
- })
-
-
-@app.route('/bt/stream')
-def stream_bt():
- """SSE stream for Bluetooth events."""
- print("[BT Stream] Client connected")
- def generate():
- import json
- print("[BT Stream] Generator started, waiting for queue...")
- while True:
- try:
- msg = bt_queue.get(timeout=1)
- print(f"[BT Stream] Got from queue: {msg.get('type')}")
- if msg.get('type') == 'device':
- print(f"[BT Stream] Sending device: {msg.get('mac')}")
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- response.headers['Connection'] = 'keep-alive'
- return response
-
-
-# ============================================
-# AIRCRAFT (ADS-B) ROUTES
-# ============================================
-
-@app.route('/adsb/tools')
-def check_adsb_tools():
- """Check for ADS-B decoding tools."""
- return jsonify({
- 'dump1090': shutil.which('dump1090') is not None or shutil.which('dump1090-mutability') is not None,
- 'rtl_adsb': shutil.which('rtl_adsb') is not None
- })
-
-
-adsb_using_service = False # Track if we're using an existing service
-
-
-def check_dump1090_service():
- """Check if dump1090 SBS port (30003) is available."""
- import socket
- try:
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(2)
- result = sock.connect_ex(('localhost', 30003))
- sock.close()
- print(f"[ADS-B] Service check: port 30003 connect result = {result}")
- if result == 0:
- return 'localhost:30003'
- except Exception as e:
- print(f"[ADS-B] Service check error: {e}")
- return None
-
-
-@app.route('/adsb/start', methods=['POST'])
-def start_adsb():
- """Start ADS-B tracking."""
- global adsb_process, adsb_using_service
-
- print("[ADS-B] Start request received")
-
- with adsb_lock:
- if adsb_process and adsb_process.poll() is None:
- print("[ADS-B] Already running (process)")
- return jsonify({'status': 'already_running', 'message': 'ADS-B already running'})
- if adsb_using_service:
- print("[ADS-B] Already running (service)")
- return jsonify({'status': 'already_running', 'message': 'ADS-B already running (using service)'})
-
- data = request.json or {}
- gain = data.get('gain', '40')
- device = data.get('device', '0')
-
- # Find dump1090
- dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-mutability')
-
- if not dump1090_path:
- return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090-mutability.'})
-
- # Start dump1090 with --net to enable SBS output on port 30003
- cmd = [dump1090_path, '--net', '--gain', gain, '--device-index', str(device), '--quiet']
- print(f"[ADS-B] Starting dump1090 with network mode: {' '.join(cmd)}")
-
- try:
- adsb_process = subprocess.Popen(
- cmd,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL
- )
-
- # Wait for dump1090 to start and open ports
- print("[ADS-B] Waiting for dump1090 to initialize...")
- time.sleep(3)
-
- # Check if process is still running
- if adsb_process.poll() is not None:
- return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check if RTL-SDR is connected.'})
-
- # Connect to SBS port for data
- print("[ADS-B] Connecting to SBS stream on localhost:30003...")
- adsb_using_service = True
- thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
- thread.start()
-
- print("[ADS-B] Started successfully")
- return jsonify({'status': 'success', 'message': 'ADS-B tracking started'})
- except Exception as e:
- print(f"[ADS-B] Start error: {e}")
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-def parse_sbs_stream(service_addr):
- """Parse SBS format data from dump1090 port 30003."""
- global adsb_aircraft, adsb_using_service
- import socket
-
- host, port = service_addr.split(':')
- port = int(port)
-
- print(f"[ADS-B] SBS stream parser started, connecting to {host}:{port}")
-
- while adsb_using_service:
- try:
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(5)
- sock.connect((host, port))
- print(f"[ADS-B] Connected to SBS stream")
-
- buffer = ""
- msg_count = 0
- last_update = time.time()
- pending_updates = set() # ICAOs with pending updates
- while adsb_using_service:
- try:
- data = sock.recv(4096).decode('utf-8', errors='ignore')
- if not data:
- break
- buffer += data
-
- while '\n' in buffer:
- line, buffer = buffer.split('\n', 1)
- line = line.strip()
- if not line:
- continue
-
- # Parse SBS format: MSG,type,sess,aircraft,hex,flight,dateG,timeG,dateL,timeL,callsign,alt,speed,heading,lat,lon,...
- parts = line.split(',')
- if len(parts) < 11 or parts[0] != 'MSG':
- continue
-
- msg_type = parts[1]
- icao = parts[4].upper()
- if not icao:
- continue
-
- aircraft = adsb_aircraft.get(icao, {'icao': icao})
-
- # MSG,1: callsign
- if msg_type == '1' and len(parts) > 10:
- callsign = parts[10].strip()
- if callsign:
- aircraft['callsign'] = callsign
-
- # MSG,3: position (alt, lat, lon)
- elif msg_type == '3':
- if len(parts) > 15:
- if parts[11]:
- try:
- aircraft['alt'] = int(float(parts[11]))
- except:
- pass
- if parts[14] and parts[15]:
- try:
- lat = float(parts[14])
- lon = float(parts[15])
- aircraft['lat'] = lat
- aircraft['lon'] = lon
- print(f"[ADS-B] Position SET: {icao} at {lat:.4f}, {lon:.4f}")
- except Exception as e:
- print(f"[ADS-B] Position parse error: {e}")
-
- # MSG,4: velocity (speed, heading)
- elif msg_type == '4' and len(parts) > 13:
- if parts[12]:
- try:
- aircraft['speed'] = int(float(parts[12]))
- except:
- pass
- if parts[13]:
- try:
- aircraft['heading'] = int(float(parts[13]))
- except:
- pass
-
- # MSG,5: callsign and altitude
- elif msg_type == '5' and len(parts) > 11:
- if parts[10]:
- callsign = parts[10].strip()
- if callsign:
- aircraft['callsign'] = callsign
- if parts[11]:
- try:
- aircraft['alt'] = int(float(parts[11]))
- except:
- pass
-
- # MSG,6: squawk
- elif msg_type == '6' and len(parts) > 17:
- if parts[17]:
- aircraft['squawk'] = parts[17]
-
- adsb_aircraft[icao] = aircraft
- pending_updates.add(icao)
- msg_count += 1
-
- # Throttle updates to frontend - send every 1 second
- now = time.time()
- if now - last_update >= 1.0:
- for update_icao in pending_updates:
- if update_icao in adsb_aircraft:
- adsb_queue.put({
- 'type': 'aircraft',
- **adsb_aircraft[update_icao]
- })
- pending_updates.clear()
- last_update = now
- if msg_count % 500 == 0:
- print(f"[ADS-B] SBS: Processed {msg_count} messages, tracking {len(adsb_aircraft)} aircraft")
-
- except socket.timeout:
- continue
-
- sock.close()
- except Exception as e:
- print(f"[ADS-B] SBS connection error: {e}, reconnecting...")
- time.sleep(2)
-
- print("[ADS-B] SBS stream parser stopped")
-
-
-@app.route('/adsb/stop', methods=['POST'])
-def stop_adsb():
- """Stop ADS-B tracking."""
- global adsb_process, adsb_aircraft, adsb_using_service
-
- with adsb_lock:
- if adsb_process:
- adsb_process.terminate()
- try:
- adsb_process.wait(timeout=5)
- except:
- adsb_process.kill()
- adsb_process = None
- adsb_using_service = False
-
- adsb_aircraft = {}
- return jsonify({'status': 'stopped'})
-
-
-@app.route('/adsb/stream')
-def stream_adsb():
- """SSE stream for ADS-B aircraft."""
- def generate():
- while True:
- try:
- msg = adsb_queue.get(timeout=1)
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- return response
-
-
-def parse_adsb_output(process):
- """Parse ADS-B output and poll dump1090 JSON for decoded data."""
- global adsb_aircraft
- import re
- import urllib.request
- import json as json_lib
-
- icao_pattern = re.compile(r'\*([0-9A-Fa-f]{6,14});')
-
- # Start a thread to poll dump1090's JSON endpoint for decoded positions
- def poll_dump1090_json():
- """Poll dump1090's aircraft.json for decoded lat/lon data."""
- json_urls = [
- 'http://localhost:30005/data/aircraft.json',
- 'http://localhost:8080/data/aircraft.json',
- 'http://localhost:30003/data/aircraft.json',
- 'http://localhost:16601/data/aircraft.json',
- 'http://localhost:8080/dump1090/data/aircraft.json'
- ]
- working_url = None
- print("[ADS-B] JSON polling thread started")
-
- while process and process.poll() is None:
- try:
- # Find working URL on first success
- urls_to_try = [working_url] if working_url else json_urls
- for url in urls_to_try:
- try:
- with urllib.request.urlopen(url, timeout=2) as response:
- data = json_lib.loads(response.read().decode())
- if not working_url:
- print(f"[ADS-B] JSON: Connected to {url}")
- working_url = url
-
- aircraft_list = data.get('aircraft', [])
- if aircraft_list:
- print(f"[ADS-B] JSON: Found {len(aircraft_list)} aircraft")
- for ac in aircraft_list:
- icao = ac.get('hex', '').upper()
- if not icao:
- continue
-
- # Update aircraft with decoded position data
- aircraft = adsb_aircraft.get(icao, {'icao': icao})
- aircraft.update({
- 'icao': icao,
- 'callsign': ac.get('flight', '').strip() or aircraft.get('callsign'),
- 'altitude': ac.get('altitude') or ac.get('alt_baro') or aircraft.get('altitude'),
- 'speed': ac.get('speed') or ac.get('gs') or aircraft.get('speed'),
- 'heading': ac.get('track') or aircraft.get('heading'),
- 'lat': ac.get('lat') or aircraft.get('lat'),
- 'lon': ac.get('lon') or aircraft.get('lon'),
- 'squawk': ac.get('squawk') or aircraft.get('squawk'),
- 'rssi': ac.get('rssi') or aircraft.get('rssi')
- })
-
- adsb_aircraft[icao] = aircraft
- adsb_queue.put({
- 'type': 'aircraft',
- **aircraft
- })
- break
- except Exception as url_err:
- continue # Try next URL
- except Exception as e:
- print(f"[ADS-B] JSON error: {e}")
- time.sleep(1)
-
- # Start JSON polling thread
- json_thread = threading.Thread(target=poll_dump1090_json, daemon=True)
- json_thread.start()
-
- # Also parse raw output for immediate ICAO detection
- try:
- for line in process.stdout:
- line = line.strip()
- if not line:
- continue
-
- # Parse raw Mode S messages for quick ICAO detection
- match = icao_pattern.search(line)
- if match:
- raw = match.group(1)
- if len(raw) >= 6:
- icao = raw[:6].upper()
-
- # Create placeholder if not seen via JSON yet
- if icao not in adsb_aircraft:
- print(f"[ADS-B] Raw: New aircraft {icao}")
- aircraft = {
- 'icao': icao,
- 'callsign': None,
- 'altitude': None,
- 'speed': None,
- 'heading': None,
- 'lat': None,
- 'lon': None
- }
- adsb_aircraft[icao] = aircraft
- adsb_queue.put({
- 'type': 'aircraft',
- **aircraft
- })
- except Exception as e:
- print(f"[ADS-B] Parse error: {e}")
-
-
-# ============================================
-# SATELLITE ROUTES
-# ============================================
-
-@app.route('/satellite/dashboard')
-def satellite_dashboard():
- """Popout hi-tech satellite tracking dashboard."""
- return render_template_string('''
-
-
-
-
-
- SATELLITE COMMAND // INTERCEPT
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-''')
-
-
-@app.route('/adsb/dashboard')
-def adsb_dashboard():
- """Popout hi-tech ADS-B aircraft tracking dashboard."""
- return render_template_string('''
-
-
-
-
-
- AIRCRAFT RADAR // INTERCEPT
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-''')
-
-
-@app.route('/satellite/predict', methods=['POST'])
-def predict_passes():
- """Calculate satellite passes using skyfield for accurate orbital prediction."""
- from datetime import datetime, timedelta
-
- try:
- from skyfield.api import load, wgs84, EarthSatellite
- from skyfield.almanac import find_discrete
- except ImportError:
- # Fallback if skyfield not installed
- return jsonify({
- 'status': 'error',
- 'message': 'skyfield library not installed. Run: pip install skyfield'
- })
-
- data = request.json
- lat = data.get('latitude', data.get('lat', 51.5074))
- lon = data.get('longitude', data.get('lon', -0.1278))
- hours = data.get('hours', 24)
- min_el = data.get('minEl', 10)
-
- # Map NORAD IDs to satellite names
- norad_to_name = {
- 25544: 'ISS',
- 25338: 'NOAA-15',
- 28654: 'NOAA-18',
- 33591: 'NOAA-19',
- 43013: 'NOAA-20',
- 40069: 'METEOR-M2',
- 57166: 'METEOR-M2-3'
- }
-
- sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
- # Convert NORAD IDs to names if needed
- satellites = []
- for sat in sat_input:
- if isinstance(sat, int) and sat in norad_to_name:
- satellites.append(norad_to_name[sat])
- else:
- satellites.append(sat)
-
- passes = []
- colors = {
- 'ISS': '#00ffff',
- 'NOAA-15': '#00ff00',
- 'NOAA-18': '#ff6600',
- 'NOAA-19': '#ff3366',
- 'NOAA-20': '#00ffaa',
- 'METEOR-M2': '#9370DB',
- 'METEOR-M2-3': '#ff00ff'
- }
- name_to_norad = {v: k for k, v in norad_to_name.items()}
-
- ts = load.timescale()
- observer = wgs84.latlon(lat, lon)
-
- t0 = ts.now()
- t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
-
- for sat_name in satellites:
- if sat_name not in TLE_SATELLITES:
- continue
-
- tle_data = TLE_SATELLITES[sat_name]
- try:
- satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
- except Exception:
- continue
-
- # Find passes by checking when satellite is above minimum elevation
- def above_horizon(t):
- diff = satellite - observer
- topocentric = diff.at(t)
- alt, _, _ = topocentric.altaz()
- return alt.degrees > 0
-
- above_horizon.step_days = 1/720 # Check every 2 minutes
-
- try:
- times, events = find_discrete(t0, t1, above_horizon)
- except Exception:
- continue
-
- # Process rise/set pairs
- i = 0
- while i < len(times):
- # Find rise event (event = True)
- if i < len(events) and events[i]:
- rise_time = times[i]
-
- # Find corresponding set event
- set_time = None
- for j in range(i + 1, len(times)):
- if not events[j]:
- set_time = times[j]
- i = j
- break
-
- if set_time is None:
- i += 1
- continue
-
- # Generate trajectory points between rise and set
- trajectory = []
- max_elevation = 0
- num_points = 30
-
- duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
-
- for k in range(num_points):
- frac = k / (num_points - 1)
- t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
-
- diff = satellite - observer
- topocentric = diff.at(t_point)
- alt, az, _ = topocentric.altaz()
-
- el = alt.degrees
- azimuth = az.degrees
-
- if el > max_elevation:
- max_elevation = el
-
- trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
-
- # Only include pass if max elevation meets minimum requirement
- if max_elevation >= min_el:
- duration_minutes = int(duration_seconds / 60)
-
- # Generate ground track (sub-satellite points)
- ground_track = []
- for k in range(60): # 60 points for smoother track
- frac = k / 59
- t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
- geocentric = satellite.at(t_point)
- subpoint = wgs84.subpoint(geocentric)
- ground_track.append({
- 'lat': float(subpoint.latitude.degrees),
- 'lon': float(subpoint.longitude.degrees)
- })
-
- # Get current position
- current_geo = satellite.at(ts.now())
- current_subpoint = wgs84.subpoint(current_geo)
- current_topo = (satellite - observer).at(ts.now())
- current_alt, current_az, _ = current_topo.altaz()
-
- passes.append({
- 'satellite': sat_name,
- 'norad': name_to_norad.get(sat_name, 0),
- 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
- 'startTimeISO': rise_time.utc_datetime().isoformat(),
- 'maxEl': float(round(max_elevation, 1)),
- 'duration': int(duration_minutes),
- 'trajectory': trajectory,
- 'groundTrack': ground_track,
- 'currentPos': {
- 'lat': float(current_subpoint.latitude.degrees),
- 'lon': float(current_subpoint.longitude.degrees)
- },
- 'color': colors.get(sat_name, '#00ff00')
- })
-
- i += 1
-
- # Sort by time
- passes.sort(key=lambda p: p['startTime'])
-
- return jsonify({
- 'status': 'success',
- 'passes': passes
- })
-
-
-@app.route('/satellite/position', methods=['POST'])
-def get_satellite_position():
- """Get real-time positions of satellites with full orbit ground track."""
- from datetime import datetime, timedelta
-
- try:
- from skyfield.api import load, wgs84, EarthSatellite
- except ImportError:
- return jsonify({'status': 'error', 'message': 'skyfield not installed'})
-
- data = request.json
- lat = data.get('latitude', data.get('lat', 51.5074))
- lon = data.get('longitude', data.get('lon', -0.1278))
- sat_input = data.get('satellites', [])
- include_track = data.get('includeTrack', True)
-
- # Map NORAD IDs to satellite names
- norad_to_name = {
- 25544: 'ISS',
- 25338: 'NOAA-15',
- 28654: 'NOAA-18',
- 33591: 'NOAA-19',
- 43013: 'NOAA-20',
- 40069: 'METEOR-M2',
- 57166: 'METEOR-M2-3'
- }
-
- # Convert NORAD IDs to names if needed
- satellites = []
- for sat in sat_input:
- if isinstance(sat, int) and sat in norad_to_name:
- satellites.append(norad_to_name[sat])
- else:
- satellites.append(sat)
-
- ts = load.timescale()
- observer = wgs84.latlon(lat, lon)
- now = ts.now()
- now_dt = now.utc_datetime()
-
- positions = []
-
- for sat_name in satellites:
- if sat_name not in TLE_SATELLITES:
- continue
-
- tle_data = TLE_SATELLITES[sat_name]
- try:
- satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
-
- # Get current geocentric position
- geocentric = satellite.at(now)
- subpoint = wgs84.subpoint(geocentric)
-
- # Get topocentric position (from observer)
- diff = satellite - observer
- topocentric = diff.at(now)
- alt, az, distance = topocentric.altaz()
-
- pos_data = {
- 'satellite': sat_name,
- 'lat': float(subpoint.latitude.degrees),
- 'lon': float(subpoint.longitude.degrees),
- 'altitude': float(geocentric.distance().km - 6371),
- 'elevation': float(alt.degrees),
- 'azimuth': float(az.degrees),
- 'distance': float(distance.km),
- 'visible': bool(alt.degrees > 0)
- }
-
- # Generate full orbit ground track (±45 minutes = ~1 orbit for LEO)
- if include_track:
- orbit_track = []
- # Past 45 minutes to future 45 minutes in 1-minute intervals
- for minutes_offset in range(-45, 46, 1):
- t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
- try:
- geo = satellite.at(t_point)
- sp = wgs84.subpoint(geo)
- orbit_track.append({
- 'lat': float(sp.latitude.degrees),
- 'lon': float(sp.longitude.degrees),
- 'past': minutes_offset < 0
- })
- except:
- continue
-
- pos_data['track'] = orbit_track
-
- positions.append(pos_data)
- except Exception:
- continue
-
- return jsonify({
- 'status': 'success',
- 'positions': positions,
- 'timestamp': datetime.utcnow().isoformat()
- })
-
-
-@app.route('/satellite/update-tle', methods=['POST'])
-def update_tle():
- """Update TLE data from CelesTrak."""
- global TLE_SATELLITES
-
- try:
- import urllib.request
-
- # Map our satellite names to CelesTrak groups
- groups_to_fetch = {
- 'stations': ['ISS'],
- 'weather': ['NOAA-15', 'NOAA-18', 'NOAA-19', 'METEOR-M2']
- }
-
- # Name mappings from CelesTrak to our internal names
- name_mappings = {
- 'ISS (ZARYA)': 'ISS',
- 'NOAA 15': 'NOAA-15',
- 'NOAA 18': 'NOAA-18',
- 'NOAA 19': 'NOAA-19',
- 'METEOR-M 2': 'METEOR-M2',
- 'METEOR-M2 2': 'METEOR-M2'
- }
-
- updated = []
-
- for group, sats in groups_to_fetch.items():
- try:
- url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
- req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
-
- with urllib.request.urlopen(req, timeout=10) as response:
- tle_data = response.read().decode('utf-8')
-
- lines = [l.strip() for l in tle_data.strip().split('\n') if l.strip()]
-
- for i in range(0, len(lines) - 2, 3):
- name = lines[i]
- line1 = lines[i + 1]
- line2 = lines[i + 2]
-
- if line1.startswith('1 ') and line2.startswith('2 '):
- # Check if this satellite is one we track
- internal_name = name_mappings.get(name)
- if internal_name and internal_name in sats:
- TLE_SATELLITES[internal_name] = (name, line1, line2)
- updated.append(internal_name)
- except Exception:
- continue
-
- return jsonify({
- 'status': 'success',
- 'message': f'Updated TLE for: {", ".join(updated) if updated else "none"}',
- 'updated': updated
- })
- except Exception as e:
- return jsonify({
- 'status': 'error',
- 'message': str(e)
- })
-
-
-@app.route('/satellite/celestrak/')
-def fetch_celestrak(category):
- """Fetch TLE data from CelesTrak for a specific category."""
- global TLE_SATELLITES
- import urllib.request
-
- # Map category names to Celestrak groups
- category_map = {
- 'stations': 'stations',
- 'visual': 'visual',
- 'weather': 'weather',
- 'noaa': 'noaa',
- 'amateur': 'amateur',
- 'starlink': 'starlink',
- 'gps-ops': 'gps-ops',
- 'iridium': 'iridium'
- }
-
- if category not in category_map:
- return jsonify({'status': 'error', 'message': 'Unknown category'})
-
- try:
- url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category_map[category]}&FORMAT=tle'
- req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
-
- with urllib.request.urlopen(req, timeout=10) as response:
- tle_data = response.read().decode('utf-8')
-
- lines = [l.strip() for l in tle_data.strip().split('\n') if l.strip()]
- satellites = []
-
- # Parse TLE (3 lines per satellite)
- for i in range(0, len(lines) - 2, 3):
- name = lines[i]
- line1 = lines[i + 1]
- line2 = lines[i + 2]
-
- if line1.startswith('1 ') and line2.startswith('2 '):
- norad = line1[2:7].strip()
- sat_id = name.replace(' ', '-').replace('/', '-').upper()[:20]
- satellites.append({
- 'id': sat_id,
- 'name': name,
- 'norad': norad,
- 'tle': [name, line1, line2]
- })
- # Also add to TLE_SATELLITES for prediction
- TLE_SATELLITES[sat_id] = (name, line1, line2)
-
- # Limit to first 50 satellites to avoid overwhelming the UI
- return jsonify({
- 'status': 'success',
- 'satellites': satellites[:50],
- 'total': len(satellites)
- })
- except urllib.error.URLError as e:
- return jsonify({'status': 'error', 'message': f'Network error: {str(e)}'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-# ============================================
-# IRIDIUM ROUTES
-# ============================================
-
-@app.route('/iridium/tools')
-def check_iridium_tools():
- """Check for Iridium decoding tools."""
- # Check for iridium-extractor or gr-iridium
- has_tool = shutil.which('iridium-extractor') is not None or shutil.which('iridium-parser') is not None
- return jsonify({'available': has_tool})
-
-
-@app.route('/iridium/start', methods=['POST'])
-def start_iridium():
- """Start Iridium burst capture."""
- global satellite_process
-
- with satellite_lock:
- if satellite_process and satellite_process.poll() is None:
- return jsonify({'status': 'error', 'message': 'Iridium capture already running'})
-
- data = request.json
- freq = data.get('freq', '1626.0')
- gain = data.get('gain', '40')
- sample_rate = data.get('sampleRate', '2.048e6')
- device = data.get('device', '0')
-
- # Check for tools
- if not shutil.which('iridium-extractor') and not shutil.which('rtl_fm'):
- return jsonify({
- 'status': 'error',
- 'message': 'Iridium tools not found. Install gr-iridium or use rtl_fm for basic capture.'
- })
-
- try:
- # For demo, use rtl_fm to capture L-band (iridium-extractor would be better)
- # Real implementation would pipe to iridium-extractor
- cmd = [
- 'rtl_fm',
- '-f', f'{float(freq)}M',
- '-g', str(gain),
- '-s', sample_rate,
- '-d', str(device),
- '-'
- ]
-
- satellite_process = subprocess.Popen(
- cmd,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE
- )
-
- # Start monitoring thread
- thread = threading.Thread(target=monitor_iridium, args=(satellite_process,), daemon=True)
- thread.start()
-
- return jsonify({'status': 'started'})
- except Exception as e:
- return jsonify({'status': 'error', 'message': str(e)})
-
-
-@app.route('/iridium/stop', methods=['POST'])
-def stop_iridium():
- """Stop Iridium capture."""
- global satellite_process, iridium_bursts
-
- with satellite_lock:
- if satellite_process:
- satellite_process.terminate()
- try:
- satellite_process.wait(timeout=5)
- except:
- satellite_process.kill()
- satellite_process = None
-
- return jsonify({'status': 'stopped'})
-
-
-@app.route('/iridium/stream')
-def stream_iridium():
- """SSE stream for Iridium bursts."""
- def generate():
- while True:
- try:
- msg = satellite_queue.get(timeout=1)
- yield f"data: {json.dumps(msg)}\n\n"
- except queue.Empty:
- yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
-
- response = Response(generate(), mimetype='text/event-stream')
- response.headers['Cache-Control'] = 'no-cache'
- response.headers['X-Accel-Buffering'] = 'no'
- return response
-
-
-def monitor_iridium(process):
- """Monitor Iridium capture and detect bursts."""
- import time
- from datetime import datetime
-
- # Simulated burst detection (real implementation would use proper Iridium decoding)
- # With gr-iridium, you'd parse the actual burst frames
-
- try:
- burst_count = 0
- while process.poll() is None:
- # Read some data
- data = process.stdout.read(1024)
- if data:
- # Simulate burst detection based on signal energy
- # Real implementation would decode Iridium frames
- if len(data) > 0 and burst_count < 100:
- # Occasional simulated burst for demo
- import random
- if random.random() < 0.01: # 1% chance per read
- burst = {
- 'type': 'burst',
- 'time': datetime.now().strftime('%H:%M:%S.%f')[:-3],
- 'frequency': f"{1616 + random.random() * 10:.3f}",
- 'data': f"Frame data (simulated) - Burst #{burst_count + 1}"
- }
- satellite_queue.put(burst)
- iridium_bursts.append(burst)
- burst_count += 1
-
- time.sleep(0.1)
- except Exception as e:
- print(f"[Iridium] Monitor error: {e}")
-
-
-def cleanup_stale_processes():
- """Kill any stale processes from previous runs (but not system services)."""
- # Note: dump1090 is NOT included here as users may run it as a system service
- processes_to_kill = ['rtl_adsb', 'rtl_433', 'multimon-ng', 'rtl_fm']
- for proc_name in processes_to_kill:
- try:
- subprocess.run(['pkill', '-9', proc_name], capture_output=True)
- except:
- pass
-
-
-def main():
- print("=" * 50)
- print(" INTERCEPT // Signal Intelligence")
- print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
- print("=" * 50)
- print()
-
- # Clean up any stale processes from previous runs
- cleanup_stale_processes()
-
- print("Open http://localhost:5050 in your browser")
- print()
- print("Press Ctrl+C to stop")
- print()
-
- app.run(host='0.0.0.0', port=5050, debug=False, threaded=True)
-
+from app import main
if __name__ == '__main__':
main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..283b666
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,118 @@
+[project]
+name = "intercept"
+version = "1.0.0"
+description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
+readme = "README.md"
+requires-python = ">=3.9"
+license = {text = "MIT"}
+authors = [
+ {name = "Intercept Contributors"}
+]
+keywords = ["sdr", "rtl-sdr", "signals", "pager", "adsb", "wifi", "bluetooth"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Environment :: Web Environment",
+ "Framework :: Flask",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: POSIX :: Linux",
+ "Operating System :: MacOS",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Topic :: Communications",
+ "Topic :: System :: Networking :: Monitoring",
+]
+dependencies = [
+ "flask>=2.0.0",
+ "skyfield>=1.45",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "pytest-cov>=4.0.0",
+ "ruff>=0.1.0",
+ "black>=23.0.0",
+ "mypy>=1.0.0",
+ "types-flask>=1.1.0",
+]
+
+[project.scripts]
+intercept = "intercept:main"
+
+[build-system]
+requires = ["setuptools>=61.0"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["routes*", "utils*", "data*"]
+
+[tool.ruff]
+target-version = "py39"
+line-length = 120
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # Pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+ "SIM", # flake8-simplify
+]
+ignore = [
+ "E501", # line too long (handled by formatter)
+ "B008", # do not perform function calls in argument defaults
+ "B905", # zip without explicit strict
+ "SIM108", # use ternary operator instead of if-else
+]
+
+[tool.ruff.lint.isort]
+known-first-party = ["app", "config", "routes", "utils", "data"]
+
+[tool.black]
+line-length = 120
+target-version = ["py39", "py310", "py311", "py312"]
+include = '\.pyi?$'
+exclude = '''
+/(
+ \.git
+ | \.mypy_cache
+ | \.pytest_cache
+ | \.venv
+ | venv
+ | __pycache__
+)/
+'''
+
+[tool.mypy]
+python_version = "3.9"
+warn_return_any = true
+warn_unused_configs = true
+ignore_missing_imports = true
+exclude = [
+ "tests/",
+ "venv/",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = "-v --tb=short"
+
+[tool.coverage.run]
+source = ["app", "routes", "utils", "data"]
+omit = ["tests/*", "*/__pycache__/*"]
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "if __name__ == .__main__.:",
+ "raise NotImplementedError",
+]
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..07180d4
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,14 @@
+# Development dependencies
+-r requirements.txt
+
+# Testing
+pytest>=7.0.0
+pytest-cov>=4.0.0
+
+# Code quality
+ruff>=0.1.0
+black>=23.0.0
+mypy>=1.0.0
+
+# Type stubs
+types-flask>=1.1.0
diff --git a/requirements.txt b/requirements.txt
index 79a3476..11f50ee 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,12 @@
+# Core dependencies
flask>=2.0.0
+
+# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
+
+# Development dependencies (install with: pip install -r requirements-dev.txt)
+# pytest>=7.0.0
+# pytest-cov>=4.0.0
+# ruff>=0.1.0
+# black>=23.0.0
+# mypy>=1.0.0
diff --git a/routes/__init__.py b/routes/__init__.py
new file mode 100644
index 0000000..d8ab836
--- /dev/null
+++ b/routes/__init__.py
@@ -0,0 +1,19 @@
+# Routes package - registers all blueprints with the Flask app
+
+def register_blueprints(app):
+ """Register all route blueprints with the Flask app."""
+ from .pager import pager_bp
+ from .sensor import sensor_bp
+ from .wifi import wifi_bp
+ from .bluetooth import bluetooth_bp
+ from .adsb import adsb_bp
+ from .satellite import satellite_bp
+ from .iridium import iridium_bp
+
+ app.register_blueprint(pager_bp)
+ app.register_blueprint(sensor_bp)
+ app.register_blueprint(wifi_bp)
+ app.register_blueprint(bluetooth_bp)
+ app.register_blueprint(adsb_bp)
+ app.register_blueprint(satellite_bp)
+ app.register_blueprint(iridium_bp)
diff --git a/routes/adsb.py b/routes/adsb.py
new file mode 100644
index 0000000..f80b4e4
--- /dev/null
+++ b/routes/adsb.py
@@ -0,0 +1,244 @@
+"""ADS-B aircraft tracking routes."""
+
+from __future__ import annotations
+
+import json
+import queue
+import shutil
+import socket
+import subprocess
+import threading
+import time
+from typing import Any, Generator
+
+from flask import Blueprint, jsonify, request, Response, render_template
+
+import app as app_module
+from utils.logging import adsb_logger as logger
+
+adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
+
+# Track if using service
+adsb_using_service = False
+
+
+def check_dump1090_service():
+ """Check if dump1090 SBS port (30003) is available."""
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(2)
+ result = sock.connect_ex(('localhost', 30003))
+ sock.close()
+ if result == 0:
+ return 'localhost:30003'
+ except Exception:
+ pass
+ return None
+
+
+def parse_sbs_stream(service_addr):
+ """Parse SBS format data from dump1090 port 30003."""
+ global adsb_using_service
+
+ host, port = service_addr.split(':')
+ port = int(port)
+
+ logger.info(f"SBS stream parser started, connecting to {host}:{port}")
+
+ while adsb_using_service:
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(5)
+ sock.connect((host, port))
+ logger.info("Connected to SBS stream")
+
+ buffer = ""
+ last_update = time.time()
+ pending_updates = set()
+
+ while adsb_using_service:
+ try:
+ data = sock.recv(4096).decode('utf-8', errors='ignore')
+ if not data:
+ break
+ buffer += data
+
+ while '\n' in buffer:
+ line, buffer = buffer.split('\n', 1)
+ line = line.strip()
+ if not line:
+ continue
+
+ parts = line.split(',')
+ if len(parts) < 11 or parts[0] != 'MSG':
+ continue
+
+ msg_type = parts[1]
+ icao = parts[4].upper()
+ if not icao:
+ continue
+
+ aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
+
+ if msg_type == '1' and len(parts) > 10:
+ callsign = parts[10].strip()
+ if callsign:
+ aircraft['callsign'] = callsign
+
+ elif msg_type == '3' and len(parts) > 15:
+ if parts[11]:
+ try:
+ aircraft['alt'] = int(float(parts[11]))
+ except (ValueError, TypeError):
+ pass
+ if parts[14] and parts[15]:
+ try:
+ aircraft['lat'] = float(parts[14])
+ aircraft['lon'] = float(parts[15])
+ except (ValueError, TypeError):
+ pass
+
+ elif msg_type == '4' and len(parts) > 13:
+ if parts[12]:
+ try:
+ aircraft['speed'] = int(float(parts[12]))
+ except (ValueError, TypeError):
+ pass
+ if parts[13]:
+ try:
+ aircraft['heading'] = int(float(parts[13]))
+ except (ValueError, TypeError):
+ pass
+
+ elif msg_type == '5' and len(parts) > 11:
+ if parts[10]:
+ callsign = parts[10].strip()
+ if callsign:
+ aircraft['callsign'] = callsign
+ if parts[11]:
+ try:
+ aircraft['alt'] = int(float(parts[11]))
+ except (ValueError, TypeError):
+ pass
+
+ elif msg_type == '6' and len(parts) > 17:
+ if parts[17]:
+ aircraft['squawk'] = parts[17]
+
+ app_module.adsb_aircraft[icao] = aircraft
+ pending_updates.add(icao)
+
+ now = time.time()
+ if now - last_update >= 1.0:
+ for update_icao in pending_updates:
+ if update_icao in app_module.adsb_aircraft:
+ app_module.adsb_queue.put({
+ 'type': 'aircraft',
+ **app_module.adsb_aircraft[update_icao]
+ })
+ pending_updates.clear()
+ last_update = now
+
+ except socket.timeout:
+ continue
+
+ sock.close()
+ except Exception as e:
+ logger.warning(f"SBS connection error: {e}, reconnecting...")
+ time.sleep(2)
+
+ logger.info("SBS stream parser stopped")
+
+
+@adsb_bp.route('/tools')
+def check_adsb_tools():
+ """Check for ADS-B decoding tools."""
+ return jsonify({
+ 'dump1090': shutil.which('dump1090') is not None or shutil.which('dump1090-mutability') is not None,
+ 'rtl_adsb': shutil.which('rtl_adsb') is not None
+ })
+
+
+@adsb_bp.route('/start', methods=['POST'])
+def start_adsb():
+ """Start ADS-B tracking."""
+ global adsb_using_service
+
+ with app_module.adsb_lock:
+ if app_module.adsb_process and app_module.adsb_process.poll() is None:
+ return jsonify({'status': 'already_running', 'message': 'ADS-B already running'})
+ if adsb_using_service:
+ return jsonify({'status': 'already_running', 'message': 'ADS-B already running (using service)'})
+
+ data = request.json or {}
+ gain = data.get('gain', '40')
+ device = data.get('device', '0')
+
+ dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-mutability')
+
+ if not dump1090_path:
+ return jsonify({'status': 'error', 'message': 'dump1090 not found.'})
+
+ cmd = [dump1090_path, '--net', '--gain', gain, '--device-index', str(device), '--quiet']
+
+ try:
+ app_module.adsb_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL
+ )
+
+ time.sleep(3)
+
+ if app_module.adsb_process.poll() is not None:
+ return jsonify({'status': 'error', 'message': 'dump1090 failed to start.'})
+
+ adsb_using_service = True
+ thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
+ thread.start()
+
+ return jsonify({'status': 'success', 'message': 'ADS-B tracking started'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@adsb_bp.route('/stop', methods=['POST'])
+def stop_adsb():
+ """Stop ADS-B tracking."""
+ global adsb_using_service
+
+ with app_module.adsb_lock:
+ if app_module.adsb_process:
+ app_module.adsb_process.terminate()
+ try:
+ app_module.adsb_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ app_module.adsb_process.kill()
+ app_module.adsb_process = None
+ adsb_using_service = False
+
+ app_module.adsb_aircraft = {}
+ return jsonify({'status': 'stopped'})
+
+
+@adsb_bp.route('/stream')
+def stream_adsb():
+ """SSE stream for ADS-B aircraft."""
+ def generate():
+ while True:
+ try:
+ msg = app_module.adsb_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ return response
+
+
+@adsb_bp.route('/dashboard')
+def adsb_dashboard():
+ """Popout ADS-B dashboard."""
+ return render_template('adsb_dashboard.html')
diff --git a/routes/bluetooth.py b/routes/bluetooth.py
new file mode 100644
index 0000000..aba437e
--- /dev/null
+++ b/routes/bluetooth.py
@@ -0,0 +1,483 @@
+"""Bluetooth reconnaissance routes."""
+
+from __future__ import annotations
+
+import fcntl
+import json
+import os
+import platform
+import pty
+import queue
+import re
+import select
+import subprocess
+import threading
+import time
+from typing import Any, Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.dependencies import check_tool
+from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
+from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
+
+bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
+
+
+def classify_bt_device(name, device_class, services, manufacturer=None):
+ """Classify Bluetooth device type based on available info."""
+ name_lower = (name or '').lower()
+ mfr_lower = (manufacturer or '').lower()
+
+ audio_patterns = [
+ 'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
+ 'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
+ 'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
+ ]
+ if any(x in name_lower for x in audio_patterns):
+ return 'audio'
+
+ wearable_patterns = [
+ 'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
+ 'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
+ ]
+ if any(x in name_lower for x in wearable_patterns):
+ return 'wearable'
+
+ phone_patterns = [
+ 'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
+ ]
+ if any(x in name_lower for x in phone_patterns):
+ return 'phone'
+
+ tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
+ if any(x in name_lower for x in tracker_patterns):
+ return 'tracker'
+
+ input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
+ if any(x in name_lower for x in input_patterns):
+ return 'input'
+
+ if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
+ return 'audio'
+ if mfr_lower in ['fitbit', 'garmin']:
+ return 'wearable'
+ if mfr_lower == 'tile':
+ return 'tracker'
+
+ if device_class:
+ major_class = (device_class >> 8) & 0x1F
+ if major_class == 1:
+ return 'computer'
+ elif major_class == 2:
+ return 'phone'
+ elif major_class == 4:
+ return 'audio'
+ elif major_class == 5:
+ return 'input'
+ elif major_class == 7:
+ return 'wearable'
+
+ return 'other'
+
+
+def detect_tracker(mac, name, manufacturer_data=None):
+ """Detect if device is a known tracker."""
+ mac_prefix = mac[:5].upper()
+
+ if any(mac_prefix.startswith(p) for p in AIRTAG_PREFIXES):
+ if manufacturer_data and b'\\x4c\\x00' in manufacturer_data:
+ return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
+
+ if any(mac_prefix.startswith(p) for p in TILE_PREFIXES):
+ return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
+
+ if any(mac_prefix.startswith(p) for p in SAMSUNG_TRACKER):
+ return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'}
+
+ name_lower = (name or '').lower()
+ if 'airtag' in name_lower:
+ return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
+ if 'tile' in name_lower:
+ return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
+
+ return None
+
+
+def detect_bt_interfaces():
+ """Detect available Bluetooth interfaces."""
+ interfaces = []
+
+ if platform.system() == 'Linux':
+ try:
+ result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
+ blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
+ for block in blocks:
+ if block.strip():
+ first_line = block.split('\n')[0]
+ match = re.match(r'(hci\d+):', first_line)
+ if match:
+ iface_name = match.group(1)
+ is_up = 'UP RUNNING' in block or '\tUP ' in block
+ interfaces.append({
+ 'name': iface_name,
+ 'type': 'hci',
+ 'status': 'up' if is_up else 'down'
+ })
+ except Exception:
+ pass
+
+ elif platform.system() == 'Darwin':
+ interfaces.append({
+ 'name': 'default',
+ 'type': 'macos',
+ 'status': 'available'
+ })
+
+ return interfaces
+
+
+def stream_bt_scan(process, scan_mode):
+ """Stream Bluetooth scan output to queue."""
+ try:
+ app_module.bt_queue.put({'type': 'status', 'text': 'started'})
+
+ if scan_mode == 'hcitool':
+ for line in iter(process.stdout.readline, b''):
+ line = line.decode('utf-8', errors='replace').strip()
+ if not line or 'LE Scan' in line:
+ continue
+
+ parts = line.split()
+ if len(parts) >= 1 and ':' in parts[0]:
+ mac = parts[0]
+ name = ' '.join(parts[1:]) if len(parts) > 1 else ''
+
+ manufacturer = get_manufacturer(mac)
+ device = {
+ 'mac': mac,
+ 'name': name or '[Unknown]',
+ 'manufacturer': manufacturer,
+ 'type': classify_bt_device(name, None, None, manufacturer),
+ 'rssi': None,
+ 'last_seen': time.time()
+ }
+
+ tracker = detect_tracker(mac, name)
+ if tracker:
+ device['tracker'] = tracker
+
+ is_new = mac not in app_module.bt_devices
+ app_module.bt_devices[mac] = device
+
+ app_module.bt_queue.put({
+ **device,
+ 'type': 'device',
+ 'device_type': device.get('type', 'other'),
+ 'action': 'new' if is_new else 'update',
+ })
+
+ elif scan_mode == 'bluetoothctl':
+ master_fd = getattr(process, '_master_fd', None)
+ if not master_fd:
+ app_module.bt_queue.put({'type': 'error', 'text': 'bluetoothctl pty not available'})
+ return
+
+ buffer = ''
+ while process.poll() is None:
+ readable, _, _ = select.select([master_fd], [], [], 1.0)
+ if readable:
+ try:
+ data = os.read(master_fd, 4096)
+ if not data:
+ break
+ buffer += data.decode('utf-8', errors='replace')
+
+ while '\n' in buffer:
+ line, buffer = buffer.split('\n', 1)
+ line = line.strip()
+ line = re.sub(r'\x1b\[[0-9;]*m', '', line)
+ line = re.sub(r'\r', '', line)
+
+ if 'Device' in line:
+ match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
+ if match:
+ mac = match.group(1).upper()
+ name = match.group(2).strip()
+
+ manufacturer = get_manufacturer(mac)
+ device = {
+ 'mac': mac,
+ 'name': name or '[Unknown]',
+ 'manufacturer': manufacturer,
+ 'type': classify_bt_device(name, None, None, manufacturer),
+ 'rssi': None,
+ 'last_seen': time.time()
+ }
+
+ tracker = detect_tracker(mac, name)
+ if tracker:
+ device['tracker'] = tracker
+
+ is_new = mac not in app_module.bt_devices
+ app_module.bt_devices[mac] = device
+
+ app_module.bt_queue.put({
+ **device,
+ 'type': 'device',
+ 'device_type': device.get('type', 'other'),
+ 'action': 'new' if is_new else 'update',
+ })
+ except OSError:
+ break
+
+ try:
+ os.close(master_fd)
+ except OSError:
+ pass
+
+ except Exception as e:
+ app_module.bt_queue.put({'type': 'error', 'text': str(e)})
+ finally:
+ process.wait()
+ app_module.bt_queue.put({'type': 'status', 'text': 'stopped'})
+ with app_module.bt_lock:
+ app_module.bt_process = None
+
+
+@bluetooth_bp.route('/reload-oui', methods=['POST'])
+def reload_oui_database_route():
+ """Reload OUI database from external file."""
+ new_db = load_oui_database()
+ if new_db:
+ OUI_DATABASE.clear()
+ OUI_DATABASE.update(new_db)
+ return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
+ return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
+
+
+@bluetooth_bp.route('/interfaces')
+def get_bt_interfaces():
+ """Get available Bluetooth interfaces and tools."""
+ interfaces = detect_bt_interfaces()
+ tools = {
+ 'hcitool': check_tool('hcitool'),
+ 'bluetoothctl': check_tool('bluetoothctl'),
+ 'hciconfig': check_tool('hciconfig'),
+ 'l2ping': check_tool('l2ping'),
+ 'sdptool': check_tool('sdptool')
+ }
+ return jsonify({
+ 'interfaces': interfaces,
+ 'tools': tools,
+ 'current_interface': app_module.bt_interface
+ })
+
+
+@bluetooth_bp.route('/scan/start', methods=['POST'])
+def start_bt_scan():
+ """Start Bluetooth scanning."""
+ with app_module.bt_lock:
+ if app_module.bt_process:
+ if app_module.bt_process.poll() is None:
+ return jsonify({'status': 'error', 'message': 'Scan already running'})
+ else:
+ app_module.bt_process = None
+
+ data = request.json
+ scan_mode = data.get('mode', 'hcitool')
+ interface = data.get('interface', 'hci0')
+ scan_ble = data.get('scan_ble', True)
+
+ app_module.bt_interface = interface
+ app_module.bt_devices = {}
+
+ while not app_module.bt_queue.empty():
+ try:
+ app_module.bt_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ try:
+ if scan_mode == 'hcitool':
+ if scan_ble:
+ cmd = ['hcitool', '-i', interface, 'lescan', '--duplicates']
+ else:
+ cmd = ['hcitool', '-i', interface, 'scan']
+
+ app_module.bt_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+
+ elif scan_mode == 'bluetoothctl':
+ master_fd, slave_fd = pty.openpty()
+ app_module.bt_process = subprocess.Popen(
+ ['bluetoothctl'],
+ stdin=slave_fd,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ close_fds=True
+ )
+ os.close(slave_fd)
+ app_module.bt_process._master_fd = master_fd
+
+ time.sleep(0.5)
+ os.write(master_fd, b'power on\n')
+ time.sleep(0.3)
+ os.write(master_fd, b'scan on\n')
+
+ else:
+ return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
+
+ time.sleep(0.5)
+
+ if app_module.bt_process.poll() is not None:
+ stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
+ app_module.bt_process = None
+ return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
+
+ thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
+ thread.daemon = True
+ thread.start()
+
+ app_module.bt_queue.put({'type': 'info', 'text': f'Started {scan_mode} scan on {interface}'})
+ return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
+
+ except FileNotFoundError as e:
+ return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@bluetooth_bp.route('/scan/stop', methods=['POST'])
+def stop_bt_scan():
+ """Stop Bluetooth scanning."""
+ with app_module.bt_lock:
+ if app_module.bt_process:
+ app_module.bt_process.terminate()
+ try:
+ app_module.bt_process.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ app_module.bt_process.kill()
+ app_module.bt_process = None
+ return jsonify({'status': 'stopped'})
+ return jsonify({'status': 'not_running'})
+
+
+@bluetooth_bp.route('/reset', methods=['POST'])
+def reset_bt_adapter():
+ """Reset Bluetooth adapter."""
+ data = request.json
+ interface = data.get('interface', 'hci0')
+
+ with app_module.bt_lock:
+ if app_module.bt_process:
+ try:
+ app_module.bt_process.terminate()
+ app_module.bt_process.wait(timeout=2)
+ except (subprocess.TimeoutExpired, OSError):
+ try:
+ app_module.bt_process.kill()
+ except OSError:
+ pass
+ app_module.bt_process = None
+
+ try:
+ subprocess.run(['pkill', '-f', 'hcitool'], capture_output=True, timeout=2)
+ subprocess.run(['pkill', '-f', 'bluetoothctl'], capture_output=True, timeout=2)
+ time.sleep(0.5)
+
+ subprocess.run(['rfkill', 'unblock', 'bluetooth'], capture_output=True, timeout=5)
+ subprocess.run(['hciconfig', interface, 'down'], capture_output=True, timeout=5)
+ time.sleep(1)
+ subprocess.run(['hciconfig', interface, 'up'], capture_output=True, timeout=5)
+ time.sleep(0.5)
+
+ result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5)
+ is_up = 'UP RUNNING' in result.stdout
+
+ return jsonify({
+ 'status': 'success' if is_up else 'warning',
+ 'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
+ 'is_up': is_up
+ })
+
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@bluetooth_bp.route('/enum', methods=['POST'])
+def enum_bt_services():
+ """Enumerate services on a Bluetooth device."""
+ data = request.json
+ target_mac = data.get('mac')
+
+ if not target_mac:
+ return jsonify({'status': 'error', 'message': 'Target MAC required'})
+
+ try:
+ result = subprocess.run(
+ ['sdptool', 'browse', target_mac],
+ capture_output=True, text=True, timeout=30
+ )
+
+ services = []
+ current_service = {}
+
+ for line in result.stdout.split('\n'):
+ line = line.strip()
+ if line.startswith('Service Name:'):
+ if current_service:
+ services.append(current_service)
+ current_service = {'name': line.split(':', 1)[1].strip()}
+ elif line.startswith('Service Description:'):
+ current_service['description'] = line.split(':', 1)[1].strip()
+
+ if current_service:
+ services.append(current_service)
+
+ app_module.bt_services[target_mac] = services
+
+ return jsonify({
+ 'status': 'success',
+ 'mac': target_mac,
+ 'services': services
+ })
+
+ except subprocess.TimeoutExpired:
+ return jsonify({'status': 'error', 'message': 'Connection timed out'})
+ except FileNotFoundError:
+ return jsonify({'status': 'error', 'message': 'sdptool not found'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@bluetooth_bp.route('/devices')
+def get_bt_devices():
+ """Get current list of discovered Bluetooth devices."""
+ return jsonify({
+ 'devices': list(app_module.bt_devices.values()),
+ 'beacons': list(app_module.bt_beacons.values()),
+ 'interface': app_module.bt_interface
+ })
+
+
+@bluetooth_bp.route('/stream')
+def stream_bt():
+ """SSE stream for Bluetooth events."""
+ def generate():
+ while True:
+ try:
+ msg = app_module.bt_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
diff --git a/routes/iridium.py b/routes/iridium.py
new file mode 100644
index 0000000..4cfdde6
--- /dev/null
+++ b/routes/iridium.py
@@ -0,0 +1,126 @@
+"""Iridium monitoring routes."""
+
+from __future__ import annotations
+
+import json
+import queue
+import random
+import shutil
+import subprocess
+import threading
+import time
+from datetime import datetime
+from typing import Any, Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.logging import iridium_logger as logger
+
+iridium_bp = Blueprint('iridium', __name__, url_prefix='/iridium')
+
+
+def monitor_iridium(process):
+ """Monitor Iridium capture and detect bursts."""
+ try:
+ burst_count = 0
+ while process.poll() is None:
+ data = process.stdout.read(1024)
+ if data:
+ if len(data) > 0 and burst_count < 100:
+ if random.random() < 0.01:
+ burst = {
+ 'type': 'burst',
+ 'time': datetime.now().strftime('%H:%M:%S.%f')[:-3],
+ 'frequency': f"{1616 + random.random() * 10:.3f}",
+ 'data': f"Frame data (simulated) - Burst #{burst_count + 1}"
+ }
+ app_module.satellite_queue.put(burst)
+ app_module.iridium_bursts.append(burst)
+ burst_count += 1
+
+ time.sleep(0.1)
+ except Exception as e:
+ logger.error(f"Monitor error: {e}")
+
+
+@iridium_bp.route('/tools')
+def check_iridium_tools():
+ """Check for Iridium decoding tools."""
+ has_tool = shutil.which('iridium-extractor') is not None or shutil.which('iridium-parser') is not None
+ return jsonify({'available': has_tool})
+
+
+@iridium_bp.route('/start', methods=['POST'])
+def start_iridium():
+ """Start Iridium burst capture."""
+ with app_module.satellite_lock:
+ if app_module.satellite_process and app_module.satellite_process.poll() is None:
+ return jsonify({'status': 'error', 'message': 'Iridium capture already running'})
+
+ data = request.json
+ freq = data.get('freq', '1626.0')
+ gain = data.get('gain', '40')
+ sample_rate = data.get('sampleRate', '2.048e6')
+ device = data.get('device', '0')
+
+ if not shutil.which('iridium-extractor') and not shutil.which('rtl_fm'):
+ return jsonify({
+ 'status': 'error',
+ 'message': 'Iridium tools not found.'
+ })
+
+ try:
+ cmd = [
+ 'rtl_fm',
+ '-f', f'{float(freq)}M',
+ '-g', str(gain),
+ '-s', sample_rate,
+ '-d', str(device),
+ '-'
+ ]
+
+ app_module.satellite_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+
+ thread = threading.Thread(target=monitor_iridium, args=(app_module.satellite_process,), daemon=True)
+ thread.start()
+
+ return jsonify({'status': 'started'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@iridium_bp.route('/stop', methods=['POST'])
+def stop_iridium():
+ """Stop Iridium capture."""
+ with app_module.satellite_lock:
+ if app_module.satellite_process:
+ app_module.satellite_process.terminate()
+ try:
+ app_module.satellite_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ app_module.satellite_process.kill()
+ app_module.satellite_process = None
+
+ return jsonify({'status': 'stopped'})
+
+
+@iridium_bp.route('/stream')
+def stream_iridium():
+ """SSE stream for Iridium bursts."""
+ def generate():
+ while True:
+ try:
+ msg = app_module.satellite_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ return response
diff --git a/routes/pager.py b/routes/pager.py
new file mode 100644
index 0000000..dfa1636
--- /dev/null
+++ b/routes/pager.py
@@ -0,0 +1,328 @@
+"""Pager decoding routes (POCSAG/FLEX)."""
+
+from __future__ import annotations
+
+import os
+import re
+import pty
+import queue
+import select
+import subprocess
+import threading
+from datetime import datetime
+from typing import Any, Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.logging import pager_logger as logger
+
+pager_bp = Blueprint('pager', __name__)
+
+
+def parse_multimon_output(line: str) -> dict[str, str] | None:
+ """Parse multimon-ng output line."""
+ line = line.strip()
+
+ # POCSAG parsing - with message content
+ pocsag_match = re.match(
+ r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
+ line
+ )
+ if pocsag_match:
+ return {
+ 'protocol': pocsag_match.group(1),
+ 'address': pocsag_match.group(2),
+ 'function': pocsag_match.group(3),
+ 'msg_type': pocsag_match.group(4),
+ 'message': pocsag_match.group(5).strip() or '[No Message]'
+ }
+
+ # POCSAG parsing - address only (no message content)
+ pocsag_addr_match = re.match(
+ r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
+ line
+ )
+ if pocsag_addr_match:
+ return {
+ 'protocol': pocsag_addr_match.group(1),
+ 'address': pocsag_addr_match.group(2),
+ 'function': pocsag_addr_match.group(3),
+ 'msg_type': 'Tone',
+ 'message': '[Tone Only]'
+ }
+
+ # FLEX parsing (standard format)
+ flex_match = re.match(
+ r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)',
+ line
+ )
+ if flex_match:
+ return {
+ 'protocol': 'FLEX',
+ 'address': flex_match.group(3),
+ 'function': flex_match.group(1),
+ 'msg_type': flex_match.group(4),
+ 'message': flex_match.group(5).strip() or '[No Message]'
+ }
+
+ # Simple FLEX format
+ flex_simple = re.match(r'FLEX:\s*(.+)', line)
+ if flex_simple:
+ return {
+ 'protocol': 'FLEX',
+ 'address': 'Unknown',
+ 'function': '',
+ 'msg_type': 'Unknown',
+ 'message': flex_simple.group(1).strip()
+ }
+
+ return None
+
+
+def log_message(msg: dict[str, Any]) -> None:
+ """Log a message to file if logging is enabled."""
+ if not app_module.logging_enabled:
+ return
+ try:
+ with open(app_module.log_file_path, 'a') as f:
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
+ except Exception as e:
+ logger.error(f"Failed to log message: {e}")
+
+
+def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
+ """Stream decoder output to queue using PTY for unbuffered output."""
+ try:
+ app_module.output_queue.put({'type': 'status', 'text': 'started'})
+
+ buffer = ""
+ while True:
+ try:
+ ready, _, _ = select.select([master_fd], [], [], 1.0)
+ except Exception:
+ break
+
+ if ready:
+ try:
+ data = os.read(master_fd, 1024)
+ if not data:
+ break
+ buffer += data.decode('utf-8', errors='replace')
+
+ while '\n' in buffer:
+ line, buffer = buffer.split('\n', 1)
+ line = line.strip()
+ if not line:
+ continue
+
+ parsed = parse_multimon_output(line)
+ if parsed:
+ parsed['timestamp'] = datetime.now().strftime('%H:%M:%S')
+ app_module.output_queue.put({'type': 'message', **parsed})
+ log_message(parsed)
+ else:
+ app_module.output_queue.put({'type': 'raw', 'text': line})
+ except OSError:
+ break
+
+ if process.poll() is not None:
+ break
+
+ except Exception as e:
+ app_module.output_queue.put({'type': 'error', 'text': str(e)})
+ finally:
+ try:
+ os.close(master_fd)
+ except OSError:
+ pass
+ process.wait()
+ app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
+ with app_module.process_lock:
+ app_module.current_process = None
+
+
+@pager_bp.route('/start', methods=['POST'])
+def start_decoding() -> Response:
+ with app_module.process_lock:
+ if app_module.current_process:
+ return jsonify({'status': 'error', 'message': 'Already running'})
+
+ data = request.json
+ freq = data.get('frequency', '929.6125')
+ gain = data.get('gain', '0')
+ squelch = data.get('squelch', '0')
+ ppm = data.get('ppm', '0')
+ device = data.get('device', '0')
+ protocols = data.get('protocols', ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX'])
+
+ # Clear queue
+ while not app_module.output_queue.empty():
+ try:
+ app_module.output_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ # Build multimon-ng decoder arguments
+ decoders = []
+ for proto in protocols:
+ if proto == 'POCSAG512':
+ decoders.extend(['-a', 'POCSAG512'])
+ elif proto == 'POCSAG1200':
+ decoders.extend(['-a', 'POCSAG1200'])
+ elif proto == 'POCSAG2400':
+ decoders.extend(['-a', 'POCSAG2400'])
+ elif proto == 'FLEX':
+ decoders.extend(['-a', 'FLEX'])
+
+ # Build rtl_fm command
+ rtl_cmd = [
+ 'rtl_fm',
+ '-d', str(device),
+ '-f', f'{freq}M',
+ '-M', 'fm',
+ '-s', '22050',
+ ]
+
+ if gain and gain != '0':
+ rtl_cmd.extend(['-g', str(gain)])
+
+ if ppm and ppm != '0':
+ rtl_cmd.extend(['-p', str(ppm)])
+
+ if squelch and squelch != '0':
+ rtl_cmd.extend(['-l', str(squelch)])
+
+ rtl_cmd.append('-')
+
+ multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
+
+ full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
+ logger.info(f"Running: {full_cmd}")
+
+ try:
+ # Create pipe: rtl_fm | multimon-ng
+ rtl_process = subprocess.Popen(
+ rtl_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+
+ # Start a thread to monitor rtl_fm stderr for errors
+ def monitor_rtl_stderr():
+ for line in rtl_process.stderr:
+ err_text = line.decode('utf-8', errors='replace').strip()
+ if err_text:
+ logger.debug(f"[RTL_FM] {err_text}")
+ app_module.output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'})
+
+ rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
+ rtl_stderr_thread.daemon = True
+ rtl_stderr_thread.start()
+
+ # Create a pseudo-terminal for multimon-ng output
+ master_fd, slave_fd = pty.openpty()
+
+ multimon_process = subprocess.Popen(
+ multimon_cmd,
+ stdin=rtl_process.stdout,
+ stdout=slave_fd,
+ stderr=slave_fd,
+ close_fds=True
+ )
+
+ os.close(slave_fd)
+ rtl_process.stdout.close()
+
+ app_module.current_process = multimon_process
+ app_module.current_process._rtl_process = rtl_process
+ app_module.current_process._master_fd = master_fd
+
+ # Start output thread with PTY master fd
+ thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
+ thread.daemon = True
+ thread.start()
+
+ app_module.output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
+
+ return jsonify({'status': 'started', 'command': full_cmd})
+
+ except FileNotFoundError as e:
+ return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@pager_bp.route('/stop', methods=['POST'])
+def stop_decoding() -> Response:
+ with app_module.process_lock:
+ if app_module.current_process:
+ # Kill rtl_fm process first
+ if hasattr(app_module.current_process, '_rtl_process'):
+ try:
+ app_module.current_process._rtl_process.terminate()
+ app_module.current_process._rtl_process.wait(timeout=2)
+ except (subprocess.TimeoutExpired, OSError):
+ try:
+ app_module.current_process._rtl_process.kill()
+ except OSError:
+ pass
+
+ # Close PTY master fd
+ if hasattr(app_module.current_process, '_master_fd'):
+ try:
+ os.close(app_module.current_process._master_fd)
+ except OSError:
+ pass
+
+ # Kill multimon-ng
+ app_module.current_process.terminate()
+ try:
+ app_module.current_process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ app_module.current_process.kill()
+
+ app_module.current_process = None
+ return jsonify({'status': 'stopped'})
+
+ return jsonify({'status': 'not_running'})
+
+
+@pager_bp.route('/status')
+def get_status() -> Response:
+ """Check if decoder is currently running."""
+ with app_module.process_lock:
+ if app_module.current_process and app_module.current_process.poll() is None:
+ return jsonify({'running': True, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
+ return jsonify({'running': False, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
+
+
+@pager_bp.route('/logging', methods=['POST'])
+def toggle_logging() -> Response:
+ """Toggle message logging."""
+ data = request.json
+ if 'enabled' in data:
+ app_module.logging_enabled = data['enabled']
+ if 'log_file' in data and data['log_file']:
+ app_module.log_file_path = data['log_file']
+ return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
+
+
+@pager_bp.route('/stream')
+def stream() -> Response:
+ import json
+
+ def generate() -> Generator[str, None, None]:
+ while True:
+ try:
+ msg = app_module.output_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
diff --git a/routes/satellite.py b/routes/satellite.py
new file mode 100644
index 0000000..ebc1e23
--- /dev/null
+++ b/routes/satellite.py
@@ -0,0 +1,381 @@
+"""Satellite tracking routes."""
+
+from __future__ import annotations
+
+import json
+import urllib.request
+from datetime import datetime, timedelta
+from typing import Any
+
+from flask import Blueprint, jsonify, request, render_template, Response
+
+from data.satellites import TLE_SATELLITES
+from utils.logging import satellite_logger as logger
+
+satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
+
+# Local TLE cache (can be updated via API)
+_tle_cache = dict(TLE_SATELLITES)
+
+
+@satellite_bp.route('/dashboard')
+def satellite_dashboard():
+ """Popout satellite tracking dashboard."""
+ return render_template('satellite_dashboard.html')
+
+
+@satellite_bp.route('/predict', methods=['POST'])
+def predict_passes():
+ """Calculate satellite passes using skyfield."""
+ try:
+ from skyfield.api import load, wgs84, EarthSatellite
+ from skyfield.almanac import find_discrete
+ except ImportError:
+ return jsonify({
+ 'status': 'error',
+ 'message': 'skyfield library not installed. Run: pip install skyfield'
+ })
+
+ data = request.json
+ lat = data.get('latitude', data.get('lat', 51.5074))
+ lon = data.get('longitude', data.get('lon', -0.1278))
+ hours = data.get('hours', 24)
+ min_el = data.get('minEl', 10)
+
+ norad_to_name = {
+ 25544: 'ISS',
+ 25338: 'NOAA-15',
+ 28654: 'NOAA-18',
+ 33591: 'NOAA-19',
+ 43013: 'NOAA-20',
+ 40069: 'METEOR-M2',
+ 57166: 'METEOR-M2-3'
+ }
+
+ sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
+ satellites = []
+ for sat in sat_input:
+ if isinstance(sat, int) and sat in norad_to_name:
+ satellites.append(norad_to_name[sat])
+ else:
+ satellites.append(sat)
+
+ passes = []
+ colors = {
+ 'ISS': '#00ffff',
+ 'NOAA-15': '#00ff00',
+ 'NOAA-18': '#ff6600',
+ 'NOAA-19': '#ff3366',
+ 'NOAA-20': '#00ffaa',
+ 'METEOR-M2': '#9370DB',
+ 'METEOR-M2-3': '#ff00ff'
+ }
+ name_to_norad = {v: k for k, v in norad_to_name.items()}
+
+ ts = load.timescale()
+ observer = wgs84.latlon(lat, lon)
+
+ t0 = ts.now()
+ t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
+
+ for sat_name in satellites:
+ if sat_name not in _tle_cache:
+ continue
+
+ tle_data = _tle_cache[sat_name]
+ try:
+ satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
+ except Exception:
+ continue
+
+ def above_horizon(t):
+ diff = satellite - observer
+ topocentric = diff.at(t)
+ alt, _, _ = topocentric.altaz()
+ return alt.degrees > 0
+
+ above_horizon.step_days = 1/720
+
+ try:
+ times, events = find_discrete(t0, t1, above_horizon)
+ except Exception:
+ continue
+
+ i = 0
+ while i < len(times):
+ if i < len(events) and events[i]:
+ rise_time = times[i]
+ set_time = None
+ for j in range(i + 1, len(times)):
+ if not events[j]:
+ set_time = times[j]
+ i = j
+ break
+
+ if set_time is None:
+ i += 1
+ continue
+
+ trajectory = []
+ max_elevation = 0
+ num_points = 30
+
+ duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
+
+ for k in range(num_points):
+ frac = k / (num_points - 1)
+ t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
+
+ diff = satellite - observer
+ topocentric = diff.at(t_point)
+ alt, az, _ = topocentric.altaz()
+
+ el = alt.degrees
+ azimuth = az.degrees
+
+ if el > max_elevation:
+ max_elevation = el
+
+ trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
+
+ if max_elevation >= min_el:
+ duration_minutes = int(duration_seconds / 60)
+
+ ground_track = []
+ for k in range(60):
+ frac = k / 59
+ t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
+ geocentric = satellite.at(t_point)
+ subpoint = wgs84.subpoint(geocentric)
+ ground_track.append({
+ 'lat': float(subpoint.latitude.degrees),
+ 'lon': float(subpoint.longitude.degrees)
+ })
+
+ current_geo = satellite.at(ts.now())
+ current_subpoint = wgs84.subpoint(current_geo)
+
+ passes.append({
+ 'satellite': sat_name,
+ 'norad': name_to_norad.get(sat_name, 0),
+ 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
+ 'startTimeISO': rise_time.utc_datetime().isoformat(),
+ 'maxEl': float(round(max_elevation, 1)),
+ 'duration': int(duration_minutes),
+ 'trajectory': trajectory,
+ 'groundTrack': ground_track,
+ 'currentPos': {
+ 'lat': float(current_subpoint.latitude.degrees),
+ 'lon': float(current_subpoint.longitude.degrees)
+ },
+ 'color': colors.get(sat_name, '#00ff00')
+ })
+
+ i += 1
+
+ passes.sort(key=lambda p: p['startTime'])
+
+ return jsonify({
+ 'status': 'success',
+ 'passes': passes
+ })
+
+
+@satellite_bp.route('/position', methods=['POST'])
+def get_satellite_position():
+ """Get real-time positions of satellites."""
+ try:
+ from skyfield.api import load, wgs84, EarthSatellite
+ except ImportError:
+ return jsonify({'status': 'error', 'message': 'skyfield not installed'})
+
+ data = request.json
+ lat = data.get('latitude', data.get('lat', 51.5074))
+ lon = data.get('longitude', data.get('lon', -0.1278))
+ sat_input = data.get('satellites', [])
+ include_track = data.get('includeTrack', True)
+
+ norad_to_name = {
+ 25544: 'ISS',
+ 25338: 'NOAA-15',
+ 28654: 'NOAA-18',
+ 33591: 'NOAA-19',
+ 43013: 'NOAA-20',
+ 40069: 'METEOR-M2',
+ 57166: 'METEOR-M2-3'
+ }
+
+ satellites = []
+ for sat in sat_input:
+ if isinstance(sat, int) and sat in norad_to_name:
+ satellites.append(norad_to_name[sat])
+ else:
+ satellites.append(sat)
+
+ ts = load.timescale()
+ observer = wgs84.latlon(lat, lon)
+ now = ts.now()
+ now_dt = now.utc_datetime()
+
+ positions = []
+
+ for sat_name in satellites:
+ if sat_name not in _tle_cache:
+ continue
+
+ tle_data = _tle_cache[sat_name]
+ try:
+ satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
+
+ geocentric = satellite.at(now)
+ subpoint = wgs84.subpoint(geocentric)
+
+ diff = satellite - observer
+ topocentric = diff.at(now)
+ alt, az, distance = topocentric.altaz()
+
+ pos_data = {
+ 'satellite': sat_name,
+ 'lat': float(subpoint.latitude.degrees),
+ 'lon': float(subpoint.longitude.degrees),
+ 'altitude': float(geocentric.distance().km - 6371),
+ 'elevation': float(alt.degrees),
+ 'azimuth': float(az.degrees),
+ 'distance': float(distance.km),
+ 'visible': bool(alt.degrees > 0)
+ }
+
+ if include_track:
+ orbit_track = []
+ for minutes_offset in range(-45, 46, 1):
+ t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
+ try:
+ geo = satellite.at(t_point)
+ sp = wgs84.subpoint(geo)
+ orbit_track.append({
+ 'lat': float(sp.latitude.degrees),
+ 'lon': float(sp.longitude.degrees),
+ 'past': minutes_offset < 0
+ })
+ except Exception:
+ continue
+
+ pos_data['track'] = orbit_track
+
+ positions.append(pos_data)
+ except Exception:
+ continue
+
+ return jsonify({
+ 'status': 'success',
+ 'positions': positions,
+ 'timestamp': datetime.utcnow().isoformat()
+ })
+
+
+@satellite_bp.route('/update-tle', methods=['POST'])
+def update_tle():
+ """Update TLE data from CelesTrak."""
+ global _tle_cache
+
+ try:
+ name_mappings = {
+ 'ISS (ZARYA)': 'ISS',
+ 'NOAA 15': 'NOAA-15',
+ 'NOAA 18': 'NOAA-18',
+ 'NOAA 19': 'NOAA-19',
+ 'METEOR-M 2': 'METEOR-M2',
+ 'METEOR-M2 3': 'METEOR-M2-3'
+ }
+
+ updated = []
+
+ for group in ['stations', 'weather']:
+ url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
+ try:
+ with urllib.request.urlopen(url, timeout=10) as response:
+ content = response.read().decode('utf-8')
+ lines = content.strip().split('\n')
+
+ i = 0
+ while i + 2 < len(lines):
+ name = lines[i].strip()
+ line1 = lines[i + 1].strip()
+ line2 = lines[i + 2].strip()
+
+ if not (line1.startswith('1 ') and line2.startswith('2 ')):
+ i += 1
+ continue
+
+ internal_name = name_mappings.get(name, name)
+
+ if internal_name in _tle_cache:
+ _tle_cache[internal_name] = (name, line1, line2)
+ updated.append(internal_name)
+
+ i += 3
+ except Exception as e:
+ logger.error(f"Error fetching {group}: {e}")
+ continue
+
+ return jsonify({
+ 'status': 'success',
+ 'updated': updated
+ })
+
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@satellite_bp.route('/celestrak/')
+def fetch_celestrak(category):
+ """Fetch TLE data from CelesTrak for a category."""
+ valid_categories = [
+ 'stations', 'weather', 'noaa', 'goes', 'resource', 'sarsat',
+ 'dmc', 'tdrss', 'argos', 'planet', 'spire', 'geo', 'intelsat',
+ 'ses', 'iridium', 'iridium-NEXT', 'starlink', 'oneweb',
+ 'amateur', 'cubesat', 'visual'
+ ]
+
+ if category not in valid_categories:
+ return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
+
+ try:
+ url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
+ with urllib.request.urlopen(url, timeout=10) as response:
+ content = response.read().decode('utf-8')
+
+ satellites = []
+ lines = content.strip().split('\n')
+
+ i = 0
+ while i + 2 < len(lines):
+ name = lines[i].strip()
+ line1 = lines[i + 1].strip()
+ line2 = lines[i + 2].strip()
+
+ if not (line1.startswith('1 ') and line2.startswith('2 ')):
+ i += 1
+ continue
+
+ try:
+ norad_id = int(line1[2:7])
+ satellites.append({
+ 'name': name,
+ 'norad': norad_id,
+ 'tle1': line1,
+ 'tle2': line2
+ })
+ except (ValueError, IndexError):
+ pass
+
+ i += 3
+
+ return jsonify({
+ 'status': 'success',
+ 'category': category,
+ 'satellites': satellites
+ })
+
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
diff --git a/routes/sensor.py b/routes/sensor.py
new file mode 100644
index 0000000..bd722dc
--- /dev/null
+++ b/routes/sensor.py
@@ -0,0 +1,157 @@
+"""RTL_433 sensor monitoring routes."""
+
+from __future__ import annotations
+
+import json
+import queue
+import subprocess
+import threading
+from datetime import datetime
+from typing import Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.logging import sensor_logger as logger
+
+sensor_bp = Blueprint('sensor', __name__)
+
+
+def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
+ """Stream rtl_433 JSON output to queue."""
+ try:
+ app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
+
+ for line in iter(process.stdout.readline, b''):
+ line = line.decode('utf-8', errors='replace').strip()
+ if not line:
+ continue
+
+ try:
+ # rtl_433 outputs JSON objects, one per line
+ data = json.loads(line)
+ data['type'] = 'sensor'
+ app_module.sensor_queue.put(data)
+
+ # Log if enabled
+ if app_module.logging_enabled:
+ try:
+ with open(app_module.log_file_path, 'a') as f:
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
+ except Exception:
+ pass
+ except json.JSONDecodeError:
+ # Not JSON, send as raw
+ app_module.sensor_queue.put({'type': 'raw', 'text': line})
+
+ except Exception as e:
+ app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
+ finally:
+ process.wait()
+ app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
+ with app_module.sensor_lock:
+ app_module.sensor_process = None
+
+
+@sensor_bp.route('/start_sensor', methods=['POST'])
+def start_sensor() -> Response:
+ with app_module.sensor_lock:
+ if app_module.sensor_process:
+ return jsonify({'status': 'error', 'message': 'Sensor already running'})
+
+ data = request.json
+ freq = data.get('frequency', '433.92')
+ gain = data.get('gain', '0')
+ ppm = data.get('ppm', '0')
+ device = data.get('device', '0')
+
+ # Clear queue
+ while not app_module.sensor_queue.empty():
+ try:
+ app_module.sensor_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ # Build rtl_433 command
+ cmd = [
+ 'rtl_433',
+ '-d', str(device),
+ '-f', f'{freq}M',
+ '-F', 'json'
+ ]
+
+ if gain and gain != '0':
+ cmd.extend(['-g', str(gain)])
+
+ if ppm and ppm != '0':
+ cmd.extend(['-p', str(ppm)])
+
+ full_cmd = ' '.join(cmd)
+ logger.info(f"Running: {full_cmd}")
+
+ try:
+ app_module.sensor_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=1
+ )
+
+ # Start output thread
+ thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
+ thread.daemon = True
+ thread.start()
+
+ # Monitor stderr
+ def monitor_stderr():
+ for line in app_module.sensor_process.stderr:
+ err = line.decode('utf-8', errors='replace').strip()
+ if err:
+ logger.debug(f"[rtl_433] {err}")
+ app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
+
+ stderr_thread = threading.Thread(target=monitor_stderr)
+ stderr_thread.daemon = True
+ stderr_thread.start()
+
+ app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
+
+ return jsonify({'status': 'started', 'command': full_cmd})
+
+ except FileNotFoundError:
+ return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@sensor_bp.route('/stop_sensor', methods=['POST'])
+def stop_sensor() -> Response:
+ with app_module.sensor_lock:
+ if app_module.sensor_process:
+ app_module.sensor_process.terminate()
+ try:
+ app_module.sensor_process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ app_module.sensor_process.kill()
+ app_module.sensor_process = None
+ return jsonify({'status': 'stopped'})
+
+ return jsonify({'status': 'not_running'})
+
+
+@sensor_bp.route('/stream_sensor')
+def stream_sensor() -> Response:
+ def generate() -> Generator[str, None, None]:
+ while True:
+ try:
+ msg = app_module.sensor_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
diff --git a/routes/wifi.py b/routes/wifi.py
new file mode 100644
index 0000000..f47e155
--- /dev/null
+++ b/routes/wifi.py
@@ -0,0 +1,772 @@
+"""WiFi reconnaissance routes."""
+
+from __future__ import annotations
+
+import fcntl
+import json
+import os
+import platform
+import queue
+import re
+import subprocess
+import threading
+import time
+from typing import Any, Generator
+
+from flask import Blueprint, jsonify, request, Response
+
+import app as app_module
+from utils.dependencies import check_tool
+from utils.logging import wifi_logger as logger
+from utils.process import is_valid_mac, is_valid_channel
+from data.oui import get_manufacturer
+
+wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
+
+# PMKID process state
+pmkid_process = None
+pmkid_lock = threading.Lock()
+
+
+def detect_wifi_interfaces():
+ """Detect available WiFi interfaces."""
+ interfaces = []
+
+ if platform.system() == 'Darwin': # macOS
+ try:
+ result = subprocess.run(['networksetup', '-listallhardwareports'],
+ capture_output=True, text=True, timeout=5)
+ lines = result.stdout.split('\n')
+ for i, line in enumerate(lines):
+ if 'Wi-Fi' in line or 'AirPort' in line:
+ for j in range(i+1, min(i+3, len(lines))):
+ if 'Device:' in lines[j]:
+ device = lines[j].split('Device:')[1].strip()
+ interfaces.append({
+ 'name': device,
+ 'type': 'internal',
+ 'monitor_capable': False,
+ 'status': 'up'
+ })
+ break
+ except Exception as e:
+ logger.error(f"Error detecting macOS interfaces: {e}")
+
+ try:
+ result = subprocess.run(['system_profiler', 'SPUSBDataType'],
+ capture_output=True, text=True, timeout=10)
+ if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
+ interfaces.append({
+ 'name': 'USB WiFi Adapter',
+ 'type': 'usb',
+ 'monitor_capable': True,
+ 'status': 'detected'
+ })
+ except Exception:
+ pass
+
+ else: # Linux
+ try:
+ result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
+ current_iface = None
+ for line in result.stdout.split('\n'):
+ line = line.strip()
+ if line.startswith('Interface'):
+ current_iface = line.split()[1]
+ elif current_iface and 'type' in line:
+ iface_type = line.split()[-1]
+ interfaces.append({
+ 'name': current_iface,
+ 'type': iface_type,
+ 'monitor_capable': True,
+ 'status': 'up'
+ })
+ current_iface = None
+ except FileNotFoundError:
+ try:
+ result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
+ for line in result.stdout.split('\n'):
+ if 'IEEE 802.11' in line:
+ iface = line.split()[0]
+ interfaces.append({
+ 'name': iface,
+ 'type': 'managed',
+ 'monitor_capable': True,
+ 'status': 'up'
+ })
+ except Exception:
+ pass
+ except Exception as e:
+ logger.error(f"Error detecting Linux interfaces: {e}")
+
+ return interfaces
+
+
+def parse_airodump_csv(csv_path):
+ """Parse airodump-ng CSV output file."""
+ networks = {}
+ clients = {}
+
+ try:
+ with open(csv_path, 'r', errors='replace') as f:
+ content = f.read()
+
+ sections = content.split('\n\n')
+
+ for section in sections:
+ lines = section.strip().split('\n')
+ if not lines:
+ continue
+
+ header = lines[0] if lines else ''
+
+ if 'BSSID' in header and 'ESSID' in header:
+ for line in lines[1:]:
+ parts = [p.strip() for p in line.split(',')]
+ if len(parts) >= 14:
+ bssid = parts[0]
+ if bssid and ':' in bssid:
+ networks[bssid] = {
+ 'bssid': bssid,
+ 'first_seen': parts[1],
+ 'last_seen': parts[2],
+ 'channel': parts[3],
+ 'speed': parts[4],
+ 'privacy': parts[5],
+ 'cipher': parts[6],
+ 'auth': parts[7],
+ 'power': parts[8],
+ 'beacons': parts[9],
+ 'ivs': parts[10],
+ 'lan_ip': parts[11],
+ 'essid': parts[13] or 'Hidden'
+ }
+
+ elif 'Station MAC' in header:
+ for line in lines[1:]:
+ parts = [p.strip() for p in line.split(',')]
+ if len(parts) >= 6:
+ station = parts[0]
+ if station and ':' in station:
+ vendor = get_manufacturer(station)
+ clients[station] = {
+ 'mac': station,
+ 'first_seen': parts[1],
+ 'last_seen': parts[2],
+ 'power': parts[3],
+ 'packets': parts[4],
+ 'bssid': parts[5],
+ 'probes': parts[6] if len(parts) > 6 else '',
+ 'vendor': vendor
+ }
+ except Exception as e:
+ logger.error(f"Error parsing CSV: {e}")
+
+ return networks, clients
+
+
+def stream_airodump_output(process, csv_path):
+ """Stream airodump-ng output to queue."""
+ try:
+ app_module.wifi_queue.put({'type': 'status', 'text': 'started'})
+ last_parse = 0
+ start_time = time.time()
+ csv_found = False
+
+ while process.poll() is None:
+ try:
+ fd = process.stderr.fileno()
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
+
+ stderr_data = process.stderr.read()
+ if stderr_data:
+ stderr_text = stderr_data.decode('utf-8', errors='replace').strip()
+ if stderr_text:
+ for line in stderr_text.split('\n'):
+ line = line.strip()
+ if line and not line.startswith('CH') and not line.startswith('Elapsed'):
+ app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng: {line}'})
+ except Exception:
+ pass
+
+ current_time = time.time()
+ if current_time - last_parse >= 2:
+ csv_file = csv_path + '-01.csv'
+ if os.path.exists(csv_file):
+ csv_found = True
+ networks, clients = parse_airodump_csv(csv_file)
+
+ for bssid, net in networks.items():
+ if bssid not in app_module.wifi_networks:
+ app_module.wifi_queue.put({
+ 'type': 'network',
+ 'action': 'new',
+ **net
+ })
+ else:
+ app_module.wifi_queue.put({
+ 'type': 'network',
+ 'action': 'update',
+ **net
+ })
+
+ for mac, client in clients.items():
+ if mac not in app_module.wifi_clients:
+ app_module.wifi_queue.put({
+ 'type': 'client',
+ 'action': 'new',
+ **client
+ })
+
+ app_module.wifi_networks = networks
+ app_module.wifi_clients = clients
+ last_parse = current_time
+
+ if current_time - start_time > 5 and not csv_found:
+ app_module.wifi_queue.put({'type': 'error', 'text': 'No scan data after 5 seconds. Check if monitor mode is properly enabled.'})
+ start_time = current_time + 30
+
+ time.sleep(0.5)
+
+ try:
+ remaining_stderr = process.stderr.read()
+ if remaining_stderr:
+ stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip()
+ if stderr_text:
+ app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited: {stderr_text}'})
+ except Exception:
+ pass
+
+ exit_code = process.returncode
+ if exit_code != 0 and exit_code is not None:
+ app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited with code {exit_code}'})
+
+ except Exception as e:
+ app_module.wifi_queue.put({'type': 'error', 'text': str(e)})
+ finally:
+ process.wait()
+ app_module.wifi_queue.put({'type': 'status', 'text': 'stopped'})
+ with app_module.wifi_lock:
+ app_module.wifi_process = None
+
+
+@wifi_bp.route('/interfaces')
+def get_wifi_interfaces():
+ """Get available WiFi interfaces."""
+ interfaces = detect_wifi_interfaces()
+ tools = {
+ 'airmon': check_tool('airmon-ng'),
+ 'airodump': check_tool('airodump-ng'),
+ 'aireplay': check_tool('aireplay-ng'),
+ 'iw': check_tool('iw')
+ }
+ return jsonify({'interfaces': interfaces, 'tools': tools, 'monitor_interface': app_module.wifi_monitor_interface})
+
+
+@wifi_bp.route('/monitor', methods=['POST'])
+def toggle_monitor_mode():
+ """Enable or disable monitor mode on an interface."""
+ data = request.json
+ interface = data.get('interface')
+ action = data.get('action', 'start')
+
+ if not interface:
+ return jsonify({'status': 'error', 'message': 'No interface specified'})
+
+ if action == 'start':
+ if check_tool('airmon-ng'):
+ try:
+ def get_wireless_interfaces():
+ interfaces = set()
+ try:
+ result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
+ for line in result.stdout.split('\n'):
+ if line and not line.startswith(' ') and 'no wireless' not in line.lower():
+ iface = line.split()[0] if line.split() else None
+ if iface:
+ interfaces.add(iface)
+ except (subprocess.SubprocessError, OSError):
+ pass
+
+ try:
+ for iface in os.listdir('/sys/class/net'):
+ if os.path.exists(f'/sys/class/net/{iface}/wireless'):
+ interfaces.add(iface)
+ except OSError:
+ pass
+
+ try:
+ result = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
+ for match in re.finditer(r'^\d+:\s+(\S+):', result.stdout, re.MULTILINE):
+ iface = match.group(1).rstrip(':')
+ if iface.startswith('wl') or 'mon' in iface:
+ interfaces.add(iface)
+ except (subprocess.SubprocessError, OSError):
+ pass
+
+ return interfaces
+
+ interfaces_before = get_wireless_interfaces()
+
+ kill_processes = data.get('kill_processes', False)
+ if kill_processes:
+ subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
+
+ result = subprocess.run(['airmon-ng', 'start', interface],
+ capture_output=True, text=True, timeout=15)
+
+ output = result.stdout + result.stderr
+
+ time.sleep(1)
+ interfaces_after = get_wireless_interfaces()
+
+ new_interfaces = interfaces_after - interfaces_before
+ monitor_iface = None
+
+ if new_interfaces:
+ for iface in new_interfaces:
+ if 'mon' in iface:
+ monitor_iface = iface
+ break
+ if not monitor_iface:
+ monitor_iface = list(new_interfaces)[0]
+
+ if not monitor_iface:
+ patterns = [
+ r'monitor mode.*enabled.*on\s+(\S+)',
+ r'\(monitor mode.*enabled.*?(\S+mon)\)',
+ r'created\s+(\S+mon)',
+ r'\bon\s+(\S+mon)\b',
+ r'\b(\S+mon)\b.*monitor',
+ r'\b(' + re.escape(interface) + r'mon)\b',
+ ]
+ for pattern in patterns:
+ match = re.search(pattern, output, re.IGNORECASE)
+ if match:
+ monitor_iface = match.group(1)
+ break
+
+ if not monitor_iface:
+ try:
+ result = subprocess.run(['iwconfig', interface], capture_output=True, text=True, timeout=5)
+ if 'Mode:Monitor' in result.stdout:
+ monitor_iface = interface
+ except (subprocess.SubprocessError, OSError):
+ pass
+
+ if not monitor_iface:
+ potential = interface + 'mon'
+ if potential in interfaces_after:
+ monitor_iface = potential
+
+ if not monitor_iface:
+ monitor_iface = interface + 'mon'
+
+ app_module.wifi_monitor_interface = monitor_iface
+ app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
+ return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
+
+ except Exception as e:
+ import traceback
+ logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
+ return jsonify({'status': 'error', 'message': str(e)})
+
+ elif check_tool('iw'):
+ try:
+ subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
+ subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
+ subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
+ app_module.wifi_monitor_interface = interface
+ return jsonify({'status': 'success', 'monitor_interface': interface})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+ else:
+ return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
+
+ else: # stop
+ if check_tool('airmon-ng'):
+ try:
+ subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
+ capture_output=True, text=True, timeout=15)
+ app_module.wifi_monitor_interface = None
+ return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+ elif check_tool('iw'):
+ try:
+ subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
+ subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
+ subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
+ app_module.wifi_monitor_interface = None
+ return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+ return jsonify({'status': 'error', 'message': 'Unknown action'})
+
+
+@wifi_bp.route('/scan/start', methods=['POST'])
+def start_wifi_scan():
+ """Start WiFi scanning with airodump-ng."""
+ with app_module.wifi_lock:
+ if app_module.wifi_process:
+ return jsonify({'status': 'error', 'message': 'Scan already running'})
+
+ data = request.json
+ interface = data.get('interface') or app_module.wifi_monitor_interface
+ channel = data.get('channel')
+ band = data.get('band', 'abg')
+
+ if not interface:
+ return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
+
+ app_module.wifi_networks = {}
+ app_module.wifi_clients = {}
+
+ while not app_module.wifi_queue.empty():
+ try:
+ app_module.wifi_queue.get_nowait()
+ except queue.Empty:
+ break
+
+ csv_path = '/tmp/intercept_wifi'
+
+ for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
+ try:
+ os.remove(f)
+ except OSError:
+ pass
+
+ cmd = [
+ 'airodump-ng',
+ '-w', csv_path,
+ '--output-format', 'csv,pcap',
+ '--band', band,
+ interface
+ ]
+
+ if channel:
+ cmd.extend(['-c', str(channel)])
+
+ logger.info(f"Running: {' '.join(cmd)}")
+
+ try:
+ app_module.wifi_process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE
+ )
+
+ time.sleep(0.5)
+
+ if app_module.wifi_process.poll() is not None:
+ stderr_output = app_module.wifi_process.stderr.read().decode('utf-8', errors='replace').strip()
+ stdout_output = app_module.wifi_process.stdout.read().decode('utf-8', errors='replace').strip()
+ exit_code = app_module.wifi_process.returncode
+ app_module.wifi_process = None
+
+ error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}'
+ error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
+
+ if 'No such device' in error_msg or 'No such interface' in error_msg:
+ error_msg = f'Interface "{interface}" not found.'
+ elif 'Operation not permitted' in error_msg:
+ error_msg = 'Permission denied. Try running with sudo.'
+
+ return jsonify({'status': 'error', 'message': error_msg})
+
+ thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
+ thread.daemon = True
+ thread.start()
+
+ app_module.wifi_queue.put({'type': 'info', 'text': f'Started scanning on {interface}'})
+
+ return jsonify({'status': 'started', 'interface': interface})
+
+ except FileNotFoundError:
+ return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@wifi_bp.route('/scan/stop', methods=['POST'])
+def stop_wifi_scan():
+ """Stop WiFi scanning."""
+ with app_module.wifi_lock:
+ if app_module.wifi_process:
+ app_module.wifi_process.terminate()
+ try:
+ app_module.wifi_process.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ app_module.wifi_process.kill()
+ app_module.wifi_process = None
+ return jsonify({'status': 'stopped'})
+ return jsonify({'status': 'not_running'})
+
+
+@wifi_bp.route('/deauth', methods=['POST'])
+def send_deauth():
+ """Send deauthentication packets."""
+ data = request.json
+ target_bssid = data.get('bssid')
+ target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
+ count = data.get('count', 5)
+ interface = data.get('interface') or app_module.wifi_monitor_interface
+
+ if not target_bssid:
+ return jsonify({'status': 'error', 'message': 'Target BSSID required'})
+
+ if not is_valid_mac(target_bssid):
+ return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
+
+ if not is_valid_mac(target_client):
+ return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
+
+ try:
+ count = int(count)
+ if count < 1 or count > 100:
+ count = 5
+ except (ValueError, TypeError):
+ count = 5
+
+ if not interface:
+ return jsonify({'status': 'error', 'message': 'No monitor interface'})
+
+ if not check_tool('aireplay-ng'):
+ return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
+
+ try:
+ cmd = [
+ 'aireplay-ng',
+ '--deauth', str(count),
+ '-a', target_bssid,
+ '-c', target_client,
+ interface
+ ]
+
+ app_module.wifi_queue.put({'type': 'info', 'text': f'Sending {count} deauth packets to {target_bssid}'})
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
+
+ if result.returncode == 0:
+ return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
+ else:
+ return jsonify({'status': 'error', 'message': result.stderr})
+
+ except subprocess.TimeoutExpired:
+ return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@wifi_bp.route('/handshake/capture', methods=['POST'])
+def capture_handshake():
+ """Start targeted handshake capture."""
+ data = request.json
+ target_bssid = data.get('bssid')
+ channel = data.get('channel')
+ interface = data.get('interface') or app_module.wifi_monitor_interface
+
+ if not target_bssid or not channel:
+ return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
+
+ if not is_valid_mac(target_bssid):
+ return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
+
+ if not is_valid_channel(channel):
+ return jsonify({'status': 'error', 'message': 'Invalid channel'})
+
+ with app_module.wifi_lock:
+ if app_module.wifi_process:
+ return jsonify({'status': 'error', 'message': 'Scan already running.'})
+
+ capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
+
+ cmd = [
+ 'airodump-ng',
+ '-c', str(channel),
+ '--bssid', target_bssid,
+ '-w', capture_path,
+ '--output-format', 'pcap',
+ interface
+ ]
+
+ try:
+ app_module.wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
+ return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@wifi_bp.route('/handshake/status', methods=['POST'])
+def check_handshake_status():
+ """Check if a handshake has been captured."""
+ data = request.json
+ capture_file = data.get('file', '')
+ target_bssid = data.get('bssid', '')
+
+ if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
+ return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
+
+ if not os.path.exists(capture_file):
+ with app_module.wifi_lock:
+ if app_module.wifi_process and app_module.wifi_process.poll() is None:
+ return jsonify({'status': 'running', 'file_exists': False, 'handshake_found': False})
+ else:
+ return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
+
+ file_size = os.path.getsize(capture_file)
+ handshake_found = False
+
+ try:
+ if target_bssid and is_valid_mac(target_bssid):
+ result = subprocess.run(
+ ['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
+ capture_output=True, text=True, timeout=10
+ )
+ output = result.stdout + result.stderr
+ if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
+ if '0 handshake' not in output:
+ handshake_found = True
+ except subprocess.TimeoutExpired:
+ pass
+ except Exception as e:
+ logger.error(f"Error checking handshake: {e}")
+
+ return jsonify({
+ 'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
+ 'file_exists': True,
+ 'file_size': file_size,
+ 'file': capture_file,
+ 'handshake_found': handshake_found
+ })
+
+
+@wifi_bp.route('/pmkid/capture', methods=['POST'])
+def capture_pmkid():
+ """Start PMKID capture using hcxdumptool."""
+ global pmkid_process
+
+ data = request.json
+ target_bssid = data.get('bssid')
+ channel = data.get('channel')
+ interface = data.get('interface') or app_module.wifi_monitor_interface
+
+ if not target_bssid:
+ return jsonify({'status': 'error', 'message': 'BSSID required'})
+
+ if not is_valid_mac(target_bssid):
+ return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
+
+ with pmkid_lock:
+ if pmkid_process and pmkid_process.poll() is None:
+ return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
+
+ capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
+ filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
+ with open(filter_file, 'w') as f:
+ f.write(target_bssid.replace(':', '').lower())
+
+ cmd = [
+ 'hcxdumptool',
+ '-i', interface,
+ '-o', capture_path,
+ '--filterlist_ap', filter_file,
+ '--filtermode', '2',
+ '--enable_status', '1'
+ ]
+
+ if channel:
+ cmd.extend(['-c', str(channel)])
+
+ try:
+ pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ return jsonify({'status': 'started', 'file': capture_path})
+ except FileNotFoundError:
+ return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
+ except Exception as e:
+ return jsonify({'status': 'error', 'message': str(e)})
+
+
+@wifi_bp.route('/pmkid/status', methods=['POST'])
+def check_pmkid_status():
+ """Check if PMKID has been captured."""
+ data = request.json
+ capture_file = data.get('file', '')
+
+ if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
+ return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
+
+ if not os.path.exists(capture_file):
+ return jsonify({'pmkid_found': False, 'file_exists': False})
+
+ file_size = os.path.getsize(capture_file)
+ pmkid_found = False
+
+ try:
+ hash_file = capture_file.replace('.pcapng', '.22000')
+ result = subprocess.run(
+ ['hcxpcapngtool', '-o', hash_file, capture_file],
+ capture_output=True, text=True, timeout=10
+ )
+ if os.path.exists(hash_file) and os.path.getsize(hash_file) > 0:
+ pmkid_found = True
+ except FileNotFoundError:
+ pmkid_found = file_size > 1000
+ except Exception:
+ pass
+
+ return jsonify({
+ 'pmkid_found': pmkid_found,
+ 'file_exists': True,
+ 'file_size': file_size,
+ 'file': capture_file
+ })
+
+
+@wifi_bp.route('/pmkid/stop', methods=['POST'])
+def stop_pmkid():
+ """Stop PMKID capture."""
+ global pmkid_process
+
+ with pmkid_lock:
+ if pmkid_process:
+ pmkid_process.terminate()
+ try:
+ pmkid_process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ pmkid_process.kill()
+ pmkid_process = None
+
+ return jsonify({'status': 'stopped'})
+
+
+@wifi_bp.route('/networks')
+def get_wifi_networks():
+ """Get current list of discovered networks."""
+ return jsonify({
+ 'networks': list(app_module.wifi_networks.values()),
+ 'clients': list(app_module.wifi_clients.values()),
+ 'handshakes': app_module.wifi_handshakes,
+ 'monitor_interface': app_module.wifi_monitor_interface
+ })
+
+
+@wifi_bp.route('/stream')
+def stream_wifi():
+ """SSE stream for WiFi events."""
+ def generate():
+ while True:
+ try:
+ msg = app_module.wifi_queue.get(timeout=1)
+ yield f"data: {json.dumps(msg)}\n\n"
+ except queue.Empty:
+ yield f"data: {json.dumps({'type': 'keepalive'})}\n\n"
+
+ response = Response(generate(), mimetype='text/event-stream')
+ response.headers['Cache-Control'] = 'no-cache'
+ response.headers['X-Accel-Buffering'] = 'no'
+ response.headers['Connection'] = 'keep-alive'
+ return response
diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html
new file mode 100644
index 0000000..2aeae26
--- /dev/null
+++ b/templates/adsb_dashboard.html
@@ -0,0 +1,997 @@
+
+
+
+
+
+ AIRCRAFT RADAR // INTERCEPT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..5b98fa3
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,9221 @@
+
+
+
+
+
+ INTERCEPT // Signal Intelligence
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
DISCLAIMER
+
+ INTERCEPT is a signal intelligence tool designed for educational purposes only .
+
+
By using this software, you acknowledge and agree that:
+
+ This tool is intended for use by cyber security professionals and researchers only
+ You will only use this software in a controlled environment with proper authorization
+ Intercepting communications without consent may be illegal in your jurisdiction
+ You are solely responsible for ensuring compliance with all applicable laws and regulations
+ The developers assume no liability for misuse of this software
+
+
+ Only proceed if you understand and accept these terms.
+
+
+ I UNDERSTAND & ACCEPT
+ DECLINE
+
+
+
+
+
+
+
+
+ █████╗ ██████╗ ██████╗███████╗███████╗███████╗
+██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
+███████║██║ ██║ █████╗ ███████╗███████╗
+██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
+██║ ██║╚██████╗╚██████╗███████╗███████║███████║
+╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
+██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
+██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
+██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
+██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
+██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
+╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝
+
+
+ root@intercepted: ~# sudo access --grant-permission
+ [sudo] password for user: ********
+ Error: User is not in the sudoers file.
+ This incident will be reported.
+
+
+
+ "In a world of locked doors, the man with the key is king.
+ And you, my friend, just threw away the key."
+
+
+ TRY AGAIN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Channel Utilization (2.4 GHz)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Channel Utilization (5 GHz)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Target Signal
+
+
No target selected
+
-- dBm
+
+
+
+
+
+
+
+
+
+
+
🕸️ Network Topology
+
+
+
+
+
+
💡 Channel Recommendation
+
+ 2.4 GHz: Use channel --
+
+
+
+ 5 GHz: Use channel --
+
+
+
+
+
+
🔗 Device Correlation
+
+
Analyzing WiFi/BT device patterns...
+
+
+
+
+
👁️ Hidden SSIDs Revealed
+
+
Monitoring probe requests...
+
+
+
+
+
📡 Client Probe Analysis
+
+ Clients: 0
+ Unique SSIDs: 0
+ Privacy Leaks: 0
+
+
+
Waiting for client probe requests...
+
+
+
+
+
+
+
+
Bluetooth Proximity Radar
+
+
+
+
+
+
+
+
+
+
ADS-B AIRCRAFT TRACKING
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ N = North |
+ Center = Overhead (90°) |
+ Edge = Horizon (0°)
+
+
+
+
+
+
+
+
+ --- Past |
+ ● Current |
+ ― Future |
+ ◉ Observer
+
+
+
+
+
+
+
+
--
+
+
+
Next Pass In
+
--:--:--
+
--
+
+
+
Visibility
+
--:--
+
Duration
+
+
+
Max Elevation
+
--°
+
--
+
+
+
Calculate passes to see countdown
+
+
+
+
+
+
+
+
+ Click "Calculate Passes" to predict satellite passes for your location.
+
+
+
+
+
+
+
+
+
+
+
+ Iridium bursts will appear here when detected.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ N = North |
+ Center = Overhead (90°) |
+ Edge = Horizon (0°)
+
+
+
+
+
+
+
--
+
+
+
Next Pass In
+
--:--:--
+
--
+
+
+
Visibility
+
--:--
+
Duration
+
+
+
Max Elevation
+
--°
+
--
+
+
+
Waiting for pass data...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Paste TLE
+ Celestrak
+
+
+
+ Paste TLE data (3 lines per satellite: name, line 1, line 2)
+
+
+
+ Add Satellites from TLE
+
+
+
+
+ Select a category to fetch satellites from Celestrak
+
+
+ 🚀 Space Stations
+ 👁️ Brightest
+ 🌤️ Weather
+ 📡 NOAA
+ 📻 Amateur Radio
+ ⭐ Starlink
+ 🛰️ GPS
+ 📱 Iridium
+
+
+
+
+
+
+
+
+
+
+
+ Device intelligence data will appear here as signals are intercepted.
+
+
+
+
+
+
+
+
+
+
+ Configure settings and click "Start Decoding" to begin.
+
+
+
+
+
+
+ RECON
+ 🔊 MUTE
+ ⬇ AUTO-SCROLL ON
+ 📄 CSV
+ 📋 JSON
+ 🔍 INTEL
+ Clear
+
+
+
+
+
+
+
+
+
+
+
+
×
+
📡 INTERCEPT Help
+
+
+ Icons
+ Modes
+ WiFi
+ Tips
+
+
+
+
+
Stats Bar Icons
+
+
📟 POCSAG messages decoded
+
📠 FLEX messages decoded
+
📨 Total messages received
+
🌡️ Unique sensors detected
+
📊 Device types found
+
✈️ Aircraft being tracked
+
🛰️ Satellites monitored
+
📡 WiFi Access Points
+
👤 Connected WiFi clients
+
🤝 Captured handshakes
+
🚁 Detected drones (click for details)
+
⚠️ Rogue APs (click for details)
+
🔵 Bluetooth devices
+
📍 BLE beacons detected
+
+
+
Mode Tab Icons
+
+
📟 Pager - POCSAG/FLEX decoder
+
📡 433MHz - Sensor decoder
+
✈️ Aircraft - ADS-B tracker
+
🛰️ Satellite - Pass prediction
+
📶 WiFi - Network scanner
+
🔵 Bluetooth - BT/BLE scanner
+
+
+
+
+
+
📟 Pager Mode
+
+ Decodes POCSAG and FLEX pager signals using RTL-SDR
+ Set frequency to local pager frequencies (common: 152-158 MHz)
+ Messages are displayed in real-time as they're decoded
+ Use presets for common pager frequencies
+
+
+
📡 433MHz Sensor Mode
+
+ Decodes wireless sensors on 433.92 MHz ISM band
+ Detects temperature, humidity, weather stations, tire pressure monitors
+ Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)
+ Device intelligence builds profiles of recurring devices
+
+
+
✈️ Aircraft Mode
+
+ Tracks aircraft via ADS-B using dump1090 or rtl_adsb
+ Interactive map with real OpenStreetMap tiles
+ Click aircraft markers to see callsign, altitude, speed, heading
+ Map auto-fits to show all tracked aircraft
+ Emergency squawk codes highlighted in red
+
+
+
🛰️ Satellite Mode
+
+ Track satellites using TLE (Two-Line Element) data
+ Add satellites manually or fetch from Celestrak by category
+ Categories: Amateur, Weather, ISS, Starlink, GPS, and more
+ View next pass predictions with elevation and duration
+ Monitor for Iridium satellite bursts
+
+
+
📶 WiFi Mode
+
+ Requires a WiFi adapter capable of monitor mode
+ Click "Enable Monitor" to put adapter in monitor mode
+ Scans all channels or lock to a specific channel
+ Detects drones by SSID patterns and manufacturer OUI
+ Rogue AP detection flags same SSID on multiple BSSIDs
+ Click network rows to target for deauth or handshake capture
+
+
+
🔵 Bluetooth Mode
+
+ Scans for classic Bluetooth and BLE devices
+ Shows device names, addresses, and signal strength
+ Manufacturer lookup from MAC address OUI
+ Radar visualization shows device proximity
+
+
+
+
+
+
Monitor Mode
+
+ Enable Monitor: Puts WiFi adapter in monitor mode for passive scanning
+ Kill Processes: Optional - stops NetworkManager/wpa_supplicant (may drop other connections)
+ Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)
+
+
+
Handshake Capture
+
+ Click "Capture" on a network to start targeted handshake capture
+ Status panel shows capture progress and file location
+ Use deauth to force clients to reconnect (only on authorized networks!)
+ Handshake files saved to /tmp/intercept_handshake_*.cap
+
+
+
Drone Detection
+
+ Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)
+ Also detected by manufacturer OUI in MAC address
+ Distance estimated from signal strength (approximate)
+ Click drone count in stats bar to see all detected drones
+
+
+
Rogue AP Detection
+
+ Flags networks where same SSID appears on multiple BSSIDs
+ Could indicate evil twin attack or legitimate multi-AP setup
+ Click rogue count to see which SSIDs are flagged
+
+
+
Proximity Alerts
+
+ Add MAC addresses to watch list for alerts when detected
+ Watch list persists in browser localStorage
+ Useful for tracking specific devices
+
+
+
Client Probe Analysis
+
+ Shows what networks client devices are looking for
+ Orange highlights indicate sensitive/private network names
+ Reveals user location history (home, work, hotels, airports)
+ Useful for security awareness and pen test reports
+
+
+
+
+
+
General Tips
+
+ Collapsible sections: Click any section header (▼) to collapse/expand
+ Sound alerts: Toggle sound on/off in settings for each mode
+ Export data: Use export buttons to save captured data as JSON
+ Device Intelligence: Tracks device patterns over time
+ Theme toggle: Click 🌙/☀️ button in header to switch dark/light mode
+
+
+
Keyboard Shortcuts
+
+ F1 - Open this help page
+ ? - Open help (when not typing in a field)
+ Escape - Close help and modal dialogs
+
+
+
Requirements
+
+ Pager/433MHz: RTL-SDR dongle, rtl_fm, multimon-ng, rtl_433
+ Aircraft: RTL-SDR dongle, dump1090 or rtl_adsb
+ Satellite: Internet connection for Celestrak (optional)
+ WiFi: Monitor-mode capable adapter, aircrack-ng suite
+ Bluetooth: Bluetooth adapter, hcitool/bluetoothctl
+ Run as root/sudo for full functionality
+
+
+
Legal Notice
+
+ Only use on networks and devices you own or have authorization to test
+ Passive monitoring may be legal; active attacks require authorization
+ Check local laws regarding radio frequency monitoring
+
+
+
+
+
+
+
+
+
×
+
🔧 Tool Dependencies
+
Check which tools are installed for each mode. ● = Installed, ● = Missing
+
+
+ Loading dependencies...
+
+
+
+
Quick Install (Debian/Ubuntu)
+
+
sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxtools
+
pip install skyfield flask
+
+
+
+
+
+
+
+
diff --git a/templates/satellite_dashboard.html b/templates/satellite_dashboard.html
new file mode 100644
index 0000000..1e45e9e
--- /dev/null
+++ b/templates/satellite_dashboard.html
@@ -0,0 +1,1547 @@
+
+
+
+
+
+ SATELLITE COMMAND // INTERCEPT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..d91c1f0
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+"""Pytest configuration and fixtures."""
+
+import pytest
+from app import app as flask_app
+from routes import register_blueprints
+
+
+@pytest.fixture
+def app():
+ """Create application for testing."""
+ register_blueprints(flask_app)
+ flask_app.config['TESTING'] = True
+ return flask_app
+
+
+@pytest.fixture
+def client(app):
+ """Create test client."""
+ return app.test_client()
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..03da64b
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,39 @@
+"""Tests for main application routes."""
+
+import pytest
+
+
+def test_index_page(client):
+ """Test that index page loads."""
+ response = client.get('/')
+ assert response.status_code == 200
+ assert b'INTERCEPT' in response.data
+
+
+def test_dependencies_endpoint(client):
+ """Test dependencies endpoint returns valid JSON."""
+ response = client.get('/dependencies')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert 'modes' in data
+ assert 'os' in data
+
+
+def test_devices_endpoint(client):
+ """Test devices endpoint returns list."""
+ response = client.get('/devices')
+ assert response.status_code == 200
+ data = response.get_json()
+ assert isinstance(data, list)
+
+
+def test_satellite_dashboard(client):
+ """Test satellite dashboard loads."""
+ response = client.get('/satellite/dashboard')
+ assert response.status_code == 200
+
+
+def test_adsb_dashboard(client):
+ """Test ADS-B dashboard loads."""
+ response = client.get('/adsb/dashboard')
+ assert response.status_code == 200
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..b977d7f
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,48 @@
+"""Tests for configuration module."""
+
+import os
+import pytest
+
+
+class TestConfigEnvVars:
+ """Tests for environment variable configuration."""
+
+ def test_default_values(self):
+ """Test that default values are set."""
+ from config import PORT, HOST, DEBUG
+
+ assert PORT == 5050
+ assert HOST == '0.0.0.0'
+ assert DEBUG is False
+
+ def test_env_override(self, monkeypatch):
+ """Test that environment variables override defaults."""
+ monkeypatch.setenv('INTERCEPT_PORT', '8080')
+ monkeypatch.setenv('INTERCEPT_DEBUG', 'true')
+
+ # Re-import to get new values
+ import importlib
+ import config
+ importlib.reload(config)
+
+ assert config.PORT == 8080
+ assert config.DEBUG is True
+
+ # Reset
+ monkeypatch.delenv('INTERCEPT_PORT', raising=False)
+ monkeypatch.delenv('INTERCEPT_DEBUG', raising=False)
+ importlib.reload(config)
+
+ def test_invalid_env_values(self, monkeypatch):
+ """Test that invalid env values fall back to defaults."""
+ monkeypatch.setenv('INTERCEPT_PORT', 'invalid')
+
+ import importlib
+ import config
+ importlib.reload(config)
+
+ # Should fall back to default
+ assert config.PORT == 5050
+
+ monkeypatch.delenv('INTERCEPT_PORT', raising=False)
+ importlib.reload(config)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..653946e
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,73 @@
+"""Tests for utility modules."""
+
+import pytest
+from utils.process import is_valid_mac, is_valid_channel
+from utils.dependencies import check_tool
+from data.oui import get_manufacturer
+
+
+class TestMacValidation:
+ """Tests for MAC address validation."""
+
+ def test_valid_mac(self):
+ """Test valid MAC addresses."""
+ assert is_valid_mac('AA:BB:CC:DD:EE:FF') is True
+ assert is_valid_mac('aa:bb:cc:dd:ee:ff') is True
+ assert is_valid_mac('00:11:22:33:44:55') is True
+
+ def test_invalid_mac(self):
+ """Test invalid MAC addresses."""
+ assert is_valid_mac('') is False
+ assert is_valid_mac(None) is False
+ assert is_valid_mac('invalid') is False
+ assert is_valid_mac('AA:BB:CC:DD:EE') is False
+ assert is_valid_mac('AA-BB-CC-DD-EE-FF') is False
+
+
+class TestChannelValidation:
+ """Tests for WiFi channel validation."""
+
+ def test_valid_channels(self):
+ """Test valid channel numbers."""
+ assert is_valid_channel(1) is True
+ assert is_valid_channel(6) is True
+ assert is_valid_channel(11) is True
+ assert is_valid_channel('36') is True
+ assert is_valid_channel(149) is True
+
+ def test_invalid_channels(self):
+ """Test invalid channel numbers."""
+ assert is_valid_channel(0) is False
+ assert is_valid_channel(-1) is False
+ assert is_valid_channel(201) is False
+ assert is_valid_channel(None) is False
+ assert is_valid_channel('invalid') is False
+
+
+class TestToolCheck:
+ """Tests for tool availability checking."""
+
+ def test_common_tools(self):
+ """Test checking for common tools."""
+ # These should return bool, regardless of whether installed
+ assert isinstance(check_tool('ls'), bool)
+ assert isinstance(check_tool('nonexistent_tool_12345'), bool)
+
+ def test_nonexistent_tool(self):
+ """Test that nonexistent tools return False."""
+ assert check_tool('nonexistent_tool_xyz_12345') is False
+
+
+class TestOuiLookup:
+ """Tests for OUI manufacturer lookup."""
+
+ def test_known_manufacturer(self):
+ """Test looking up known manufacturers."""
+ # Apple prefix
+ result = get_manufacturer('00:25:DB:AA:BB:CC')
+ assert result == 'Apple' or result == 'Unknown'
+
+ def test_unknown_manufacturer(self):
+ """Test looking up unknown manufacturer."""
+ result = get_manufacturer('FF:FF:FF:FF:FF:FF')
+ assert result == 'Unknown'
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..af4b50e
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,14 @@
+# Utility modules for INTERCEPT
+from .dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
+from .process import cleanup_stale_processes, is_valid_mac, is_valid_channel, detect_devices
+from .logging import (
+ get_logger,
+ app_logger,
+ pager_logger,
+ sensor_logger,
+ wifi_logger,
+ bluetooth_logger,
+ adsb_logger,
+ satellite_logger,
+ iridium_logger,
+)
diff --git a/utils/dependencies.py b/utils/dependencies.py
new file mode 100644
index 0000000..8ddea96
--- /dev/null
+++ b/utils/dependencies.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import logging
+import shutil
+from typing import Any
+
+logger = logging.getLogger('intercept.dependencies')
+
+
+def check_tool(name: str) -> bool:
+ """Check if a tool is installed."""
+ return shutil.which(name) is not None
+
+
+# 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/EliasOewornal/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',
+ 'brew': 'brew install dump1090-mutability',
+ 'manual': 'https://github.com/flightaware/dump1090'
+ },
+ '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'
+ }
+ }
+ }
+ },
+ '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
+ }
+ }
+ },
+ 'iridium': {
+ 'name': 'Iridium Monitoring',
+ 'tools': {
+ 'iridium-extractor': {
+ 'required': False,
+ 'description': 'Iridium burst extractor',
+ 'install': {
+ 'manual': 'https://github.com/muccc/gr-iridium'
+ }
+ }
+ }
+ }
+}
+
+
+def check_all_dependencies() -> dict[str, dict[str, Any]]:
+ """Check all tool dependencies and return status."""
+ results: dict[str, dict[str, Any]] = {}
+
+ for mode, config in TOOL_DEPENDENCIES.items():
+ mode_result = {
+ 'name': config['name'],
+ 'tools': {},
+ 'ready': True,
+ 'missing_required': []
+ }
+
+ for tool, tool_config in config['tools'].items():
+ # Check if it's a Python module
+ if tool_config.get('python_module'):
+ try:
+ __import__(tool)
+ installed = True
+ except Exception as e:
+ logger.debug(f"Failed to import {tool}: {type(e).__name__}: {e}")
+ installed = False
+ else:
+ # Check for 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']
+ }
+
+ if tool_config['required'] and not installed:
+ mode_result['ready'] = False
+ mode_result['missing_required'].append(tool)
+
+ results[mode] = mode_result
+
+ return results
diff --git a/utils/logging.py b/utils/logging.py
new file mode 100644
index 0000000..c1e2738
--- /dev/null
+++ b/utils/logging.py
@@ -0,0 +1,30 @@
+"""Logging utilities for intercept application."""
+
+from __future__ import annotations
+
+import logging
+import sys
+
+from config import LOG_LEVEL, LOG_FORMAT
+
+
+def get_logger(name: str) -> logging.Logger:
+ """Get a configured logger for a module."""
+ logger = logging.getLogger(name)
+ if not logger.handlers:
+ handler = logging.StreamHandler(sys.stderr)
+ handler.setFormatter(logging.Formatter(LOG_FORMAT))
+ logger.addHandler(handler)
+ logger.setLevel(LOG_LEVEL)
+ return logger
+
+
+# Pre-configured loggers for each module
+app_logger = get_logger('intercept')
+pager_logger = get_logger('intercept.pager')
+sensor_logger = get_logger('intercept.sensor')
+wifi_logger = get_logger('intercept.wifi')
+bluetooth_logger = get_logger('intercept.bluetooth')
+adsb_logger = get_logger('intercept.adsb')
+satellite_logger = get_logger('intercept.satellite')
+iridium_logger = get_logger('intercept.iridium')
diff --git a/utils/process.py b/utils/process.py
new file mode 100644
index 0000000..4c4c8de
--- /dev/null
+++ b/utils/process.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import subprocess
+import re
+from typing import Any
+
+from .dependencies import check_tool
+
+
+def cleanup_stale_processes() -> None:
+ """Kill any stale processes from previous runs (but not system services)."""
+ # Note: dump1090 is NOT included here as users may run it as a system service
+ processes_to_kill = ['rtl_adsb', 'rtl_433', 'multimon-ng', 'rtl_fm']
+ for proc_name in processes_to_kill:
+ try:
+ subprocess.run(['pkill', '-9', proc_name], capture_output=True)
+ except (subprocess.SubprocessError, OSError):
+ pass
+
+
+def is_valid_mac(mac: str | None) -> bool:
+ """Validate MAC address format."""
+ if not mac:
+ return False
+ return bool(re.match(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', mac))
+
+
+def is_valid_channel(channel: str | int | None) -> bool:
+ """Validate WiFi channel number."""
+ try:
+ ch = int(channel) # type: ignore[arg-type]
+ return 1 <= ch <= 200
+ except (ValueError, TypeError):
+ return False
+
+
+def detect_devices() -> list[dict[str, Any]]:
+ """Detect RTL-SDR devices."""
+ devices: list[dict[str, Any]] = []
+
+ if not check_tool('rtl_test'):
+ return devices
+
+ try:
+ result = subprocess.run(
+ ['rtl_test', '-t'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ output = result.stderr + result.stdout
+
+ # Parse device info
+ device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
+
+ for line in output.split('\n'):
+ line = line.strip()
+ match = re.match(device_pattern, line)
+ if match:
+ devices.append({
+ 'index': int(match.group(1)),
+ 'name': match.group(2).strip().rstrip(','),
+ 'serial': match.group(3) or 'N/A'
+ })
+
+ if not devices:
+ found_match = re.search(r'Found (\d+) device', output)
+ if found_match:
+ count = int(found_match.group(1))
+ for i in range(count):
+ devices.append({
+ 'index': i,
+ 'name': f'RTL-SDR Device {i}',
+ 'serial': 'Unknown'
+ })
+
+ except Exception:
+ pass
+
+ return devices