Merge branch 'main' into main

This commit is contained in:
Jon Ander Oribe
2026-01-08 06:57:33 +01:00
committed by GitHub
38 changed files with 8113 additions and 2434 deletions

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ Thumbs.db
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
# Package manager lock files
uv.lock

82
CHANGELOG.md Normal file
View File

@@ -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)

View File

@@ -3,24 +3,46 @@
FROM python:3.11-slim FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project"
LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory # Set working directory
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools # RTL-SDR tools
rtl-sdr \ rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder # 433MHz decoder
rtl-433 \ rtl-433 \
# Pager decoder # Pager decoder
multimon-ng \ multimon-ng \
# Audio tools for Listening Post
ffmpeg \
# WiFi tools (aircrack-ng suite) # WiFi tools (aircrack-ng suite)
aircrack-ng \ aircrack-ng \
iw \
wireless-tools \
# Bluetooth tools # Bluetooth tools
bluez \ bluez \
# Cleanup bluetooth \
# GPS support
gpsd-clients \
# Utilities
curl \
procps \
&& rm -rf /var/lib/apt/lists/* && 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 first for better caching
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r 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 application code
COPY . . COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
# Expose web interface port # Expose web interface port
EXPOSE 5050 EXPOSE 5050
# Environment variables with defaults # Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \ ENV INTERCEPT_HOST=0.0.0.0 \
INTERCEPT_PORT=5050 \ 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 # Run the application
CMD ["python", "intercept.py"] CMD ["python", "intercept.py"]

103
README.md
View File

@@ -8,7 +8,7 @@
<p align="center"> <p align="center">
<strong>Signal Intelligence Platform</strong><br> <strong>Signal Intelligence Platform</strong><br>
A web-based front-end for signal intelligence tools. A web-based interface for software-defined radio tools.
</p> </p>
<p align="center"> <p align="center">
@@ -17,29 +17,23 @@
--- ---
## What is INTERCEPT? ## Features
INTERCEPT provides a unified web interface for signal intelligence tools:
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng - **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433 - **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map - **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 - **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 - **Bluetooth Scanning** - Device discovery and tracker detection
--- ---
## Community ## Installation / Debian / Ubuntu / MacOS
<p align="center"> ```
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
</p>
---
## Quick Start
**1. Clone and run:**
```bash ```bash
git clone https://github.com/smittix/intercept.git git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
@@ -47,72 +41,67 @@ cd intercept
sudo python3 intercept.py sudo python3 intercept.py
``` ```
Open http://localhost:5050 in your browser. ### Docker (Alternative)
## Usage of Black Formatter
```bash
uv run black . # If you use UV
black . # For Python
```
<details>
<summary><strong>Alternative: Install with uv</strong></summary>
```bash ```bash
git clone https://github.com/smittix/intercept.git git clone https://github.com/smittix/intercept.git
cd intercept cd intercept
uv venv docker-compose up -d
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
sudo python3 intercept.py
``` ```
</details>
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md). > **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### Open the Interface
After starting, open **http://localhost:5050** in your browser.
--- ---
## Requirements ## Hardware Requirements
- **Python 3.9+** | Hardware | Purpose | Price |
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF |----------|---------|-------|
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng | **RTL-SDR** | Required for all SDR features | ~$25-35 |
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
| **GPS** | Any Linux supported GPS Unit | ~10 |
Quick install (Ubuntu/Debian): Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
```bash
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
```
See [Hardware & Installation](docs/HARDWARE.md) for full details. | :exclamation: Not using an RTL-SDR Device? |
|-----------------------------------------------
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
| :exclamation: GPS Usage |
|-----------------------------------------------
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
---
## Discord Server
<p align="center">
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
</p>
--- ---
## Documentation ## Documentation
| Document | Description | - [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|----------|-------------| - [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
| [Features](docs/FEATURES.md) | Complete feature list for all modules | - [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
| [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.
--- ---
## Disclaimer ## 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 - Only use with proper authorization
- Intercepting communications without consent may be illegal - Intercepting communications without consent may be illegal
- WiFi/Bluetooth attacks require explicit permission
- You are responsible for compliance with applicable laws - 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/) | [Leaflet.js](https://leafletjs.com/) |
[Celestrak](https://celestrak.org/) [Celestrak](https://celestrak.org/)

138
app.py
View File

@@ -29,6 +29,17 @@ from config import VERSION
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory 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 # Create Flask app
@@ -40,32 +51,32 @@ app = Flask(__name__)
# Pager decoder # Pager decoder
current_process = None current_process = None
output_queue = queue.Queue() output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
process_lock = threading.Lock() process_lock = threading.Lock()
# RTL_433 sensor # RTL_433 sensor
sensor_process = None sensor_process = None
sensor_queue = queue.Queue() sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
sensor_lock = threading.Lock() sensor_lock = threading.Lock()
# WiFi # WiFi
wifi_process = None wifi_process = None
wifi_queue = queue.Queue() wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
wifi_lock = threading.Lock() wifi_lock = threading.Lock()
# Bluetooth # Bluetooth
bt_process = None bt_process = None
bt_queue = queue.Queue() bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
bt_lock = threading.Lock() bt_lock = threading.Lock()
# ADS-B aircraft # ADS-B aircraft
adsb_process = None adsb_process = None
adsb_queue = queue.Queue() adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
adsb_lock = threading.Lock() adsb_lock = threading.Lock()
# Satellite/Iridium # Satellite/Iridium
satellite_process = None satellite_process = None
satellite_queue = queue.Queue() satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
satellite_lock = threading.Lock() satellite_lock = threading.Lock()
# ============================================ # ============================================
@@ -76,23 +87,30 @@ satellite_lock = threading.Lock()
logging_enabled = False logging_enabled = False
log_file_path = 'pager_messages.log' log_file_path = 'pager_messages.log'
# WiFi state # WiFi state - using DataStore for automatic cleanup
wifi_monitor_interface = None wifi_monitor_interface = None
wifi_networks = {} # BSSID -> network info wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
wifi_clients = {} # Client MAC -> client info wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
wifi_handshakes = [] # Captured handshakes wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
# Bluetooth state # Bluetooth state - using DataStore for automatic cleanup
bt_interface = None bt_interface = None
bt_devices = {} # MAC -> device info bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons) bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
bt_services = {} # MAC -> list of services bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
# Aircraft (ADS-B) state # Aircraft (ADS-B) state - using DataStore for automatic cleanup
adsb_aircraft = {} # ICAO hex -> aircraft info adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
# Satellite state # 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 # Determine OS for install instructions
system = platform.system().lower() system = platform.system().lower()
if system == 'darwin': if system == 'darwin':
install_method = 'brew' pkg_manager = 'brew'
elif system == 'linux': elif system == 'linux':
install_method = 'apt' pkg_manager = 'apt'
else: else:
install_method = 'manual' pkg_manager = 'manual'
return jsonify({ return jsonify({
'status': 'success',
'os': system, 'os': system,
'install_method': install_method, 'pkg_manager': pkg_manager,
'modes': results 'modes': results
}) })
@@ -159,14 +178,14 @@ def export_aircraft() -> Response:
for icao, ac in adsb_aircraft.items(): for icao, ac in adsb_aircraft.items():
writer.writerow([ writer.writerow([
icao, icao,
ac.get('callsign', ''), ac.get('callsign', '') if isinstance(ac, dict) else '',
ac.get('altitude', ''), ac.get('altitude', '') if isinstance(ac, dict) else '',
ac.get('speed', ''), ac.get('speed', '') if isinstance(ac, dict) else '',
ac.get('heading', ''), ac.get('heading', '') if isinstance(ac, dict) else '',
ac.get('lat', ''), ac.get('lat', '') if isinstance(ac, dict) else '',
ac.get('lon', ''), ac.get('lon', '') if isinstance(ac, dict) else '',
ac.get('squawk', ''), ac.get('squawk', '') if isinstance(ac, dict) else '',
ac.get('lastSeen', '') ac.get('lastSeen', '') if isinstance(ac, dict) else ''
]) ])
response = Response(output.getvalue(), mimetype='text/csv') response = Response(output.getvalue(), mimetype='text/csv')
@@ -175,7 +194,7 @@ def export_aircraft() -> Response:
else: else:
return jsonify({ return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(), '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(): for bssid, net in wifi_networks.items():
writer.writerow([ writer.writerow([
bssid, bssid,
net.get('ssid', ''), net.get('ssid', '') if isinstance(net, dict) else '',
net.get('channel', ''), net.get('channel', '') if isinstance(net, dict) else '',
net.get('signal', ''), net.get('signal', '') if isinstance(net, dict) else '',
net.get('encryption', ''), net.get('encryption', '') if isinstance(net, dict) else '',
net.get('clients', 0) net.get('clients', 0) if isinstance(net, dict) else 0
]) ])
response = Response(output.getvalue(), mimetype='text/csv') response = Response(output.getvalue(), mimetype='text/csv')
@@ -208,8 +227,8 @@ def export_wifi() -> Response:
else: else:
return jsonify({ return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(), 'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'networks': list(wifi_networks.values()), 'networks': wifi_networks.values(),
'clients': list(wifi_clients.values()) 'clients': wifi_clients.values()
}) })
@@ -229,11 +248,11 @@ def export_bluetooth() -> Response:
for mac, dev in bt_devices.items(): for mac, dev in bt_devices.items():
writer.writerow([ writer.writerow([
mac, mac,
dev.get('name', ''), dev.get('name', '') if isinstance(dev, dict) else '',
dev.get('rssi', ''), dev.get('rssi', '') if isinstance(dev, dict) else '',
dev.get('type', ''), dev.get('type', '') if isinstance(dev, dict) else '',
dev.get('manufacturer', ''), dev.get('manufacturer', '') if isinstance(dev, dict) else '',
dev.get('lastSeen', '') dev.get('lastSeen', '') if isinstance(dev, dict) else ''
]) ])
response = Response(output.getvalue(), mimetype='text/csv') response = Response(output.getvalue(), mimetype='text/csv')
@@ -242,11 +261,35 @@ def export_bluetooth() -> Response:
else: else:
return jsonify({ return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(), 'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'devices': list(bt_devices.values()), 'devices': bt_devices.values(),
'beacons': list(bt_beacons.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']) @app.route('/killall', methods=['POST'])
def kill_all() -> Response: def kill_all() -> Response:
"""Kill all decoder and WiFi processes.""" """Kill all decoder and WiFi processes."""
@@ -343,6 +386,13 @@ def main() -> None:
# Clean up any stale processes from previous runs # Clean up any stale processes from previous runs
cleanup_stale_processes() 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 # Register blueprints
from routes import register_blueprints from routes import register_blueprints
register_blueprints(app) register_blueprints(app)

View File

@@ -7,7 +7,7 @@ import os
import sys import sys
# Application version # Application version
VERSION = "1.2.0" VERSION = "2.0.0"
def _get_env(key: str, default: str) -> str: def _get_env(key: str, default: str) -> str:

37
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -1,93 +1,75 @@
# Hardware & Installation # Hardware & Advanced Setup
## Supported SDR Hardware ## Supported SDR Hardware
| Hardware | Frequency Range | Gain Range | TX | Price | Notes | | Hardware | Frequency Range | Price | Notes |
|----------|-----------------|------------|-----|-------|-------| |----------|-----------------|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly | | **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR | | **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-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 ## Quick Install
- **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
```
### macOS (Homebrew) ### 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 brew install soapysdr limesuite soapylms7
# HackRF (optional) # HackRF support (optional)
brew install hackrf soapyhackrf brew install hackrf soapyhackrf
``` ```
### Arch Linux ### Debian / Ubuntu / Raspberry Pi OS
```bash
# Core tools
sudo pacman -S rtl-sdr multimon-ng
yay -S rtl_433 dump1090
# LimeSDR/HackRF (optional) ```bash
sudo pacman -S soapysdr limesuite hackrf # 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 ```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
@@ -99,9 +81,9 @@ sudo udevadm control --reload-rules
sudo udevadm trigger 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: 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 sudo modprobe -r dvb_usb_rtl28xxu
``` ```
---
## Verify Installation ## Verify Installation
Check what's installed: ### Check dependencies
```bash ```bash
python3 intercept.py --check-deps python3 intercept.py --check-deps
``` ```
Test SDR detection: ### Test SDR detection
```bash ```bash
# RTL-SDR # RTL-SDR
rtl_test rtl_test
# LimeSDR/HackRF # LimeSDR/HackRF (via SoapySDR)
SoapySDRUtil --find SoapySDRUtil --find
``` ```
## Python Dependencies ---
### Option 1: setup.sh (Recommended) ## Python Environment
### Using setup.sh (Recommended)
```bash ```bash
./setup.sh ./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 ```bash
python3 -m venv .venv python3 -m venv venv
source .venv/bin/activate source venv/bin/activate
pip install -r requirements.txt 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 ```bash
# Install uv (if not already installed) # Standard
curl -LsSf https://astral.sh/uv/install.sh | sh sudo python3 intercept.py
# Create venv and install deps # With virtual environment
uv venv sudo venv/bin/python intercept.py
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
# Or just install deps in existing environment # Custom port
uv pip install -r requirements.txt INTERCEPT_PORT=8080 sudo python3 intercept.py
``` ```
### Option 4: pip with pyproject.toml Open **http://localhost:5050** in your browser.
```bash
pip install . # Install as package ---
pip install -e . # Install in editable mode (for development)
pip install -e ".[dev]" # Include dev dependencies ## 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

View File

@@ -101,6 +101,7 @@ Then unplug and replug your RTL-SDR.
3. Check for other applications: `lsof | grep rtl` 3. Check for other applications: `lsof | grep rtl`
### LimeSDR/HackRF not detected ### LimeSDR/HackRF not detected
Ensure the correct SoapySDR module for your hardware is installed first
1. Verify SoapySDR is installed: `SoapySDRUtil --info` 1. Verify SoapySDR is installed: `SoapySDRUtil --info`
2. Check driver is loaded: `SoapySDRUtil --find` 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 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 ## Decoding Issues
### No messages appearing (Pager mode) ### No messages appearing (Pager mode)
@@ -170,15 +156,20 @@ sudo usermod -a -G bluetooth $USER
3. Check pager services are active in your area 3. Check pager services are active in your area
4. Ensure antenna is connected 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) ### 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) 2. Check antenna is connected (1090 MHz antenna recommended)
3. Ensure clear view of sky 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 ### 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 2. Check TLE data is valid and recent
3. Verify observer location is set correctly 3. Verify observer location is set correctly

BIN
instance/intercept.db Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "intercept" name = "intercept"
version = "1.2.0" version = "2.0.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth" description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"

View File

@@ -9,6 +9,9 @@ def register_blueprints(app):
from .adsb import adsb_bp from .adsb import adsb_bp
from .satellite import satellite_bp from .satellite import satellite_bp
from .gps import gps_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(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -17,3 +20,6 @@ def register_blueprints(app):
app.register_blueprint(adsb_bp) app.register_blueprint(adsb_bp)
app.register_blueprint(satellite_bp) app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp) app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)

View File

@@ -22,6 +22,20 @@ from utils.validation import (
) )
from utils.sse import format_sse from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType 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') adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -30,6 +44,14 @@ adsb_using_service = False
adsb_connected = False adsb_connected = False
adsb_messages_received = 0 adsb_messages_received = 0
adsb_last_message_time = None 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) # Common installation paths for dump1090 (when not in PATH)
DUMP1090_PATHS = [ DUMP1090_PATHS = [
@@ -63,22 +85,22 @@ def find_dump1090():
def check_dump1090_service(): def check_dump1090_service():
"""Check if dump1090 SBS port (30003) is available.""" """Check if dump1090 SBS port is available."""
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2) sock.settimeout(SOCKET_CONNECT_TIMEOUT)
result = sock.connect_ex(('localhost', 30003)) result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
sock.close() sock.close()
if result == 0: if result == 0:
return 'localhost:30003' return f'localhost:{ADSB_SBS_PORT}'
except Exception: except OSError:
pass pass
return None return None
def parse_sbs_stream(service_addr): def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 port 30003.""" """Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
host, port = service_addr.split(':') host, port = service_addr.split(':')
port = int(port) port = int(port)
@@ -90,7 +112,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service: while adsb_using_service:
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5) sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port)) sock.connect((host, port))
adsb_connected = True adsb_connected = True
logger.info("Connected to SBS stream") logger.info("Connected to SBS stream")
@@ -98,12 +120,16 @@ def parse_sbs_stream(service_addr):
buffer = "" buffer = ""
last_update = time.time() last_update = time.time()
pending_updates = set() pending_updates = set()
adsb_bytes_received = 0
adsb_lines_received = 0
while adsb_using_service: while adsb_using_service:
try: try:
data = sock.recv(4096).decode('utf-8', errors='ignore') data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data: if not data:
logger.warning("SBS connection closed (no data)")
break break
adsb_bytes_received += len(data)
buffer += data buffer += data
while '\n' in buffer: while '\n' in buffer:
@@ -112,8 +138,15 @@ def parse_sbs_stream(service_addr):
if not line: if not line:
continue 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(',') parts = line.split(',')
if len(parts) < 11 or parts[0] != 'MSG': if len(parts) < 11 or parts[0] != 'MSG':
if adsb_lines_received <= 5:
logger.debug(f"Skipping non-MSG line: {line[:50]}")
continue continue
msg_type = parts[1] msg_type = parts[1]
@@ -121,7 +154,19 @@ def parse_sbs_stream(service_addr):
if not icao: if not icao:
continue 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: if msg_type == '1' and len(parts) > 10:
callsign = parts[10].strip() callsign = parts[10].strip()
@@ -141,7 +186,7 @@ def parse_sbs_stream(service_addr):
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
elif msg_type == '4' and len(parts) > 13: elif msg_type == '4' and len(parts) > 16:
if parts[12]: if parts[12]:
try: try:
aircraft['speed'] = int(float(parts[12])) aircraft['speed'] = int(float(parts[12]))
@@ -152,6 +197,11 @@ def parse_sbs_stream(service_addr):
aircraft['heading'] = int(float(parts[13])) aircraft['heading'] = int(float(parts[13]))
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if parts[16]:
try:
aircraft['vertical_rate'] = int(float(parts[16]))
except (ValueError, TypeError):
pass
elif msg_type == '5' and len(parts) > 11: elif msg_type == '5' and len(parts) > 11:
if parts[10]: if parts[10]:
@@ -168,13 +218,13 @@ def parse_sbs_stream(service_addr):
if parts[17]: if parts[17]:
aircraft['squawk'] = parts[17] aircraft['squawk'] = parts[17]
app_module.adsb_aircraft[icao] = aircraft app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao) pending_updates.add(icao)
adsb_messages_received += 1 adsb_messages_received += 1
adsb_last_message_time = time.time() adsb_last_message_time = time.time()
now = time.time() now = time.time()
if now - last_update >= 1.0: if now - last_update >= ADSB_UPDATE_INTERVAL:
for update_icao in pending_updates: for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft: if update_icao in app_module.adsb_aircraft:
app_module.adsb_queue.put({ app_module.adsb_queue.put({
@@ -189,10 +239,10 @@ def parse_sbs_stream(service_addr):
sock.close() sock.close()
adsb_connected = False adsb_connected = False
except Exception as e: except OSError as e:
adsb_connected = False adsb_connected = False
logger.warning(f"SBS connection error: {e}, reconnecting...") logger.warning(f"SBS connection error: {e}, reconnecting...")
time.sleep(2) time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False adsb_connected = False
logger.info("SBS stream parser stopped") logger.info("SBS stream parser stopped")
@@ -200,25 +250,52 @@ def parse_sbs_stream(service_addr):
@adsb_bp.route('/tools') @adsb_bp.route('/tools')
def check_adsb_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({ return jsonify({
'dump1090': find_dump1090() is not None, 'dump1090': has_dump1090,
'rtl_adsb': shutil.which('rtl_adsb') is not None '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') @adsb_bp.route('/status')
def adsb_status(): def adsb_status():
"""Get ADS-B tracking status for debugging.""" """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({ return jsonify({
'tracking_active': adsb_using_service, 'tracking_active': adsb_using_service,
'connected_to_sbs': adsb_connected, 'connected_to_sbs': adsb_connected,
'messages_received': adsb_messages_received, 'messages_received': adsb_messages_received,
'bytes_received': adsb_bytes_received,
'lines_received': adsb_lines_received,
'last_message_time': adsb_last_message_time, 'last_message_time': adsb_last_message_time,
'aircraft_count': len(app_module.adsb_aircraft), 'aircraft_count': len(app_module.adsb_aircraft),
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data 'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
'queue_size': app_module.adsb_queue.qsize(), 'queue_size': app_module.adsb_queue.qsize(),
'dump1090_path': find_dump1090(), 'dump1090_path': find_dump1090(),
'dump1090_running': dump1090_running,
'port_30003_open': check_dump1090_service() is not None 'port_30003_open': check_dump1090_service() is not None
}) })
@@ -291,9 +368,12 @@ def start_adsb():
if app_module.adsb_process: if app_module.adsb_process:
try: try:
app_module.adsb_process.terminate() app_module.adsb_process.terminate()
app_module.adsb_process.wait(timeout=2) app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except Exception: except (subprocess.TimeoutExpired, OSError):
pass try:
app_module.adsb_process.kill()
except OSError:
pass
app_module.adsb_process = None app_module.adsb_process = None
# Create device object and build command via abstraction layer # Create device object and build command via abstraction layer
@@ -314,16 +394,32 @@ def start_adsb():
app_module.adsb_process = subprocess.Popen( app_module.adsb_process = subprocess.Popen(
cmd, cmd,
stdout=subprocess.DEVNULL, 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: 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 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() thread.start()
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'}) return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
@@ -340,13 +436,14 @@ def stop_adsb():
if app_module.adsb_process: if app_module.adsb_process:
app_module.adsb_process.terminate() app_module.adsb_process.terminate()
try: try:
app_module.adsb_process.wait(timeout=5) app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
app_module.adsb_process.kill() app_module.adsb_process.kill()
app_module.adsb_process = None app_module.adsb_process = None
adsb_using_service = False adsb_using_service = False
app_module.adsb_aircraft = {} app_module.adsb_aircraft.clear()
_looked_up_icaos.clear()
return jsonify({'status': 'stopped'}) return jsonify({'status': 'stopped'})
@@ -355,16 +452,15 @@ def stream_adsb():
"""SSE stream for ADS-B aircraft.""" """SSE stream for ADS-B aircraft."""
def generate(): def generate():
last_keepalive = time.time() last_keepalive = time.time()
keepalive_interval = 30.0
while True: while True:
try: try:
msg = app_module.adsb_queue.get(timeout=1) msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time() last_keepalive = time.time()
yield format_sse(msg) yield format_sse(msg)
except queue.Empty: except queue.Empty:
now = time.time() now = time.time()
if now - last_keepalive >= keepalive_interval: if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'}) yield format_sse({'type': 'keepalive'})
last_keepalive = now last_keepalive = now
@@ -378,3 +474,38 @@ def stream_adsb():
def adsb_dashboard(): def adsb_dashboard():
"""Popout ADS-B dashboard.""" """Popout ADS-B dashboard."""
return render_template('adsb_dashboard.html') 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)

View File

@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse from utils.sse import format_sse
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER 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') bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
if platform.system() == 'Linux': if platform.system() == 'Linux':
try: 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) blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for block in blocks: for block in blocks:
if block.strip(): if block.strip():
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
'type': 'hci', 'type': 'hci',
'status': 'up' if is_up else 'down' 'status': 'up' if is_up else 'down'
}) })
except Exception: except FileNotFoundError:
pass 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': elif platform.system() == 'Darwin':
interfaces.append({ interfaces.append({

119
routes/correlation.py Normal file
View File

@@ -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

View File

@@ -1,9 +1,8 @@
"""GPS dongle routes for USB GPS device support.""" """GPS routes for gpsd daemon support."""
from __future__ import annotations from __future__ import annotations
import queue import queue
import threading
import time import time
from typing import Generator from typing import Generator
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger from utils.logging import get_logger
from utils.sse import format_sse from utils.sse import format_sse
from utils.gps import ( from utils.gps import (
detect_gps_devices,
is_serial_available,
get_gps_reader, get_gps_reader,
start_gps,
start_gpsd, start_gpsd,
stop_gps, stop_gps,
get_current_position, get_current_position,
GPSPosition, GPSPosition,
GPSDClient,
) )
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
pass pass
@gps_bp.route('/available') @gps_bp.route('/auto-connect', methods=['POST'])
def check_gps_available(): def auto_connect_gps():
"""Check if GPS dongle support is available.""" """
return jsonify({ Automatically connect to gpsd if available.
'available': is_serial_available(),
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
})
Called on page load to seamlessly enable GPS if gpsd is running.
@gps_bp.route('/gpsd/check') Returns current status if already connected.
def check_gpsd_available(): """
"""Check if gpsd is reachable."""
import socket import socket
host = request.args.get('host', 'localhost') # Check if already running
port = int(request.args.get('port', 2947)) 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: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0) sock.settimeout(1.0)
sock.connect((host, port)) sock.connect((host, port))
sock.close() sock.close()
except Exception:
return jsonify({ return jsonify({
'available': True, 'status': 'unavailable',
'host': host, 'message': 'gpsd not running'
'port': port,
'message': f'gpsd reachable at {host}:{port}'
}) })
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 # Clear the queue
while not _gps_queue.empty(): while not _gps_queue.empty():
@@ -139,80 +83,26 @@ def start_gps_reader():
except queue.Empty: except queue.Empty:
break break
# Start the GPS reader with callback pre-registered (avoids race condition) # Start the gpsd client
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
success = start_gpsd(host, port, callback=_position_callback) success = start_gpsd(host, port, callback=_position_callback)
if success: if success:
return jsonify({ return jsonify({
'status': 'started', 'status': 'connected',
'host': host, 'source': 'gpsd',
'port': port, 'has_fix': False,
'source': 'gpsd' 'position': None
}) })
else: else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({ return jsonify({
'status': 'error', 'status': 'unavailable',
'message': f'Failed to connect to gpsd: {error}' 'message': 'Failed to connect to gpsd'
}), 500 })
@gps_bp.route('/stop', methods=['POST']) @gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader(): def stop_gps_reader():
"""Stop GPS reader.""" """Stop GPS client."""
reader = get_gps_reader() reader = get_gps_reader()
if reader: if reader:
reader.remove_callback(_position_callback) reader.remove_callback(_position_callback)
@@ -224,7 +114,7 @@ def stop_gps_reader():
@gps_bp.route('/status') @gps_bp.route('/status')
def get_gps_status(): def get_gps_status():
"""Get current GPS reader status.""" """Get current GPS client status."""
reader = get_gps_reader() reader = get_gps_reader()
if not reader: if not reader:
@@ -233,7 +123,7 @@ def get_gps_status():
'device': None, 'device': None,
'position': None, 'position': None,
'error': None, 'error': None,
'message': 'GPS reader not started' 'message': 'GPS client not started'
}) })
position = reader.position position = reader.position
@@ -262,7 +152,7 @@ def get_position():
if not reader or not reader.is_running: if not reader or not reader.is_running:
return jsonify({ return jsonify({
'status': 'error', 'status': 'error',
'message': 'GPS reader not running' 'message': 'GPS client not running'
}), 400 }), 400
else: else:
return jsonify({ return jsonify({
@@ -273,22 +163,22 @@ def get_position():
@gps_bp.route('/debug') @gps_bp.route('/debug')
def debug_gps(): def debug_gps():
"""Debug endpoint showing GPS reader state.""" """Debug endpoint showing GPS client state."""
reader = get_gps_reader() reader = get_gps_reader()
if not reader: if not reader:
return jsonify({ return jsonify({
'reader': None, 'reader': None,
'message': 'No GPS reader initialized' 'message': 'No GPS client initialized'
}) })
position = reader.position position = reader.position
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
return jsonify({ return jsonify({
'running': reader.is_running, 'running': reader.is_running,
'source': source, 'source': 'gpsd',
'device': reader.device_path, 'device': reader.device_path,
'baudrate': reader.baudrate, 'host': reader.host,
'port': reader.port,
'has_position': position is not None, 'has_position': position is not None,
'position': position.to_dict() if position else None, 'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None, 'last_update': reader.last_update.isoformat() if reader.last_update else None,

768
routes/listening_post.py Normal file
View File

@@ -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',
}
)

228
routes/settings.py Normal file
View File

@@ -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('/<key>', methods=['GET'])
def get_single_setting(key: str) -> Response:
"""Get a single setting by key."""
try:
value = get_setting(key)
if value is None:
return jsonify({
'status': 'not_found',
'key': key
}), 404
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error getting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['PUT'])
def update_single_setting(key: str) -> Response:
"""Update a single setting."""
data = request.json or {}
value = data.get('value')
if value is None and 'value' not in data:
return jsonify({
'status': 'error',
'message': 'Value is required'
}), 400
try:
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
"""Delete a setting."""
try:
deleted = delete_setting(key)
if deleted:
return jsonify({
'status': 'success',
'key': key,
'deleted': True
})
else:
return jsonify({
'status': 'not_found',
'key': key
}), 404
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Signal History Endpoints
# =============================================================================
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
def get_device_signal_history(mode: str, device_id: str) -> Response:
"""Get signal strength history for a device."""
limit = request.args.get('limit', 100, type=int)
since_minutes = request.args.get('since', 60, type=int)
# Validate mode
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
if mode not in valid_modes:
return jsonify({
'status': 'error',
'message': f'Invalid mode. Valid modes: {valid_modes}'
}), 400
try:
history = get_signal_history(mode, device_id, limit, since_minutes)
return jsonify({
'status': 'success',
'mode': mode,
'device_id': device_id,
'history': history
})
except Exception as e:
logger.error(f"Error getting signal history: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/signal-history', methods=['POST'])
def add_signal_history() -> Response:
"""Add a signal strength reading (for internal use)."""
data = request.json or {}
mode = data.get('mode')
device_id = data.get('device_id')
signal_strength = data.get('signal_strength')
if not all([mode, device_id, signal_strength is not None]):
return jsonify({
'status': 'error',
'message': 'mode, device_id, and signal_strength are required'
}), 400
try:
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
return jsonify({'status': 'success'})
except Exception as e:
logger.error(f"Error adding signal reading: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Device Correlation Endpoints
# =============================================================================
@settings_bp.route('/correlations', methods=['GET'])
def get_device_correlations() -> Response:
"""Get device correlations between WiFi and Bluetooth."""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
try:
correlations = get_correlations(min_confidence)
return jsonify({
'status': 'success',
'correlations': correlations
})
except Exception as e:
logger.error(f"Error getting correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500

View File

@@ -16,12 +16,32 @@ from typing import Any, Generator
from flask import Blueprint, jsonify, request, Response from flask import Blueprint, jsonify, request, Response
import app as app_module import app as app_module
from utils.dependencies import check_tool from utils.dependencies import check_tool, get_tool_path
from utils.logging import wifi_logger as logger from utils.logging import wifi_logger as logger
from utils.process import is_valid_mac, is_valid_channel from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address from utils.validation import validate_wifi_channel, validate_mac_address
from utils.sse import format_sse from utils.sse import format_sse
from data.oui import get_manufacturer from data.oui import get_manufacturer
from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
WIFI_CSV_PARSE_INTERVAL,
WIFI_CSV_TIMEOUT_WARNING,
SUBPROCESS_TIMEOUT_SHORT,
SUBPROCESS_TIMEOUT_MEDIUM,
SUBPROCESS_TIMEOUT_LONG,
DEAUTH_TIMEOUT,
MIN_DEAUTH_COUNT,
MAX_DEAUTH_COUNT,
DEFAULT_DEAUTH_COUNT,
PROCESS_START_WAIT,
MONITOR_MODE_DELAY,
WIFI_CAPTURE_PATH_PREFIX,
HANDSHAKE_CAPTURE_PATH_PREFIX,
PMKID_CAPTURE_PATH_PREFIX,
)
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi') wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
if platform.system() == 'Darwin': # macOS if platform.system() == 'Darwin': # macOS
try: try:
result = subprocess.run(['networksetup', '-listallhardwareports'], result = subprocess.run(['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5) capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
lines = result.stdout.split('\n') lines = result.stdout.split('\n')
for i, line in enumerate(lines): for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line: if 'Wi-Fi' in line or 'AirPort' in line:
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
'status': 'up' 'status': 'up'
}) })
break break
except Exception as e: except FileNotFoundError:
logger.debug("networksetup not found")
except subprocess.TimeoutExpired:
logger.warning("networksetup timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting macOS interfaces: {e}") logger.error(f"Error detecting macOS interfaces: {e}")
try: try:
result = subprocess.run(['system_profiler', 'SPUSBDataType'], result = subprocess.run(['system_profiler', 'SPUSBDataType'],
capture_output=True, text=True, timeout=10) capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout: if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
interfaces.append({ interfaces.append({
'name': 'USB WiFi Adapter', 'name': 'USB WiFi Adapter',
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
'monitor_capable': True, 'monitor_capable': True,
'status': 'detected' 'status': 'detected'
}) })
except Exception: except FileNotFoundError:
pass logger.debug("system_profiler not found")
except subprocess.TimeoutExpired:
logger.debug("system_profiler timed out")
except subprocess.SubprocessError as e:
logger.debug(f"Error running system_profiler: {e}")
else: # Linux else: # Linux
try: try:
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5) result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
current_iface = None current_iface = None
for line in result.stdout.split('\n'): for line in result.stdout.split('\n'):
line = line.strip() line = line.strip()
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
}) })
current_iface = None current_iface = None
except FileNotFoundError: except FileNotFoundError:
# Fall back to iwconfig if iw is not available
try: try:
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5) result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
for line in result.stdout.split('\n'): for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line: if 'IEEE 802.11' in line:
iface = line.split()[0] iface = line.split()[0]
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
'monitor_capable': True, 'monitor_capable': True,
'status': 'up' 'status': 'up'
}) })
except Exception: except FileNotFoundError:
pass logger.debug("Neither iw nor iwconfig found")
except Exception as e: except subprocess.SubprocessError as e:
logger.debug(f"Error running iwconfig: {e}")
except subprocess.TimeoutExpired:
logger.warning("iw command timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting Linux interfaces: {e}") logger.error(f"Error detecting Linux interfaces: {e}")
return interfaces return interfaces
@@ -312,10 +345,11 @@ def toggle_monitor_mode():
interfaces_before = get_wireless_interfaces() interfaces_before = get_wireless_interfaces()
kill_processes = data.get('kill_processes', False) kill_processes = data.get('kill_processes', False)
airmon_path = get_tool_path('airmon-ng')
if kill_processes: if kill_processes:
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10) subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
result = subprocess.run(['airmon-ng', 'start', interface], result = subprocess.run([airmon_path, 'start', interface],
capture_output=True, text=True, timeout=15) capture_output=True, text=True, timeout=15)
output = result.stdout + result.stderr output = result.stdout + result.stderr
@@ -396,7 +430,8 @@ def toggle_monitor_mode():
else: # stop else: # stop
if check_tool('airmon-ng'): if check_tool('airmon-ng'):
try: try:
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface], airmon_path = get_tool_path('airmon-ng')
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
capture_output=True, text=True, timeout=15) capture_output=True, text=True, timeout=15)
app_module.wifi_monitor_interface = None app_module.wifi_monitor_interface = None
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'}) return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
@@ -447,8 +482,9 @@ def start_wifi_scan():
except OSError: except OSError:
pass pass
airodump_path = get_tool_path('airodump-ng')
cmd = [ cmd = [
'airodump-ng', airodump_path,
'-w', csv_path, '-w', csv_path,
'--output-format', 'csv,pcap', '--output-format', 'csv,pcap',
'--band', band, '--band', band,
@@ -546,8 +582,9 @@ def send_deauth():
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'}) return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
try: try:
aireplay_path = get_tool_path('aireplay-ng')
cmd = [ cmd = [
'aireplay-ng', aireplay_path,
'--deauth', str(count), '--deauth', str(count),
'-a', target_bssid, '-a', target_bssid,
'-c', target_client, '-c', target_client,
@@ -592,8 +629,9 @@ def capture_handshake():
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}' capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
airodump_path = get_tool_path('airodump-ng')
cmd = [ cmd = [
'airodump-ng', airodump_path,
'-c', str(channel), '-c', str(channel),
'--bssid', target_bssid, '--bssid', target_bssid,
'-w', capture_path, '-w', capture_path,
@@ -631,14 +669,16 @@ def check_handshake_status():
try: try:
if target_bssid and is_valid_mac(target_bssid): if target_bssid and is_valid_mac(target_bssid):
result = subprocess.run( aircrack_path = get_tool_path('aircrack-ng')
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file], if aircrack_path:
capture_output=True, text=True, timeout=10 result = subprocess.run(
) [aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
output = result.stdout + result.stderr capture_output=True, text=True, timeout=10
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()): )
if '0 handshake' not in output: output = result.stdout + result.stderr
handshake_found = True 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: except subprocess.TimeoutExpired:
pass pass
except Exception as e: except Exception as e:

755
setup.sh Executable file → Normal file
View File

@@ -1,18 +1,43 @@
#!/bin/bash #!/usr/bin/env bash
# # INTERCEPT Setup Script (best-effort installs, hard-fail verification)
# INTERCEPT Setup Script
# Installs Python dependencies and checks for external tools
#
set -e # ---- Force bash even if launched with sh ----
if [ -z "${BASH_VERSION:-}" ]; then
echo "[x] This script must be run with bash (not sh)."
echo " Run: bash $0"
exec bash "$0" "$@"
fi
# Colors for output set -Eeuo pipefail
# Ensure admin paths are searchable (many tools live here)
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
# ----------------------------
# Pretty output
# ----------------------------
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[x]${NC} $*"; }
on_error() {
local line="$1"
local cmd="${2:-unknown}"
fail "Setup failed at line ${line}: ${cmd}"
exit 1
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
# ----------------------------
# Banner
# ----------------------------
echo -e "${BLUE}" echo -e "${BLUE}"
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ " echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|" echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
@@ -20,460 +45,356 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | " echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| " echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
echo -e "${NC}" echo -e "${NC}"
echo "Signal Intelligence Platform - Setup Script" echo "INTERCEPT - Setup Script"
echo "============================================" echo "============================================"
echo "" echo
# ----------------------------
# Helpers
# ----------------------------
cmd_exists() {
local c="$1"
command -v "$c" >/dev/null 2>&1 && return 0
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
return 1
}
have_any() {
local c
for c in "$@"; do
cmd_exists "$c" && return 0
done
return 1
}
need_sudo() {
if [[ "$(id -u)" -eq 0 ]]; then
SUDO=""
ok "Running as root"
else
if cmd_exists sudo; then
SUDO="sudo"
else
fail "sudo is not installed and you're not root."
echo "Either run as root or install sudo first."
exit 1
fi
fi
}
# Detect OS
detect_os() { detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos" OS="macos"
PKG_MANAGER="brew" elif [[ -f /etc/debian_version ]]; then
elif [[ -f /etc/debian_version ]]; then OS="debian"
OS="debian" else
PKG_MANAGER="apt" OS="unknown"
elif [[ -f /etc/redhat-release ]]; then fi
OS="redhat" info "Detected OS: ${OS}"
PKG_MANAGER="dnf" [[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
elif [[ -f /etc/arch-release ]]; then
OS="arch"
PKG_MANAGER="pacman"
else
OS="unknown"
PKG_MANAGER="unknown"
fi
echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
} }
# Check if a command exists # ----------------------------
check_cmd() { # Required tool checks (with alternates)
command -v "$1" &> /dev/null # ----------------------------
missing_required=()
check_required() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, required)"
missing_required+=("$label")
fi
} }
# Check if a package is installable (has a candidate version)
pkg_available() {
local candidate
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
}
# Setup sudo command (empty if running as root)
setup_sudo() {
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
echo -e "${BLUE}Running as root${NC}"
elif check_cmd sudo; then
SUDO="sudo"
else
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
echo ""
echo "Please either:"
echo " 1. Run this script as root: su -c './setup.sh'"
echo " 2. Install sudo: apt install sudo"
exit 1
fi
}
# Install Python dependencies
install_python_deps() {
echo ""
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
if ! check_cmd python3; then
echo -e "${RED}Error: Python 3 is not installed${NC}"
echo "Please install Python 3.9 or later"
exit 1
fi
# Check Python version (need 3.9+)
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python version: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
echo -e "${RED}Error: Python 3.9 or later is required${NC}"
echo "You have Python $PYTHON_VERSION"
echo ""
echo "Please upgrade Python:"
if [ -n "$SUDO" ]; then
echo " Ubuntu/Debian: sudo apt install python3.11"
else
echo " Ubuntu/Debian: apt install python3.11"
fi
echo " macOS: brew install python@3.11"
exit 1
fi
# Check if we're in a virtual environment
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
elif [ -f "venv/bin/activate" ]; then
echo "Found existing venv, activating..."
source venv/bin/activate
pip install -r requirements.txt
else
# Try direct pip install first, fall back to venv if it fails (PEP 668)
echo "Attempting to install dependencies..."
if python3 -m pip install -r requirements.txt 2>/dev/null; then
echo -e "${GREEN}Python dependencies installed successfully${NC}"
return
fi
# If pip install failed (likely PEP 668), create a virtual environment
echo ""
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
echo "Creating virtual environment..."
# Remove any incomplete venv directory from previous failed attempts
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
echo "Removing incomplete venv directory..."
rm -rf venv
fi
if ! python3 -m venv venv; then
echo -e "${RED}Error: Failed to create virtual environment${NC}"
echo ""
echo "On Debian/Ubuntu, install the venv module with:"
if [ -n "$SUDO" ]; then
echo " sudo apt install python3-venv"
else
echo " apt install python3-venv"
fi
echo ""
echo "Then run this setup script again."
exit 1
fi
source venv/bin/activate
pip install -r requirements.txt
echo ""
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
echo "You must activate it before running INTERCEPT:"
echo " source venv/bin/activate"
if [ -n "$SUDO" ]; then
echo " sudo venv/bin/python intercept.py"
else
echo " venv/bin/python intercept.py"
fi
fi
echo -e "${GREEN}Python dependencies installed successfully${NC}"
}
# Check external tools
check_tools() { check_tools() {
echo "" info "Checking required tools..."
echo -e "${BLUE}[2/3] Checking external tools...${NC}" missing_required=()
echo ""
MISSING_TOOLS=() echo
MISSING_CORE=false info "Core SDR:"
MISSING_WIFI=false check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
MISSING_BLUETOOTH=false check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_required "dump1090" "ADS-B decoder" dump1090
# Core SDR tools echo
echo "Core SDR Tools:" info "GPS:"
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core" check_required "gpsd" "GPS daemon" gpsd
check_tool "rtl_test" "RTL-SDR device detection" "core"
check_tool "multimon-ng" "Pager decoder" "core"
check_tool "rtl_433" "433MHz sensor decoder" "core"
check_tool "dump1090" "ADS-B decoder" "core"
echo "" echo
echo "Additional SDR Hardware (optional):" info "Audio:"
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional" check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
check_tool "LimeUtil" "LimeSDR tools" "optional"
check_tool "hackrf_info" "HackRF tools" "optional"
echo "" echo
echo "WiFi Tools:" info "WiFi:"
check_tool "airmon-ng" "WiFi monitor mode" "wifi" check_required "airmon-ng" "Monitor mode helper" airmon-ng
check_tool "airodump-ng" "WiFi scanner" "wifi" check_required "airodump-ng" "WiFi scanner" airodump-ng
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
check_required "hcxdumptool" "PMKID capture" hcxdumptool
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
echo "" echo
echo "Bluetooth Tools:" info "Bluetooth:"
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth" check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
check_tool "hcitool" "Bluetooth HCI tool" "bluetooth" check_required "hcitool" "Bluetooth scan utility" hcitool
check_required "hciconfig" "Bluetooth adapter config" hciconfig
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then echo
echo "" info "SoapySDR:"
echo -e "${YELLOW}Some tools are missing.${NC}" check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
fi echo
} }
check_tool() { # ----------------------------
local cmd=$1 # Python venv + deps
local desc=$2 # ----------------------------
local category=$3 check_python_version() {
if check_cmd "$cmd"; then if ! cmd_exists python3; then
echo -e " ${GREEN}${NC} $cmd - $desc" fail "python3 not found."
else [[ "$OS" == "macos" ]] && echo "Install with: brew install python"
echo -e " ${RED}${NC} $cmd - $desc ${YELLOW}(not found)${NC}" [[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
MISSING_TOOLS+=("$cmd") exit 1
case "$category" in fi
core) MISSING_CORE=true ;;
wifi) MISSING_WIFI=true ;; local ver
bluetooth) MISSING_BLUETOOTH=true ;; ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
esac info "Python version: ${ver}"
fi
python3 - <<'PY'
import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
ok "Python version OK (>= 3.9)"
} }
# Install tools on Debian/Ubuntu install_python_deps() {
install_debian_tools() { info "Setting up Python virtual environment..."
echo "" check_python_version
echo -e "${BLUE}[3/3] Installing tools...${NC}"
echo ""
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then if [[ ! -f requirements.txt ]]; then
echo -e "${GREEN}All tools are already installed!${NC}" warn "requirements.txt not found; skipping Python dependency install."
return return 0
fi fi
echo -e "${YELLOW}The following tool categories need to be installed:${NC}" if [[ ! -d venv ]]; then
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)" python3 -m venv venv
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)" ok "Created venv/"
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)" else
echo "" ok "Using existing venv/"
fi
read -p "Would you like to install missing tools automatically? [Y/n] " -n 1 -r # shellcheck disable=SC1091
echo "" source venv/bin/activate
if [[ ! $REPLY =~ ^[Nn]$ ]]; then python -m pip install --upgrade pip setuptools wheel >/dev/null
echo "" ok "Upgraded pip tooling"
echo "Updating package lists..."
$SUDO apt update
# Core SDR tools info "Installing Python requirements..."
if $MISSING_CORE; then python -m pip install -r requirements.txt
echo "" ok "Python dependencies installed"
echo -e "${BLUE}Installing Core SDR tools...${NC}" echo
# Install packages that are reliably available
$SUDO apt install -y rtl-sdr multimon-ng
# rtl-433 may be named differently or unavailable
if pkg_available rtl-433; then
$SUDO apt install -y rtl-433
elif pkg_available rtl433; then
$SUDO apt install -y rtl433
else
echo -e "${YELLOW}Note: rtl-433 not found in repositories. Install manually or from source.${NC}"
fi
# dump1090 - try available variants, not available on all Debian versions
if pkg_available dump1090-fa; then
$SUDO apt install -y dump1090-fa
elif pkg_available dump1090-mutability; then
$SUDO apt install -y dump1090-mutability
elif pkg_available dump1090; then
$SUDO apt install -y dump1090
elif ! check_cmd dump1090; then
echo ""
echo -e "${YELLOW}Note: dump1090 not available in your repos (e.g. Debian Trixie).${NC}"
echo " FlightAware version: https://flightaware.com/adsb/piaware/install"
echo " Or from source: https://github.com/flightaware/dump1090"
fi
fi
# WiFi tools
if $MISSING_WIFI; then
echo ""
echo -e "${BLUE}Installing WiFi tools...${NC}"
$SUDO apt install -y aircrack-ng
fi
# Bluetooth tools
if $MISSING_BLUETOOTH; then
echo ""
echo -e "${BLUE}Installing Bluetooth tools...${NC}"
$SUDO apt install -y bluez bluetooth
fi
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
# Setup udev rules automatically
setup_udev_rules_auto
else
echo ""
echo "Skipping automatic installation."
show_manual_instructions
fi
} }
# Setup udev rules automatically (Debian) # ----------------------------
setup_udev_rules_auto() { # macOS install (Homebrew)
echo "" # ----------------------------
echo -e "${BLUE}Setting up RTL-SDR udev rules...${NC}" ensure_brew() {
cmd_exists brew && return 0
warn "Homebrew not found. Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then if [[ -x /opt/homebrew/bin/brew ]]; then
echo "udev rules already exist, skipping." eval "$(/opt/homebrew/bin/brew shellenv)"
return elif [[ -x /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
}
brew_install() {
local pkg="$1"
if brew list --formula "$pkg" >/dev/null 2>&1; then
ok "brew: ${pkg} already installed"
return 0
fi
info "brew: installing ${pkg}..."
brew install "$pkg"
ok "brew: installed ${pkg}"
}
install_macos_packages() {
ensure_brew
info "Installing packages via Homebrew..."
brew_install librtlsdr
brew_install multimon-ng
brew_install ffmpeg
brew_install rtl_433
# ADS-B (may not exist)
warn "Attempting dump1090 install via Homebrew (may be unavailable)..."
(brew_install dump1090-mutability) || true
brew_install aircrack-ng
brew_install hcxtools
brew_install soapysdr
brew_install gpsd
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
echo
}
# ----------------------------
# Debian/Ubuntu install (APT)
# ----------------------------
apt_install() { $SUDO apt-get install -y --no-install-recommends "$@" >/dev/null; }
apt_try_install_any() {
local p
for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
ok "apt: installed ${p}"
return 0
fi fi
done
return 1
}
read -p "Would you like to setup RTL-SDR udev rules? [Y/n] " -n 1 -r install_dump1090_from_source_debian() {
echo "" info "dump1090 not available via APT. Building from source (required)..."
if [[ ! $REPLY =~ ^[Nn]$ ]]; then apt_install build-essential git pkg-config \
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev
local orig_dir tmp_dir
orig_dir="$(pwd)"
tmp_dir="$(mktemp -d)"
cleanup() { cd "$orig_dir" >/dev/null 2>&1 || true; rm -rf "$tmp_dir"; }
trap cleanup EXIT
info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling FlightAware dump1090..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (FlightAware)."
return 0
fi
warn "FlightAware build failed. Falling back to antirez/dump1090..."
rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone antirez dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling antirez dump1090..."
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (antirez)."
}
setup_udev_rules_debian() {
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
info "Installing RTL-SDR udev rules..."
$SUDO tee "$rules_file" >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666" SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
EOF' EOF
$SUDO udevadm control --reload-rules $SUDO udevadm control --reload-rules || true
$SUDO udevadm trigger $SUDO udevadm trigger || true
echo -e "${GREEN}udev rules installed!${NC}" ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
echo "Please unplug and replug your RTL-SDR device." echo
fi
} }
# Show manual installation instructions install_debian_packages() {
show_manual_instructions() { need_sudo
echo "" info "Updating APT package lists..."
echo -e "${BLUE}Manual installation instructions:${NC}" $SUDO apt-get update -y >/dev/null
echo ""
if [[ "$OS" == "macos" ]]; then info "Installing required packages via APT..."
echo -e "${YELLOW}macOS (Homebrew):${NC}" apt_install rtl-sdr
echo "" apt_install multimon-ng
apt_install ffmpeg
if ! check_cmd brew; then apt_try_install_any rtl-433 rtl433 || true
echo "First, install Homebrew:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
echo ""
fi
echo "# Core SDR tools" apt_install aircrack-ng || true
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability" apt_install hcxdumptool || true
echo "" apt_install hcxtools || true
echo "# LimeSDR support (optional)" apt_install bluez bluetooth || true
echo "brew install soapysdr limesuite soapylms7" apt_install soapysdr-tools || true
echo "" apt_install gpsd gpsd-clients || true
echo "# HackRF support (optional)"
echo "brew install hackrf soapyhackrf"
echo ""
echo "# WiFi tools"
echo "brew install aircrack-ng"
elif [[ "$OS" == "debian" ]]; then # dump1090: apt first; source fallback; hard fail inside if it can't build
echo -e "${YELLOW}Ubuntu/Debian:${NC}" if ! cmd_exists dump1090; then
echo "" apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
echo "# Core SDR tools" fi
echo "sudo apt update" cmd_exists dump1090 || install_dump1090_from_source_debian
echo "sudo apt install rtl-sdr multimon-ng rtl-433"
echo ""
echo "# dump1090 (try one of these - package name varies):"
echo "sudo apt install dump1090-fa # FlightAware version"
echo "# Or install from: https://flightaware.com/adsb/piaware/install"
echo ""
echo "# LimeSDR support (optional)"
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
echo ""
echo "# HackRF support (optional)"
echo "sudo apt install hackrf soapysdr-module-hackrf"
echo ""
echo "# WiFi tools"
echo "sudo apt install aircrack-ng"
echo ""
echo "# Bluetooth tools"
echo "sudo apt install bluez bluetooth"
elif [[ "$OS" == "arch" ]]; then setup_udev_rules_debian
echo -e "${YELLOW}Arch Linux:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo pacman -S rtl-sdr multimon-ng"
echo "yay -S rtl_433 dump1090"
echo ""
echo "# LimeSDR/HackRF support (optional)"
echo "sudo pacman -S soapysdr limesuite hackrf"
elif [[ "$OS" == "redhat" ]]; then
echo -e "${YELLOW}Fedora/RHEL:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo dnf install rtl-sdr"
echo "# multimon-ng, rtl_433, dump1090 may need to be built from source"
else
echo "Please install the following tools manually:"
for tool in "${MISSING_TOOLS[@]}"; do
echo " - $tool"
done
fi
} }
# Show installation instructions (decides auto vs manual) # ----------------------------
install_or_show_instructions() { # Final summary / hard fail
if [[ "$OS" == "debian" ]]; then # ----------------------------
install_debian_tools final_summary_and_hard_fail() {
else check_tools
echo ""
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}" echo "============================================"
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then if [[ "${#missing_required[@]}" -eq 0 ]]; then
echo "" ok "All REQUIRED tools are installed."
echo -e "${GREEN}All tools are installed!${NC}" else
else fail "Missing REQUIRED tools:"
show_manual_instructions for t in "${missing_required[@]}"; do echo " - $t"; done
fi echo
fi fail "Exiting because required tools are missing."
echo
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
exit 1
fi
echo
echo "To start INTERCEPT:"
echo " source venv/bin/activate"
echo " sudo python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"
echo
} }
# RTL-SDR udev rules (Linux only) # ----------------------------
setup_udev_rules() { # MAIN
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then # ----------------------------
echo ""
echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
echo ""
echo "If your RTL-SDR is not detected, you may need to add udev rules:"
echo ""
echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
echo "EOF'"
echo ""
echo "sudo udevadm control --reload-rules"
echo "sudo udevadm trigger"
echo ""
echo "Then unplug and replug your RTL-SDR device."
fi
}
# Main
main() { main() {
detect_os detect_os
setup_sudo
install_python_deps
check_tools
install_or_show_instructions
# Show udev rules instructions for non-Debian Linux (Debian handles it automatically) if [[ "$OS" == "macos" ]]; then
if [[ "$OS" != "debian" ]]; then install_macos_packages
setup_udev_rules else
fi install_debian_packages
fi
echo "" install_python_deps
echo "============================================" final_summary_and_hard_fail
echo -e "${GREEN}Setup complete!${NC}"
echo ""
echo "To start INTERCEPT:"
if [ -d "venv" ]; then
echo " source venv/bin/activate"
if [ -n "$SUDO" ]; then
echo " sudo venv/bin/python intercept.py"
else
echo " venv/bin/python intercept.py"
fi
else
if [ -n "$SUDO" ]; then
echo " sudo python3 intercept.py"
else
echo " python3 intercept.py"
fi
fi
echo ""
echo "Then open http://localhost:5050 in your browser"
echo ""
} }
main "$@" main "$@"

View File

@@ -5,24 +5,27 @@
} }
:root { :root {
--bg-dark: #0a0a0f; --bg-dark: #0a0c10;
--bg-panel: #0d1117; --bg-panel: #0f1218;
--bg-card: #161b22; --bg-card: #151a23;
--border-glow: #00ff88; --border-color: #1f2937;
--text-primary: #e6edf3; --border-glow: #4a9eff;
--text-secondary: #8b949e; --text-primary: #e8eaed;
--accent-green: #00ff88; --text-secondary: #9ca3af;
--accent-cyan: #00d4ff; --text-dim: #4b5563;
--accent-orange: #ff9500; --accent-green: #22c55e;
--accent-red: #ff4444; --accent-cyan: #4a9eff;
--accent-yellow: #ffcc00; --accent-orange: #f59e0b;
--grid-line: rgba(0, 255, 136, 0.1); --accent-red: #ef4444;
--radar-cyan: #00ffff; --accent-yellow: #eab308;
--radar-bg: #1a1a2e; --accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
} }
body { body {
font-family: 'Rajdhani', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -44,18 +47,18 @@ body {
z-index: 0; z-index: 0;
} }
/* Scan line effect */ /* Scan line effect - subtle */
.scanline { .scanline {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 4px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent); background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 4s linear infinite; animation: scan 6s linear infinite;
pointer-events: none; pointer-events: none;
z-index: 1000; z-index: 1000;
opacity: 0.5; opacity: 0.3;
} }
@keyframes scan { @keyframes scan {
@@ -73,20 +76,20 @@ body {
position: relative; position: relative;
z-index: 10; z-index: 10;
padding: 12px 20px; padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%); background: var(--bg-panel);
border-bottom: 1px solid rgba(0, 255, 136, 0.3); border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.logo { .logo {
font-family: 'Orbitron', monospace; font-family: 'Inter', sans-serif;
font-size: 24px; font-size: 20px;
font-weight: 900; font-weight: 700;
letter-spacing: 4px; letter-spacing: 3px;
color: var(--accent-green); color: var(--text-primary);
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green); text-transform: uppercase;
} }
.logo span { .logo span {
@@ -115,8 +118,8 @@ body {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--accent-green); background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-green); box-shadow: 0 0 10px var(--accent-cyan);
animation: pulse 2s ease-in-out infinite; animation: pulse 2s ease-in-out infinite;
} }
@@ -144,8 +147,8 @@ body {
} }
.stat-badge { .stat-badge {
background: rgba(0, 255, 136, 0.1); background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
padding: 4px 10px; padding: 4px 10px;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
@@ -153,7 +156,7 @@ body {
} }
.stat-badge .value { .stat-badge .value {
color: var(--accent-green); color: var(--accent-cyan);
font-weight: 600; font-weight: 600;
} }
@@ -165,15 +168,15 @@ body {
.datetime { .datetime {
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 12px; font-size: 12px;
color: var(--accent-green); color: var(--accent-cyan);
} }
.back-link { .back-link {
color: var(--accent-green); color: var(--accent-cyan);
text-decoration: none; text-decoration: none;
font-size: 11px; font-size: 11px;
padding: 4px 10px; padding: 4px 10px;
border: 1px solid var(--accent-green); border: 1px solid var(--accent-cyan);
border-radius: 4px; border-radius: 4px;
} }
@@ -192,7 +195,7 @@ body {
/* Panels */ /* Panels */
.panel { .panel {
background: var(--bg-panel); background: var(--bg-panel);
border: 1px solid rgba(0, 255, 136, 0.2); border: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
@@ -204,19 +207,19 @@ body {
left: 0; left: 0;
right: 0; right: 0;
height: 2px; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent); background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
} }
.panel-header { .panel-header {
padding: 10px 15px; padding: 10px 15px;
background: rgba(0, 255, 136, 0.05); background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(0, 255, 136, 0.1); border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
letter-spacing: 2px; letter-spacing: 2px;
text-transform: uppercase; text-transform: uppercase;
color: var(--accent-green); color: var(--accent-cyan);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -225,7 +228,7 @@ body {
.panel-indicator { .panel-indicator {
width: 6px; width: 6px;
height: 6px; height: 6px;
background: var(--accent-green); background: var(--accent-cyan);
border-radius: 50%; border-radius: 50%;
animation: blink 1s ease-in-out infinite; animation: blink 1s ease-in-out infinite;
} }
@@ -300,7 +303,7 @@ body {
grid-row: 1; grid-row: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-left: 1px solid rgba(0, 255, 136, 0.2); border-left: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden; overflow: hidden;
} }
@@ -310,13 +313,13 @@ body {
padding: 10px; padding: 10px;
gap: 8px; gap: 8px;
background: var(--bg-panel); background: var(--bg-panel);
border-bottom: 1px solid rgba(0, 255, 136, 0.2); border-bottom: 1px solid rgba(74, 158, 255, 0.2);
} }
.view-btn { .view-btn {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
background: transparent; background: transparent;
color: var(--text-secondary); color: var(--text-secondary);
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
@@ -330,13 +333,13 @@ body {
} }
.view-btn:hover { .view-btn:hover {
border-color: var(--accent-green); border-color: var(--accent-cyan);
color: var(--accent-green); color: var(--accent-cyan);
} }
.view-btn.active { .view-btn.active {
background: var(--accent-green); background: var(--accent-cyan);
border-color: var(--accent-green); border-color: var(--accent-cyan);
color: var(--bg-dark); color: var(--bg-dark);
} }
@@ -355,8 +358,8 @@ body {
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--accent-green); color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-green); text-shadow: 0 0 15px var(--accent-cyan);
text-align: center; text-align: center;
margin-bottom: 12px; margin-bottom: 12px;
} }
@@ -371,7 +374,7 @@ body {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 4px; border-radius: 4px;
padding: 8px; padding: 8px;
border-left: 2px solid var(--accent-green); border-left: 2px solid var(--accent-cyan);
} }
.telemetry-label { .telemetry-label {
@@ -404,7 +407,7 @@ body {
.aircraft-item { .aircraft-item {
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.15); border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px; border-radius: 4px;
padding: 8px 10px; padding: 8px 10px;
margin-bottom: 6px; margin-bottom: 6px;
@@ -413,14 +416,14 @@ body {
} }
.aircraft-item:hover { .aircraft-item:hover {
border-color: var(--accent-green); border-color: var(--accent-cyan);
background: rgba(0, 255, 136, 0.05); background: rgba(74, 158, 255, 0.05);
} }
.aircraft-item.selected { .aircraft-item.selected {
border-color: var(--accent-green); border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2); box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(0, 255, 136, 0.1); background: rgba(74, 158, 255, 0.1);
} }
.aircraft-header { .aircraft-header {
@@ -434,14 +437,14 @@ body {
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: var(--accent-green); color: var(--accent-cyan);
} }
.aircraft-icao { .aircraft-icao {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 9px; font-size: 9px;
color: var(--text-secondary); color: var(--text-secondary);
background: rgba(0, 255, 136, 0.1); background: rgba(74, 158, 255, 0.1);
padding: 2px 5px; padding: 2px 5px;
border-radius: 3px; border-radius: 3px;
} }
@@ -475,10 +478,28 @@ body {
grid-row: 2; grid-row: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; flex-wrap: nowrap;
padding: 10px 20px; gap: 8px;
padding: 8px 15px;
background: var(--bg-panel); background: var(--bg-panel);
border-top: 1px solid rgba(0, 255, 136, 0.3); border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
}
.controls-bar label {
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
cursor: pointer;
}
.controls-bar select,
.controls-bar input[type="text"],
.controls-bar input[type="number"] {
padding: 3px 5px;
font-size: 10px;
} }
.control-group { .control-group {
@@ -497,15 +518,15 @@ body {
} }
.control-group input[type="checkbox"] { .control-group input[type="checkbox"] {
accent-color: var(--accent-green); accent-color: var(--accent-cyan);
} }
.control-group select { .control-group select {
padding: 6px 10px; padding: 6px 10px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-green); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-size: 11px;
} }
@@ -514,9 +535,9 @@ body {
width: 80px; width: 80px;
padding: 6px 8px; padding: 6px 8px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-green); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-size: 11px;
} }
@@ -531,9 +552,9 @@ body {
/* Start/stop button */ /* Start/stop button */
.start-btn { .start-btn {
padding: 8px 20px; padding: 8px 20px;
border: 1px solid var(--accent-green); border: 1px solid var(--accent-cyan);
background: rgba(0, 255, 136, 0.1); background: rgba(74, 158, 255, 0.1);
color: var(--accent-green); color: var(--accent-cyan);
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -546,9 +567,9 @@ body {
} }
.start-btn:hover { .start-btn:hover {
background: var(--accent-green); background: var(--accent-cyan);
color: var(--bg-dark); color: var(--bg-dark);
box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
} }
.start-btn.active { .start-btn.active {
@@ -564,10 +585,10 @@ body {
/* GPS button */ /* GPS button */
.gps-btn { .gps-btn {
padding: 6px 10px; padding: 6px 10px;
background: rgba(0, 255, 136, 0.2); background: rgba(74, 158, 255, 0.2);
border: 1px solid rgba(0, 255, 136, 0.3); border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px; border-radius: 4px;
color: var(--accent-green); color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
@@ -578,10 +599,15 @@ body {
background: var(--bg-dark) !important; background: var(--bg-dark) !important;
} }
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-panel) !important; background: var(--bg-panel) !important;
color: var(--accent-green) !important; color: var(--accent-cyan) !important;
border-color: rgba(0, 255, 136, 0.3) !important; border-color: var(--border-color) !important;
} }
.leaflet-control-attribution { .leaflet-control-attribution {
@@ -600,7 +626,7 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--accent-green); background: var(--accent-cyan);
border-radius: 3px; border-radius: 3px;
} }
@@ -632,7 +658,7 @@ body {
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
border-left: none; border-left: none;
border-top: 1px solid rgba(0, 255, 136, 0.2); border-top: 1px solid rgba(74, 158, 255, 0.2);
max-height: 300px; max-height: 300px;
} }
@@ -641,3 +667,159 @@ body {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
/* Airband Audio Controls */
.airband-divider {
width: 1px;
height: 20px;
background: var(--accent-cyan);
opacity: 0.4;
margin: 0 5px;
flex-shrink: 0;
}
.airband-controls {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.airband-btn {
padding: 6px 12px;
background: rgba(74, 158, 255, 0.1);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
}
.airband-btn:hover {
background: rgba(74, 158, 255, 0.2);
}
.airband-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: var(--accent-green);
color: var(--accent-green);
}
.airband-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.airband-icon {
font-size: 10px;
}
.airband-status {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 0 8px;
}
#airbandSquelch {
accent-color: var(--accent-cyan);
}
/* Airband Audio Visualizer */
.airband-visualizer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-left: 1px solid var(--border-color);
margin-left: 5px;
}
.airband-visualizer .signal-meter {
width: 80px;
}
.airband-visualizer .meter-bar {
height: 10px;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
position: relative;
overflow: hidden;
opacity: 0.3;
}
.airband-visualizer .meter-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
width: 0%;
transition: width 0.05s ease-out;
}
.airband-visualizer .meter-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: #fff;
opacity: 0.8;
transition: left 0.05s ease-out;
left: 0%;
}
#airbandSpectrumCanvas {
border-radius: 3px;
background: rgba(0, 0, 0, 0.4);
}
/* GPS Indicator */
.gps-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid #22c55e;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-indicator .gps-dot {
width: 6px;
height: 6px;
background: #22c55e;
border-radius: 50%;
animation: gps-pulse 2s ease-in-out infinite;
}
@keyframes gps-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,22 +5,25 @@
} }
:root { :root {
--bg-dark: #0a0a0f; --bg-dark: #0a0c10;
--bg-panel: #0d1117; --bg-panel: #0f1218;
--bg-card: #161b22; --bg-card: #151a23;
--border-glow: #00d4ff; --border-color: #1f2937;
--text-primary: #e6edf3; --border-glow: #4a9eff;
--text-secondary: #8b949e; --text-primary: #e8eaed;
--accent-cyan: #00d4ff; --text-secondary: #9ca3af;
--accent-green: #00ff88; --text-dim: #4b5563;
--accent-orange: #ff9500; --accent-cyan: #4a9eff;
--accent-red: #ff4444; --accent-green: #22c55e;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-purple: #a855f7; --accent-purple: #a855f7;
--grid-line: rgba(0, 212, 255, 0.1); --accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
} }
body { body {
font-family: 'Rajdhani', sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark); background: var(--bg-dark);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -82,28 +85,28 @@ body {
position: relative; position: relative;
z-index: 10; z-index: 10;
padding: 12px 20px; padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%); background: var(--bg-panel);
border-bottom: 1px solid rgba(0, 212, 255, 0.3); border-bottom: 1px solid var(--border-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.logo { .logo {
font-family: 'Orbitron', monospace; font-family: 'Inter', sans-serif;
font-size: 24px; font-size: 20px;
font-weight: 900; font-weight: 700;
letter-spacing: 4px; letter-spacing: 3px;
color: var(--accent-cyan); color: var(--text-primary);
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan); text-transform: uppercase;
} }
.logo span { .logo span {
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 12px;
margin-left: 15px; margin-left: 15px;
letter-spacing: 2px; letter-spacing: 1px;
} }
/* Stats badges in header */ /* Stats badges in header */
@@ -113,7 +116,7 @@ body {
} }
.stat-badge { .stat-badge {
background: rgba(0, 212, 255, 0.1); background: var(--bg-card);
border: 1px solid rgba(0, 212, 255, 0.3); border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px; border-radius: 4px;
padding: 4px 10px; padding: 4px 10px;
@@ -600,10 +603,15 @@ body {
background: var(--bg-dark) !important; background: var(--bg-dark) !important;
} }
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a { .leaflet-control-zoom a {
background: var(--bg-panel) !important; background: var(--bg-panel) !important;
color: var(--accent-cyan) !important; color: var(--accent-cyan) !important;
border-color: rgba(0, 212, 255, 0.3) !important; border-color: var(--border-color) !important;
} }
.leaflet-control-attribution { .leaflet-control-attribution {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // INTERCEPT</title> <title>SATELLITE COMMAND // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
@@ -247,8 +247,8 @@
worldCopyJump: true worldCopyJump: true
}); });
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '©OpenStreetMap, ©CartoDB' attribution: OpenStreetMap contributors'
}).addTo(groundMap); }).addTo(groundMap);
} }

