diff --git a/.gitignore b/.gitignore index adf2ebd..dcac9f3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ Thumbs.db dist/ build/ *.egg-info/ + +# Package manager lock files +uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f6fe950 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +All notable changes to INTERCEPT will be documented in this file. + +## [2.0.0] - 2025-01-06 + +### Added +- **Listening Post Mode** - New frequency scanner with automatic signal detection + - Scans frequency ranges and stops on detected signals + - Real-time audio monitoring with ffmpeg integration + - Skip button to continue scanning after signal detection + - Configurable dwell time, squelch, and step size + - Preset frequency bands (FM broadcast, Air band, Marine, etc.) + - Activity log of detected signals +- **Aircraft Dashboard Improvements** + - Dependency warning when rtl_fm or ffmpeg not installed + - Auto-restart audio when switching frequencies + - Fixed toolbar overflow with custom frequency input +- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer +- **Settings System** - SQLite-based persistent settings storage +- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database + +### Changed +- **Documentation Overhaul** + - Simplified README with clear macOS and Debian installation steps + - Added Docker installation option + - Complete tool reference table in HARDWARE.md + - Removed redundant/confusing content +- **Setup Script Rewrite** + - Full macOS support with Homebrew auto-installation + - Improved Debian/Ubuntu package detection + - Added ffmpeg to tool checks + - Better error messages with platform-specific install commands +- **Dockerfile Updated** + - Added ffmpeg for Listening Post audio encoding + - Added dump1090 with fallback for different package names + +### Fixed +- SoapySDR device detection for RTL-SDR and HackRF +- Aircraft dashboard toolbar layout when using custom frequency input +- Frequency switching now properly stops/restarts audio + +### Technical +- Added `utils/constants.py` for centralized configuration values +- Added `utils/database.py` for SQLite settings storage +- Added `utils/correlation.py` for device correlation logic +- Added `routes/listening_post.py` for scanner endpoints +- Added `routes/settings.py` for settings API +- Added `routes/correlation.py` for correlation API + +--- + +## [1.2.0] - 2024-12-XX + +### Added +- Airspy SDR support +- GPS coordinate persistence +- SoapySDR device detection improvements + +### Fixed +- RTL-SDR and HackRF detection via SoapySDR + +--- + +## [1.1.0] - 2024-XX-XX + +### Added +- Satellite tracking with TLE data +- Full-screen dashboard for aircraft radar +- Full-screen dashboard for satellite tracking + +--- + +## [1.0.0] - 2024-XX-XX + +### Initial Release +- Pager decoding (POCSAG/FLEX) +- 433MHz sensor decoding +- ADS-B aircraft tracking +- WiFi reconnaissance +- Bluetooth scanning +- Multi-SDR support (RTL-SDR, LimeSDR, HackRF) diff --git a/Dockerfile b/Dockerfile index 1c88b70..d702409 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,24 +3,46 @@ FROM python:3.11-slim +LABEL maintainer="INTERCEPT Project" +LABEL description="Signal Intelligence Platform for SDR monitoring" + # Set working directory WORKDIR /app -# Install system dependencies for RTL-SDR tools +# Install system dependencies for SDR tools RUN apt-get update && apt-get install -y --no-install-recommends \ # RTL-SDR tools rtl-sdr \ + librtlsdr-dev \ + libusb-1.0-0-dev \ # 433MHz decoder rtl-433 \ # Pager decoder multimon-ng \ + # Audio tools for Listening Post + ffmpeg \ # WiFi tools (aircrack-ng suite) aircrack-ng \ + iw \ + wireless-tools \ # Bluetooth tools bluez \ - # Cleanup + bluetooth \ + # GPS support + gpsd-clients \ + # Utilities + curl \ + procps \ && rm -rf /var/lib/apt/lists/* +# Install dump1090 for ADS-B (package name varies by distribution) +RUN apt-get update && \ + (apt-get install -y --no-install-recommends dump1090-mutability || \ + apt-get install -y --no-install-recommends dump1090-fa || \ + apt-get install -y --no-install-recommends dump1090 || \ + echo "Note: dump1090 not available in repos, ADS-B features limited") && \ + rm -rf /var/lib/apt/lists/* + # Copy requirements first for better caching COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -28,13 +50,21 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application code COPY . . +# Create data directory for persistence +RUN mkdir -p /app/data + # Expose web interface port EXPOSE 5050 # Environment variables with defaults ENV INTERCEPT_HOST=0.0.0.0 \ INTERCEPT_PORT=5050 \ - INTERCEPT_LOG_LEVEL=INFO + INTERCEPT_LOG_LEVEL=INFO \ + PYTHONUNBUFFERED=1 + +# Health check using the new endpoint +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -sf http://localhost:5050/health || exit 1 # Run the application CMD ["python", "intercept.py"] diff --git a/README.md b/README.md index c6d1111..d4f9ee3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
Signal Intelligence Platform
- A web-based front-end for signal intelligence tools.
+ A web-based interface for software-defined radio tools.
@@ -17,29 +17,23 @@ --- -## What is INTERCEPT? - -INTERCEPT provides a unified web interface for signal intelligence tools: +## Features - **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng -- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433 -- **Aircraft Tracking** - ADS-B via dump1090 with real-time map +- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433 +- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar +- **Listening Post** - Frequency scanner with audio monitoring - **Satellite Tracking** - Pass prediction using TLE data -- **WiFi Recon** - Monitor mode scanning via aircrack-ng +- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng - **Bluetooth Scanning** - Device discovery and tracker detection --- -## Community +## Installation / Debian / Ubuntu / MacOS -
- Join our Discord -
- ---- - -## Quick Start +``` +**1. Clone and run:** ```bash git clone https://github.com/smittix/intercept.git cd intercept @@ -47,72 +41,67 @@ cd intercept sudo python3 intercept.py ``` -Open http://localhost:5050 in your browser. - -## Usage of Black Formatter -```bash -uv run black . # If you use UV -black . # For Python -``` - -+ Join our Discord +
--- ## Documentation -| Document | Description | -|----------|-------------| -| [Features](docs/FEATURES.md) | Complete feature list for all modules | -| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode | -| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues | -| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation | - ---- - -## Development - -This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools. - -Contributions and improvements welcome. +- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode +- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup +- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions --- ## Disclaimer -**This software is for educational purposes only.** +This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools. + +**This software is for educational and authorized testing purposes only.** - Only use with proper authorization - Intercepting communications without consent may be illegal -- WiFi/Bluetooth attacks require explicit permission - You are responsible for compliance with applicable laws --- @@ -135,3 +124,5 @@ Created by **smittix** - [GitHub](https://github.com/smittix) [Leaflet.js](https://leafletjs.com/) | [Celestrak](https://celestrak.org/) + + diff --git a/app.py b/app.py index 4c23541..27fcdc9 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,17 @@ from config import VERSION from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.process import cleanup_stale_processes from utils.sdr import SDRFactory +from utils.cleanup import DataStore, cleanup_manager +from utils.constants import ( + MAX_AIRCRAFT_AGE_SECONDS, + MAX_WIFI_NETWORK_AGE_SECONDS, + MAX_BT_DEVICE_AGE_SECONDS, + QUEUE_MAX_SIZE, +) + +# Track application start time for uptime calculation +import time as _time +_app_start_time = _time.time() # Create Flask app @@ -40,32 +51,32 @@ app = Flask(__name__) # Pager decoder current_process = None -output_queue = queue.Queue() +output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) process_lock = threading.Lock() # RTL_433 sensor sensor_process = None -sensor_queue = queue.Queue() +sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) sensor_lock = threading.Lock() # WiFi wifi_process = None -wifi_queue = queue.Queue() +wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) wifi_lock = threading.Lock() # Bluetooth bt_process = None -bt_queue = queue.Queue() +bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) bt_lock = threading.Lock() # ADS-B aircraft adsb_process = None -adsb_queue = queue.Queue() +adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) adsb_lock = threading.Lock() # Satellite/Iridium satellite_process = None -satellite_queue = queue.Queue() +satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) satellite_lock = threading.Lock() # ============================================ @@ -76,23 +87,30 @@ satellite_lock = threading.Lock() logging_enabled = False log_file_path = 'pager_messages.log' -# WiFi state +# WiFi state - using DataStore for automatic cleanup wifi_monitor_interface = None -wifi_networks = {} # BSSID -> network info -wifi_clients = {} # Client MAC -> client info -wifi_handshakes = [] # Captured handshakes +wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks') +wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients') +wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned) -# Bluetooth state +# Bluetooth state - using DataStore for automatic cleanup bt_interface = None -bt_devices = {} # MAC -> device info -bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons) -bt_services = {} # MAC -> list of services +bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices') +bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons') +bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested) -# Aircraft (ADS-B) state -adsb_aircraft = {} # ICAO hex -> aircraft info +# Aircraft (ADS-B) state - using DataStore for automatic cleanup +adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft') # Satellite state -satellite_passes = [] # Predicted satellite passes +satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated) + +# Register data stores with cleanup manager +cleanup_manager.register(wifi_networks) +cleanup_manager.register(wifi_clients) +cleanup_manager.register(bt_devices) +cleanup_manager.register(bt_beacons) +cleanup_manager.register(adsb_aircraft) # ============================================ @@ -130,15 +148,16 @@ def get_dependencies() -> Response: # Determine OS for install instructions system = platform.system().lower() if system == 'darwin': - install_method = 'brew' + pkg_manager = 'brew' elif system == 'linux': - install_method = 'apt' + pkg_manager = 'apt' else: - install_method = 'manual' + pkg_manager = 'manual' return jsonify({ + 'status': 'success', 'os': system, - 'install_method': install_method, + 'pkg_manager': pkg_manager, 'modes': results }) @@ -159,14 +178,14 @@ def export_aircraft() -> Response: for icao, ac in adsb_aircraft.items(): writer.writerow([ icao, - ac.get('callsign', ''), - ac.get('altitude', ''), - ac.get('speed', ''), - ac.get('heading', ''), - ac.get('lat', ''), - ac.get('lon', ''), - ac.get('squawk', ''), - ac.get('lastSeen', '') + ac.get('callsign', '') if isinstance(ac, dict) else '', + ac.get('altitude', '') if isinstance(ac, dict) else '', + ac.get('speed', '') if isinstance(ac, dict) else '', + ac.get('heading', '') if isinstance(ac, dict) else '', + ac.get('lat', '') if isinstance(ac, dict) else '', + ac.get('lon', '') if isinstance(ac, dict) else '', + ac.get('squawk', '') if isinstance(ac, dict) else '', + ac.get('lastSeen', '') if isinstance(ac, dict) else '' ]) response = Response(output.getvalue(), mimetype='text/csv') @@ -175,7 +194,7 @@ def export_aircraft() -> Response: else: return jsonify({ 'timestamp': __import__('datetime').datetime.utcnow().isoformat(), - 'aircraft': list(adsb_aircraft.values()) + 'aircraft': adsb_aircraft.values() }) @@ -195,11 +214,11 @@ def export_wifi() -> Response: for bssid, net in wifi_networks.items(): writer.writerow([ bssid, - net.get('ssid', ''), - net.get('channel', ''), - net.get('signal', ''), - net.get('encryption', ''), - net.get('clients', 0) + net.get('ssid', '') if isinstance(net, dict) else '', + net.get('channel', '') if isinstance(net, dict) else '', + net.get('signal', '') if isinstance(net, dict) else '', + net.get('encryption', '') if isinstance(net, dict) else '', + net.get('clients', 0) if isinstance(net, dict) else 0 ]) response = Response(output.getvalue(), mimetype='text/csv') @@ -208,8 +227,8 @@ def export_wifi() -> Response: else: return jsonify({ 'timestamp': __import__('datetime').datetime.utcnow().isoformat(), - 'networks': list(wifi_networks.values()), - 'clients': list(wifi_clients.values()) + 'networks': wifi_networks.values(), + 'clients': wifi_clients.values() }) @@ -229,11 +248,11 @@ def export_bluetooth() -> Response: for mac, dev in bt_devices.items(): writer.writerow([ mac, - dev.get('name', ''), - dev.get('rssi', ''), - dev.get('type', ''), - dev.get('manufacturer', ''), - dev.get('lastSeen', '') + dev.get('name', '') if isinstance(dev, dict) else '', + dev.get('rssi', '') if isinstance(dev, dict) else '', + dev.get('type', '') if isinstance(dev, dict) else '', + dev.get('manufacturer', '') if isinstance(dev, dict) else '', + dev.get('lastSeen', '') if isinstance(dev, dict) else '' ]) response = Response(output.getvalue(), mimetype='text/csv') @@ -242,11 +261,35 @@ def export_bluetooth() -> Response: else: return jsonify({ 'timestamp': __import__('datetime').datetime.utcnow().isoformat(), - 'devices': list(bt_devices.values()), - 'beacons': list(bt_beacons.values()) + 'devices': bt_devices.values(), + 'beacons': bt_beacons.values() }) +@app.route('/health') +def health_check() -> Response: + """Health check endpoint for monitoring.""" + import time + return jsonify({ + 'status': 'healthy', + 'version': VERSION, + 'uptime_seconds': round(time.time() - _app_start_time, 2), + 'processes': { + 'pager': current_process is not None and (current_process.poll() is None if current_process else False), + 'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False), + 'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False), + 'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False), + 'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False), + }, + 'data': { + 'aircraft_count': len(adsb_aircraft), + 'wifi_networks_count': len(wifi_networks), + 'wifi_clients_count': len(wifi_clients), + 'bt_devices_count': len(bt_devices), + } + }) + + @app.route('/killall', methods=['POST']) def kill_all() -> Response: """Kill all decoder and WiFi processes.""" @@ -343,6 +386,13 @@ def main() -> None: # Clean up any stale processes from previous runs cleanup_stale_processes() + # Initialize database for settings storage + from utils.database import init_db + init_db() + + # Start automatic cleanup of stale data entries + cleanup_manager.start() + # Register blueprints from routes import register_blueprints register_blueprints(app) diff --git a/config.py b/config.py index d736955..fc37be9 100644 --- a/config.py +++ b/config.py @@ -7,7 +7,7 @@ import os import sys # Application version -VERSION = "1.2.0" +VERSION = "2.0.0" def _get_env(key: str, default: str) -> str: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..674015b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# INTERCEPT - Signal Intelligence Platform +# Docker Compose configuration for easy deployment + +services: + intercept: + build: . + container_name: intercept + ports: + - "5050:5050" + # Privileged mode required for USB SDR device access + # Alternatively, use device mapping (see below) + privileged: true + # USB device mapping (alternative to privileged mode) + # devices: + # - /dev/bus/usb:/dev/bus/usb + volumes: + # Persist data directory + - ./data:/app/data + # Optional: mount logs directory + # - ./logs:/app/logs + environment: + - INTERCEPT_HOST=0.0.0.0 + - INTERCEPT_PORT=5050 + - INTERCEPT_LOG_LEVEL=INFO + # Network mode for WiFi scanning (requires host network) + # network_mode: host + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:5050/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +# Optional: Add volume for persistent SQLite database +# volumes: +# intercept-data: diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 2bde523..75bbbee 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -1,93 +1,75 @@ -# Hardware & Installation +# Hardware & Advanced Setup ## Supported SDR Hardware -| Hardware | Frequency Range | Gain Range | TX | Price | Notes | -|----------|-----------------|------------|-----|-------|-------| -| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly | -| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR | -| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR | +| Hardware | Frequency Range | Price | Notes | +|----------|-----------------|-------|-------| +| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners | +| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR | +| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR | -INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI. +INTERCEPT automatically detects connected devices. -## Requirements +--- -### Hardware -- **SDR Device** - RTL-SDR, LimeSDR, or HackRF -- **WiFi adapter** capable of monitor mode (for WiFi features) -- **Bluetooth adapter** (for Bluetooth features) -- **GPS dongle** (optional, for precise location) - -### Software -- **Python 3.9+** required -- External tools (see installation below) - -## Tool Installation - -### Core SDR Tools - -| Tool | macOS | Ubuntu/Debian | Purpose | -|------|-------|---------------|---------| -| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support | -| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding | -| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors | -| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft | -| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance | -| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning | - -### LimeSDR / HackRF Support (Optional) - -| Tool | macOS | Ubuntu/Debian | Purpose | -|------|-------|---------------|---------| -| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction | -| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support | -| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support | -| readsb | Build from source | Build from source | ADS-B with SoapySDR | - -> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver. - -## Quick Install Commands - -### Ubuntu/Debian -> [!NOTE] -> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it. -```bash -# Core tools -sudo apt update -sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth - -# LimeSDR (optional) -sudo apt install soapysdr-tools limesuite soapysdr-module-lms7 - -# HackRF (optional) -sudo apt install hackrf soapysdr-module-hackrf -``` +## Quick Install ### macOS (Homebrew) -```bash -# Core tools -brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng -# LimeSDR (optional) +```bash +# Install Homebrew if needed +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Core tools (required) +brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg + +# ADS-B aircraft tracking +brew install dump1090-mutability + +# WiFi tools (optional) +brew install aircrack-ng + +# LimeSDR support (optional) brew install soapysdr limesuite soapylms7 -# HackRF (optional) +# HackRF support (optional) brew install hackrf soapyhackrf ``` -### Arch Linux -```bash -# Core tools -sudo pacman -S rtl-sdr multimon-ng -yay -S rtl_433 dump1090 +### Debian / Ubuntu / Raspberry Pi OS -# LimeSDR/HackRF (optional) -sudo pacman -S soapysdr limesuite hackrf +```bash +# Update package lists +sudo apt update + +# Core tools (required) +sudo apt install -y python3 python3-pip python3-venv python3-skyfield +sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg + +# ADS-B aircraft tracking +sudo apt install -y dump1090-mutability +# Alternative: dump1090-fa (FlightAware version) + +# WiFi tools (optional) +sudo apt install -y aircrack-ng + +# Bluetooth tools (optional) +sudo apt install -y bluez bluetooth + +# LimeSDR support (optional) +sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7 + +# HackRF support (optional) +sudo apt install -y hackrf soapysdr-module-hackrf ``` -## Linux udev Rules +--- -If your SDR isn't detected, add udev rules: +## RTL-SDR Setup (Linux) + +### Add udev rules + +If your RTL-SDR isn't detected, create udev rules: ```bash sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF @@ -99,9 +81,9 @@ sudo udevadm control --reload-rules sudo udevadm trigger ``` -Then unplug and replug your device. +Then unplug and replug your RTL-SDR. -## Blacklist DVB-T Driver (Linux) +### Blacklist DVB-T driver The default DVB-T driver conflicts with rtl-sdr: @@ -110,57 +92,120 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf sudo modprobe -r dvb_usb_rtl28xxu ``` +--- + ## Verify Installation -Check what's installed: +### Check dependencies ```bash python3 intercept.py --check-deps ``` -Test SDR detection: +### Test SDR detection ```bash # RTL-SDR rtl_test -# LimeSDR/HackRF +# LimeSDR/HackRF (via SoapySDR) SoapySDRUtil --find ``` -## Python Dependencies +--- -### Option 1: setup.sh (Recommended) +## Python Environment + +### Using setup.sh (Recommended) ```bash ./setup.sh ``` -This creates a virtual environment and installs dependencies automatically. -### Option 2: pip +This automatically: +- Detects your OS +- Creates a virtual environment if needed (for PEP 668 systems) +- Installs Python dependencies +- Checks for required tools + +### Manual setup ```bash -python3 -m venv .venv -source .venv/bin/activate +python3 -m venv venv +source venv/bin/activate pip install -r requirements.txt ``` -### Option 3: uv (Fast alternative) -[uv](https://github.com/astral-sh/uv) is a fast Python package installer. +--- + +## Running INTERCEPT + +After installation: ```bash -# Install uv (if not already installed) -curl -LsSf https://astral.sh/uv/install.sh | sh +# Standard +sudo python3 intercept.py -# Create venv and install deps -uv venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate -uv sync +# With virtual environment +sudo venv/bin/python intercept.py -# Or just install deps in existing environment -uv pip install -r requirements.txt +# Custom port +INTERCEPT_PORT=8080 sudo python3 intercept.py ``` -### Option 4: pip with pyproject.toml -```bash -pip install . # Install as package -pip install -e . # Install in editable mode (for development) -pip install -e ".[dev]" # Include dev dependencies -``` +Open **http://localhost:5050** in your browser. + +--- + +## Complete Tool Reference + +| Tool | Package (Debian) | Package (macOS) | Required For | +|------|------------------|-----------------|--------------| +| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post | +| `rtl_test` | rtl-sdr | librtlsdr | SDR detection | +| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding | +| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors | +| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking | +| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio | +| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode | +| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning | +| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) | +| `hcitool` | bluez | N/A | Bluetooth scanning | +| `bluetoothctl` | bluez | N/A | Bluetooth control | +| `hciconfig` | bluez | N/A | Bluetooth config | + +### Optional tools: +| Tool | Package (Debian) | Package (macOS) | Purpose | +|------|------------------|-----------------|---------| +| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder | +| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support | +| `LimeUtil` | limesuite | limesuite | LimeSDR native tools | +| `hackrf_info` | hackrf | hackrf | HackRF native tools | + +### Python dependencies (requirements.txt): +| Package | Purpose | +|---------|---------| +| `flask` | Web server | +| `skyfield` | Satellite tracking | + +--- + +## dump1090 Notes + +### Package names vary by distribution: +- `dump1090-mutability` - Most common +- `dump1090-fa` - FlightAware version (recommended) +- `dump1090` - Generic + +### Not in repositories (Debian Trixie)? + +Install FlightAware's version: +https://flightaware.com/adsb/piaware/install + +Or build from source: +https://github.com/flightaware/dump1090 + +--- + +## Notes + +- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed +- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux +- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index d3f6451..7e4a922 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -101,6 +101,7 @@ Then unplug and replug your RTL-SDR. 3. Check for other applications: `lsof | grep rtl` ### LimeSDR/HackRF not detected +Ensure the correct SoapySDR module for your hardware is installed first 1. Verify SoapySDR is installed: `SoapySDRUtil --info` 2. Check driver is loaded: `SoapySDRUtil --find` @@ -146,21 +147,6 @@ Run with sudo or add your user to the bluetooth group: sudo usermod -a -G bluetooth $USER ``` -## GPS Issues - -### GPS dongle not detected - -1. Install pyserial: `pip install pyserial` -2. Check device is connected: - - Linux: `ls /dev/ttyUSB* /dev/ttyACM*` - - macOS: `ls /dev/tty.usb*` -3. Add user to dialout group (Linux): - ```bash - sudo usermod -a -G dialout $USER - ``` -4. Most GPS dongles use 9600 baud (default in INTERCEPT) -5. GPS needs clear sky view to get a fix - ## Decoding Issues ### No messages appearing (Pager mode) @@ -170,15 +156,20 @@ sudo usermod -a -G bluetooth $USER 3. Check pager services are active in your area 4. Ensure antenna is connected +### Cannot install dump1090 in Debian (ADS-B mode) + +On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you. + ### No aircraft appearing (ADS-B mode) -1. Verify dump1090 or readsb is installed +1. Verify dump1090 is installed 2. Check antenna is connected (1090 MHz antenna recommended) 3. Ensure clear view of sky -4. Set correct observer location for range calculations +4. Set correct observer location for range calculations or use gpsd ### Satellite passes not calculating -1. Ensure skyfield is installed: `pip install skyfield` +1. Ensure skyfield is installed: `apt install python3-skyfield` 2. Check TLE data is valid and recent 3. Verify observer location is set correctly + diff --git a/instance/intercept.db b/instance/intercept.db new file mode 100644 index 0000000..15137ad Binary files /dev/null and b/instance/intercept.db differ diff --git a/pyproject.toml b/pyproject.toml index 8e66a5e..9ea365b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "intercept" -version = "1.2.0" +version = "2.0.0" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" readme = "README.md" requires-python = ">=3.9" diff --git a/routes/__init__.py b/routes/__init__.py index c25ee36..5b6a326 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -9,6 +9,9 @@ def register_blueprints(app): from .adsb import adsb_bp from .satellite import satellite_bp from .gps import gps_bp + from .settings import settings_bp + from .correlation import correlation_bp + from .listening_post import listening_post_bp app.register_blueprint(pager_bp) app.register_blueprint(sensor_bp) @@ -17,3 +20,6 @@ def register_blueprints(app): app.register_blueprint(adsb_bp) app.register_blueprint(satellite_bp) app.register_blueprint(gps_bp) + app.register_blueprint(settings_bp) + app.register_blueprint(correlation_bp) + app.register_blueprint(listening_post_bp) diff --git a/routes/adsb.py b/routes/adsb.py index 81cd1c6..230c40a 100644 --- a/routes/adsb.py +++ b/routes/adsb.py @@ -22,6 +22,20 @@ from utils.validation import ( ) from utils.sse import format_sse from utils.sdr import SDRFactory, SDRType +from utils.constants import ( + ADSB_SBS_PORT, + ADSB_TERMINATE_TIMEOUT, + PROCESS_TERMINATE_TIMEOUT, + SBS_SOCKET_TIMEOUT, + SBS_RECONNECT_DELAY, + SOCKET_BUFFER_SIZE, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + SOCKET_CONNECT_TIMEOUT, + ADSB_UPDATE_INTERVAL, + DUMP1090_START_WAIT, +) +from utils import aircraft_db adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb') @@ -30,6 +44,14 @@ adsb_using_service = False adsb_connected = False adsb_messages_received = 0 adsb_last_message_time = None +adsb_bytes_received = 0 +adsb_lines_received = 0 + +# Track ICAOs already looked up in aircraft database (avoid repeated lookups) +_looked_up_icaos: set[str] = set() + +# Load aircraft database at module init +aircraft_db.load_database() # Common installation paths for dump1090 (when not in PATH) DUMP1090_PATHS = [ @@ -63,22 +85,22 @@ def find_dump1090(): def check_dump1090_service(): - """Check if dump1090 SBS port (30003) is available.""" + """Check if dump1090 SBS port is available.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) - result = sock.connect_ex(('localhost', 30003)) + sock.settimeout(SOCKET_CONNECT_TIMEOUT) + result = sock.connect_ex(('localhost', ADSB_SBS_PORT)) sock.close() if result == 0: - return 'localhost:30003' - except Exception: + return f'localhost:{ADSB_SBS_PORT}' + except OSError: pass return None def parse_sbs_stream(service_addr): - """Parse SBS format data from dump1090 port 30003.""" - global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time + """Parse SBS format data from dump1090 SBS port.""" + global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received host, port = service_addr.split(':') port = int(port) @@ -90,7 +112,7 @@ def parse_sbs_stream(service_addr): while adsb_using_service: try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) + sock.settimeout(SBS_SOCKET_TIMEOUT) sock.connect((host, port)) adsb_connected = True logger.info("Connected to SBS stream") @@ -98,12 +120,16 @@ def parse_sbs_stream(service_addr): buffer = "" last_update = time.time() pending_updates = set() + adsb_bytes_received = 0 + adsb_lines_received = 0 while adsb_using_service: try: - data = sock.recv(4096).decode('utf-8', errors='ignore') + data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore') if not data: + logger.warning("SBS connection closed (no data)") break + adsb_bytes_received += len(data) buffer += data while '\n' in buffer: @@ -112,8 +138,15 @@ def parse_sbs_stream(service_addr): if not line: continue + adsb_lines_received += 1 + # Log first few lines for debugging + if adsb_lines_received <= 3: + logger.info(f"SBS line {adsb_lines_received}: {line[:100]}") + parts = line.split(',') if len(parts) < 11 or parts[0] != 'MSG': + if adsb_lines_received <= 5: + logger.debug(f"Skipping non-MSG line: {line[:50]}") continue msg_type = parts[1] @@ -121,7 +154,19 @@ def parse_sbs_stream(service_addr): if not icao: continue - aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao}) + aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao} + + # Look up aircraft type from database (once per ICAO) + if icao not in _looked_up_icaos: + _looked_up_icaos.add(icao) + db_info = aircraft_db.lookup(icao) + if db_info: + if db_info['registration']: + aircraft['registration'] = db_info['registration'] + if db_info['type_code']: + aircraft['type_code'] = db_info['type_code'] + if db_info['type_desc']: + aircraft['type_desc'] = db_info['type_desc'] if msg_type == '1' and len(parts) > 10: callsign = parts[10].strip() @@ -141,7 +186,7 @@ def parse_sbs_stream(service_addr): except (ValueError, TypeError): pass - elif msg_type == '4' and len(parts) > 13: + elif msg_type == '4' and len(parts) > 16: if parts[12]: try: aircraft['speed'] = int(float(parts[12])) @@ -152,6 +197,11 @@ def parse_sbs_stream(service_addr): aircraft['heading'] = int(float(parts[13])) except (ValueError, TypeError): pass + if parts[16]: + try: + aircraft['vertical_rate'] = int(float(parts[16])) + except (ValueError, TypeError): + pass elif msg_type == '5' and len(parts) > 11: if parts[10]: @@ -168,13 +218,13 @@ def parse_sbs_stream(service_addr): if parts[17]: aircraft['squawk'] = parts[17] - app_module.adsb_aircraft[icao] = aircraft + app_module.adsb_aircraft.set(icao, aircraft) pending_updates.add(icao) adsb_messages_received += 1 adsb_last_message_time = time.time() now = time.time() - if now - last_update >= 1.0: + if now - last_update >= ADSB_UPDATE_INTERVAL: for update_icao in pending_updates: if update_icao in app_module.adsb_aircraft: app_module.adsb_queue.put({ @@ -189,10 +239,10 @@ def parse_sbs_stream(service_addr): sock.close() adsb_connected = False - except Exception as e: + except OSError as e: adsb_connected = False logger.warning(f"SBS connection error: {e}, reconnecting...") - time.sleep(2) + time.sleep(SBS_RECONNECT_DELAY) adsb_connected = False logger.info("SBS stream parser stopped") @@ -200,25 +250,52 @@ def parse_sbs_stream(service_addr): @adsb_bp.route('/tools') def check_adsb_tools(): - """Check for ADS-B decoding tools.""" + """Check for ADS-B decoding tools and hardware.""" + # Check available decoders + has_dump1090 = find_dump1090() is not None + has_readsb = shutil.which('readsb') is not None + has_rtl_adsb = shutil.which('rtl_adsb') is not None + + # Check what SDR hardware is detected + devices = SDRFactory.detect_devices() + has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices) + has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices) + soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)] + + # Determine if readsb is needed but missing + needs_readsb = has_soapy_sdr and not has_readsb + return jsonify({ - 'dump1090': find_dump1090() is not None, - 'rtl_adsb': shutil.which('rtl_adsb') is not None + 'dump1090': has_dump1090, + 'readsb': has_readsb, + 'rtl_adsb': has_rtl_adsb, + 'has_rtlsdr': has_rtlsdr, + 'has_soapy_sdr': has_soapy_sdr, + 'soapy_types': soapy_types, + 'needs_readsb': needs_readsb }) @adsb_bp.route('/status') def adsb_status(): """Get ADS-B tracking status for debugging.""" + # Check if dump1090 process is still running + dump1090_running = False + if app_module.adsb_process: + dump1090_running = app_module.adsb_process.poll() is None + return jsonify({ 'tracking_active': adsb_using_service, 'connected_to_sbs': adsb_connected, 'messages_received': adsb_messages_received, + 'bytes_received': adsb_bytes_received, + 'lines_received': adsb_lines_received, 'last_message_time': adsb_last_message_time, 'aircraft_count': len(app_module.adsb_aircraft), 'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data 'queue_size': app_module.adsb_queue.qsize(), 'dump1090_path': find_dump1090(), + 'dump1090_running': dump1090_running, 'port_30003_open': check_dump1090_service() is not None }) @@ -291,9 +368,12 @@ def start_adsb(): if app_module.adsb_process: try: app_module.adsb_process.terminate() - app_module.adsb_process.wait(timeout=2) - except Exception: - pass + app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT) + except (subprocess.TimeoutExpired, OSError): + try: + app_module.adsb_process.kill() + except OSError: + pass app_module.adsb_process = None # Create device object and build command via abstraction layer @@ -314,16 +394,32 @@ def start_adsb(): app_module.adsb_process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL + stderr=subprocess.PIPE ) - time.sleep(3) + time.sleep(DUMP1090_START_WAIT) if app_module.adsb_process.poll() is not None: - return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'}) + # Process exited - try to get error message + stderr_output = '' + if app_module.adsb_process.stderr: + try: + stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip() + except Exception: + pass + if sdr_type == SDRType.RTL_SDR: + error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}) + else: + error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.' + if stderr_output: + error_msg += f' Error: {stderr_output[:200]}' + return jsonify({'status': 'error', 'message': error_msg}) adsb_using_service = True - thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True) + thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True) thread.start() return jsonify({'status': 'started', 'message': 'ADS-B tracking started'}) @@ -340,13 +436,14 @@ def stop_adsb(): if app_module.adsb_process: app_module.adsb_process.terminate() try: - app_module.adsb_process.wait(timeout=5) + app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT) except subprocess.TimeoutExpired: app_module.adsb_process.kill() app_module.adsb_process = None adsb_using_service = False - app_module.adsb_aircraft = {} + app_module.adsb_aircraft.clear() + _looked_up_icaos.clear() return jsonify({'status': 'stopped'}) @@ -355,16 +452,15 @@ def stream_adsb(): """SSE stream for ADS-B aircraft.""" def generate(): last_keepalive = time.time() - keepalive_interval = 30.0 while True: try: - msg = app_module.adsb_queue.get(timeout=1) + msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT) last_keepalive = time.time() yield format_sse(msg) except queue.Empty: now = time.time() - if now - last_keepalive >= keepalive_interval: + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: yield format_sse({'type': 'keepalive'}) last_keepalive = now @@ -378,3 +474,38 @@ def stream_adsb(): def adsb_dashboard(): """Popout ADS-B dashboard.""" return render_template('adsb_dashboard.html') + + +# ============================================ +# AIRCRAFT DATABASE MANAGEMENT +# ============================================ + +@adsb_bp.route('/aircraft-db/status') +def aircraft_db_status(): + """Get aircraft database status.""" + return jsonify(aircraft_db.get_db_status()) + + +@adsb_bp.route('/aircraft-db/check-updates') +def aircraft_db_check_updates(): + """Check for aircraft database updates.""" + result = aircraft_db.check_for_updates() + return jsonify(result) + + +@adsb_bp.route('/aircraft-db/download', methods=['POST']) +def aircraft_db_download(): + """Download/update aircraft database.""" + global _looked_up_icaos + result = aircraft_db.download_database() + if result.get('success'): + # Clear lookup cache so new data is used + _looked_up_icaos.clear() + return jsonify(result) + + +@adsb_bp.route('/aircraft-db/delete', methods=['POST']) +def aircraft_db_delete(): + """Delete aircraft database.""" + result = aircraft_db.delete_database() + return jsonify(result) diff --git a/routes/bluetooth.py b/routes/bluetooth.py index aa33b2d..1c8eb21 100644 --- a/routes/bluetooth.py +++ b/routes/bluetooth.py @@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger from utils.sse import format_sse from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER +from utils.constants import ( + BT_TERMINATE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + SSE_QUEUE_TIMEOUT, + SUBPROCESS_TIMEOUT_SHORT, + SERVICE_ENUM_TIMEOUT, + PROCESS_START_WAIT, + BT_RESET_DELAY, + BT_ADAPTER_DOWN_WAIT, + PROCESS_TERMINATE_TIMEOUT, +) bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt') @@ -113,7 +124,7 @@ def detect_bt_interfaces(): if platform.system() == 'Linux': try: - result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5) + result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT) blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE) for block in blocks: if block.strip(): @@ -127,8 +138,12 @@ def detect_bt_interfaces(): 'type': 'hci', 'status': 'up' if is_up else 'down' }) - except Exception: - pass + except FileNotFoundError: + logger.debug("hciconfig not found") + except subprocess.TimeoutExpired: + logger.warning("hciconfig timed out") + except subprocess.SubprocessError as e: + logger.warning(f"Error running hciconfig: {e}") elif platform.system() == 'Darwin': interfaces.append({ diff --git a/routes/correlation.py b/routes/correlation.py new file mode 100644 index 0000000..7869eb0 --- /dev/null +++ b/routes/correlation.py @@ -0,0 +1,119 @@ +"""Device correlation routes.""" + +from __future__ import annotations + +from flask import Blueprint, jsonify, request, Response + +import app as app_module +from utils.correlation import get_correlations +from utils.logging import get_logger + +logger = get_logger('intercept.correlation') + +correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation') + + +@correlation_bp.route('', methods=['GET']) +def get_device_correlations() -> Response: + """ + Get device correlations between WiFi and Bluetooth. + + Query params: + min_confidence: Minimum confidence threshold (default 0.5) + include_historical: Include database correlations (default true) + """ + min_confidence = request.args.get('min_confidence', 0.5, type=float) + include_historical = request.args.get('include_historical', 'true').lower() == 'true' + + try: + # Get current device data + wifi_devices = dict(app_module.wifi_networks) + wifi_devices.update(dict(app_module.wifi_clients)) + bt_devices = dict(app_module.bt_devices) + + # Calculate correlations + correlations = get_correlations( + wifi_devices=wifi_devices, + bt_devices=bt_devices, + min_confidence=min_confidence, + include_historical=include_historical + ) + + return jsonify({ + 'status': 'success', + 'correlations': correlations, + 'wifi_count': len(wifi_devices), + 'bt_count': len(bt_devices) + }) + except Exception as e: + logger.error(f"Error calculating correlations: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + + +@correlation_bp.route('/analyze', methods=['POST']) +def analyze_correlation() -> Response: + """ + Analyze specific device pair for correlation. + + Request body: + wifi_mac: WiFi device MAC address + bt_mac: Bluetooth device MAC address + """ + data = request.json or {} + wifi_mac = data.get('wifi_mac') + bt_mac = data.get('bt_mac') + + if not wifi_mac or not bt_mac: + return jsonify({ + 'status': 'error', + 'message': 'wifi_mac and bt_mac are required' + }), 400 + + try: + # Get device data + wifi_device = app_module.wifi_networks.get(wifi_mac) + if not wifi_device: + wifi_device = app_module.wifi_clients.get(wifi_mac) + + bt_device = app_module.bt_devices.get(bt_mac) + + if not wifi_device: + return jsonify({ + 'status': 'error', + 'message': f'WiFi device {wifi_mac} not found' + }), 404 + + if not bt_device: + return jsonify({ + 'status': 'error', + 'message': f'Bluetooth device {bt_mac} not found' + }), 404 + + # Calculate correlation for this specific pair + correlations = get_correlations( + wifi_devices={wifi_mac: wifi_device}, + bt_devices={bt_mac: bt_device}, + min_confidence=0.0, # Show even low confidence for analysis + include_historical=True + ) + + if correlations: + return jsonify({ + 'status': 'success', + 'correlation': correlations[0] + }) + else: + return jsonify({ + 'status': 'success', + 'correlation': None, + 'message': 'No correlation detected between these devices' + }) + except Exception as e: + logger.error(f"Error analyzing correlation: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 diff --git a/routes/gps.py b/routes/gps.py index 03ca72f..4a80c91 100644 --- a/routes/gps.py +++ b/routes/gps.py @@ -1,9 +1,8 @@ -"""GPS dongle routes for USB GPS device support.""" +"""GPS routes for gpsd daemon support.""" from __future__ import annotations import queue -import threading import time from typing import Generator @@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response from utils.logging import get_logger from utils.sse import format_sse from utils.gps import ( - detect_gps_devices, - is_serial_available, get_gps_reader, - start_gps, start_gpsd, stop_gps, get_current_position, GPSPosition, - GPSDClient, ) logger = get_logger('intercept.gps') @@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None: pass -@gps_bp.route('/available') -def check_gps_available(): - """Check if GPS dongle support is available.""" - return jsonify({ - 'available': is_serial_available(), - 'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial' - }) +@gps_bp.route('/auto-connect', methods=['POST']) +def auto_connect_gps(): + """ + Automatically connect to gpsd if available. - -@gps_bp.route('/gpsd/check') -def check_gpsd_available(): - """Check if gpsd is reachable.""" + Called on page load to seamlessly enable GPS if gpsd is running. + Returns current status if already connected. + """ import socket - host = request.args.get('host', 'localhost') - port = int(request.args.get('port', 2947)) + # Check if already running + reader = get_gps_reader() + if reader and reader.is_running: + position = reader.position + return jsonify({ + 'status': 'connected', + 'source': 'gpsd', + 'has_fix': position is not None, + 'position': position.to_dict() if position else None + }) + # Try to connect to gpsd on localhost:2947 + host = 'localhost' + port = 2947 + + # First check if gpsd is reachable try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2.0) + sock.settimeout(1.0) sock.connect((host, port)) sock.close() + except Exception: return jsonify({ - 'available': True, - 'host': host, - 'port': port, - 'message': f'gpsd reachable at {host}:{port}' + 'status': 'unavailable', + 'message': 'gpsd not running' }) - except Exception as e: - return jsonify({ - 'available': False, - 'host': host, - 'port': port, - 'message': f'Cannot connect to gpsd at {host}:{port}: {e}' - }) - - -@gps_bp.route('/devices') -def list_gps_devices(): - """List available GPS serial devices.""" - if not is_serial_available(): - return jsonify({ - 'status': 'error', - 'message': 'pyserial not installed' - }), 503 - - devices = detect_gps_devices() - return jsonify({ - 'status': 'ok', - 'devices': devices - }) - - -@gps_bp.route('/start', methods=['POST']) -def start_gps_reader(): - """Start GPS reader on specified device.""" - if not is_serial_available(): - return jsonify({ - 'status': 'error', - 'message': 'pyserial not installed' - }), 503 - - # Check if already running - reader = get_gps_reader() - if reader and reader.is_running: - return jsonify({ - 'status': 'error', - 'message': 'GPS reader already running' - }), 409 - - data = request.json or {} - device_path = data.get('device') - baudrate = data.get('baudrate', 9600) - - if not device_path: - return jsonify({ - 'status': 'error', - 'message': 'Device path required' - }), 400 - - # Validate baudrate - valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200] - if baudrate not in valid_baudrates: - return jsonify({ - 'status': 'error', - 'message': f'Invalid baudrate. Valid options: {valid_baudrates}' - }), 400 # Clear the queue while not _gps_queue.empty(): @@ -139,80 +83,26 @@ def start_gps_reader(): except queue.Empty: break - # Start the GPS reader with callback pre-registered (avoids race condition) - success = start_gps(device_path, baudrate, callback=_position_callback) - - if success: - return jsonify({ - 'status': 'started', - 'device': device_path, - 'baudrate': baudrate, - 'source': 'serial' - }) - else: - reader = get_gps_reader() - error = reader.error if reader else 'Unknown error' - return jsonify({ - 'status': 'error', - 'message': f'Failed to start GPS reader: {error}' - }), 500 - - -@gps_bp.route('/gpsd/start', methods=['POST']) -def start_gpsd_client(): - """Start GPS client connected to gpsd.""" - # Check if already running - reader = get_gps_reader() - if reader and reader.is_running: - return jsonify({ - 'status': 'error', - 'message': 'GPS reader already running' - }), 409 - - data = request.json or {} - host = data.get('host', 'localhost') - port = data.get('port', 2947) - - # Validate port - try: - port = int(port) - if not (1 <= port <= 65535): - raise ValueError("Port out of range") - except (ValueError, TypeError): - return jsonify({ - 'status': 'error', - 'message': 'Invalid port number' - }), 400 - - # Clear the queue - while not _gps_queue.empty(): - try: - _gps_queue.get_nowait() - except queue.Empty: - break - - # Start the gpsd client with callback pre-registered + # Start the gpsd client success = start_gpsd(host, port, callback=_position_callback) if success: return jsonify({ - 'status': 'started', - 'host': host, - 'port': port, - 'source': 'gpsd' + 'status': 'connected', + 'source': 'gpsd', + 'has_fix': False, + 'position': None }) else: - reader = get_gps_reader() - error = reader.error if reader else 'Unknown error' return jsonify({ - 'status': 'error', - 'message': f'Failed to connect to gpsd: {error}' - }), 500 + 'status': 'unavailable', + 'message': 'Failed to connect to gpsd' + }) @gps_bp.route('/stop', methods=['POST']) def stop_gps_reader(): - """Stop GPS reader.""" + """Stop GPS client.""" reader = get_gps_reader() if reader: reader.remove_callback(_position_callback) @@ -224,7 +114,7 @@ def stop_gps_reader(): @gps_bp.route('/status') def get_gps_status(): - """Get current GPS reader status.""" + """Get current GPS client status.""" reader = get_gps_reader() if not reader: @@ -233,7 +123,7 @@ def get_gps_status(): 'device': None, 'position': None, 'error': None, - 'message': 'GPS reader not started' + 'message': 'GPS client not started' }) position = reader.position @@ -262,7 +152,7 @@ def get_position(): if not reader or not reader.is_running: return jsonify({ 'status': 'error', - 'message': 'GPS reader not running' + 'message': 'GPS client not running' }), 400 else: return jsonify({ @@ -273,22 +163,22 @@ def get_position(): @gps_bp.route('/debug') def debug_gps(): - """Debug endpoint showing GPS reader state.""" + """Debug endpoint showing GPS client state.""" reader = get_gps_reader() if not reader: return jsonify({ 'reader': None, - 'message': 'No GPS reader initialized' + 'message': 'No GPS client initialized' }) position = reader.position - source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial' return jsonify({ 'running': reader.is_running, - 'source': source, + 'source': 'gpsd', 'device': reader.device_path, - 'baudrate': reader.baudrate, + 'host': reader.host, + 'port': reader.port, 'has_position': position is not None, 'position': position.to_dict() if position else None, 'last_update': reader.last_update.isoformat() if reader.last_update else None, diff --git a/routes/listening_post.py b/routes/listening_post.py new file mode 100644 index 0000000..bddb38a --- /dev/null +++ b/routes/listening_post.py @@ -0,0 +1,768 @@ +"""Listening Post routes for radio monitoring and frequency scanning.""" + +from __future__ import annotations + +import json +import os +import queue +import shutil +import subprocess +import threading +import time +from datetime import datetime +from typing import Generator, Optional, List, Dict + +from flask import Blueprint, jsonify, request, Response + +from utils.logging import get_logger +from utils.sse import format_sse +from utils.constants import ( + SSE_QUEUE_TIMEOUT, + SSE_KEEPALIVE_INTERVAL, + PROCESS_TERMINATE_TIMEOUT, +) + +logger = get_logger('intercept.listening_post') + +listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening') + +# ============================================ +# GLOBAL STATE +# ============================================ + +# Audio demodulation state +audio_process = None +audio_rtl_process = None +audio_lock = threading.Lock() +audio_running = False +audio_frequency = 0.0 +audio_modulation = 'fm' + +# Scanner state +scanner_thread: Optional[threading.Thread] = None +scanner_running = False +scanner_lock = threading.Lock() +scanner_paused = False +scanner_current_freq = 0.0 +scanner_config = { + 'start_freq': 88.0, + 'end_freq': 108.0, + 'step': 0.1, + 'modulation': 'wfm', + 'squelch': 20, + 'dwell_time': 10.0, # Seconds to stay on active frequency + 'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning) + 'device': 0, + 'gain': 40, +} + +# Activity log +activity_log: List[Dict] = [] +activity_log_lock = threading.Lock() +MAX_LOG_ENTRIES = 500 + +# SSE queue for scanner events +scanner_queue: queue.Queue = queue.Queue(maxsize=100) + + +# ============================================ +# HELPER FUNCTIONS +# ============================================ + +def find_rtl_fm() -> str | None: + """Find rtl_fm binary.""" + return shutil.which('rtl_fm') + + +def find_ffmpeg() -> str | None: + """Find ffmpeg for audio encoding.""" + return shutil.which('ffmpeg') + + + + +def add_activity_log(event_type: str, frequency: float, details: str = ''): + """Add entry to activity log.""" + with activity_log_lock: + entry = { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'type': event_type, + 'frequency': frequency, + 'details': details, + } + activity_log.insert(0, entry) + # Trim log + while len(activity_log) > MAX_LOG_ENTRIES: + activity_log.pop() + + # Also push to SSE queue + try: + scanner_queue.put_nowait({ + 'type': 'log', + 'entry': entry + }) + except queue.Full: + pass + + +# ============================================ +# SCANNER IMPLEMENTATION +# ============================================ + +def scanner_loop(): + """Main scanner loop - scans frequencies looking for signals.""" + global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal + global audio_process, audio_rtl_process, audio_running, audio_frequency + + logger.info("Scanner thread started") + add_activity_log('scanner_start', scanner_config['start_freq'], + f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz") + + rtl_fm_path = find_rtl_fm() + + if not rtl_fm_path: + logger.error("rtl_fm not found") + add_activity_log('error', 0, 'rtl_fm not found') + scanner_running = False + return + + current_freq = scanner_config['start_freq'] + last_signal_time = 0 + signal_detected = False + + # Convert step from kHz to MHz + step_mhz = scanner_config['step'] / 1000.0 + + try: + while scanner_running: + # Check if paused + if scanner_paused: + time.sleep(0.1) + continue + + scanner_current_freq = current_freq + + # Notify clients of frequency change + try: + scanner_queue.put_nowait({ + 'type': 'freq_change', + 'frequency': current_freq, + 'scanning': not signal_detected + }) + except queue.Full: + pass + + # Start rtl_fm at this frequency + freq_hz = int(current_freq * 1e6) + mod = scanner_config['modulation'] + + # Sample rates + if mod == 'wfm': + sample_rate = 170000 + resample_rate = 32000 + elif mod in ['usb', 'lsb']: + sample_rate = 12000 + resample_rate = 12000 + else: + sample_rate = 24000 + resample_rate = 24000 + + # Don't use squelch in rtl_fm - we want to analyze raw audio + rtl_cmd = [ + rtl_fm_path, + '-M', mod, + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(scanner_config['gain']), + '-d', str(scanner_config['device']), + ] + + try: + # Start rtl_fm + rtl_proc = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL + ) + + # Read audio data for analysis + audio_data = b'' + + # Read audio samples for a short period + sample_duration = 0.25 # 250ms - balance between speed and detection + bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono + + while len(audio_data) < bytes_needed and scanner_running: + chunk = rtl_proc.stdout.read(4096) + if not chunk: + break + audio_data += chunk + + # Clean up rtl_fm + rtl_proc.terminate() + try: + rtl_proc.wait(timeout=1) + except subprocess.TimeoutExpired: + rtl_proc.kill() + + # Analyze audio level + audio_detected = False + rms = 0 + threshold = 3000 + if len(audio_data) > 100: + import struct + samples = struct.unpack(f'{len(audio_data)//2}h', audio_data) + # Calculate RMS level (root mean square) + rms = (sum(s*s for s in samples) / len(samples)) ** 0.5 + + # WFM (broadcast FM) has much higher audio output - needs higher threshold + # AM/NFM have lower output levels + if mod == 'wfm': + # WFM: threshold 4000-12000 based on squelch + threshold = 4000 + (scanner_config['squelch'] * 80) + else: + # AM/NFM: threshold 1500-8000 based on squelch + threshold = 1500 + (scanner_config['squelch'] * 65) + + audio_detected = rms > threshold + + # Send level info to clients + try: + scanner_queue.put_nowait({ + 'type': 'scan_update', + 'frequency': current_freq, + 'level': int(rms), + 'threshold': int(threshold) if 'threshold' in dir() else 0, + 'detected': audio_detected + }) + except queue.Full: + pass + + if audio_detected and scanner_running: + if not signal_detected: + # New signal found! + signal_detected = True + last_signal_time = time.time() + add_activity_log('signal_found', current_freq, + f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})') + logger.info(f"Signal found at {current_freq} MHz") + + # Start audio streaming for user + _start_audio_stream(current_freq, mod) + + try: + scanner_queue.put_nowait({ + 'type': 'signal_found', + 'frequency': current_freq, + 'modulation': mod, + 'audio_streaming': True + }) + except queue.Full: + pass + + # Check for skip signal + if scanner_skip_signal: + scanner_skip_signal = False + signal_detected = False + _stop_audio_stream() + try: + scanner_queue.put_nowait({ + 'type': 'signal_skipped', + 'frequency': current_freq + }) + except queue.Full: + pass + # Move to next frequency (step is in kHz, convert to MHz) + current_freq += step_mhz + if current_freq > scanner_config['end_freq']: + current_freq = scanner_config['start_freq'] + continue + + # Stay on this frequency (dwell) but check periodically + dwell_start = time.time() + while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running: + if scanner_skip_signal: + break + time.sleep(0.2) + + last_signal_time = time.time() + + else: + # No signal at this frequency + if signal_detected: + # Signal lost + duration = time.time() - last_signal_time + scanner_config['dwell_time'] + add_activity_log('signal_lost', current_freq, + f'Signal lost after {duration:.1f}s') + signal_detected = False + + # Stop audio + _stop_audio_stream() + + try: + scanner_queue.put_nowait({ + 'type': 'signal_lost', + 'frequency': current_freq + }) + except queue.Full: + pass + + # Move to next frequency (step is in kHz, convert to MHz) + current_freq += step_mhz + if current_freq > scanner_config['end_freq']: + current_freq = scanner_config['start_freq'] + add_activity_log('scan_cycle', current_freq, 'Scan cycle complete') + + time.sleep(scanner_config['scan_delay']) + + except Exception as e: + logger.error(f"Scanner error at {current_freq} MHz: {e}") + time.sleep(0.5) + + except Exception as e: + logger.error(f"Scanner loop error: {e}") + finally: + scanner_running = False + _stop_audio_stream() + add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped') + logger.info("Scanner thread stopped") + + +def _start_audio_stream(frequency: float, modulation: str): + """Start audio streaming at given frequency.""" + global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation + + with audio_lock: + # Stop any existing stream + _stop_audio_stream_internal() + + rtl_fm_path = find_rtl_fm() + ffmpeg_path = find_ffmpeg() + + if not rtl_fm_path or not ffmpeg_path: + return + + freq_hz = int(frequency * 1e6) + + if modulation == 'wfm': + sample_rate = 170000 + resample_rate = 32000 + elif modulation in ['usb', 'lsb']: + sample_rate = 12000 + resample_rate = 12000 + else: + sample_rate = 24000 + resample_rate = 24000 + + rtl_cmd = [ + rtl_fm_path, + '-M', modulation, + '-f', str(freq_hz), + '-s', str(sample_rate), + '-r', str(resample_rate), + '-g', str(scanner_config['gain']), + '-d', str(scanner_config['device']), + '-l', str(scanner_config['squelch']), + ] + + encoder_cmd = [ + ffmpeg_path, + '-f', 's16le', + '-ar', str(resample_rate), + '-ac', '1', + '-i', 'pipe:0', + '-f', 'mp3', + '-b:a', '64k', + '-flush_packets', '1', + 'pipe:1' + ] + + try: + logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}") + audio_rtl_process = subprocess.Popen( + rtl_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + logger.info(f"Starting ffmpeg: {' '.join(encoder_cmd)}") + audio_process = subprocess.Popen( + encoder_cmd, + stdin=audio_rtl_process.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0 + ) + + audio_rtl_process.stdout.close() + + # Brief delay to check if processes started successfully + time.sleep(0.2) + + if audio_rtl_process.poll() is not None: + stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else '' + logger.error(f"rtl_fm exited immediately: {stderr}") + return + + if audio_process.poll() is not None: + stderr = audio_process.stderr.read().decode() if audio_process.stderr else '' + logger.error(f"ffmpeg exited immediately: {stderr}") + return + + audio_running = True + audio_frequency = frequency + audio_modulation = modulation + logger.info(f"Audio stream started: {frequency} MHz ({modulation})") + + except Exception as e: + logger.error(f"Failed to start audio stream: {e}") + + +def _stop_audio_stream(): + """Stop audio streaming.""" + with audio_lock: + _stop_audio_stream_internal() + + +def _stop_audio_stream_internal(): + """Internal stop (must hold lock).""" + global audio_process, audio_rtl_process, audio_running, audio_frequency + + if audio_process: + try: + audio_process.terminate() + audio_process.wait(timeout=1) + except: + try: + audio_process.kill() + except: + pass + audio_process = None + + if audio_rtl_process: + try: + audio_rtl_process.terminate() + audio_rtl_process.wait(timeout=1) + except: + try: + audio_rtl_process.kill() + except: + pass + audio_rtl_process = None + + audio_running = False + audio_frequency = 0.0 + + +# ============================================ +# API ENDPOINTS +# ============================================ + +@listening_post_bp.route('/tools') +def check_tools() -> Response: + """Check for required tools.""" + rtl_fm = find_rtl_fm() + ffmpeg = find_ffmpeg() + + return jsonify({ + 'rtl_fm': rtl_fm is not None, + 'ffmpeg': ffmpeg is not None, + 'available': rtl_fm is not None and ffmpeg is not None + }) + + +@listening_post_bp.route('/scanner/start', methods=['POST']) +def start_scanner() -> Response: + """Start the frequency scanner.""" + global scanner_thread, scanner_running, scanner_config + + with scanner_lock: + if scanner_running: + return jsonify({ + 'status': 'error', + 'message': 'Scanner already running' + }), 409 + + data = request.json or {} + + # Update scanner config + try: + scanner_config['start_freq'] = float(data.get('start_freq', 88.0)) + scanner_config['end_freq'] = float(data.get('end_freq', 108.0)) + scanner_config['step'] = float(data.get('step', 0.1)) + scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower() + scanner_config['squelch'] = int(data.get('squelch', 20)) + scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0)) + scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5)) + scanner_config['device'] = int(data.get('device', 0)) + scanner_config['gain'] = int(data.get('gain', 40)) + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid parameter: {e}' + }), 400 + + # Validate + if scanner_config['start_freq'] >= scanner_config['end_freq']: + return jsonify({ + 'status': 'error', + 'message': 'start_freq must be less than end_freq' + }), 400 + + # Check tools + if not find_rtl_fm(): + return jsonify({ + 'status': 'error', + 'message': 'rtl_fm not found. Install rtl-sdr tools.' + }), 503 + + # Start scanner thread + scanner_running = True + scanner_thread = threading.Thread(target=scanner_loop, daemon=True) + scanner_thread.start() + + return jsonify({ + 'status': 'started', + 'config': scanner_config + }) + + +@listening_post_bp.route('/scanner/stop', methods=['POST']) +def stop_scanner() -> Response: + """Stop the frequency scanner.""" + global scanner_running + + scanner_running = False + _stop_audio_stream() + + return jsonify({'status': 'stopped'}) + + +@listening_post_bp.route('/scanner/pause', methods=['POST']) +def pause_scanner() -> Response: + """Pause/resume the scanner.""" + global scanner_paused + + scanner_paused = not scanner_paused + + if scanner_paused: + add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused') + else: + add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed') + + return jsonify({ + 'status': 'paused' if scanner_paused else 'resumed', + 'paused': scanner_paused + }) + + +# Flag to trigger skip from API +scanner_skip_signal = False + + +@listening_post_bp.route('/scanner/skip', methods=['POST']) +def skip_signal() -> Response: + """Skip current signal and continue scanning.""" + global scanner_skip_signal + + if not scanner_running: + return jsonify({ + 'status': 'error', + 'message': 'Scanner not running' + }), 400 + + scanner_skip_signal = True + add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz') + + return jsonify({ + 'status': 'skipped', + 'frequency': scanner_current_freq + }) + + +@listening_post_bp.route('/scanner/status') +def scanner_status() -> Response: + """Get scanner status.""" + return jsonify({ + 'running': scanner_running, + 'paused': scanner_paused, + 'current_freq': scanner_current_freq, + 'config': scanner_config, + 'audio_streaming': audio_running, + 'audio_frequency': audio_frequency + }) + + +@listening_post_bp.route('/scanner/stream') +def stream_scanner_events() -> Response: + """SSE stream for scanner events.""" + def generate() -> Generator[str, None, None]: + last_keepalive = time.time() + + while True: + try: + msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT) + last_keepalive = time.time() + yield format_sse(msg) + except queue.Empty: + now = time.time() + if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL: + yield format_sse({'type': 'keepalive'}) + last_keepalive = now + + response = Response(generate(), mimetype='text/event-stream') + response.headers['Cache-Control'] = 'no-cache' + response.headers['X-Accel-Buffering'] = 'no' + return response + + +@listening_post_bp.route('/scanner/log') +def get_activity_log() -> Response: + """Get activity log.""" + limit = request.args.get('limit', 100, type=int) + with activity_log_lock: + return jsonify({ + 'log': activity_log[:limit], + 'total': len(activity_log) + }) + + +@listening_post_bp.route('/scanner/log/clear', methods=['POST']) +def clear_activity_log() -> Response: + """Clear activity log.""" + with activity_log_lock: + activity_log.clear() + return jsonify({'status': 'cleared'}) + + +@listening_post_bp.route('/presets') +def get_presets() -> Response: + """Get scanner presets.""" + presets = [ + {'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'}, + {'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'}, + {'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'}, + {'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'}, + {'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'}, + {'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'}, + {'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'}, + {'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'}, + ] + return jsonify({'presets': presets}) + + +# ============================================ +# MANUAL AUDIO ENDPOINTS (for direct listening) +# ============================================ + +@listening_post_bp.route('/audio/start', methods=['POST']) +def start_audio() -> Response: + """Start audio at specific frequency (manual mode).""" + global scanner_running + + # Stop scanner if running + if scanner_running: + scanner_running = False + time.sleep(0.5) + + data = request.json or {} + + try: + frequency = float(data.get('frequency', 0)) + modulation = str(data.get('modulation', 'wfm')).lower() + squelch = int(data.get('squelch', 0)) + gain = int(data.get('gain', 40)) + device = int(data.get('device', 0)) + except (ValueError, TypeError) as e: + return jsonify({ + 'status': 'error', + 'message': f'Invalid parameter: {e}' + }), 400 + + if frequency <= 0: + return jsonify({ + 'status': 'error', + 'message': 'frequency is required' + }), 400 + + valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb'] + if modulation not in valid_mods: + return jsonify({ + 'status': 'error', + 'message': f'Invalid modulation. Use: {", ".join(valid_mods)}' + }), 400 + + # Update config for audio + scanner_config['squelch'] = squelch + scanner_config['gain'] = gain + scanner_config['device'] = device + + _start_audio_stream(frequency, modulation) + + if audio_running: + add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})') + return jsonify({ + 'status': 'started', + 'frequency': frequency, + 'modulation': modulation, + 'stream_url': '/listening/audio/stream' + }) + else: + return jsonify({ + 'status': 'error', + 'message': 'Failed to start audio. Check that rtl_fm and ffmpeg are installed, and that an SDR device is connected and not in use by another process.' + }), 500 + + +@listening_post_bp.route('/audio/stop', methods=['POST']) +def stop_audio() -> Response: + """Stop audio.""" + _stop_audio_stream() + return jsonify({'status': 'stopped'}) + + +@listening_post_bp.route('/audio/status') +def audio_status() -> Response: + """Get audio status.""" + return jsonify({ + 'running': audio_running, + 'frequency': audio_frequency, + 'modulation': audio_modulation + }) + + +@listening_post_bp.route('/audio/stream') +def stream_audio() -> Response: + """Stream MP3 audio.""" + # Wait briefly for audio to start (handles race condition with /audio/start) + for _ in range(10): + if audio_running and audio_process: + break + time.sleep(0.1) + + if not audio_running or not audio_process: + # Return empty audio response instead of JSON (browser audio element can't parse JSON) + return Response(b'', mimetype='audio/mpeg', status=204) + + def generate(): + chunk_size = 4096 + try: + while audio_running and audio_process and audio_process.poll() is None: + chunk = audio_process.stdout.read(chunk_size) + if not chunk: + break + yield chunk + except Exception as e: + logger.error(f"Audio stream error: {e}") + + return Response( + generate(), + mimetype='audio/mpeg', + headers={ + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'no-cache, no-store', + 'X-Accel-Buffering': 'no', + 'Transfer-Encoding': 'chunked', + } + ) diff --git a/routes/settings.py b/routes/settings.py new file mode 100644 index 0000000..8f4d3c2 --- /dev/null +++ b/routes/settings.py @@ -0,0 +1,228 @@ +"""Settings management routes.""" + +from __future__ import annotations + +from flask import Blueprint, jsonify, request, Response + +from utils.database import ( + get_setting, + set_setting, + delete_setting, + get_all_settings, + get_signal_history, + add_signal_reading, + get_correlations, +) +from utils.logging import get_logger + +logger = get_logger('intercept.settings') + +settings_bp = Blueprint('settings', __name__, url_prefix='/settings') + + +@settings_bp.route('', methods=['GET']) +def get_settings() -> Response: + """Get all settings.""" + try: + settings = get_all_settings() + return jsonify({ + 'status': 'success', + 'settings': settings + }) + except Exception as e: + logger.error(f"Error getting settings: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + + +@settings_bp.route('', methods=['POST']) +def save_settings() -> Response: + """Save one or more settings.""" + data = request.json or {} + + if not data: + return jsonify({ + 'status': 'error', + 'message': 'No settings provided' + }), 400 + + try: + saved = [] + for key, value in data.items(): + # Validate key (alphanumeric, underscores, dots, hyphens) + if not key or not all(c.isalnum() or c in '_.-' for c in key): + continue + + set_setting(key, value) + saved.append(key) + + return jsonify({ + 'status': 'success', + 'saved': saved + }) + except Exception as e: + logger.error(f"Error saving settings: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + + +@settings_bp.route('/sudo apt install build-essential libsoapysdr-dev librtlsdr-dev
+git clone https://github.com/wiedehopf/readsb.git
+cd readsb
+make HAVE_SOAPYSDR=1
+sudo make install
+ sudo apt install rtl-sdr ffmpeg (Debian) or brew install librtlsdr ffmpeg (macOS)
+