mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6bd5d65a | |||
| dcb1b4e3a6 | |||
| b5547d3fa9 | |||
| 7a112c84be | |||
| b3b3566a27 | |||
| f77c501db6 | |||
| 68e179bfd2 | |||
| 20d9178159 | |||
| b2c32173e1 | |||
| 82a2883f82 | |||
| 1807d736b1 | |||
| f2b1839fdc | |||
| 564ef3706f | |||
| 417fa280c3 | |||
| 5077e56d76 | |||
| 3a7c429c4b | |||
| f7ccd56ec0 | |||
| 1f2a7ee523 |
@@ -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 sox 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 sox 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 sox to tool checks
|
||||
- Better error messages with platform-specific install commands
|
||||
- **Dockerfile Updated**
|
||||
- Added sox and libsox-fmt-all for Listening Post audio
|
||||
- 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)
|
||||
+34
-3
@@ -3,24 +3,47 @@
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="INTERCEPT Project"
|
||||
LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for RTL-SDR tools
|
||||
# Install system dependencies for SDR tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# RTL-SDR tools
|
||||
rtl-sdr \
|
||||
librtlsdr-dev \
|
||||
libusb-1.0-0-dev \
|
||||
# 433MHz decoder
|
||||
rtl-433 \
|
||||
# Pager decoder
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
sox \
|
||||
libsox-fmt-all \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
wireless-tools \
|
||||
# Bluetooth tools
|
||||
bluez \
|
||||
# Cleanup
|
||||
bluetooth \
|
||||
# GPS support
|
||||
gpsd-clients \
|
||||
# Utilities
|
||||
curl \
|
||||
procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dump1090 for ADS-B (package name varies by distribution)
|
||||
RUN apt-get update && \
|
||||
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
||||
apt-get install -y --no-install-recommends dump1090-fa || \
|
||||
apt-get install -y --no-install-recommends dump1090 || \
|
||||
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
@@ -28,13 +51,21 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directory for persistence
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
INTERCEPT_PORT=5050 \
|
||||
INTERCEPT_LOG_LEVEL=INFO
|
||||
INTERCEPT_LOG_LEVEL=INFO \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Health check using the new endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -sf http://localhost:5050/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "intercept.py"]
|
||||
|
||||
@@ -8,28 +8,150 @@
|
||||
|
||||
<p align="center">
|
||||
<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 align="center">
|
||||
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/screenshot_main.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## What is INTERCEPT?
|
||||
|
||||
INTERCEPT provides a unified web interface for signal intelligence tools:
|
||||
## Features
|
||||
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||
- **Listening Post** - Frequency scanner with audio monitoring
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
|
||||
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### macOS
|
||||
|
||||
**1. Install Homebrew** (if not already installed):
|
||||
```bash
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
```
|
||||
|
||||
**2. Install dependencies:**
|
||||
```bash
|
||||
# Required
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
|
||||
|
||||
# For ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
|
||||
# For WiFi scanning (optional)
|
||||
brew install aircrack-ng
|
||||
```
|
||||
|
||||
**3. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
**1. Install dependencies:**
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-pip python3-venv git
|
||||
|
||||
# Required SDR tools
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
|
||||
|
||||
# For ADS-B aircraft tracking (package name varies)
|
||||
sudo apt install -y dump1090-mutability # or dump1090-fa
|
||||
|
||||
# For WiFi scanning (optional)
|
||||
sudo apt install -y aircrack-ng
|
||||
|
||||
# For Bluetooth scanning (optional)
|
||||
sudo apt install -y bluez bluetooth
|
||||
```
|
||||
|
||||
**2. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
|
||||
> **Note:** On Raspberry Pi or headless systems, you may need to run `sudo venv/bin/python intercept.py` if a virtual environment was created.
|
||||
|
||||
### Docker (Alternative)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
| Hardware | Purpose | Price |
|
||||
|----------|---------|-------|
|
||||
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
|
||||
| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
|
||||
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||
|
||||
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### RTL-SDR not detected (Linux)
|
||||
|
||||
Add udev rules:
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
Then unplug and replug your RTL-SDR.
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+)
|
||||
|
||||
The setup script handles this automatically by creating a virtual environment. Run:
|
||||
```bash
|
||||
./setup.sh
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### dump1090 not available (Debian Trixie)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. Install from FlightAware:
|
||||
- https://flightaware.com/adsb/piaware/install
|
||||
|
||||
### Verify installation
|
||||
|
||||
```bash
|
||||
python3 intercept.py --check-deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
<p align="center">
|
||||
@@ -38,62 +160,20 @@ INTERCEPT provides a unified web interface for signal intelligence tools:
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
|
||||
Open http://localhost:5050 in your browser.
|
||||
|
||||
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.9+**
|
||||
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
|
||||
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
|
||||
|
||||
Quick install (Ubuntu/Debian):
|
||||
```bash
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
|
||||
```
|
||||
|
||||
See [Hardware & Installation](docs/HARDWARE.md) for full details.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
|
||||
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
|
||||
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||
|
||||
Contributions and improvements welcome.
|
||||
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This software is for educational purposes only.**
|
||||
**This software is for educational and authorized testing purposes only.**
|
||||
|
||||
- Only use with proper authorization
|
||||
- Intercepting communications without consent may be illegal
|
||||
- WiFi/Bluetooth attacks require explicit permission
|
||||
- You are responsible for compliance with applicable laws
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +29,17 @@ from config import VERSION
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
from utils.cleanup import DataStore, cleanup_manager
|
||||
from utils.constants import (
|
||||
MAX_AIRCRAFT_AGE_SECONDS,
|
||||
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||
MAX_BT_DEVICE_AGE_SECONDS,
|
||||
QUEUE_MAX_SIZE,
|
||||
)
|
||||
|
||||
# Track application start time for uptime calculation
|
||||
import time as _time
|
||||
_app_start_time = _time.time()
|
||||
|
||||
|
||||
# Create Flask app
|
||||
@@ -40,32 +51,32 @@ app = Flask(__name__)
|
||||
|
||||
# Pager decoder
|
||||
current_process = None
|
||||
output_queue = queue.Queue()
|
||||
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
process_lock = threading.Lock()
|
||||
|
||||
# RTL_433 sensor
|
||||
sensor_process = None
|
||||
sensor_queue = queue.Queue()
|
||||
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
sensor_lock = threading.Lock()
|
||||
|
||||
# WiFi
|
||||
wifi_process = None
|
||||
wifi_queue = queue.Queue()
|
||||
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
wifi_lock = threading.Lock()
|
||||
|
||||
# Bluetooth
|
||||
bt_process = None
|
||||
bt_queue = queue.Queue()
|
||||
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
bt_lock = threading.Lock()
|
||||
|
||||
# ADS-B aircraft
|
||||
adsb_process = None
|
||||
adsb_queue = queue.Queue()
|
||||
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
adsb_lock = threading.Lock()
|
||||
|
||||
# Satellite/Iridium
|
||||
satellite_process = None
|
||||
satellite_queue = queue.Queue()
|
||||
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
satellite_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
@@ -76,23 +87,30 @@ satellite_lock = threading.Lock()
|
||||
logging_enabled = False
|
||||
log_file_path = 'pager_messages.log'
|
||||
|
||||
# WiFi state
|
||||
# WiFi state - using DataStore for automatic cleanup
|
||||
wifi_monitor_interface = None
|
||||
wifi_networks = {} # BSSID -> network info
|
||||
wifi_clients = {} # Client MAC -> client info
|
||||
wifi_handshakes = [] # Captured handshakes
|
||||
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
|
||||
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
|
||||
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
|
||||
|
||||
# Bluetooth state
|
||||
# Bluetooth state - using DataStore for automatic cleanup
|
||||
bt_interface = None
|
||||
bt_devices = {} # MAC -> device info
|
||||
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
|
||||
bt_services = {} # MAC -> list of services
|
||||
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
|
||||
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
|
||||
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
|
||||
|
||||
# Aircraft (ADS-B) state
|
||||
adsb_aircraft = {} # ICAO hex -> aircraft info
|
||||
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes
|
||||
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||
|
||||
# Register data stores with cleanup manager
|
||||
cleanup_manager.register(wifi_networks)
|
||||
cleanup_manager.register(wifi_clients)
|
||||
cleanup_manager.register(bt_devices)
|
||||
cleanup_manager.register(bt_beacons)
|
||||
cleanup_manager.register(adsb_aircraft)
|
||||
|
||||
|
||||
# ============================================
|
||||
@@ -130,15 +148,16 @@ def get_dependencies() -> Response:
|
||||
# Determine OS for install instructions
|
||||
system = platform.system().lower()
|
||||
if system == 'darwin':
|
||||
install_method = 'brew'
|
||||
pkg_manager = 'brew'
|
||||
elif system == 'linux':
|
||||
install_method = 'apt'
|
||||
pkg_manager = 'apt'
|
||||
else:
|
||||
install_method = 'manual'
|
||||
pkg_manager = 'manual'
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'os': system,
|
||||
'install_method': install_method,
|
||||
'pkg_manager': pkg_manager,
|
||||
'modes': results
|
||||
})
|
||||
|
||||
@@ -159,14 +178,14 @@ def export_aircraft() -> Response:
|
||||
for icao, ac in adsb_aircraft.items():
|
||||
writer.writerow([
|
||||
icao,
|
||||
ac.get('callsign', ''),
|
||||
ac.get('altitude', ''),
|
||||
ac.get('speed', ''),
|
||||
ac.get('heading', ''),
|
||||
ac.get('lat', ''),
|
||||
ac.get('lon', ''),
|
||||
ac.get('squawk', ''),
|
||||
ac.get('lastSeen', '')
|
||||
ac.get('callsign', '') if isinstance(ac, dict) else '',
|
||||
ac.get('altitude', '') if isinstance(ac, dict) else '',
|
||||
ac.get('speed', '') if isinstance(ac, dict) else '',
|
||||
ac.get('heading', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lat', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lon', '') if isinstance(ac, dict) else '',
|
||||
ac.get('squawk', '') if isinstance(ac, dict) else '',
|
||||
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -175,7 +194,7 @@ def export_aircraft() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'aircraft': list(adsb_aircraft.values())
|
||||
'aircraft': adsb_aircraft.values()
|
||||
})
|
||||
|
||||
|
||||
@@ -195,11 +214,11 @@ def export_wifi() -> Response:
|
||||
for bssid, net in wifi_networks.items():
|
||||
writer.writerow([
|
||||
bssid,
|
||||
net.get('ssid', ''),
|
||||
net.get('channel', ''),
|
||||
net.get('signal', ''),
|
||||
net.get('encryption', ''),
|
||||
net.get('clients', 0)
|
||||
net.get('ssid', '') if isinstance(net, dict) else '',
|
||||
net.get('channel', '') if isinstance(net, dict) else '',
|
||||
net.get('signal', '') if isinstance(net, dict) else '',
|
||||
net.get('encryption', '') if isinstance(net, dict) else '',
|
||||
net.get('clients', 0) if isinstance(net, dict) else 0
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -208,8 +227,8 @@ def export_wifi() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'networks': list(wifi_networks.values()),
|
||||
'clients': list(wifi_clients.values())
|
||||
'networks': wifi_networks.values(),
|
||||
'clients': wifi_clients.values()
|
||||
})
|
||||
|
||||
|
||||
@@ -229,11 +248,11 @@ def export_bluetooth() -> Response:
|
||||
for mac, dev in bt_devices.items():
|
||||
writer.writerow([
|
||||
mac,
|
||||
dev.get('name', ''),
|
||||
dev.get('rssi', ''),
|
||||
dev.get('type', ''),
|
||||
dev.get('manufacturer', ''),
|
||||
dev.get('lastSeen', '')
|
||||
dev.get('name', '') if isinstance(dev, dict) else '',
|
||||
dev.get('rssi', '') if isinstance(dev, dict) else '',
|
||||
dev.get('type', '') if isinstance(dev, dict) else '',
|
||||
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
|
||||
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
@@ -242,11 +261,35 @@ def export_bluetooth() -> Response:
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'devices': list(bt_devices.values()),
|
||||
'beacons': list(bt_beacons.values())
|
||||
'devices': bt_devices.values(),
|
||||
'beacons': bt_beacons.values()
|
||||
})
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health_check() -> Response:
|
||||
"""Health check endpoint for monitoring."""
|
||||
import time
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'version': VERSION,
|
||||
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||
'processes': {
|
||||
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||
},
|
||||
'data': {
|
||||
'aircraft_count': len(adsb_aircraft),
|
||||
'wifi_networks_count': len(wifi_networks),
|
||||
'wifi_clients_count': len(wifi_clients),
|
||||
'bt_devices_count': len(bt_devices),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
@@ -343,6 +386,13 @@ def main() -> None:
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
|
||||
# Initialize database for settings storage
|
||||
from utils.database import init_db
|
||||
init_db()
|
||||
|
||||
# Start automatic cleanup of stale data entries
|
||||
cleanup_manager.start()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "1.1.0"
|
||||
VERSION = "2.0.0"
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
|
||||
@@ -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:
|
||||
@@ -31,6 +31,10 @@ Complete feature list for all modules.
|
||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_radar.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
@@ -43,6 +47,13 @@ Complete feature list for all modules.
|
||||
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
|
||||
- **Multiple satellite tracking** simultaneously
|
||||
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat.png" alt="Screenshot">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="/static/images/screenshots/screenshot_sat_2.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
## WiFi Reconnaissance
|
||||
|
||||
- **Monitor mode** management via airmon-ng
|
||||
@@ -108,3 +119,4 @@ Complete feature list for all modules.
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
- **Disclaimer acceptance** on first use
|
||||
- **Auto-stop** when switching between modes
|
||||
|
||||
|
||||
+161
-75
@@ -1,91 +1,75 @@
|
||||
# Hardware & Installation
|
||||
# Hardware & Advanced Setup
|
||||
|
||||
## Supported SDR Hardware
|
||||
|
||||
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|
||||
|----------|-----------------|------------|-----|-------|-------|
|
||||
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
|
||||
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
|
||||
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||
| Hardware | Frequency Range | Price | Notes |
|
||||
|----------|-----------------|-------|-------|
|
||||
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
|
||||
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
|
||||
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||
|
||||
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
|
||||
INTERCEPT automatically detects connected devices.
|
||||
|
||||
## Requirements
|
||||
---
|
||||
|
||||
### Hardware
|
||||
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
|
||||
- **WiFi adapter** capable of monitor mode (for WiFi features)
|
||||
- **Bluetooth adapter** (for Bluetooth features)
|
||||
- **GPS dongle** (optional, for precise location)
|
||||
|
||||
### Software
|
||||
- **Python 3.9+** required
|
||||
- External tools (see installation below)
|
||||
|
||||
## Tool Installation
|
||||
|
||||
### Core SDR Tools
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
|
||||
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
|
||||
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
|
||||
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
|
||||
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
|
||||
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
|
||||
|
||||
### LimeSDR / HackRF Support (Optional)
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
|
||||
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
|
||||
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
|
||||
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
|
||||
|
||||
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
|
||||
|
||||
## Quick Install Commands
|
||||
|
||||
### Ubuntu/Debian
|
||||
```bash
|
||||
# Core tools
|
||||
sudo apt update
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
|
||||
|
||||
# LimeSDR (optional)
|
||||
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
|
||||
|
||||
# HackRF (optional)
|
||||
sudo apt install hackrf soapysdr-module-hackrf
|
||||
```
|
||||
## Quick Install
|
||||
|
||||
### macOS (Homebrew)
|
||||
```bash
|
||||
# Core tools
|
||||
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
|
||||
|
||||
# LimeSDR (optional)
|
||||
```bash
|
||||
# Install Homebrew if needed
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Core tools (required)
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
|
||||
# WiFi tools (optional)
|
||||
brew install aircrack-ng
|
||||
|
||||
# LimeSDR support (optional)
|
||||
brew install soapysdr limesuite soapylms7
|
||||
|
||||
# HackRF (optional)
|
||||
# HackRF support (optional)
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
# Core tools
|
||||
sudo pacman -S rtl-sdr multimon-ng
|
||||
yay -S rtl_433 dump1090
|
||||
### Debian / Ubuntu / Raspberry Pi OS
|
||||
|
||||
# LimeSDR/HackRF (optional)
|
||||
sudo pacman -S soapysdr limesuite hackrf
|
||||
```bash
|
||||
# Update package lists
|
||||
sudo apt update
|
||||
|
||||
# Core tools (required)
|
||||
sudo apt install -y python3 python3-pip python3-venv
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
sudo apt install -y dump1090-mutability
|
||||
# Alternative: dump1090-fa (FlightAware version)
|
||||
|
||||
# WiFi tools (optional)
|
||||
sudo apt install -y aircrack-ng
|
||||
|
||||
# Bluetooth tools (optional)
|
||||
sudo apt install -y bluez bluetooth
|
||||
|
||||
# LimeSDR support (optional)
|
||||
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
|
||||
|
||||
# HackRF support (optional)
|
||||
sudo apt install -y hackrf soapysdr-module-hackrf
|
||||
```
|
||||
|
||||
## Linux udev Rules
|
||||
---
|
||||
|
||||
If your SDR isn't detected, add udev rules:
|
||||
## RTL-SDR Setup (Linux)
|
||||
|
||||
### Add udev rules
|
||||
|
||||
If your RTL-SDR isn't detected, create udev rules:
|
||||
|
||||
```bash
|
||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
@@ -97,9 +81,9 @@ sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger
|
||||
```
|
||||
|
||||
Then unplug and replug your device.
|
||||
Then unplug and replug your RTL-SDR.
|
||||
|
||||
## Blacklist DVB-T Driver (Linux)
|
||||
### Blacklist DVB-T driver
|
||||
|
||||
The default DVB-T driver conflicts with rtl-sdr:
|
||||
|
||||
@@ -108,18 +92,120 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo modprobe -r dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verify Installation
|
||||
|
||||
Check what's installed:
|
||||
### Check dependencies
|
||||
```bash
|
||||
python3 intercept.py --check-deps
|
||||
```
|
||||
|
||||
Test SDR detection:
|
||||
### Test SDR detection
|
||||
```bash
|
||||
# RTL-SDR
|
||||
rtl_test
|
||||
|
||||
# LimeSDR/HackRF
|
||||
# LimeSDR/HackRF (via SoapySDR)
|
||||
SoapySDRUtil --find
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Python Environment
|
||||
|
||||
### Using setup.sh (Recommended)
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Detects your OS
|
||||
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||
- Installs Python dependencies
|
||||
- Checks for required tools
|
||||
|
||||
### Manual setup
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running INTERCEPT
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
# Standard
|
||||
sudo python3 intercept.py
|
||||
|
||||
# With virtual environment
|
||||
sudo venv/bin/python intercept.py
|
||||
|
||||
# Custom port
|
||||
INTERCEPT_PORT=8080 sudo python3 intercept.py
|
||||
```
|
||||
|
||||
Open **http://localhost:5050** in your browser.
|
||||
|
||||
---
|
||||
|
||||
## Complete Tool Reference
|
||||
|
||||
| Tool | Package (Debian) | Package (macOS) | Required For |
|
||||
|------|------------------|-----------------|--------------|
|
||||
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
|
||||
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
|
||||
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
|
||||
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
|
||||
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
|
||||
| `sox` | sox | sox | 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 (optional) |
|
||||
| `pyserial` | USB GPS dongle support (optional) |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
Binary file not shown.
+7
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "1.0.0"
|
||||
version = "2.0.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
@@ -28,8 +28,14 @@ classifiers = [
|
||||
dependencies = [
|
||||
"flask>=2.0.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/smittix/intercept"
|
||||
Repository = "https://github.com/smittix/intercept"
|
||||
Issues = "https://github.com/smittix/intercept/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
|
||||
@@ -9,6 +9,9 @@ def register_blueprints(app):
|
||||
from .adsb import adsb_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
from .settings import settings_bp
|
||||
from .correlation import correlation_bp
|
||||
from .listening_post import listening_post_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
@@ -17,3 +20,6 @@ def register_blueprints(app):
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(correlation_bp)
|
||||
app.register_blueprint(listening_post_bp)
|
||||
|
||||
+38
-23
@@ -22,6 +22,19 @@ from utils.validation import (
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
from utils.constants import (
|
||||
ADSB_SBS_PORT,
|
||||
ADSB_TERMINATE_TIMEOUT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
SBS_SOCKET_TIMEOUT,
|
||||
SBS_RECONNECT_DELAY,
|
||||
SOCKET_BUFFER_SIZE,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SOCKET_CONNECT_TIMEOUT,
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -63,21 +76,21 @@ def find_dump1090():
|
||||
|
||||
|
||||
def check_dump1090_service():
|
||||
"""Check if dump1090 SBS port (30003) is available."""
|
||||
"""Check if dump1090 SBS port is available."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex(('localhost', 30003))
|
||||
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
|
||||
result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return 'localhost:30003'
|
||||
except Exception:
|
||||
return f'localhost:{ADSB_SBS_PORT}'
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 port 30003."""
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
@@ -90,7 +103,7 @@ def parse_sbs_stream(service_addr):
|
||||
while adsb_using_service:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
logger.info("Connected to SBS stream")
|
||||
@@ -101,7 +114,7 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(4096).decode('utf-8', errors='ignore')
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
@@ -121,7 +134,7 @@ def parse_sbs_stream(service_addr):
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
@@ -168,13 +181,13 @@ def parse_sbs_stream(service_addr):
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
|
||||
app_module.adsb_aircraft[icao] = aircraft
|
||||
app_module.adsb_aircraft.set(icao, aircraft)
|
||||
pending_updates.add(icao)
|
||||
adsb_messages_received += 1
|
||||
adsb_last_message_time = time.time()
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= 1.0:
|
||||
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
@@ -189,10 +202,10 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
adsb_connected = False
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
time.sleep(2)
|
||||
time.sleep(SBS_RECONNECT_DELAY)
|
||||
|
||||
adsb_connected = False
|
||||
logger.info("SBS stream parser stopped")
|
||||
@@ -291,9 +304,12 @@ def start_adsb():
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
app_module.adsb_process.terminate()
|
||||
app_module.adsb_process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.adsb_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
@@ -317,13 +333,13 @@ def start_adsb():
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
time.sleep(3)
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||
@@ -340,13 +356,13 @@ def stop_adsb():
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
app_module.adsb_process.wait(timeout=5)
|
||||
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
app_module.adsb_process = None
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft = {}
|
||||
app_module.adsb_aircraft.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -355,16 +371,15 @@ def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=1)
|
||||
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
|
||||
+18
-3
@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
from utils.constants import (
|
||||
BT_TERMINATE_TIMEOUT,
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
SSE_QUEUE_TIMEOUT,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
SERVICE_ENUM_TIMEOUT,
|
||||
PROCESS_START_WAIT,
|
||||
BT_RESET_DELAY,
|
||||
BT_ADAPTER_DOWN_WAIT,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
|
||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||
|
||||
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
|
||||
'type': 'hci',
|
||||
'status': 'up' if is_up else 'down'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logger.debug("hciconfig not found")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("hciconfig timed out")
|
||||
except subprocess.SubprocessError as e:
|
||||
logger.warning(f"Error running hciconfig: {e}")
|
||||
|
||||
elif platform.system() == 'Darwin':
|
||||
interfaces.append({
|
||||
|
||||
@@ -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
|
||||
+88
-8
@@ -16,9 +16,11 @@ from utils.gps import (
|
||||
is_serial_available,
|
||||
get_gps_reader,
|
||||
start_gps,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
GPSDClient,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
@@ -51,6 +53,34 @@ def check_gps_available():
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/check')
|
||||
def check_gpsd_available():
|
||||
"""Check if gpsd is reachable."""
|
||||
import socket
|
||||
|
||||
host = request.args.get('host', 'localhost')
|
||||
port = int(request.args.get('port', 2947))
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'host': host,
|
||||
'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."""
|
||||
@@ -109,19 +139,15 @@ def start_gps_reader():
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the GPS reader
|
||||
success = start_gps(device_path, baudrate)
|
||||
# Start the GPS reader with callback pre-registered (avoids race condition)
|
||||
success = start_gps(device_path, baudrate, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
# Register callback for SSE streaming
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.add_callback(_position_callback)
|
||||
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': device_path,
|
||||
'baudrate': baudrate
|
||||
'baudrate': baudrate,
|
||||
'source': 'serial'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
@@ -132,6 +158,58 @@ def start_gps_reader():
|
||||
}), 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)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'source': 'gpsd'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to connect to gpsd: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
@@ -205,8 +283,10 @@ def debug_gps():
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': source,
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'has_position': position is not None,
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
"""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 find_sox() -> str | None:
|
||||
"""Find sox for audio encoding."""
|
||||
return shutil.which('sox')
|
||||
|
||||
|
||||
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:
|
||||
audio_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=audio_rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
audio_rtl_process.stdout.close()
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = 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()
|
||||
sox = find_sox()
|
||||
can_stream = ffmpeg is not None or sox is not None
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'ffmpeg': ffmpeg is not None,
|
||||
'sox': sox is not None,
|
||||
'can_stream': can_stream,
|
||||
'available': rtl_fm is not None and can_stream
|
||||
})
|
||||
|
||||
|
||||
@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'
|
||||
}), 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."""
|
||||
if not audio_running or not audio_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Audio not running'
|
||||
}), 400
|
||||
|
||||
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',
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
+43
-10
@@ -22,6 +22,26 @@ from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.sse import format_sse
|
||||
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')
|
||||
|
||||
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
|
||||
if platform.system() == 'Darwin': # macOS
|
||||
try:
|
||||
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')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
||||
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
|
||||
'status': 'up'
|
||||
})
|
||||
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}")
|
||||
|
||||
try:
|
||||
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:
|
||||
interfaces.append({
|
||||
'name': 'USB WiFi Adapter',
|
||||
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
|
||||
'monitor_capable': True,
|
||||
'status': 'detected'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
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
|
||||
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
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
|
||||
})
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
# Fall back to iwconfig if iw is not available
|
||||
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'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
except FileNotFoundError:
|
||||
logger.debug("Neither iw nor iwconfig found")
|
||||
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}")
|
||||
|
||||
return interfaces
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# INTERCEPT Setup Script
|
||||
# Installs Python dependencies and checks for external tools
|
||||
# Installs dependencies for macOS and Debian/Ubuntu
|
||||
#
|
||||
|
||||
set -e
|
||||
@@ -32,17 +32,11 @@ detect_os() {
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
OS="debian"
|
||||
PKG_MANAGER="apt"
|
||||
elif [[ -f /etc/redhat-release ]]; then
|
||||
OS="redhat"
|
||||
PKG_MANAGER="dnf"
|
||||
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)"
|
||||
echo -e "${BLUE}Detected OS:${NC} $OS"
|
||||
}
|
||||
|
||||
# Check if a command exists
|
||||
@@ -50,235 +44,448 @@ check_cmd() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
|
||||
# Install Python dependencies
|
||||
# Check if a package is installable (Debian)
|
||||
pkg_available() {
|
||||
local candidate
|
||||
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
|
||||
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
|
||||
}
|
||||
|
||||
# Setup sudo command
|
||||
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}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 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"
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo "Install with: brew install python@3.11"
|
||||
else
|
||||
echo "Install with: sudo apt install python3"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python version (need 3.9+)
|
||||
# Check Python version
|
||||
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:"
|
||||
echo " Ubuntu/Debian: sudo apt install python3.11"
|
||||
echo " macOS: brew install python@3.11"
|
||||
echo -e "${RED}Error: Python 3.9 or later is required (you have $PYTHON_VERSION)${NC}"
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo "Upgrade with: brew install python@3.11"
|
||||
else
|
||||
echo "Upgrade with: sudo apt install python3.11"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're in a virtual environment
|
||||
# Install dependencies
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
||||
pip install -r requirements.txt
|
||||
elif [ -d "venv" ]; then
|
||||
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..."
|
||||
# Try pip install, fall back to venv if needed (PEP 668)
|
||||
if python3 -m pip install -r requirements.txt 2>/dev/null; then
|
||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||
echo -e "${GREEN}Python dependencies installed${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..."
|
||||
python3 -m venv venv
|
||||
echo -e "${YELLOW}Creating virtual environment...${NC}"
|
||||
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
if ! python3 -m venv venv; then
|
||||
echo -e "${RED}Error: Failed to create virtual environment${NC}"
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo "Install with: sudo apt install python3-venv"
|
||||
fi
|
||||
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"
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
echo -e "${YELLOW}NOTE: Virtual environment created.${NC}"
|
||||
echo "Activate with: source venv/bin/activate"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||
echo -e "${GREEN}Python dependencies installed${NC}"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# TOOL CHECKING
|
||||
# ============================================
|
||||
check_tool() {
|
||||
local cmd=$1
|
||||
local desc=$2
|
||||
local category=$3
|
||||
if check_cmd "$cmd"; then
|
||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
||||
return 0
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
||||
MISSING_TOOLS+=("$cmd")
|
||||
case "$category" in
|
||||
core) MISSING_CORE=true ;;
|
||||
audio) MISSING_AUDIO=true ;;
|
||||
wifi) MISSING_WIFI=true ;;
|
||||
bluetooth) MISSING_BLUETOOTH=true ;;
|
||||
esac
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check external tools
|
||||
check_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
|
||||
echo ""
|
||||
|
||||
MISSING_TOOLS=()
|
||||
MISSING_CORE=false
|
||||
MISSING_AUDIO=false
|
||||
MISSING_WIFI=false
|
||||
MISSING_BLUETOOTH=false
|
||||
|
||||
# Core SDR tools
|
||||
echo "Core SDR Tools:"
|
||||
check_tool "rtl_fm" "RTL-SDR FM demodulator"
|
||||
check_tool "rtl_test" "RTL-SDR device detection"
|
||||
check_tool "multimon-ng" "Pager decoder"
|
||||
check_tool "rtl_433" "433MHz sensor decoder"
|
||||
check_tool "dump1090" "ADS-B decoder"
|
||||
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
|
||||
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 "Additional SDR Hardware (optional):"
|
||||
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)"
|
||||
check_tool "LimeUtil" "LimeSDR tools"
|
||||
check_tool "hackrf_info" "HackRF tools"
|
||||
echo "Audio Tools:"
|
||||
check_tool "sox" "Audio player/processor" "audio"
|
||||
# ffmpeg is optional alternative to sox
|
||||
if check_cmd ffmpeg; then
|
||||
echo -e " ${GREEN}✓${NC} ffmpeg - Audio encoder (optional)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "WiFi Tools:"
|
||||
check_tool "airmon-ng" "WiFi monitor mode"
|
||||
check_tool "airodump-ng" "WiFi scanner"
|
||||
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
|
||||
check_tool "airodump-ng" "WiFi scanner" "wifi"
|
||||
# aireplay-ng is optional (for deauth)
|
||||
if check_cmd aireplay-ng; then
|
||||
echo -e " ${GREEN}✓${NC} aireplay-ng - Deauthentication (optional)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Bluetooth Tools:"
|
||||
check_tool "bluetoothctl" "Bluetooth controller"
|
||||
check_tool "hcitool" "Bluetooth HCI tool"
|
||||
check_tool "hcitool" "Bluetooth scanner" "bluetooth"
|
||||
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
|
||||
check_tool "hciconfig" "Bluetooth adapter config" "bluetooth"
|
||||
|
||||
echo ""
|
||||
echo "Optional (LimeSDR/HackRF):"
|
||||
if check_cmd SoapySDRUtil; then
|
||||
echo -e " ${GREEN}✓${NC} SoapySDRUtil - SoapySDR support"
|
||||
else
|
||||
echo -e " ${YELLOW}-${NC} SoapySDRUtil - Not installed (optional)"
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Some tools are missing. See installation instructions below.${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tool() {
|
||||
local cmd=$1
|
||||
local desc=$2
|
||||
if check_cmd "$cmd"; then
|
||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
||||
echo -e "${YELLOW}Some tools are missing.${NC}"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
||||
MISSING_TOOLS+=("$cmd")
|
||||
echo ""
|
||||
echo -e "${GREEN}All tools installed!${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Show installation instructions
|
||||
show_install_instructions() {
|
||||
# ============================================
|
||||
# macOS INSTALLATION
|
||||
# ============================================
|
||||
install_macos_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
|
||||
echo -e "${BLUE}[3/3] Installing tools (macOS)...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are installed!${NC}"
|
||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Run the following commands to install missing tools:"
|
||||
# Check for Homebrew
|
||||
if ! check_cmd brew; then
|
||||
echo -e "${YELLOW}Homebrew is not installed.${NC}"
|
||||
echo ""
|
||||
read -p "Would you like to install Homebrew? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Installing Homebrew..."
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
# Add brew to PATH for this session
|
||||
if [[ -f /opt/homebrew/bin/brew ]]; then
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
elif [[ -f /usr/local/bin/brew ]]; then
|
||||
eval "$(/usr/local/bin/brew shellenv)"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping tool installation. Install manually with:"
|
||||
show_macos_manual
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}The following will be installed:${NC}"
|
||||
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl_433, dump1090)"
|
||||
$MISSING_AUDIO && echo " - Audio tools (sox)"
|
||||
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
|
||||
echo ""
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
||||
echo ""
|
||||
read -p "Install missing tools? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
# Check if Homebrew is installed
|
||||
if ! check_cmd brew; then
|
||||
echo "First, install Homebrew:"
|
||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
# Core SDR tools
|
||||
if $MISSING_CORE; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Core SDR tools...${NC}"
|
||||
brew install librtlsdr multimon-ng rtl_433 2>/dev/null || true
|
||||
|
||||
# dump1090
|
||||
if ! check_cmd dump1090; then
|
||||
brew install dump1090-mutability 2>/dev/null || \
|
||||
echo -e "${YELLOW}Note: dump1090 may need manual installation${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "# Core SDR tools"
|
||||
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
|
||||
echo ""
|
||||
echo "# LimeSDR support (optional)"
|
||||
echo "brew install soapysdr limesuite soapylms7"
|
||||
echo ""
|
||||
echo "# HackRF support (optional)"
|
||||
echo "brew install hackrf soapyhackrf"
|
||||
echo ""
|
||||
echo "# WiFi tools"
|
||||
echo "brew install aircrack-ng"
|
||||
# Audio tools
|
||||
if $MISSING_AUDIO; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Audio tools...${NC}"
|
||||
brew install sox
|
||||
fi
|
||||
|
||||
elif [[ "$OS" == "debian" ]]; then
|
||||
echo -e "${YELLOW}Ubuntu/Debian:${NC}"
|
||||
echo ""
|
||||
echo "# Core SDR tools"
|
||||
echo "sudo apt update"
|
||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability"
|
||||
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"
|
||||
# WiFi tools
|
||||
if $MISSING_WIFI; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing WiFi tools...${NC}"
|
||||
brew install aircrack-ng
|
||||
fi
|
||||
|
||||
elif [[ "$OS" == "arch" ]]; then
|
||||
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"
|
||||
|
||||
echo -e "${GREEN}Tool installation complete!${NC}"
|
||||
else
|
||||
echo "Please install the following tools manually:"
|
||||
for tool in "${MISSING_TOOLS[@]}"; do
|
||||
echo " - $tool"
|
||||
done
|
||||
show_macos_manual
|
||||
fi
|
||||
}
|
||||
|
||||
# RTL-SDR udev rules (Linux only)
|
||||
show_macos_manual() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Manual installation (macOS):${NC}"
|
||||
echo ""
|
||||
echo "# Required tools"
|
||||
echo "brew install librtlsdr multimon-ng rtl_433 sox"
|
||||
echo ""
|
||||
echo "# ADS-B tracking"
|
||||
echo "brew install dump1090-mutability"
|
||||
echo ""
|
||||
echo "# WiFi scanning (optional)"
|
||||
echo "brew install aircrack-ng"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# DEBIAN INSTALLATION
|
||||
# ============================================
|
||||
install_debian_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installing tools (Debian/Ubuntu)...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}The following will be installed:${NC}"
|
||||
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
|
||||
$MISSING_AUDIO && echo " - Audio tools (sox)"
|
||||
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
|
||||
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
|
||||
echo ""
|
||||
|
||||
read -p "Install missing tools? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Updating package lists..."
|
||||
$SUDO apt update
|
||||
|
||||
# Core SDR tools
|
||||
if $MISSING_CORE; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Core SDR tools...${NC}"
|
||||
$SUDO apt install -y rtl-sdr multimon-ng 2>/dev/null || true
|
||||
|
||||
# rtl-433 (package name varies)
|
||||
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 in repositories, install manually${NC}"
|
||||
fi
|
||||
|
||||
# dump1090 (package varies by distribution)
|
||||
if ! check_cmd dump1090; then
|
||||
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
|
||||
else
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: dump1090 not in repositories.${NC}"
|
||||
echo "Install from: https://flightaware.com/adsb/piaware/install"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Audio tools
|
||||
if $MISSING_AUDIO; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Audio tools...${NC}"
|
||||
$SUDO apt install -y sox
|
||||
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
|
||||
setup_udev_rules
|
||||
else
|
||||
show_debian_manual
|
||||
fi
|
||||
}
|
||||
|
||||
show_debian_manual() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Manual installation (Debian/Ubuntu):${NC}"
|
||||
echo ""
|
||||
echo "# Required tools"
|
||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433 sox"
|
||||
echo ""
|
||||
echo "# ADS-B tracking"
|
||||
echo "sudo apt install dump1090-mutability # or dump1090-fa"
|
||||
echo ""
|
||||
echo "# WiFi scanning (optional)"
|
||||
echo "sudo apt install aircrack-ng"
|
||||
echo ""
|
||||
echo "# Bluetooth scanning (optional)"
|
||||
echo "sudo apt install bluez bluetooth"
|
||||
}
|
||||
|
||||
setup_udev_rules() {
|
||||
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."
|
||||
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Setup RTL-SDR udev rules? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
$SUDO udevadm control --reload-rules
|
||||
$SUDO udevadm trigger
|
||||
echo -e "${GREEN}udev rules installed!${NC}"
|
||||
echo "Please unplug and replug your RTL-SDR."
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
# ============================================
|
||||
# MAIN
|
||||
# ============================================
|
||||
main() {
|
||||
detect_os
|
||||
|
||||
if [[ "$OS" == "unknown" ]]; then
|
||||
echo -e "${RED}Unsupported OS. This script supports macOS and Debian/Ubuntu.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
setup_sudo
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
check_tools
|
||||
show_install_instructions
|
||||
setup_udev_rules
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
install_macos_tools
|
||||
else
|
||||
install_debian_tools
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "To start INTERCEPT:"
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
echo " source venv/bin/activate"
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
fi
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo " sudo python3 intercept.py"
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo ""
|
||||
|
||||
+214
-80
@@ -5,24 +5,27 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--border-glow: #00ff88;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent-green: #00ff88;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-orange: #ff9500;
|
||||
--accent-red: #ff4444;
|
||||
--accent-yellow: #ffcc00;
|
||||
--grid-line: rgba(0, 255, 136, 0.1);
|
||||
--radar-cyan: #00ffff;
|
||||
--radar-bg: #1a1a2e;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-green: #22c55e;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-yellow: #eab308;
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
--radar-cyan: #4a9eff;
|
||||
--radar-bg: #0f1218;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -44,18 +47,18 @@ body {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Scan line effect */
|
||||
/* Scan line effect - subtle */
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
||||
animation: scan 4s linear infinite;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scan 6s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
@@ -73,20 +76,20 @@ body {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.3);
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 4px;
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
@@ -115,8 +118,8 @@ body {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px var(--accent-cyan);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -144,8 +147,8 @@ body {
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
@@ -153,7 +156,7 @@ body {
|
||||
}
|
||||
|
||||
.stat-badge .value {
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -165,15 +168,15 @@ body {
|
||||
.datetime {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--accent-green);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -192,7 +195,7 @@ body {
|
||||
/* Panels */
|
||||
.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;
|
||||
position: relative;
|
||||
}
|
||||
@@ -204,19 +207,19 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 15px;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.1);
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -225,7 +228,7 @@ body {
|
||||
.panel-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
@@ -300,7 +303,7 @@ body {
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -310,13 +313,13 @@ body {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
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 {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Orbitron', monospace;
|
||||
@@ -330,13 +333,13 @@ body {
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
@@ -355,8 +358,8 @@ body {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 15px var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px var(--accent-cyan);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -371,7 +374,7 @@ body {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border-left: 2px solid var(--accent-green);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.telemetry-label {
|
||||
@@ -404,7 +407,7 @@ body {
|
||||
|
||||
.aircraft-item {
|
||||
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;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
@@ -413,14 +416,14 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-item:hover {
|
||||
border-color: var(--accent-green);
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-color: var(--accent-cyan);
|
||||
background: rgba(74, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
.aircraft-item.selected {
|
||||
border-color: var(--accent-green);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.aircraft-header {
|
||||
@@ -434,14 +437,14 @@ body {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.aircraft-icao {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -475,10 +478,11 @@ body {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -497,15 +501,15 @@ body {
|
||||
}
|
||||
|
||||
.control-group input[type="checkbox"] {
|
||||
accent-color: var(--accent-green);
|
||||
accent-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
padding: 6px 10px;
|
||||
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;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -514,9 +518,9 @@ body {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
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;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -531,9 +535,9 @@ body {
|
||||
/* Start/stop button */
|
||||
.start-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid var(--accent-green);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -546,9 +550,9 @@ body {
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
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 {
|
||||
@@ -564,10 +568,10 @@ body {
|
||||
/* GPS button */
|
||||
.gps-btn {
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
background: rgba(74, 158, 255, 0.2);
|
||||
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-green);
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
@@ -578,10 +582,15 @@ body {
|
||||
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 {
|
||||
background: var(--bg-panel) !important;
|
||||
color: var(--accent-green) !important;
|
||||
border-color: rgba(0, 255, 136, 0.3) !important;
|
||||
color: var(--accent-cyan) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
@@ -600,7 +609,7 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-green);
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -632,7 +641,7 @@ body {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -640,4 +649,129 @@ body {
|
||||
grid-row: 3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Airband Audio Controls */
|
||||
.airband-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
+650
-312
File diff suppressed because it is too large
Load Diff
@@ -5,22 +5,25 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--border-glow: #00d4ff;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-green: #00ff88;
|
||||
--accent-orange: #ff9500;
|
||||
--accent-red: #ff4444;
|
||||
--bg-dark: #0a0c10;
|
||||
--bg-panel: #0f1218;
|
||||
--bg-card: #151a23;
|
||||
--border-color: #1f2937;
|
||||
--border-glow: #4a9eff;
|
||||
--text-primary: #e8eaed;
|
||||
--text-secondary: #9ca3af;
|
||||
--text-dim: #4b5563;
|
||||
--accent-cyan: #4a9eff;
|
||||
--accent-green: #22c55e;
|
||||
--accent-orange: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-purple: #a855f7;
|
||||
--grid-line: rgba(0, 212, 255, 0.1);
|
||||
--accent-amber: #d4a853;
|
||||
--grid-line: rgba(74, 158, 255, 0.08);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
@@ -82,28 +85,28 @@ body {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 4px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
margin-left: 15px;
|
||||
letter-spacing: 2px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Stats badges in header */
|
||||
@@ -113,7 +116,7 @@ body {
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
@@ -600,10 +603,15 @@ body {
|
||||
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 {
|
||||
background: var(--bg-panel) !important;
|
||||
color: var(--accent-cyan) !important;
|
||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
+440
-14
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AIRCRAFT RADAR // 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" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
|
||||
@@ -141,6 +141,7 @@
|
||||
<option value="manual">Manual</option>
|
||||
<option value="browser">Browser</option>
|
||||
<option value="dongle">USB GPS</option>
|
||||
<option value="gpsd">gpsd</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group" id="browserGpsGroup">
|
||||
@@ -159,6 +160,13 @@
|
||||
<button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button>
|
||||
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
|
||||
</div>
|
||||
<div class="control-group gps-gpsd-controls" style="display: none;">
|
||||
<input type="text" id="gpsdHost" value="localhost" placeholder="Host" style="width: 80px; font-size: 10px;">
|
||||
<span style="color: #666;">:</span>
|
||||
<input type="number" id="gpsdPort" value="2947" min="1" max="65535" style="width: 50px; font-size: 10px;">
|
||||
<button class="gps-btn gps-connect-btn" onclick="startGpsdClient()">Connect</button>
|
||||
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
|
||||
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
|
||||
@@ -171,6 +179,50 @@
|
||||
<input type="number" id="remoteSbsPort" value="30003" min="1" max="65535" style="width: 55px; font-size: 10px;">
|
||||
</div>
|
||||
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
|
||||
<div class="airband-divider"></div>
|
||||
<div class="control-group airband-controls">
|
||||
<span class="control-label" style="color: var(--accent-cyan);">AIRBAND:</span>
|
||||
<select id="airbandFreqSelect" onchange="updateAirbandFreq()">
|
||||
<option value="121.5">121.5 MHz (Guard)</option>
|
||||
<option value="118.0">118.0 MHz</option>
|
||||
<option value="119.1">119.1 MHz</option>
|
||||
<option value="120.5">120.5 MHz</option>
|
||||
<option value="123.45">123.45 MHz (Air-Air)</option>
|
||||
<option value="127.85">127.85 MHz</option>
|
||||
<option value="128.825">128.825 MHz</option>
|
||||
<option value="132.0">132.0 MHz</option>
|
||||
<option value="134.725">134.725 MHz</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" style="width: 70px; display: none;">
|
||||
</div>
|
||||
<div class="control-group airband-controls">
|
||||
<span class="control-label">SDR:</span>
|
||||
<select id="airbandDeviceSelect" style="width: 80px;">
|
||||
<option value="0">Dev 0</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group airband-controls">
|
||||
<span class="control-label">SQ:</span>
|
||||
<input type="range" id="airbandSquelch" min="0" max="100" value="20" style="width: 60px;">
|
||||
</div>
|
||||
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">
|
||||
<span class="airband-icon">▶</span> LISTEN
|
||||
</button>
|
||||
<div class="airband-status">
|
||||
<span id="airbandStatus" style="color: var(--text-muted);">OFF</span>
|
||||
</div>
|
||||
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
|
||||
<!-- Airband Visualizer (compact) -->
|
||||
<div class="airband-visualizer" id="airbandVisualizerContainer" style="display: none;">
|
||||
<div class="signal-meter">
|
||||
<div class="meter-bar">
|
||||
<div class="meter-fill" id="airbandSignalMeter"></div>
|
||||
<div class="meter-peak" id="airbandSignalPeak"></div>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="airbandSpectrumCanvas" width="120" height="30"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -208,8 +260,17 @@
|
||||
messageTimestamps: []
|
||||
};
|
||||
|
||||
// Observer location and range rings
|
||||
let observerLocation = { lat: 51.5074, lon: -0.1278 };
|
||||
// Observer location and range rings (load from localStorage or default to London)
|
||||
let observerLocation = (function() {
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
let rangeRingsLayer = null;
|
||||
let observerMarker = null;
|
||||
|
||||
@@ -834,6 +895,10 @@
|
||||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||||
observerLocation.lat = lat;
|
||||
observerLocation.lon = lon;
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
if (radarMap) {
|
||||
radarMap.setView([lat, lon], radarMap.getZoom());
|
||||
}
|
||||
@@ -858,6 +923,10 @@
|
||||
(position) => {
|
||||
observerLocation.lat = position.coords.latitude;
|
||||
observerLocation.lon = position.coords.longitude;
|
||||
|
||||
// Save to localStorage for persistence
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
|
||||
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
|
||||
if (radarMap) {
|
||||
@@ -881,18 +950,22 @@
|
||||
const source = document.getElementById('gpsSource').value;
|
||||
const browserGroup = document.getElementById('browserGpsGroup');
|
||||
const dongleControls = document.querySelector('.gps-dongle-controls');
|
||||
const gpsdControls = document.querySelector('.gps-gpsd-controls');
|
||||
|
||||
// Hide all first
|
||||
browserGroup.style.display = 'none';
|
||||
dongleControls.style.display = 'none';
|
||||
gpsdControls.style.display = 'none';
|
||||
|
||||
if (source === 'dongle') {
|
||||
browserGroup.style.display = 'none';
|
||||
dongleControls.style.display = 'flex';
|
||||
refreshGpsDevices();
|
||||
} else if (source === 'browser') {
|
||||
browserGroup.style.display = 'flex';
|
||||
dongleControls.style.display = 'none';
|
||||
} else {
|
||||
browserGroup.style.display = 'none';
|
||||
dongleControls.style.display = 'none';
|
||||
} else if (source === 'gpsd') {
|
||||
gpsdControls.style.display = 'flex';
|
||||
}
|
||||
// 'manual' keeps everything hidden
|
||||
}
|
||||
|
||||
async function refreshGpsDevices() {
|
||||
@@ -935,8 +1008,7 @@
|
||||
if (data.status === 'started') {
|
||||
gpsConnected = true;
|
||||
startGpsStream();
|
||||
document.querySelector('.gps-connect-btn').style.display = 'none';
|
||||
document.querySelector('.gps-disconnect-btn').style.display = 'block';
|
||||
updateGpsButtons(true, '.gps-dongle-controls');
|
||||
} else {
|
||||
alert('Failed to start GPS: ' + data.message);
|
||||
}
|
||||
@@ -945,6 +1017,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function startGpsdClient() {
|
||||
const host = document.getElementById('gpsdHost').value || 'localhost';
|
||||
const port = parseInt(document.getElementById('gpsdPort').value) || 2947;
|
||||
|
||||
try {
|
||||
const response = await fetch('/gps/gpsd/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: host, port: port })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'started') {
|
||||
gpsConnected = true;
|
||||
startGpsStream();
|
||||
updateGpsButtons(true, '.gps-gpsd-controls');
|
||||
} else {
|
||||
alert('Failed to connect to gpsd: ' + data.message);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('gpsd connection error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGpsButtons(connected, containerSelector) {
|
||||
// Update buttons in the specified container
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (container) {
|
||||
const connectBtn = container.querySelector('.gps-connect-btn');
|
||||
const disconnectBtn = container.querySelector('.gps-disconnect-btn');
|
||||
if (connectBtn) connectBtn.style.display = connected ? 'none' : 'block';
|
||||
if (disconnectBtn) disconnectBtn.style.display = connected ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function stopGpsDongle() {
|
||||
try {
|
||||
if (gpsEventSource) {
|
||||
@@ -953,8 +1060,9 @@
|
||||
}
|
||||
await fetch('/gps/stop', { method: 'POST' });
|
||||
gpsConnected = false;
|
||||
document.querySelector('.gps-connect-btn').style.display = 'block';
|
||||
document.querySelector('.gps-disconnect-btn').style.display = 'none';
|
||||
// Reset buttons in both containers
|
||||
updateGpsButtons(false, '.gps-dongle-controls');
|
||||
updateGpsButtons(false, '.gps-gpsd-controls');
|
||||
} catch (e) {
|
||||
console.warn('GPS stop error:', e);
|
||||
}
|
||||
@@ -1027,6 +1135,12 @@
|
||||
// INITIALIZATION
|
||||
// ============================================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize observer location input fields from saved location
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
initMap();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
@@ -1047,8 +1161,8 @@
|
||||
maxZoom: 15
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '©OpenStreetMap, ©CartoDB'
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(radarMap);
|
||||
|
||||
if (navigator.geolocation) {
|
||||
@@ -1484,6 +1598,318 @@
|
||||
scheduleUIUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AIRBAND AUDIO
|
||||
// ============================================
|
||||
let isAirbandPlaying = false;
|
||||
|
||||
// Web Audio API for airband visualization
|
||||
let airbandAudioContext = null;
|
||||
let airbandAnalyser = null;
|
||||
let airbandSource = null;
|
||||
let airbandVisualizerId = null;
|
||||
let airbandPeakLevel = 0;
|
||||
|
||||
function initAirbandVisualizer() {
|
||||
const audioPlayer = document.getElementById('airbandPlayer');
|
||||
|
||||
if (!airbandAudioContext) {
|
||||
airbandAudioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
if (airbandAudioContext.state === 'suspended') {
|
||||
airbandAudioContext.resume();
|
||||
}
|
||||
|
||||
if (!airbandSource) {
|
||||
try {
|
||||
airbandSource = airbandAudioContext.createMediaElementSource(audioPlayer);
|
||||
airbandAnalyser = airbandAudioContext.createAnalyser();
|
||||
airbandAnalyser.fftSize = 128;
|
||||
airbandAnalyser.smoothingTimeConstant = 0.7;
|
||||
|
||||
airbandSource.connect(airbandAnalyser);
|
||||
airbandAnalyser.connect(airbandAudioContext.destination);
|
||||
} catch (e) {
|
||||
console.warn('Could not create airband audio source:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('airbandVisualizerContainer').style.display = 'flex';
|
||||
drawAirbandVisualizer();
|
||||
}
|
||||
|
||||
function drawAirbandVisualizer() {
|
||||
if (!airbandAnalyser) return;
|
||||
|
||||
const canvas = document.getElementById('airbandSpectrumCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const bufferLength = airbandAnalyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
function draw() {
|
||||
airbandVisualizerId = requestAnimationFrame(draw);
|
||||
airbandAnalyser.getByteFrequencyData(dataArray);
|
||||
|
||||
// Signal meter
|
||||
let sum = 0;
|
||||
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
|
||||
const average = sum / bufferLength;
|
||||
const levelPercent = (average / 255) * 100;
|
||||
|
||||
if (levelPercent > airbandPeakLevel) {
|
||||
airbandPeakLevel = levelPercent;
|
||||
} else {
|
||||
airbandPeakLevel *= 0.95;
|
||||
}
|
||||
|
||||
const meterFill = document.getElementById('airbandSignalMeter');
|
||||
const meterPeak = document.getElementById('airbandSignalPeak');
|
||||
if (meterFill) meterFill.style.width = levelPercent + '%';
|
||||
if (meterPeak) meterPeak.style.left = Math.min(airbandPeakLevel, 100) + '%';
|
||||
|
||||
// Draw spectrum
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = canvas.width / bufferLength * 2;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * canvas.height;
|
||||
const hue = 200 - (i / bufferLength) * 60;
|
||||
ctx.fillStyle = `hsl(${hue}, 80%, ${40 + (dataArray[i] / 255) * 30}%)`;
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
|
||||
x += barWidth;
|
||||
}
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
function stopAirbandVisualizer() {
|
||||
if (airbandVisualizerId) {
|
||||
cancelAnimationFrame(airbandVisualizerId);
|
||||
airbandVisualizerId = null;
|
||||
}
|
||||
|
||||
const meterFill = document.getElementById('airbandSignalMeter');
|
||||
const meterPeak = document.getElementById('airbandSignalPeak');
|
||||
if (meterFill) meterFill.style.width = '0%';
|
||||
if (meterPeak) meterPeak.style.left = '0%';
|
||||
airbandPeakLevel = 0;
|
||||
|
||||
const container = document.getElementById('airbandVisualizerContainer');
|
||||
if (container) container.style.display = 'none';
|
||||
}
|
||||
|
||||
function initAirband() {
|
||||
// Populate device selector
|
||||
fetch('/devices')
|
||||
.then(r => r.json())
|
||||
.then(devices => {
|
||||
const select = document.getElementById('airbandDeviceSelect');
|
||||
select.innerHTML = '';
|
||||
if (devices.length === 0) {
|
||||
select.innerHTML = '<option value="0">No SDR</option>';
|
||||
} else {
|
||||
devices.forEach((dev, i) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = dev.index || i;
|
||||
opt.textContent = `Dev ${dev.index || i}`;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Check if audio tools are available
|
||||
fetch('/listening/tools')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const missingTools = [];
|
||||
if (!data.rtl_fm) missingTools.push('rtl_fm');
|
||||
if (!data.sox) missingTools.push('sox (audio player)');
|
||||
|
||||
if (missingTools.length > 0) {
|
||||
document.getElementById('airbandBtn').disabled = true;
|
||||
document.getElementById('airbandBtn').style.opacity = '0.5';
|
||||
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
|
||||
|
||||
// Show warning banner
|
||||
showAirbandWarning(missingTools);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Endpoint not available, disable airband
|
||||
document.getElementById('airbandBtn').disabled = true;
|
||||
document.getElementById('airbandBtn').style.opacity = '0.5';
|
||||
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
|
||||
});
|
||||
}
|
||||
|
||||
function showAirbandWarning(missingTools) {
|
||||
const warning = document.createElement('div');
|
||||
warning.id = 'airbandWarning';
|
||||
warning.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 70px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
const toolList = missingTools.join(', ');
|
||||
warning.innerHTML = `
|
||||
<div style="font-weight: bold; margin-bottom: 6px;">⚠️ Airband Listen Unavailable</div>
|
||||
<div>Missing required tools: <strong>${toolList}</strong></div>
|
||||
<div style="margin-top: 8px; font-size: 10px; opacity: 0.9;">
|
||||
Install with: <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">sudo apt install rtl-sdr sox</code> (Debian) or <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">brew install librtlsdr sox</code> (macOS)
|
||||
</div>
|
||||
<button onclick="this.parentElement.remove()" style="position: absolute; top: 5px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
|
||||
`;
|
||||
document.body.appendChild(warning);
|
||||
|
||||
// Auto-dismiss after 15 seconds
|
||||
setTimeout(() => {
|
||||
if (warning.parentElement) {
|
||||
warning.style.opacity = '0';
|
||||
warning.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => warning.remove(), 300);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
function updateAirbandFreq() {
|
||||
const select = document.getElementById('airbandFreqSelect');
|
||||
const customInput = document.getElementById('airbandCustomFreq');
|
||||
if (select.value === 'custom') {
|
||||
customInput.style.display = 'inline-block';
|
||||
} else {
|
||||
customInput.style.display = 'none';
|
||||
// If audio is playing, restart on new frequency
|
||||
if (isAirbandPlaying) {
|
||||
stopAirband();
|
||||
setTimeout(() => startAirband(), 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom frequency input changes
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const customInput = document.getElementById('airbandCustomFreq');
|
||||
if (customInput) {
|
||||
customInput.addEventListener('change', () => {
|
||||
// If audio is playing, restart on new custom frequency
|
||||
if (isAirbandPlaying) {
|
||||
stopAirband();
|
||||
setTimeout(() => startAirband(), 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function getAirbandFrequency() {
|
||||
const select = document.getElementById('airbandFreqSelect');
|
||||
if (select.value === 'custom') {
|
||||
return parseFloat(document.getElementById('airbandCustomFreq').value) || 121.5;
|
||||
}
|
||||
return parseFloat(select.value);
|
||||
}
|
||||
|
||||
function toggleAirband() {
|
||||
if (isAirbandPlaying) {
|
||||
stopAirband();
|
||||
} else {
|
||||
startAirband();
|
||||
}
|
||||
}
|
||||
|
||||
function startAirband() {
|
||||
const frequency = getAirbandFrequency();
|
||||
const device = parseInt(document.getElementById('airbandDeviceSelect').value);
|
||||
const squelch = parseInt(document.getElementById('airbandSquelch').value);
|
||||
|
||||
document.getElementById('airbandStatus').textContent = 'STARTING...';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||||
|
||||
fetch('/spectrum/audio/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
frequency: frequency,
|
||||
modulation: 'am', // Airband uses AM
|
||||
squelch: squelch,
|
||||
gain: 40,
|
||||
device: device
|
||||
})
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'started') {
|
||||
isAirbandPlaying = true;
|
||||
|
||||
// Start browser audio playback
|
||||
const audioPlayer = document.getElementById('airbandPlayer');
|
||||
audioPlayer.src = '/spectrum/audio/stream?' + Date.now();
|
||||
|
||||
// Initialize visualizer before playing
|
||||
initAirbandVisualizer();
|
||||
|
||||
audioPlayer.play().catch(e => {
|
||||
console.warn('Audio autoplay blocked:', e);
|
||||
});
|
||||
|
||||
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">⏹</span> STOP';
|
||||
document.getElementById('airbandBtn').classList.add('active');
|
||||
document.getElementById('airbandStatus').textContent = frequency.toFixed(3) + ' MHz';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-green)';
|
||||
} else {
|
||||
document.getElementById('airbandStatus').textContent = 'ERROR';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
|
||||
alert('Airband Error: ' + (data.message || 'Failed to start'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('airbandStatus').textContent = 'ERROR';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
|
||||
});
|
||||
}
|
||||
|
||||
function stopAirband() {
|
||||
// Stop visualizer
|
||||
stopAirbandVisualizer();
|
||||
|
||||
// Stop browser audio
|
||||
const audioPlayer = document.getElementById('airbandPlayer');
|
||||
audioPlayer.pause();
|
||||
audioPlayer.src = '';
|
||||
|
||||
fetch('/spectrum/audio/stop', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(() => {
|
||||
isAirbandPlaying = false;
|
||||
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">▶</span> LISTEN';
|
||||
document.getElementById('airbandBtn').classList.remove('active');
|
||||
document.getElementById('airbandStatus').textContent = 'OFF';
|
||||
document.getElementById('airbandStatus').style.color = 'var(--text-muted)';
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Initialize airband on page load
|
||||
document.addEventListener('DOMContentLoaded', initAirband);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1985
-169
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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" />
|
||||
<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') }}">
|
||||
@@ -247,8 +247,8 @@
|
||||
worldCopyJump: true
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '©OpenStreetMap, ©CartoDB'
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(groundMap);
|
||||
}
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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_'
|
||||
@@ -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
|
||||
@@ -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
|
||||
+280
-6
@@ -15,7 +15,7 @@ import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
from typing import Optional, Callable, Union
|
||||
|
||||
logger = logging.getLogger('intercept.gps')
|
||||
|
||||
@@ -457,24 +457,264 @@ class GPSReader:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
class GPSDClient:
|
||||
"""
|
||||
Connects to gpsd daemon for GPS data.
|
||||
|
||||
gpsd provides a unified interface for GPS devices and handles
|
||||
device management, making it ideal when gpsd is already running.
|
||||
"""
|
||||
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 2947
|
||||
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._socket: Optional['socket.socket'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
self._device: Optional[str] = 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 client 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
|
||||
|
||||
@property
|
||||
def device_path(self) -> str:
|
||||
"""Return gpsd connection info (for compatibility with GPSReader)."""
|
||||
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:
|
||||
"""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 receiving GPS data from gpsd."""
|
||||
import socket
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.settimeout(5.0)
|
||||
self._socket.connect((self.host, self.port))
|
||||
|
||||
# Enable JSON watch mode
|
||||
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
|
||||
self._socket.send(watch_cmd.encode('ascii'))
|
||||
|
||||
self._running = True
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
if self._socket:
|
||||
try:
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop receiving GPS data."""
|
||||
self._running = False
|
||||
|
||||
if self._socket:
|
||||
try:
|
||||
# Disable watch mode
|
||||
self._socket.send(b'?WATCH={"enable":false}\n')
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread loop for reading gpsd data."""
|
||||
import json
|
||||
import socket
|
||||
|
||||
buffer = ""
|
||||
message_count = 0
|
||||
|
||||
print(f"[GPS] gpsd read loop started", flush=True)
|
||||
|
||||
while self._running and self._socket:
|
||||
try:
|
||||
self._socket.settimeout(1.0)
|
||||
data = self._socket.recv(4096)
|
||||
|
||||
if not data:
|
||||
logger.warning("gpsd connection closed")
|
||||
with self._lock:
|
||||
self._error = "Connection closed by gpsd"
|
||||
break
|
||||
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
|
||||
# Process complete JSON lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
msg_class = msg.get('class', '')
|
||||
|
||||
message_count += 1
|
||||
if message_count <= 5 or message_count % 20 == 0:
|
||||
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
|
||||
|
||||
if msg_class == 'TPV':
|
||||
self._handle_tpv(msg)
|
||||
elif msg_class == 'DEVICES':
|
||||
# Track connected device
|
||||
devices = msg.get('devices', [])
|
||||
if devices:
|
||||
self._device = devices[0].get('path', 'unknown')
|
||||
print(f"[GPS] gpsd device: {self._device}", flush=True)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"gpsd read error: {e}")
|
||||
with self._lock:
|
||||
self._error = str(e)
|
||||
break
|
||||
|
||||
def _handle_tpv(self, msg: dict) -> None:
|
||||
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
|
||||
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
mode = msg.get('mode', 0)
|
||||
|
||||
if mode < 2:
|
||||
# No fix yet
|
||||
return
|
||||
|
||||
lat = msg.get('lat')
|
||||
lon = msg.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
time_str = msg.get('time')
|
||||
if time_str:
|
||||
try:
|
||||
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
||||
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
position = GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd (not knots)
|
||||
heading=msg.get('track'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
device=self._device or f"gpsd://{self.host}:{self.port}",
|
||||
)
|
||||
|
||||
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
||||
self._update_position(position)
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
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}")
|
||||
|
||||
|
||||
# Type alias for GPS source (either serial reader or gpsd client)
|
||||
GPSSource = Union[GPSReader, GPSDClient]
|
||||
|
||||
# Global GPS reader instance
|
||||
_gps_reader: Optional[GPSReader] = None
|
||||
_gps_reader: Optional[GPSSource] = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSReader]:
|
||||
"""Get the global GPS reader instance."""
|
||||
def get_gps_reader() -> Optional[GPSSource]:
|
||||
"""Get the global GPS reader/client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_reader
|
||||
|
||||
|
||||
def start_gps(device_path: str, baudrate: int = 9600) -> bool:
|
||||
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
|
||||
@@ -487,11 +727,45 @@ def start_gps(device_path: str, baudrate: int = 9600) -> bool:
|
||||
_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,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS client connected to gpsd.
|
||||
|
||||
Args:
|
||||
host: gpsd host (default localhost)
|
||||
port: gpsd port (default 2947)
|
||||
callback: Optional callback for position updates
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
|
||||
_gps_reader = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
|
||||
|
||||
def stop_gps() -> None:
|
||||
"""Stop the global GPS reader."""
|
||||
"""Stop the global GPS reader/client."""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
|
||||
@@ -30,6 +30,7 @@ from .detection import detect_all_devices
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .validation import (
|
||||
SDRValidationError,
|
||||
validate_frequency,
|
||||
@@ -49,6 +50,7 @@ class SDRFactory:
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -214,6 +216,7 @@ __all__ = [
|
||||
'RTLSDRCommandBuilder',
|
||||
'LimeSDRCommandBuilder',
|
||||
'HackRFCommandBuilder',
|
||||
'AirspyCommandBuilder',
|
||||
# Validation
|
||||
'SDRValidationError',
|
||||
'validate_frequency',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Airspy command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
|
||||
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class AirspyCommandBuilder(CommandBuilder):
|
||||
"""Airspy command builder using SoapySDR tools."""
|
||||
|
||||
# Airspy R2/Mini capabilities (most common)
|
||||
# HF+ has different range but same interface
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.AIRSPY,
|
||||
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
|
||||
freq_max_mhz=1800.0, # 1.8 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
|
||||
sample_rates=[2500000, 3000000, 6000000, 10000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # Airspy has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for Airspy."""
|
||||
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver={driver},serial={device.serial}'
|
||||
return f'driver={driver}'
|
||||
|
||||
def _format_gain(self, gain: float) -> str:
|
||||
"""
|
||||
Format gain string for Airspy.
|
||||
|
||||
Airspy has three gain stages:
|
||||
- LNA: 0-15 dB
|
||||
- Mixer: 0-15 dB
|
||||
- VGA: 0-15 dB
|
||||
|
||||
This distributes the requested gain across stages.
|
||||
"""
|
||||
if gain <= 15:
|
||||
return f'LNA={int(gain)},MIX=0,VGA=0'
|
||||
elif gain <= 30:
|
||||
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
|
||||
else:
|
||||
vga = min(15, int(gain - 30))
|
||||
return f'LNA=15,MIX=15,VGA={vga}'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with Airspy.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', self._format_gain(gain)])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return Airspy capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.AIRSPY
|
||||
@@ -18,6 +18,7 @@ class SDRType(Enum):
|
||||
RTL_SDR = "rtlsdr"
|
||||
LIME_SDR = "limesdr"
|
||||
HACKRF = "hackrf"
|
||||
AIRSPY = "airspy"
|
||||
# Future support
|
||||
# USRP = "usrp"
|
||||
# BLADE_RF = "bladerf"
|
||||
|
||||
+30
-17
@@ -28,11 +28,13 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
@@ -60,6 +62,8 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
||||
'lime': SDRType.LIME_SDR,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||
# Future support
|
||||
# 'uhd': SDRType.USRP,
|
||||
# 'bladerf': SDRType.BLADE_RF,
|
||||
@@ -140,15 +144,17 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
return devices
|
||||
|
||||
|
||||
def detect_soapy_devices() -> list[SDRDevice]:
|
||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect SDR devices via SoapySDR.
|
||||
|
||||
This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible
|
||||
devices. RTL-SDR devices may also appear here but we prefer the native
|
||||
detection for those.
|
||||
This detects LimeSDR, HackRF, Airspy, and other SoapySDR-compatible devices.
|
||||
|
||||
Args:
|
||||
skip_types: Set of SDRType values to skip (e.g., if already found via native detection)
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_types = skip_types or set()
|
||||
|
||||
if not _check_tool('SoapySDRUtil'):
|
||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||
@@ -177,7 +183,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
# Start of new device block
|
||||
if line.startswith('Found device'):
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts)
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
current_device = {}
|
||||
continue
|
||||
|
||||
@@ -190,7 +196,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
|
||||
# Don't forget the last device
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts)
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("SoapySDRUtil timed out")
|
||||
@@ -203,7 +209,8 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
||||
def _add_soapy_device(
|
||||
devices: list[SDRDevice],
|
||||
device_info: dict,
|
||||
device_counts: dict[SDRType, int]
|
||||
device_counts: dict[SDRType, int],
|
||||
skip_types: set[SDRType]
|
||||
) -> None:
|
||||
"""Add a device from SoapySDR detection to the list."""
|
||||
driver = device_info.get('driver', '').lower()
|
||||
@@ -213,8 +220,9 @@ def _add_soapy_device(
|
||||
logger.debug(f"Unknown SoapySDR driver: {driver}")
|
||||
return
|
||||
|
||||
# Skip RTL-SDR devices from SoapySDR (we use native detection)
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Skip device types that were already found via native detection
|
||||
if sdr_type in skip_types:
|
||||
logger.debug(f"Skipping {driver} from SoapySDR (already found via native detection)")
|
||||
return
|
||||
|
||||
# Track device index per type
|
||||
@@ -294,19 +302,24 @@ def detect_all_devices() -> list[SDRDevice]:
|
||||
Returns a unified list of SDRDevice objects sorted by type and index.
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_in_soapy: set[SDRType] = set()
|
||||
|
||||
# RTL-SDR via native tool (primary method)
|
||||
devices.extend(detect_rtlsdr_devices())
|
||||
rtlsdr_devices = detect_rtlsdr_devices()
|
||||
devices.extend(rtlsdr_devices)
|
||||
if rtlsdr_devices:
|
||||
skip_in_soapy.add(SDRType.RTL_SDR)
|
||||
|
||||
# SoapySDR devices (LimeSDR, HackRF, etc.)
|
||||
soapy_devices = detect_soapy_devices()
|
||||
# Native HackRF detection (primary method)
|
||||
hackrf_devices = detect_hackrf_devices()
|
||||
devices.extend(hackrf_devices)
|
||||
if hackrf_devices:
|
||||
skip_in_soapy.add(SDRType.HACKRF)
|
||||
|
||||
# SoapySDR devices (LimeSDR, Airspy, and fallback for HackRF/RTL-SDR if native failed)
|
||||
soapy_devices = detect_soapy_devices(skip_types=skip_in_soapy)
|
||||
devices.extend(soapy_devices)
|
||||
|
||||
# Native HackRF detection (fallback if SoapySDR didn't find it)
|
||||
hackrf_from_soapy = any(d.sdr_type == SDRType.HACKRF for d in soapy_devices)
|
||||
if not hackrf_from_soapy:
|
||||
devices.extend(detect_hackrf_devices())
|
||||
|
||||
# Sort by type name, then index
|
||||
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user