340
tests/test_correlation.py Normal file
View File

@@ -0,0 +1,340 @@
"""Tests for device correlation engine."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
class TestDeviceCorrelator:
"""Tests for DeviceCorrelator class."""
def test_correlate_same_oui(self):
"""Test correlation detects same OUI."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'essid': 'TestNetwork',
'power': -65
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'name': 'TestPhone',
'rssi': -60
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
assert len(correlations) >= 1
assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33'
assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66'
assert correlations[0]['confidence'] > 0
def test_correlate_timing(self):
"""Test correlation considers timing."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=30)
now = datetime.now()
# Devices appearing at the same time
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'essid': 'Network1'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'Device1'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have some confidence from timing correlation
if correlations:
assert correlations[0]['confidence'] > 0
def test_correlate_no_overlap(self):
"""Test no correlation when devices don't overlap."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=30,
min_confidence=0.6
)
now = datetime.now()
old = now - timedelta(hours=1)
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': old,
'last_seen': old,
'essid': 'OldNetwork'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'NewDevice'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# With high min_confidence and no OUI match, should be empty
assert len(correlations) == 0
def test_correlate_manufacturer_match(self):
"""Test correlation boosts confidence for same manufacturer."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'essid': 'Network'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'name': 'iPhone'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have correlation with bonus for manufacturer match
assert len(correlations) >= 1
def test_correlate_empty_inputs(self):
"""Test correlation handles empty inputs."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
# Empty WiFi
assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == []
# Empty Bluetooth
assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == []
# Both empty
assert correlator.correlate({}, {}) == []
def test_correlate_sorting(self):
"""Test correlations are sorted by confidence."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=60,
min_confidence=0.0
)
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:11:11': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:22:22:22': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if len(correlations) >= 2:
# Should be sorted by confidence (highest first)
assert correlations[0]['confidence'] >= correlations[1]['confidence']
class TestGetCorrelations:
"""Tests for get_correlations function."""
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_live(self, mock_db, mock_correlator):
"""Test get_correlations with live data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = []
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=False
)
assert len(results) == 1
mock_correlator.correlate.assert_called_once()
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_historical(self, mock_db, mock_correlator):
"""Test get_correlations includes historical data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = []
mock_db.return_value = [
{
'wifi_mac': 'CC:CC:CC:CC:CC:CC',
'bt_mac': 'DD:DD:DD:DD:DD:DD',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
results = get_correlations(
wifi_devices={},
bt_devices={},
include_historical=True
)
assert len(results) == 1
assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC'
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_deduplication(self, mock_db, mock_correlator):
"""Test get_correlations deduplicates live and historical."""
from utils.correlation import get_correlations
# Same correlation from both sources
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=True
)
# Should deduplicate - only one entry for the same device pair
matching = [r for r in results
if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
class TestCorrelationReason:
"""Tests for correlation reason generation."""
def test_reason_same_oui(self):
"""Test reason includes OUI match."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if correlations:
assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower()
def test_reason_timing(self):
"""Test reason includes timing information."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now + timedelta(seconds=5),
'last_seen': now + timedelta(seconds=5)
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# If correlation found, should mention timing
if correlations and correlations[0]['confidence'] > 0.3:
assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason']

256
tests/test_database.py Normal file
View File

@@ -0,0 +1,256 @@
"""Tests for database utilities."""
import os
import tempfile
import pytest
from pathlib import Path
from unittest.mock import patch
# Need to patch DB_PATH before importing database module
@pytest.fixture(autouse=True)
def temp_db():
"""Use a temporary database for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
test_db_path = Path(tmpdir) / 'test_intercept.db'
test_db_dir = Path(tmpdir)
with patch('utils.database.DB_PATH', test_db_path), \
patch('utils.database.DB_DIR', test_db_dir):
# Import after patching
from utils.database import init_db, close_db
init_db()
yield test_db_path
close_db()
class TestSettingsCRUD:
"""Tests for settings CRUD operations."""
def test_set_and_get_string(self, temp_db):
"""Test setting and getting string values."""
from utils.database import set_setting, get_setting
set_setting('test_key', 'test_value')
assert get_setting('test_key') == 'test_value'
def test_set_and_get_int(self, temp_db):
"""Test setting and getting integer values."""
from utils.database import set_setting, get_setting
set_setting('int_key', 42)
result = get_setting('int_key')
assert result == 42
assert isinstance(result, int)
def test_set_and_get_float(self, temp_db):
"""Test setting and getting float values."""
from utils.database import set_setting, get_setting
set_setting('float_key', 3.14)
result = get_setting('float_key')
assert result == 3.14
assert isinstance(result, float)
def test_set_and_get_bool(self, temp_db):
"""Test setting and getting boolean values."""
from utils.database import set_setting, get_setting
set_setting('bool_true', True)
set_setting('bool_false', False)
assert get_setting('bool_true') is True
assert get_setting('bool_false') is False
def test_set_and_get_dict(self, temp_db):
"""Test setting and getting dictionary values."""
from utils.database import set_setting, get_setting
test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}}
set_setting('dict_key', test_dict)
result = get_setting('dict_key')
assert result == test_dict
assert result['nested']['a'] == 1
def test_set_and_get_list(self, temp_db):
"""Test setting and getting list values."""
from utils.database import set_setting, get_setting
test_list = [1, 2, 3, 'four', {'five': 5}]
set_setting('list_key', test_list)
result = get_setting('list_key')
assert result == test_list
def test_get_nonexistent_key(self, temp_db):
"""Test getting a key that doesn't exist."""
from utils.database import get_setting
assert get_setting('nonexistent') is None
assert get_setting('nonexistent', 'default') == 'default'
def test_update_existing_setting(self, temp_db):
"""Test updating an existing setting."""
from utils.database import set_setting, get_setting
set_setting('update_key', 'original')
assert get_setting('update_key') == 'original'
set_setting('update_key', 'updated')
assert get_setting('update_key') == 'updated'
def test_delete_setting(self, temp_db):
"""Test deleting a setting."""
from utils.database import set_setting, get_setting, delete_setting
set_setting('delete_key', 'value')
assert get_setting('delete_key') == 'value'
result = delete_setting('delete_key')
assert result is True
assert get_setting('delete_key') is None
def test_delete_nonexistent_setting(self, temp_db):
"""Test deleting a setting that doesn't exist."""
from utils.database import delete_setting
result = delete_setting('nonexistent_key')
assert result is False
def test_get_all_settings(self, temp_db):
"""Test getting all settings."""
from utils.database import set_setting, get_all_settings
set_setting('key1', 'value1')
set_setting('key2', 42)
set_setting('key3', True)
all_settings = get_all_settings()
assert 'key1' in all_settings
assert all_settings['key1'] == 'value1'
assert all_settings['key2'] == 42
assert all_settings['key3'] is True
class TestSignalHistory:
"""Tests for signal history operations."""
def test_add_and_get_signal_reading(self, temp_db):
"""Test adding and retrieving signal readings."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 3
# Results should be in chronological order
assert history[0]['signal'] == -65
assert history[1]['signal'] == -62
assert history[2]['signal'] == -70
def test_signal_history_with_metadata(self, temp_db):
"""Test signal readings with metadata."""
from utils.database import add_signal_reading, get_signal_history
metadata = {'channel': 6, 'ssid': 'TestNetwork'}
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 1
assert history[0]['metadata'] == metadata
def test_signal_history_limit(self, temp_db):
"""Test signal history respects limit parameter."""
from utils.database import add_signal_reading, get_signal_history
for i in range(10):
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5)
assert len(history) == 5
def test_signal_history_different_devices(self, temp_db):
"""Test signal history isolates different devices."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65)
add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70)
history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA')
history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB')
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]['signal'] == -65
assert history_b[0]['signal'] == -70
def test_cleanup_old_signal_history(self, temp_db):
"""Test cleanup of old signal history."""
from utils.database import add_signal_reading, cleanup_old_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
# Cleanup with 0 hours should remove everything
deleted = cleanup_old_signal_history(max_age_hours=0)
# Note: This may or may not delete depending on timing
assert isinstance(deleted, int)
class TestDeviceCorrelations:
"""Tests for device correlation operations."""
def test_add_and_get_correlation(self, temp_db):
"""Test adding and retrieving correlations."""
from utils.database import add_correlation, get_correlations
add_correlation(
wifi_mac='AA:AA:AA:AA:AA:AA',
bt_mac='BB:BB:BB:BB:BB:BB',
confidence=0.85,
metadata={'reason': 'timing'}
)
correlations = get_correlations(min_confidence=0.5)
assert len(correlations) >= 1
found = next(
(c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'),
None
)
assert found is not None
assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB'
assert found['confidence'] == 0.85
def test_correlation_confidence_filter(self, temp_db):
"""Test correlation filtering by confidence."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4)
high_confidence = get_correlations(min_confidence=0.7)
all_confidence = get_correlations(min_confidence=0.3)
assert len(high_confidence) == 1
assert len(all_confidence) == 2
def test_correlation_upsert(self, temp_db):
"""Test that correlations are updated on conflict."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5)
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
correlations = get_correlations(min_confidence=0.0)
matching = [c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
assert matching[0]['confidence'] == 0.9

376
tests/test_routes.py Normal file
View File

@@ -0,0 +1,376 @@
"""Tests for Flask routes and API endpoints."""
import json
import pytest
from unittest.mock import patch, MagicMock
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
from routes import register_blueprints
from utils.database import init_db
app_module.app.config['TESTING'] = True
# Initialize database for settings tests
init_db()
# Register blueprints only if not already registered (normally done in main())
# Check if any blueprint is already registered to avoid re-registration
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestHealthEndpoint:
"""Tests for health check endpoint."""
def test_health_check(self, client):
"""Test health endpoint returns expected data."""
response = client.get('/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'healthy'
assert 'version' in data
assert 'uptime_seconds' in data
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
"""Test getting device list."""
response = client.get('/devices')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, list)
@patch('app.SDRFactory.detect_devices')
def test_devices_returns_list(self, mock_detect, client):
"""Test devices endpoint returns list format."""
mock_device = MagicMock()
mock_device.to_dict.return_value = {
'index': 0,
'name': 'Test RTL-SDR',
'sdr_type': 'rtlsdr'
}
mock_detect.return_value = [mock_device]
response = client.get('/devices')
data = json.loads(response.data)
assert len(data) == 1
assert data[0]['name'] == 'Test RTL-SDR'
class TestDependenciesEndpoint:
"""Tests for dependencies endpoint."""
def test_get_dependencies(self, client):
"""Test getting dependency status."""
response = client.get('/dependencies')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'os' in data
assert 'pkg_manager' in data
assert 'modes' in data
class TestSettingsEndpoints:
"""Tests for settings API endpoints."""
def test_get_settings(self, client):
"""Test getting all settings."""
response = client.get('/settings')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'settings' in data
def test_save_settings(self, client):
"""Test saving settings."""
response = client.post(
'/settings',
data=json.dumps({'test_key': 'test_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'test_key' in data['saved']
def test_save_empty_settings(self, client):
"""Test saving empty settings returns error."""
response = client.post(
'/settings',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
def test_get_single_setting(self, client):
"""Test getting a single setting."""
# First save a setting
client.post(
'/settings',
data=json.dumps({'my_setting': 'my_value'}),
content_type='application/json'
)
# Then retrieve it
response = client.get('/settings/my_setting')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'my_value'
def test_get_nonexistent_setting(self, client):
"""Test getting a setting that doesn't exist."""
response = client.get('/settings/nonexistent_key_xyz')
assert response.status_code == 404
def test_update_setting(self, client):
"""Test updating a setting via PUT."""
response = client.put(
'/settings/update_test',
data=json.dumps({'value': 'updated_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'updated_value'
def test_delete_setting(self, client):
"""Test deleting a setting."""
# First create a setting
client.post(
'/settings',
data=json.dumps({'delete_me': 'value'}),
content_type='application/json'
)
# Then delete it
response = client.delete('/settings/delete_me')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['deleted'] is True
class TestCorrelationEndpoints:
"""Tests for correlation API endpoints."""
def test_get_correlations(self, client):
"""Test getting device correlations."""
response = client.get('/correlation')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'correlations' in data
assert 'wifi_count' in data
assert 'bt_count' in data
def test_correlations_with_confidence_filter(self, client):
"""Test correlation endpoint respects confidence filter."""
response = client.get('/correlation?min_confidence=0.8')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
class TestListeningPostEndpoints:
"""Tests for listening post endpoints."""
def test_tools_check(self, client):
"""Test listening post tools availability check."""
response = client.get('/listening/tools')
assert response.status_code == 200
data = json.loads(response.data)
assert 'rtl_fm' in data
assert 'available' in data
def test_scanner_status(self, client):
"""Test scanner status endpoint."""
response = client.get('/listening/scanner/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'paused' in data
assert 'current_freq' in data
def test_presets(self, client):
"""Test scanner presets endpoint."""
response = client.get('/listening/presets')
assert response.status_code == 200
data = json.loads(response.data)
assert 'presets' in data
assert len(data['presets']) > 0
# Check preset structure
preset = data['presets'][0]
assert 'name' in preset
assert 'start' in preset
assert 'end' in preset
assert 'mod' in preset
def test_scanner_stop_when_not_running(self, client):
"""Test stopping scanner when not running."""
response = client.post('/listening/scanner/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_activity_log(self, client):
"""Test getting activity log."""
response = client.get('/listening/scanner/log')
assert response.status_code == 200
data = json.loads(response.data)
assert 'log' in data
assert 'total' in data
def test_scanner_skip_when_not_running(self, client):
"""Test skip signal when scanner not running returns error."""
response = client.post('/listening/scanner/skip')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestAudioEndpoints:
"""Tests for audio demodulation endpoints."""
def test_audio_status(self, client):
"""Test audio status endpoint."""
response = client.get('/listening/audio/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'frequency' in data
assert 'modulation' in data
def test_audio_stop_when_not_running(self, client):
"""Test stopping audio when not running."""
response = client.post('/listening/audio/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_audio_start_missing_frequency(self, client):
"""Test starting audio without frequency returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'frequency' in data['message'].lower()
def test_audio_start_invalid_modulation(self, client):
"""Test starting audio with invalid modulation returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({
'frequency': 98.1,
'modulation': 'invalid_mode'
}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'modulation' in data['message'].lower()
def test_audio_stream_when_not_running(self, client):
"""Test audio stream when not running returns error."""
response = client.get('/listening/audio/stream')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestExportEndpoints:
"""Tests for data export endpoints."""
def test_export_aircraft_json(self, client):
"""Test exporting aircraft data as JSON."""
response = client.get('/export/aircraft?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_aircraft_csv(self, client):
"""Test exporting aircraft data as CSV."""
response = client.get('/export/aircraft?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_wifi_json(self, client):
"""Test exporting WiFi data as JSON."""
response = client.get('/export/wifi?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_wifi_csv(self, client):
"""Test exporting WiFi data as CSV."""
response = client.get('/export/wifi?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_bluetooth_json(self, client):
"""Test exporting Bluetooth data as JSON."""
response = client.get('/export/bluetooth?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_bluetooth_csv(self, client):
"""Test exporting Bluetooth data as CSV."""
response = client.get('/export/bluetooth?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type

120
tests/test_validation.py Normal file
View File

@@ -0,0 +1,120 @@
"""Comprehensive tests for validation utilities."""
import pytest
from utils.validation import (
validate_frequency,
validate_gain,
validate_device_index,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
class TestFrequencyValidation:
"""Tests for frequency validation."""
def test_valid_frequencies(self):
"""Test valid frequency values."""
assert validate_frequency('152.0') == '152.0'
assert validate_frequency(152.0) == '152.0'
assert validate_frequency('1090') == '1090'
assert validate_frequency(433.92) == '433.92'
def test_frequency_range(self):
"""Test frequency range limits."""
# RTL-SDR typical range: 24MHz - 1766MHz
assert validate_frequency('24') == '24'
assert validate_frequency('1700') == '1700'
def test_invalid_frequencies(self):
"""Test invalid frequency values."""
with pytest.raises(ValueError):
validate_frequency('')
with pytest.raises(ValueError):
validate_frequency('abc')
with pytest.raises(ValueError):
validate_frequency(-100)
with pytest.raises(ValueError):
validate_frequency(0)
class TestGainValidation:
"""Tests for gain validation."""
def test_valid_gains(self):
"""Test valid gain values."""
assert validate_gain('0') == '0'
assert validate_gain('40') == '40'
assert validate_gain(49.6) == '49.6'
assert validate_gain('auto') == 'auto'
def test_invalid_gains(self):
"""Test invalid gain values."""
with pytest.raises(ValueError):
validate_gain(-10)
with pytest.raises(ValueError):
validate_gain(100)
with pytest.raises(ValueError):
validate_gain('invalid')
class TestDeviceIndexValidation:
"""Tests for device index validation."""
def test_valid_indices(self):
"""Test valid device indices."""
assert validate_device_index('0') == '0'
assert validate_device_index(0) == '0'
assert validate_device_index('1') == '1'
assert validate_device_index(3) == '3'
def test_invalid_indices(self):
"""Test invalid device indices."""
with pytest.raises(ValueError):
validate_device_index(-1)
with pytest.raises(ValueError):
validate_device_index('abc')
with pytest.raises(ValueError):
validate_device_index(100)
class TestRtlTcpHostValidation:
"""Tests for RTL-TCP host validation."""
def test_valid_hosts(self):
"""Test valid host values."""
assert validate_rtl_tcp_host('localhost') == 'localhost'
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
def test_invalid_hosts(self):
"""Test invalid host values."""
with pytest.raises(ValueError):
validate_rtl_tcp_host('')
with pytest.raises(ValueError):
validate_rtl_tcp_host('invalid host with spaces')
with pytest.raises(ValueError):
validate_rtl_tcp_host('host;rm -rf /')
class TestRtlTcpPortValidation:
"""Tests for RTL-TCP port validation."""
def test_valid_ports(self):
"""Test valid port values."""
assert validate_rtl_tcp_port(1234) == 1234
assert validate_rtl_tcp_port('1234') == 1234
assert validate_rtl_tcp_port(30003) == 30003
assert validate_rtl_tcp_port(65535) == 65535
def test_invalid_ports(self):
"""Test invalid port values."""
with pytest.raises(ValueError):
validate_rtl_tcp_port(0)
with pytest.raises(ValueError):
validate_rtl_tcp_port(-1)
with pytest.raises(ValueError):
validate_rtl_tcp_port(70000)
with pytest.raises(ValueError):
validate_rtl_tcp_port('abc')

268
utils/aircraft_db.py Normal file
View File

@@ -0,0 +1,268 @@
"""Aircraft database for ICAO hex to type/registration lookup."""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from datetime import datetime
from typing import Any
from urllib.request import urlopen, Request
from urllib.error import URLError
logger = logging.getLogger('intercept.aircraft_db')
# Database file location (project root)
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
# Mictronics database URLs (raw GitHub)
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
# In-memory cache
_aircraft_cache: dict[str, dict[str, str]] = {}
_types_cache: dict[str, str] = {}
_cache_lock = threading.Lock()
_db_loaded = False
_db_version: str | None = None
_update_available: bool = False
_latest_version: str | None = None
def get_db_status() -> dict[str, Any]:
"""Get current database status."""
exists = os.path.exists(DB_FILE)
meta = _load_meta()
return {
'installed': exists,
'version': meta.get('version') if meta else None,
'downloaded': meta.get('downloaded') if meta else None,
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
'update_available': _update_available,
'latest_version': _latest_version,
}
def _load_meta() -> dict[str, Any] | None:
"""Load database metadata."""
try:
if os.path.exists(DB_META_FILE):
with open(DB_META_FILE, 'r') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Error loading aircraft db meta: {e}")
return None
def _save_meta(version: str) -> None:
"""Save database metadata."""
try:
meta = {
'version': version,
'downloaded': datetime.utcnow().isoformat() + 'Z',
}
with open(DB_META_FILE, 'w') as f:
json.dump(meta, f, indent=2)
except Exception as e:
logger.warning(f"Error saving aircraft db meta: {e}")
def load_database() -> bool:
"""Load aircraft database into memory. Returns True if successful."""
global _aircraft_cache, _types_cache, _db_loaded, _db_version
if not os.path.exists(DB_FILE):
logger.info("Aircraft database not installed")
return False
try:
with _cache_lock:
with open(DB_FILE, 'r') as f:
data = json.load(f)
_aircraft_cache = data.get('aircraft', {})
_types_cache = data.get('types', {})
_db_loaded = True
meta = _load_meta()
_db_version = meta.get('version') if meta else 'unknown'
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
return True
except Exception as e:
logger.error(f"Error loading aircraft database: {e}")
return False
def lookup(icao: str) -> dict[str, str] | None:
"""
Look up aircraft by ICAO hex code.
Returns dict with keys: registration, type_code, type_desc
Or None if not found.
"""
if not _db_loaded:
return None
icao_upper = icao.upper()
with _cache_lock:
aircraft = _aircraft_cache.get(icao_upper)
if not aircraft:
return None
# Database format is array: [registration, type_code, flags, ...]
# Handle both list format (from Mictronics) and dict format (legacy)
if isinstance(aircraft, list):
reg = aircraft[0] if len(aircraft) > 0 else ''
type_code = aircraft[1] if len(aircraft) > 1 else ''
else:
# Dict format fallback
reg = aircraft.get('r', '')
type_code = aircraft.get('t', '')
# Look up type description
type_desc = ''
if type_code and type_code in _types_cache:
type_desc = _types_cache[type_code]
return {
'registration': reg,
'type_code': type_code,
'type_desc': type_desc,
}
def check_for_updates() -> dict[str, Any]:
"""
Check GitHub for database updates.
Returns status dict with update_available flag.
"""
global _update_available, _latest_version
try:
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=10) as response:
commits = json.loads(response.read().decode('utf-8'))
if commits and len(commits) > 0:
latest_sha = commits[0]['sha'][:8]
latest_date = commits[0]['commit']['committer']['date']
_latest_version = f"{latest_date[:10]}_{latest_sha}"
meta = _load_meta()
current_version = meta.get('version') if meta else None
_update_available = current_version != _latest_version
return {
'success': True,
'current_version': current_version,
'latest_version': _latest_version,
'update_available': _update_available,
}
except URLError as e:
logger.warning(f"Failed to check for updates: {e}")
return {'success': False, 'error': str(e)}
except Exception as e:
logger.warning(f"Error checking for updates: {e}")
return {'success': False, 'error': str(e)}
return {'success': False, 'error': 'Unknown error'}
def download_database(progress_callback=None) -> dict[str, Any]:
"""
Download latest aircraft database from Mictronics repo.
Returns status dict.
"""
global _update_available
try:
if progress_callback:
progress_callback('Downloading aircraft database...')
# Download aircraft database
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=60) as response:
aircraft_data = json.loads(response.read().decode('utf-8'))
if progress_callback:
progress_callback('Downloading type codes...')
# Download types database
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=30) as response:
types_data = json.loads(response.read().decode('utf-8'))
if progress_callback:
progress_callback('Processing database...')
# Combine into single file
combined = {
'aircraft': aircraft_data,
'types': types_data,
}
# Save to file
with open(DB_FILE, 'w') as f:
json.dump(combined, f, separators=(',', ':')) # Compact JSON
# Get version from GitHub
version = datetime.utcnow().strftime('%Y-%m-%d')
try:
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
with urlopen(req, timeout=10) as response:
commits = json.loads(response.read().decode('utf-8'))
if commits:
sha = commits[0]['sha'][:8]
date = commits[0]['commit']['committer']['date'][:10]
version = f"{date}_{sha}"
except Exception:
pass
_save_meta(version)
_update_available = False
# Reload into memory
load_database()
return {
'success': True,
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
'version': version,
}
except URLError as e:
logger.error(f"Download failed: {e}")
return {'success': False, 'error': f'Download failed: {e}'}
except Exception as e:
logger.error(f"Error downloading database: {e}")
return {'success': False, 'error': str(e)}
def delete_database() -> dict[str, Any]:
"""Delete local database files."""
global _aircraft_cache, _types_cache, _db_loaded, _db_version
try:
with _cache_lock:
_aircraft_cache = {}
_types_cache = {}
_db_loaded = False
_db_version = None
if os.path.exists(DB_FILE):
os.remove(DB_FILE)
if os.path.exists(DB_META_FILE):
os.remove(DB_META_FILE)
return {'success': True, 'message': 'Database deleted'}
except Exception as e:
return {'success': False, 'error': str(e)}

View File

@@ -99,6 +99,23 @@ class DataStore:
with self._lock: with self._lock:
return key in self.data return key in self.data
def __getitem__(self, key: str) -> Any:
"""Get an entry using subscript notation."""
with self._lock:
return self.data[key]
def __setitem__(self, key: str, value: Any) -> None:
"""Set an entry using subscript notation."""
with self._lock:
self.data[key] = value
self.timestamps[key] = time.time()
def __delitem__(self, key: str) -> None:
"""Delete an entry using subscript notation."""
with self._lock:
del self.data[key]
del self.timestamps[key]
def cleanup(self) -> int: def cleanup(self) -> int:
""" """
Remove entries older than max_age. Remove entries older than max_age.

213
utils/constants.py Normal file
View File

@@ -0,0 +1,213 @@
"""
INTERCEPT - Constants and Magic Numbers
Centralized location for all hardcoded values used throughout the application.
This improves maintainability and makes the codebase self-documenting.
"""
from __future__ import annotations
# =============================================================================
# NETWORK PORTS
# =============================================================================
# ADS-B SBS data output port (dump1090 default)
ADSB_SBS_PORT = 30003
# GPS daemon port (gpsd default)
GPSD_PORT = 2947
# RTL-TCP server port (rtl_tcp default)
RTL_TCP_PORT = 1234
# =============================================================================
# PROCESS TIMEOUTS (seconds)
# =============================================================================
# General process termination timeout
PROCESS_TERMINATE_TIMEOUT = 2
# ADS-B process termination (dump1090 needs longer)
ADSB_TERMINATE_TIMEOUT = 5
# WiFi process termination (airodump-ng)
WIFI_TERMINATE_TIMEOUT = 3
# Bluetooth process termination
BT_TERMINATE_TIMEOUT = 3
# PMKID process termination
PMKID_TERMINATE_TIMEOUT = 5
# Socket connection timeout
SOCKET_CONNECT_TIMEOUT = 2
# SBS stream socket timeout
SBS_SOCKET_TIMEOUT = 5
# Subprocess command timeout (short operations)
SUBPROCESS_TIMEOUT_SHORT = 5
# Subprocess command timeout (medium operations)
SUBPROCESS_TIMEOUT_MEDIUM = 10
# Subprocess command timeout (long operations like airmon-ng)
SUBPROCESS_TIMEOUT_LONG = 15
# External HTTP request timeout (TLE fetching, etc.)
HTTP_REQUEST_TIMEOUT = 10
# Deauth command timeout
DEAUTH_TIMEOUT = 30
# Service enumeration timeout (sdptool browse)
SERVICE_ENUM_TIMEOUT = 30
# =============================================================================
# SSE (Server-Sent Events) SETTINGS
# =============================================================================
# Keepalive interval for SSE streams (seconds)
SSE_KEEPALIVE_INTERVAL = 30.0
# Queue get timeout for SSE generators (seconds)
SSE_QUEUE_TIMEOUT = 1.0
# =============================================================================
# DATA RETENTION / CLEANUP (seconds)
# =============================================================================
# Maximum age for aircraft data before cleanup
MAX_AIRCRAFT_AGE_SECONDS = 300 # 5 minutes
# Maximum age for WiFi network data before cleanup
MAX_WIFI_NETWORK_AGE_SECONDS = 600 # 10 minutes
# Maximum age for Bluetooth device data before cleanup
MAX_BT_DEVICE_AGE_SECONDS = 300 # 5 minutes
# ADS-B queue batch update interval
ADSB_UPDATE_INTERVAL = 1.0 # seconds
# =============================================================================
# QUEUE LIMITS
# =============================================================================
# Maximum queue size for all data queues
QUEUE_MAX_SIZE = 1000
# GPS queue size (smaller, more frequent updates)
GPS_QUEUE_MAX_SIZE = 100
# =============================================================================
# DATA PARSING
# =============================================================================
# WiFi CSV parse interval (seconds)
WIFI_CSV_PARSE_INTERVAL = 2.0
# Minimum time before warning about no CSV data
WIFI_CSV_TIMEOUT_WARNING = 5.0
# Socket receive buffer size
SOCKET_BUFFER_SIZE = 4096
# PTY read buffer size
PTY_BUFFER_SIZE = 1024
# =============================================================================
# EXTERNAL SERVICE LIMITS
# =============================================================================
# Maximum response size for external HTTP requests (bytes)
MAX_HTTP_RESPONSE_SIZE = 1024 * 1024 # 1 MB
# Deauth packet count limits
MIN_DEAUTH_COUNT = 1
MAX_DEAUTH_COUNT = 100
DEFAULT_DEAUTH_COUNT = 5
# =============================================================================
# VALIDATION LIMITS
# =============================================================================
# Squelch range
MIN_SQUELCH = 0
MAX_SQUELCH = 1000
# Valid GPS baudrates
VALID_GPS_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]
# Port range
MIN_PORT = 1
MAX_PORT = 65535
# =============================================================================
# SATELLITE TRACKING
# =============================================================================
# Default observer location (London)
DEFAULT_LATITUDE = 51.5074
DEFAULT_LONGITUDE = -0.1278
# Allowed TLE hosts for security
ALLOWED_TLE_HOSTS = [
'celestrak.org',
'celestrak.com',
'www.celestrak.org',
'www.celestrak.com'
]
# Earth radius (km) - WGS84 mean
EARTH_RADIUS_KM = 6371
# Trajectory calculation points
TRAJECTORY_POINTS = 30
GROUND_TRACK_POINTS = 60
ORBIT_TRACK_RANGE_MINUTES = 45
# =============================================================================
# SLEEP/DELAY TIMES (seconds)
# =============================================================================
# Wait after starting process before checking status
PROCESS_START_WAIT = 0.5
# Wait after dump1090 start before connecting
DUMP1090_START_WAIT = 3.0
# Delay between monitor mode operations
MONITOR_MODE_DELAY = 1.0
# Bluetooth adapter reset delays
BT_RESET_DELAY = 0.5
BT_ADAPTER_DOWN_WAIT = 1.0
# SBS reconnection delay on error
SBS_RECONNECT_DELAY = 2.0
# =============================================================================
# FILE PATHS
# =============================================================================
# Default pager log file
DEFAULT_PAGER_LOG_FILE = 'pager_messages.log'
# WiFi capture temp path prefix
WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi'
# Handshake capture path prefix
HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
# PMKID capture path prefix
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'

313
utils/correlation.py Normal file
View File

@@ -0,0 +1,313 @@
"""
Device correlation engine for matching WiFi and Bluetooth devices.
Uses timing-based correlation to identify when WiFi and Bluetooth
signals likely belong to the same physical device.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from utils.database import add_correlation, get_correlations as db_get_correlations
logger = logging.getLogger('intercept.correlation')
@dataclass
class DeviceObservation:
"""A single observation of a device."""
mac: str
first_seen: datetime
last_seen: datetime
rssi: int | None = None
name: str | None = None
manufacturer: str | None = None
class DeviceCorrelator:
"""
Correlates WiFi and Bluetooth devices based on timing patterns.
Devices are considered potentially correlated if:
1. They appear within a short time window of each other
2. They have similar signal strength patterns (optional)
3. They share the same OUI/manufacturer (bonus confidence)
"""
def __init__(
self,
time_window_seconds: int = 30,
min_confidence: float = 0.5,
rssi_threshold: int = 20
):
"""
Initialize correlator.
Args:
time_window_seconds: Max time difference for correlation (default 30s)
min_confidence: Minimum confidence score to report (default 0.5)
rssi_threshold: Max RSSI difference for signal-based correlation
"""
self.time_window = timedelta(seconds=time_window_seconds)
self.min_confidence = min_confidence
self.rssi_threshold = rssi_threshold
def correlate(
self,
wifi_devices: dict[str, dict[str, Any]],
bt_devices: dict[str, dict[str, Any]]
) -> list[dict]:
"""
Find correlations between WiFi and Bluetooth devices.
Args:
wifi_devices: Dict of WiFi devices keyed by MAC
bt_devices: Dict of Bluetooth devices keyed by MAC
Returns:
List of correlation results with confidence scores
"""
correlations = []
for wifi_mac, wifi_data in wifi_devices.items():
wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi')
if not wifi_obs:
continue
for bt_mac, bt_data in bt_devices.items():
bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth')
if not bt_obs:
continue
confidence = self._calculate_confidence(wifi_obs, bt_obs)
if confidence >= self.min_confidence:
correlations.append({
'wifi_mac': wifi_mac,
'wifi_name': wifi_obs.name,
'bt_mac': bt_mac,
'bt_name': bt_obs.name,
'confidence': round(confidence, 2),
'reason': self._get_correlation_reason(wifi_obs, bt_obs)
})
# Persist high-confidence correlations
if confidence >= 0.7:
try:
add_correlation(
wifi_mac=wifi_mac,
bt_mac=bt_mac,
confidence=confidence,
metadata={
'wifi_name': wifi_obs.name,
'bt_name': bt_obs.name
}
)
except Exception as e:
logger.debug(f"Failed to persist correlation: {e}")
# Sort by confidence (highest first)
correlations.sort(key=lambda x: x['confidence'], reverse=True)
return correlations
def _to_observation(
self,
mac: str,
data: dict[str, Any],
device_type: str
) -> DeviceObservation | None:
"""Convert device dict to observation."""
try:
# Handle different timestamp formats
first_seen = data.get('first_seen') or data.get('firstSeen')
last_seen = data.get('last_seen') or data.get('lastSeen')
if isinstance(first_seen, str):
first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00'))
elif isinstance(first_seen, (int, float)):
first_seen = datetime.fromtimestamp(first_seen / 1000)
else:
first_seen = datetime.now()
if isinstance(last_seen, str):
last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
elif isinstance(last_seen, (int, float)):
last_seen = datetime.fromtimestamp(last_seen / 1000)
else:
last_seen = datetime.now()
# Get RSSI (different field names)
rssi = data.get('rssi') or data.get('power') or data.get('signal')
if rssi is not None:
rssi = int(rssi)
# Get name
name = data.get('name') or data.get('essid') or data.get('ssid')
# Get manufacturer
manufacturer = data.get('manufacturer') or data.get('vendor')
return DeviceObservation(
mac=mac,
first_seen=first_seen,
last_seen=last_seen,
rssi=rssi,
name=name,
manufacturer=manufacturer
)
except Exception as e:
logger.debug(f"Failed to parse device {mac}: {e}")
return None
def _calculate_confidence(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> float:
"""
Calculate correlation confidence score.
Score components:
- Timing overlap: 0.0-0.5 (primary factor)
- Same manufacturer: +0.2
- Similar RSSI: +0.1
- Both named: +0.1
Returns:
Confidence score 0.0-1.0
"""
confidence = 0.0
# Timing correlation (most important)
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
# Linear decay from 0.5 to 0.0 as time difference increases
timing_score = 0.5 * (1 - time_diff / self.time_window.total_seconds())
confidence += timing_score
else:
# Check if observation windows overlap at all
wifi_end = wifi.last_seen
bt_end = bt.last_seen
# If observation periods overlap
if wifi.first_seen <= bt_end and bt.first_seen <= wifi_end:
confidence += 0.25 # Partial credit for overlapping presence
# Manufacturer match
if wifi.manufacturer and bt.manufacturer:
wifi_mfg = wifi.manufacturer.lower()
bt_mfg = bt.manufacturer.lower()
if wifi_mfg == bt_mfg:
confidence += 0.2
elif wifi_mfg[:5] == bt_mfg[:5]: # Partial match
confidence += 0.1
# OUI match (first 3 octets of MAC)
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
confidence += 0.15
# RSSI similarity
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
rssi_score = 0.1 * (1 - rssi_diff / self.rssi_threshold)
confidence += rssi_score
# Both have names (suggests user device)
if wifi.name and bt.name:
confidence += 0.05
return min(confidence, 1.0)
def _get_correlation_reason(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> str:
"""Generate human-readable reason for correlation."""
reasons = []
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
reasons.append(f"appeared within {int(time_diff)}s")
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
reasons.append("same OUI")
if wifi.manufacturer and bt.manufacturer:
if wifi.manufacturer.lower() == bt.manufacturer.lower():
reasons.append(f"same manufacturer ({wifi.manufacturer})")
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
reasons.append("similar signal strength")
return "; ".join(reasons) if reasons else "timing overlap"
# Global correlator instance
correlator = DeviceCorrelator()
def get_correlations(
wifi_devices: dict[str, dict] | None = None,
bt_devices: dict[str, dict] | None = None,
min_confidence: float = 0.5,
include_historical: bool = True
) -> list[dict]:
"""
Get device correlations.
Args:
wifi_devices: Current WiFi devices (or None to use only historical)
bt_devices: Current Bluetooth devices (or None to use only historical)
min_confidence: Minimum confidence threshold
include_historical: Include correlations from database
Returns:
List of correlations sorted by confidence
"""
results = []
# Get live correlations
if wifi_devices and bt_devices:
correlator.min_confidence = min_confidence
results.extend(correlator.correlate(wifi_devices, bt_devices))
# Get historical correlations from database
if include_historical:
try:
historical = db_get_correlations(min_confidence)
for h in historical:
# Avoid duplicates
existing = next(
(r for r in results
if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']),
None
)
if not existing:
results.append({
'wifi_mac': h['wifi_mac'],
'bt_mac': h['bt_mac'],
'confidence': h['confidence'],
'reason': 'historical correlation',
'first_seen': h['first_seen'],
'last_seen': h['last_seen']
})
except Exception as e:
logger.debug(f"Failed to get historical correlations: {e}")
# Sort by confidence
results.sort(key=lambda x: x['confidence'], reverse=True)
return results

351
utils/database.py Normal file
View File

@@ -0,0 +1,351 @@
"""
SQLite database utilities for persistent settings storage.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger('intercept.database')
# Database file location
DB_DIR = Path(__file__).parent.parent / 'instance'
DB_PATH = DB_DIR / 'intercept.db'
# Thread-local storage for connections
_local = threading.local()
def get_db_path() -> Path:
"""Get the database file path, creating directory if needed."""
DB_DIR.mkdir(parents=True, exist_ok=True)
return DB_PATH
def get_connection() -> sqlite3.Connection:
"""Get a thread-local database connection."""
if not hasattr(_local, 'connection') or _local.connection is None:
db_path = get_db_path()
_local.connection = sqlite3.connect(str(db_path), check_same_thread=False)
_local.connection.row_factory = sqlite3.Row
# Enable foreign keys
_local.connection.execute('PRAGMA foreign_keys = ON')
return _local.connection
@contextmanager
def get_db():
"""Context manager for database operations."""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def init_db() -> None:
"""Initialize the database schema."""
db_path = get_db_path()
logger.info(f"Initializing database at {db_path}")
with get_db() as conn:
# Settings table for key-value storage
conn.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'string',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Signal history table for graphs
conn.execute('''
CREATE TABLE IF NOT EXISTS signal_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
device_id TEXT NOT NULL,
signal_strength REAL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT
)
''')
# Create index for faster queries
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device
ON signal_history(mode, device_id, timestamp)
''')
# Device correlation table
conn.execute('''
CREATE TABLE IF NOT EXISTS device_correlations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wifi_mac TEXT,
bt_mac TEXT,
confidence REAL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
UNIQUE(wifi_mac, bt_mac)
)
''')
logger.info("Database initialized successfully")
def close_db() -> None:
"""Close the thread-local database connection."""
if hasattr(_local, 'connection') and _local.connection is not None:
_local.connection.close()
_local.connection = None
# =============================================================================
# Settings Functions
# =============================================================================
def get_setting(key: str, default: Any = None) -> Any:
"""
Get a setting value by key.
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value (auto-converted from JSON for complex types)
"""
with get_db() as conn:
cursor = conn.execute(
'SELECT value, value_type FROM settings WHERE key = ?',
(key,)
)
row = cursor.fetchone()
if row is None:
return default
value, value_type = row['value'], row['value_type']
# Convert based on type
if value_type == 'json':
try:
return json.loads(value)
except json.JSONDecodeError:
return default
elif value_type == 'int':
return int(value)
elif value_type == 'float':
return float(value)
elif value_type == 'bool':
return value.lower() in ('true', '1', 'yes')
else:
return value
def set_setting(key: str, value: Any) -> None:
"""
Set a setting value.
Args:
key: Setting key
value: Setting value (will be JSON-encoded for complex types)
"""
# Determine value type and string representation
if isinstance(value, bool):
value_type = 'bool'
str_value = 'true' if value else 'false'
elif isinstance(value, int):
value_type = 'int'
str_value = str(value)
elif isinstance(value, float):
value_type = 'float'
str_value = str(value)
elif isinstance(value, (dict, list)):
value_type = 'json'
str_value = json.dumps(value)
else:
value_type = 'string'
str_value = str(value)
with get_db() as conn:
conn.execute('''
INSERT INTO settings (key, value, value_type, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
value_type = excluded.value_type,
updated_at = CURRENT_TIMESTAMP
''', (key, str_value, value_type))
def delete_setting(key: str) -> bool:
"""
Delete a setting.
Args:
key: Setting key
Returns:
True if setting was deleted, False if not found
"""
with get_db() as conn:
cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,))
return cursor.rowcount > 0
def get_all_settings() -> dict[str, Any]:
"""Get all settings as a dictionary."""
with get_db() as conn:
cursor = conn.execute('SELECT key, value, value_type FROM settings')
settings = {}
for row in cursor:
key, value, value_type = row['key'], row['value'], row['value_type']
if value_type == 'json':
try:
settings[key] = json.loads(value)
except json.JSONDecodeError:
settings[key] = value
elif value_type == 'int':
settings[key] = int(value)
elif value_type == 'float':
settings[key] = float(value)
elif value_type == 'bool':
settings[key] = value.lower() in ('true', '1', 'yes')
else:
settings[key] = value
return settings
# =============================================================================
# Signal History Functions
# =============================================================================
def add_signal_reading(
mode: str,
device_id: str,
signal_strength: float,
metadata: dict | None = None
) -> None:
"""Add a signal strength reading."""
with get_db() as conn:
conn.execute('''
INSERT INTO signal_history (mode, device_id, signal_strength, metadata)
VALUES (?, ?, ?, ?)
''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None))
def get_signal_history(
mode: str,
device_id: str,
limit: int = 100,
since_minutes: int = 60
) -> list[dict]:
"""
Get signal history for a device.
Args:
mode: Mode (wifi, bluetooth, adsb, etc.)
device_id: Device identifier (MAC, ICAO, etc.)
limit: Maximum number of readings
since_minutes: Only get readings from last N minutes
Returns:
List of signal readings with timestamp
"""
with get_db() as conn:
cursor = conn.execute('''
SELECT signal_strength, timestamp, metadata
FROM signal_history
WHERE mode = ? AND device_id = ?
AND timestamp > datetime('now', ?)
ORDER BY timestamp DESC
LIMIT ?
''', (mode, device_id, f'-{since_minutes} minutes', limit))
results = []
for row in cursor:
results.append({
'signal': row['signal_strength'],
'timestamp': row['timestamp'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
return list(reversed(results)) # Return in chronological order
def cleanup_old_signal_history(max_age_hours: int = 24) -> int:
"""
Remove old signal history entries.
Args:
max_age_hours: Maximum age in hours
Returns:
Number of deleted entries
"""
with get_db() as conn:
cursor = conn.execute('''
DELETE FROM signal_history
WHERE timestamp < datetime('now', ?)
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
# =============================================================================
# Device Correlation Functions
# =============================================================================
def add_correlation(
wifi_mac: str,
bt_mac: str,
confidence: float,
metadata: dict | None = None
) -> None:
"""Add or update a device correlation."""
with get_db() as conn:
conn.execute('''
INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET
confidence = excluded.confidence,
last_seen = CURRENT_TIMESTAMP,
metadata = excluded.metadata
''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None))
def get_correlations(min_confidence: float = 0.5) -> list[dict]:
"""Get all device correlations above minimum confidence."""
with get_db() as conn:
cursor = conn.execute('''
SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata
FROM device_correlations
WHERE confidence >= ?
ORDER BY confidence DESC
''', (min_confidence,))
results = []
for row in cursor:
results.append({
'wifi_mac': row['wifi_mac'],
'bt_mac': row['bt_mac'],
'confidence': row['confidence'],
'first_seen': row['first_seen'],
'last_seen': row['last_seen'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
return results

View File

@@ -1,15 +1,35 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import shutil import shutil
from typing import Any from typing import Any
logger = logging.getLogger('intercept.dependencies') logger = logging.getLogger('intercept.dependencies')
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
def check_tool(name: str) -> bool: def check_tool(name: str) -> bool:
"""Check if a tool is installed.""" """Check if a tool is installed."""
return shutil.which(name) is not None return get_tool_path(name) is not None
def get_tool_path(name: str) -> str | None:
"""Get the full path to a tool, checking standard PATH and extra locations."""
# First check standard PATH
path = shutil.which(name)
if path:
return path
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
for extra_path in EXTRA_TOOL_PATHS:
full_path = os.path.join(extra_path, name)
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
return full_path
return None
# Comprehensive tool dependency definitions # Comprehensive tool dependency definitions

View File

@@ -1,32 +1,20 @@
""" """
GPS dongle support for INTERCEPT. GPS support for INTERCEPT via gpsd daemon.
Provides detection and reading of USB GPS dongles via serial port. Provides GPS location data by connecting to the gpsd daemon.
Parses NMEA sentences to extract location data.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import re
import glob
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional, Callable, Union from typing import Optional, Callable
logger = logging.getLogger('intercept.gps') logger = logging.getLogger('intercept.gps')
# Try to import serial, but don't fail if not available
try:
import serial
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
logger.warning("pyserial not installed - GPS dongle support disabled")
@dataclass @dataclass
class GPSPosition: class GPSPosition:
@@ -34,10 +22,10 @@ class GPSPosition:
latitude: float latitude: float
longitude: float longitude: float
altitude: Optional[float] = None altitude: Optional[float] = None
speed: Optional[float] = None # knots speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees heading: Optional[float] = None # degrees
satellites: Optional[int] = None satellites: Optional[int] = None
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None
device: Optional[str] = None device: Optional[str] = None
@@ -56,407 +44,6 @@ class GPSPosition:
} }
def detect_gps_devices() -> list[dict]:
"""
Detect potential GPS serial devices.
Returns a list of device info dictionaries.
"""
devices = []
# Common GPS device patterns by platform
patterns = []
if os.name == 'posix':
# Linux
patterns.extend([
'/dev/ttyUSB*', # USB serial adapters
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
'/dev/gps*', # gpsd symlinks
])
# macOS
patterns.extend([
'/dev/tty.usbserial*',
'/dev/tty.usbmodem*',
'/dev/cu.usbserial*',
'/dev/cu.usbmodem*',
])
for pattern in patterns:
for path in glob.glob(pattern):
# Try to get device info
device_info = {
'path': path,
'name': os.path.basename(path),
'type': 'serial',
}
# Check if it's readable
if os.access(path, os.R_OK):
device_info['accessible'] = True
else:
device_info['accessible'] = False
device_info['error'] = 'Permission denied'
devices.append(device_info)
return devices
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
"""
Parse NMEA coordinate format to decimal degrees.
NMEA format: DDDMM.MMMM or DDMM.MMMM
"""
if not coord or not direction:
return None
try:
# Find the decimal point
dot_pos = coord.index('.')
# Degrees are everything before the last 2 digits before decimal
degrees = int(coord[:dot_pos - 2])
minutes = float(coord[dot_pos - 2:])
result = degrees + (minutes / 60.0)
# Apply direction
if direction in ('S', 'W'):
result = -result
return result
except (ValueError, IndexError):
return None
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
"""
if len(parts) < 10:
return None
try:
fix_quality = int(parts[6]) if parts[6] else 0
# No fix
if fix_quality == 0:
return None
lat = parse_nmea_coordinate(parts[2], parts[3])
lon = parse_nmea_coordinate(parts[4], parts[5])
if lat is None or lon is None:
return None
# Parse optional fields
satellites = int(parts[7]) if parts[7] else None
altitude = float(parts[9]) if parts[9] else None
# Parse time (HHMMSS.sss)
timestamp = None
if parts[1]:
try:
time_str = parts[1].split('.')[0]
if len(time_str) >= 6:
now = datetime.utcnow()
timestamp = now.replace(
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
microsecond=0
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
altitude=altitude,
satellites=satellites,
fix_quality=fix_quality,
timestamp=timestamp,
)
except (ValueError, IndexError) as e:
logger.debug(f"GGA parse error: {e}")
return None
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPRMC/GNRMC sentence (Recommended Minimum).
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
"""
if len(parts) < 8:
return None
try:
# Check status (A=active/valid, V=void/invalid)
if parts[2] != 'A':
return None
lat = parse_nmea_coordinate(parts[3], parts[4])
lon = parse_nmea_coordinate(parts[5], parts[6])
if lat is None or lon is None:
return None
# Parse optional fields
speed = float(parts[7]) if parts[7] else None # knots
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
# Parse timestamp
timestamp = None
if parts[1] and len(parts) > 9 and parts[9]:
try:
time_str = parts[1].split('.')[0]
date_str = parts[9]
if len(time_str) >= 6 and len(date_str) >= 6:
timestamp = datetime(
year=2000 + int(date_str[4:6]),
month=int(date_str[2:4]),
day=int(date_str[0:2]),
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
speed=speed,
heading=heading,
timestamp=timestamp,
fix_quality=1, # RMC with A status means valid fix
)
except (ValueError, IndexError) as e:
logger.debug(f"RMC parse error: {e}")
return None
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
"""
Parse an NMEA sentence and extract position data.
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
"""
sentence = sentence.strip()
# Validate checksum if present
if '*' in sentence:
data, checksum = sentence.rsplit('*', 1)
if data.startswith('$'):
data = data[1:]
# Calculate checksum
calc_checksum = 0
for char in data:
calc_checksum ^= ord(char)
try:
if int(checksum, 16) != calc_checksum:
logger.debug(f"Checksum mismatch: {sentence}")
return None
except ValueError:
pass
# Remove $ prefix if present
if sentence.startswith('$'):
sentence = sentence[1:]
# Remove checksum for parsing
if '*' in sentence:
sentence = sentence.split('*')[0]
parts = sentence.split(',')
if not parts:
return None
msg_type = parts[0]
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
if msg_type.endswith('GGA'):
return parse_gga(parts)
elif msg_type.endswith('RMC'):
return parse_rmc(parts)
return None
class GPSReader:
"""
Reads GPS data from a serial device.
Runs in a background thread and maintains current position.
"""
def __init__(self, device_path: str, baudrate: int = 9600):
self.device_path = device_path
self.baudrate = baudrate
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._serial: Optional['serial.Serial'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
@property
def position(self) -> Optional[GPSPosition]:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def is_running(self) -> bool:
"""Check if the reader is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
"""Get any error message."""
with self._lock:
return self._error
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def start(self) -> bool:
"""Start reading GPS data in a background thread."""
if not SERIAL_AVAILABLE:
self._error = "pyserial not installed"
return False
if self._running:
return True
try:
self._serial = serial.Serial(
self.device_path,
baudrate=self.baudrate,
timeout=1.0
)
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Started GPS reader on {self.device_path}")
return True
except serial.SerialException as e:
self._error = str(e)
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
return False
def stop(self) -> None:
"""Stop reading GPS data."""
self._running = False
if self._serial:
try:
self._serial.close()
except Exception:
pass
self._serial = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Stopped GPS reader on {self.device_path}")
def _read_loop(self) -> None:
"""Background thread loop for reading GPS data."""
buffer = ""
sentence_count = 0
bytes_read = 0
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
while self._running and self._serial:
try:
# Read available data
waiting = self._serial.in_waiting
if waiting:
data = self._serial.read(waiting)
bytes_read += len(data)
if bytes_read <= 500 or bytes_read % 1000 == 0:
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
buffer += data.decode('ascii', errors='ignore')
# Process complete lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$'):
sentence_count += 1
# Log first few sentences and periodically after that
if sentence_count <= 10 or sentence_count % 50 == 0:
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
position = parse_nmea_sentence(line)
if position:
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
position.device = self.device_path
self._update_position(position)
else:
time.sleep(0.1)
except serial.SerialException as e:
logger.error(f"GPS read error: {e}")
with self._lock:
self._error = str(e)
break
except Exception as e:
logger.debug(f"GPS parse error: {e}")
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
# Merge data from different sentence types
if self._position:
# Keep altitude from GGA if RMC doesn't have it
if position.altitude is None and self._position.altitude:
position.altitude = self._position.altitude
# Keep satellites from GGA
if position.satellites is None and self._position.satellites:
position.satellites = self._position.satellites
self._position = position
self._last_update = datetime.utcnow()
self._error = None
# Notify callbacks
for callback in self._callbacks:
try:
callback(position)
except Exception as e:
logger.error(f"GPS callback error: {e}")
class GPSDClient: class GPSDClient:
""" """
Connects to gpsd daemon for GPS data. Connects to gpsd daemon for GPS data.
@@ -506,14 +93,9 @@ class GPSDClient:
@property @property
def device_path(self) -> str: def device_path(self) -> str:
"""Return gpsd connection info (for compatibility with GPSReader).""" """Return gpsd connection info."""
return f"gpsd://{self.host}:{self.port}" return f"gpsd://{self.host}:{self.port}"
@property
def baudrate(self) -> int:
"""Return 0 for gpsd (for compatibility with GPSReader)."""
return 0
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None: def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates.""" """Add a callback to be called on position updates."""
self._callbacks.append(callback) self._callbacks.append(callback)
@@ -667,7 +249,7 @@ class GPSDClient:
latitude=lat, latitude=lat,
longitude=lon, longitude=lon,
altitude=msg.get('alt'), altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd (not knots) speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'), heading=msg.get('track'),
fix_quality=mode, fix_quality=mode,
timestamp=timestamp, timestamp=timestamp,
@@ -692,47 +274,15 @@ class GPSDClient:
logger.error(f"GPS callback error: {e}") logger.error(f"GPS callback error: {e}")
# Type alias for GPS source (either serial reader or gpsd client) # Global GPS client instance
GPSSource = Union[GPSReader, GPSDClient] _gps_client: Optional[GPSDClient] = None
# Global GPS reader instance
_gps_reader: Optional[GPSSource] = None
_gps_lock = threading.Lock() _gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSSource]: def get_gps_reader() -> Optional[GPSDClient]:
"""Get the global GPS reader/client instance.""" """Get the global GPS client instance."""
with _gps_lock: with _gps_lock:
return _gps_reader return _gps_client
def start_gps(device_path: str, baudrate: int = 9600,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
"""
Start the global GPS reader.
Args:
device_path: Path to the GPS serial device
baudrate: Serial baudrate (default 9600)
callback: Optional callback for position updates (registered before start to avoid race condition)
Returns:
True if started successfully
"""
global _gps_reader
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
_gps_reader = GPSReader(device_path, baudrate)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
return _gps_reader.start()
def start_gpsd(host: str = 'localhost', port: int = 2947, def start_gpsd(host: str = 'localhost', port: int = 2947,
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
Returns: Returns:
True if started successfully True if started successfully
""" """
global _gps_reader global _gps_client
with _gps_lock: with _gps_lock:
# Stop existing reader if any # Stop existing client if any
if _gps_reader: if _gps_client:
_gps_reader.stop() _gps_client.stop()
_gps_reader = GPSDClient(host, port) _gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition # Register callback BEFORE starting to avoid race condition
if callback: if callback:
_gps_reader.add_callback(callback) _gps_client.add_callback(callback)
return _gps_reader.start() return _gps_client.start()
def stop_gps() -> None: def stop_gps() -> None:
"""Stop the global GPS reader/client.""" """Stop the global GPS client."""
global _gps_reader global _gps_client
with _gps_lock: with _gps_lock:
if _gps_reader: if _gps_client:
_gps_reader.stop() _gps_client.stop()
_gps_reader = None _gps_client = None
def get_current_position() -> Optional[GPSPosition]: def get_current_position() -> Optional[GPSPosition]:
"""Get the current GPS position from the global reader.""" """Get the current GPS position from the global client."""
reader = get_gps_reader() client = get_gps_reader()
if reader: if client:
return reader.position return client.position
return None return None
def is_serial_available() -> bool:
"""Check if pyserial is available."""
return SERIAL_AVAILABLE

View File

@@ -144,6 +144,15 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
return devices return devices
def _find_soapy_util() -> str | None:
"""Find SoapySDR utility command (name varies by distribution)."""
# Try different command names used across distributions
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
if _check_tool(cmd):
return cmd
return None
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]: def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
""" """
Detect SDR devices via SoapySDR. Detect SDR devices via SoapySDR.
@@ -156,13 +165,14 @@ def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRD
devices: list[SDRDevice] = [] devices: list[SDRDevice] = []
skip_types = skip_types or set() skip_types = skip_types or set()
if not _check_tool('SoapySDRUtil'): soapy_cmd = _find_soapy_util()
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection") if not soapy_cmd:
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
return devices return devices
try: try:
result = subprocess.run( result = subprocess.run(
['SoapySDRUtil', '--find'], [soapy_cmd, '--find'],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10 timeout=10