mirror of
https://github.com/smittix/intercept.git
synced 2026-05-24 16:54:48 -07:00
Merge branch 'main' into main
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ Thumbs.db
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
|
# Package manager lock files
|
||||||
|
uv.lock
|
||||||
|
|||||||
82
CHANGELOG.md
Normal file
82
CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to INTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.0.0] - 2025-01-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Listening Post Mode** - New frequency scanner with automatic signal detection
|
||||||
|
- Scans frequency ranges and stops on detected signals
|
||||||
|
- Real-time audio monitoring with ffmpeg integration
|
||||||
|
- Skip button to continue scanning after signal detection
|
||||||
|
- Configurable dwell time, squelch, and step size
|
||||||
|
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
|
||||||
|
- Activity log of detected signals
|
||||||
|
- **Aircraft Dashboard Improvements**
|
||||||
|
- Dependency warning when rtl_fm or ffmpeg not installed
|
||||||
|
- Auto-restart audio when switching frequencies
|
||||||
|
- Fixed toolbar overflow with custom frequency input
|
||||||
|
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
|
||||||
|
- **Settings System** - SQLite-based persistent settings storage
|
||||||
|
- **Comprehensive Test Suite** - Added tests for routes, validation, correlation, database
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Documentation Overhaul**
|
||||||
|
- Simplified README with clear macOS and Debian installation steps
|
||||||
|
- Added Docker installation option
|
||||||
|
- Complete tool reference table in HARDWARE.md
|
||||||
|
- Removed redundant/confusing content
|
||||||
|
- **Setup Script Rewrite**
|
||||||
|
- Full macOS support with Homebrew auto-installation
|
||||||
|
- Improved Debian/Ubuntu package detection
|
||||||
|
- Added ffmpeg to tool checks
|
||||||
|
- Better error messages with platform-specific install commands
|
||||||
|
- **Dockerfile Updated**
|
||||||
|
- Added ffmpeg for Listening Post audio encoding
|
||||||
|
- Added dump1090 with fallback for different package names
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SoapySDR device detection for RTL-SDR and HackRF
|
||||||
|
- Aircraft dashboard toolbar layout when using custom frequency input
|
||||||
|
- Frequency switching now properly stops/restarts audio
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Added `utils/constants.py` for centralized configuration values
|
||||||
|
- Added `utils/database.py` for SQLite settings storage
|
||||||
|
- Added `utils/correlation.py` for device correlation logic
|
||||||
|
- Added `routes/listening_post.py` for scanner endpoints
|
||||||
|
- Added `routes/settings.py` for settings API
|
||||||
|
- Added `routes/correlation.py` for correlation API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2024-12-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Airspy SDR support
|
||||||
|
- GPS coordinate persistence
|
||||||
|
- SoapySDR device detection improvements
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- RTL-SDR and HackRF detection via SoapySDR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2024-XX-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Satellite tracking with TLE data
|
||||||
|
- Full-screen dashboard for aircraft radar
|
||||||
|
- Full-screen dashboard for satellite tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-XX-XX
|
||||||
|
|
||||||
|
### Initial Release
|
||||||
|
- Pager decoding (POCSAG/FLEX)
|
||||||
|
- 433MHz sensor decoding
|
||||||
|
- ADS-B aircraft tracking
|
||||||
|
- WiFi reconnaissance
|
||||||
|
- Bluetooth scanning
|
||||||
|
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
|
||||||
36
Dockerfile
36
Dockerfile
@@ -3,24 +3,46 @@
|
|||||||
|
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL maintainer="INTERCEPT Project"
|
||||||
|
LABEL description="Signal Intelligence Platform for SDR monitoring"
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies for RTL-SDR tools
|
# Install system dependencies for SDR tools
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# RTL-SDR tools
|
# RTL-SDR tools
|
||||||
rtl-sdr \
|
rtl-sdr \
|
||||||
|
librtlsdr-dev \
|
||||||
|
libusb-1.0-0-dev \
|
||||||
# 433MHz decoder
|
# 433MHz decoder
|
||||||
rtl-433 \
|
rtl-433 \
|
||||||
# Pager decoder
|
# Pager decoder
|
||||||
multimon-ng \
|
multimon-ng \
|
||||||
|
# Audio tools for Listening Post
|
||||||
|
ffmpeg \
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
|
iw \
|
||||||
|
wireless-tools \
|
||||||
# Bluetooth tools
|
# Bluetooth tools
|
||||||
bluez \
|
bluez \
|
||||||
# Cleanup
|
bluetooth \
|
||||||
|
# GPS support
|
||||||
|
gpsd-clients \
|
||||||
|
# Utilities
|
||||||
|
curl \
|
||||||
|
procps \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install dump1090 for ADS-B (package name varies by distribution)
|
||||||
|
RUN apt-get update && \
|
||||||
|
(apt-get install -y --no-install-recommends dump1090-mutability || \
|
||||||
|
apt-get install -y --no-install-recommends dump1090-fa || \
|
||||||
|
apt-get install -y --no-install-recommends dump1090 || \
|
||||||
|
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
@@ -28,13 +50,21 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Create data directory for persistence
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
|
||||||
# Environment variables with defaults
|
# Environment variables with defaults
|
||||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||||
INTERCEPT_PORT=5050 \
|
INTERCEPT_PORT=5050 \
|
||||||
INTERCEPT_LOG_LEVEL=INFO
|
INTERCEPT_LOG_LEVEL=INFO \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Health check using the new endpoint
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -sf http://localhost:5050/health || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "intercept.py"]
|
CMD ["python", "intercept.py"]
|
||||||
|
|||||||
103
README.md
103
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>Signal Intelligence Platform</strong><br>
|
<strong>Signal Intelligence Platform</strong><br>
|
||||||
A web-based front-end for signal intelligence tools.
|
A web-based interface for software-defined radio tools.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -17,29 +17,23 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What is INTERCEPT?
|
## Features
|
||||||
|
|
||||||
INTERCEPT provides a unified web interface for signal intelligence tools:
|
|
||||||
|
|
||||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||||
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
|
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
|
||||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
|
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
|
||||||
|
- **Listening Post** - Frequency scanner with audio monitoring
|
||||||
- **Satellite Tracking** - Pass prediction using TLE data
|
- **Satellite Tracking** - Pass prediction using TLE data
|
||||||
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
|
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
|
||||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
- **Bluetooth Scanning** - Device discovery and tracker detection
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Community
|
## Installation / Debian / Ubuntu / MacOS
|
||||||
|
|
||||||
<p align="center">
|
```
|
||||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
|
**1. Clone and run:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
@@ -47,72 +41,67 @@ cd intercept
|
|||||||
sudo python3 intercept.py
|
sudo python3 intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:5050 in your browser.
|
### Docker (Alternative)
|
||||||
|
|
||||||
## Usage of Black Formatter
|
|
||||||
```bash
|
|
||||||
uv run black . # If you use UV
|
|
||||||
black . # For Python
|
|
||||||
```
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><strong>Alternative: Install with uv</strong></summary>
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/smittix/intercept.git
|
git clone https://github.com/smittix/intercept.git
|
||||||
cd intercept
|
cd intercept
|
||||||
uv venv
|
docker-compose up -d
|
||||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
||||||
uv sync
|
|
||||||
sudo python3 intercept.py
|
|
||||||
```
|
```
|
||||||
</details>
|
|
||||||
|
|
||||||
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
|
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
|
||||||
|
|
||||||
|
### Open the Interface
|
||||||
|
|
||||||
|
After starting, open **http://localhost:5050** in your browser.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Hardware Requirements
|
||||||
|
|
||||||
- **Python 3.9+**
|
| Hardware | Purpose | Price |
|
||||||
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
|
|----------|---------|-------|
|
||||||
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
|
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
|
||||||
|
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
|
||||||
|
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||||
|
| **GPS** | Any Linux supported GPS Unit | ~10 |
|
||||||
|
|
||||||
Quick install (Ubuntu/Debian):
|
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||||
```bash
|
|
||||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Hardware & Installation](docs/HARDWARE.md) for full details.
|
| :exclamation: Not using an RTL-SDR Device? |
|
||||||
|
|-----------------------------------------------
|
||||||
|
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
|
||||||
|
|
||||||
|
| :exclamation: GPS Usage |
|
||||||
|
|-----------------------------------------------
|
||||||
|
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discord Server
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Description |
|
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
|
||||||
|----------|-------------|
|
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
|
||||||
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
|
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
|
||||||
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
|
|
||||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
|
|
||||||
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
|
||||||
|
|
||||||
Contributions and improvements welcome.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
**This software is for educational purposes only.**
|
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||||
|
|
||||||
|
**This software is for educational and authorized testing purposes only.**
|
||||||
|
|
||||||
- Only use with proper authorization
|
- Only use with proper authorization
|
||||||
- Intercepting communications without consent may be illegal
|
- Intercepting communications without consent may be illegal
|
||||||
- WiFi/Bluetooth attacks require explicit permission
|
|
||||||
- You are responsible for compliance with applicable laws
|
- You are responsible for compliance with applicable laws
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -135,3 +124,5 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
|||||||
[Leaflet.js](https://leafletjs.com/) |
|
[Leaflet.js](https://leafletjs.com/) |
|
||||||
[Celestrak](https://celestrak.org/)
|
[Celestrak](https://celestrak.org/)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
138
app.py
138
app.py
@@ -29,6 +29,17 @@ from config import VERSION
|
|||||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||||
from utils.process import cleanup_stale_processes
|
from utils.process import cleanup_stale_processes
|
||||||
from utils.sdr import SDRFactory
|
from utils.sdr import SDRFactory
|
||||||
|
from utils.cleanup import DataStore, cleanup_manager
|
||||||
|
from utils.constants import (
|
||||||
|
MAX_AIRCRAFT_AGE_SECONDS,
|
||||||
|
MAX_WIFI_NETWORK_AGE_SECONDS,
|
||||||
|
MAX_BT_DEVICE_AGE_SECONDS,
|
||||||
|
QUEUE_MAX_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track application start time for uptime calculation
|
||||||
|
import time as _time
|
||||||
|
_app_start_time = _time.time()
|
||||||
|
|
||||||
|
|
||||||
# Create Flask app
|
# Create Flask app
|
||||||
@@ -40,32 +51,32 @@ app = Flask(__name__)
|
|||||||
|
|
||||||
# Pager decoder
|
# Pager decoder
|
||||||
current_process = None
|
current_process = None
|
||||||
output_queue = queue.Queue()
|
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
process_lock = threading.Lock()
|
process_lock = threading.Lock()
|
||||||
|
|
||||||
# RTL_433 sensor
|
# RTL_433 sensor
|
||||||
sensor_process = None
|
sensor_process = None
|
||||||
sensor_queue = queue.Queue()
|
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
sensor_lock = threading.Lock()
|
sensor_lock = threading.Lock()
|
||||||
|
|
||||||
# WiFi
|
# WiFi
|
||||||
wifi_process = None
|
wifi_process = None
|
||||||
wifi_queue = queue.Queue()
|
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
wifi_lock = threading.Lock()
|
wifi_lock = threading.Lock()
|
||||||
|
|
||||||
# Bluetooth
|
# Bluetooth
|
||||||
bt_process = None
|
bt_process = None
|
||||||
bt_queue = queue.Queue()
|
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
bt_lock = threading.Lock()
|
bt_lock = threading.Lock()
|
||||||
|
|
||||||
# ADS-B aircraft
|
# ADS-B aircraft
|
||||||
adsb_process = None
|
adsb_process = None
|
||||||
adsb_queue = queue.Queue()
|
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
adsb_lock = threading.Lock()
|
adsb_lock = threading.Lock()
|
||||||
|
|
||||||
# Satellite/Iridium
|
# Satellite/Iridium
|
||||||
satellite_process = None
|
satellite_process = None
|
||||||
satellite_queue = queue.Queue()
|
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
satellite_lock = threading.Lock()
|
satellite_lock = threading.Lock()
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -76,23 +87,30 @@ satellite_lock = threading.Lock()
|
|||||||
logging_enabled = False
|
logging_enabled = False
|
||||||
log_file_path = 'pager_messages.log'
|
log_file_path = 'pager_messages.log'
|
||||||
|
|
||||||
# WiFi state
|
# WiFi state - using DataStore for automatic cleanup
|
||||||
wifi_monitor_interface = None
|
wifi_monitor_interface = None
|
||||||
wifi_networks = {} # BSSID -> network info
|
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
|
||||||
wifi_clients = {} # Client MAC -> client info
|
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
|
||||||
wifi_handshakes = [] # Captured handshakes
|
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
|
||||||
|
|
||||||
# Bluetooth state
|
# Bluetooth state - using DataStore for automatic cleanup
|
||||||
bt_interface = None
|
bt_interface = None
|
||||||
bt_devices = {} # MAC -> device info
|
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
|
||||||
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
|
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
|
||||||
bt_services = {} # MAC -> list of services
|
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
|
||||||
|
|
||||||
# Aircraft (ADS-B) state
|
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
|
||||||
adsb_aircraft = {} # ICAO hex -> aircraft info
|
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
|
||||||
|
|
||||||
# Satellite state
|
# Satellite state
|
||||||
satellite_passes = [] # Predicted satellite passes
|
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
|
||||||
|
|
||||||
|
# Register data stores with cleanup manager
|
||||||
|
cleanup_manager.register(wifi_networks)
|
||||||
|
cleanup_manager.register(wifi_clients)
|
||||||
|
cleanup_manager.register(bt_devices)
|
||||||
|
cleanup_manager.register(bt_beacons)
|
||||||
|
cleanup_manager.register(adsb_aircraft)
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -130,15 +148,16 @@ def get_dependencies() -> Response:
|
|||||||
# Determine OS for install instructions
|
# Determine OS for install instructions
|
||||||
system = platform.system().lower()
|
system = platform.system().lower()
|
||||||
if system == 'darwin':
|
if system == 'darwin':
|
||||||
install_method = 'brew'
|
pkg_manager = 'brew'
|
||||||
elif system == 'linux':
|
elif system == 'linux':
|
||||||
install_method = 'apt'
|
pkg_manager = 'apt'
|
||||||
else:
|
else:
|
||||||
install_method = 'manual'
|
pkg_manager = 'manual'
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
'os': system,
|
'os': system,
|
||||||
'install_method': install_method,
|
'pkg_manager': pkg_manager,
|
||||||
'modes': results
|
'modes': results
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,14 +178,14 @@ def export_aircraft() -> Response:
|
|||||||
for icao, ac in adsb_aircraft.items():
|
for icao, ac in adsb_aircraft.items():
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
icao,
|
icao,
|
||||||
ac.get('callsign', ''),
|
ac.get('callsign', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('altitude', ''),
|
ac.get('altitude', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('speed', ''),
|
ac.get('speed', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('heading', ''),
|
ac.get('heading', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('lat', ''),
|
ac.get('lat', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('lon', ''),
|
ac.get('lon', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('squawk', ''),
|
ac.get('squawk', '') if isinstance(ac, dict) else '',
|
||||||
ac.get('lastSeen', '')
|
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
|
||||||
])
|
])
|
||||||
|
|
||||||
response = Response(output.getvalue(), mimetype='text/csv')
|
response = Response(output.getvalue(), mimetype='text/csv')
|
||||||
@@ -175,7 +194,7 @@ def export_aircraft() -> Response:
|
|||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||||
'aircraft': list(adsb_aircraft.values())
|
'aircraft': adsb_aircraft.values()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -195,11 +214,11 @@ def export_wifi() -> Response:
|
|||||||
for bssid, net in wifi_networks.items():
|
for bssid, net in wifi_networks.items():
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
bssid,
|
bssid,
|
||||||
net.get('ssid', ''),
|
net.get('ssid', '') if isinstance(net, dict) else '',
|
||||||
net.get('channel', ''),
|
net.get('channel', '') if isinstance(net, dict) else '',
|
||||||
net.get('signal', ''),
|
net.get('signal', '') if isinstance(net, dict) else '',
|
||||||
net.get('encryption', ''),
|
net.get('encryption', '') if isinstance(net, dict) else '',
|
||||||
net.get('clients', 0)
|
net.get('clients', 0) if isinstance(net, dict) else 0
|
||||||
])
|
])
|
||||||
|
|
||||||
response = Response(output.getvalue(), mimetype='text/csv')
|
response = Response(output.getvalue(), mimetype='text/csv')
|
||||||
@@ -208,8 +227,8 @@ def export_wifi() -> Response:
|
|||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||||
'networks': list(wifi_networks.values()),
|
'networks': wifi_networks.values(),
|
||||||
'clients': list(wifi_clients.values())
|
'clients': wifi_clients.values()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -229,11 +248,11 @@ def export_bluetooth() -> Response:
|
|||||||
for mac, dev in bt_devices.items():
|
for mac, dev in bt_devices.items():
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
mac,
|
mac,
|
||||||
dev.get('name', ''),
|
dev.get('name', '') if isinstance(dev, dict) else '',
|
||||||
dev.get('rssi', ''),
|
dev.get('rssi', '') if isinstance(dev, dict) else '',
|
||||||
dev.get('type', ''),
|
dev.get('type', '') if isinstance(dev, dict) else '',
|
||||||
dev.get('manufacturer', ''),
|
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
|
||||||
dev.get('lastSeen', '')
|
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
|
||||||
])
|
])
|
||||||
|
|
||||||
response = Response(output.getvalue(), mimetype='text/csv')
|
response = Response(output.getvalue(), mimetype='text/csv')
|
||||||
@@ -242,11 +261,35 @@ def export_bluetooth() -> Response:
|
|||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||||
'devices': list(bt_devices.values()),
|
'devices': bt_devices.values(),
|
||||||
'beacons': list(bt_beacons.values())
|
'beacons': bt_beacons.values()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health_check() -> Response:
|
||||||
|
"""Health check endpoint for monitoring."""
|
||||||
|
import time
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'version': VERSION,
|
||||||
|
'uptime_seconds': round(time.time() - _app_start_time, 2),
|
||||||
|
'processes': {
|
||||||
|
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
|
||||||
|
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
|
||||||
|
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
|
||||||
|
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
|
||||||
|
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
|
||||||
|
},
|
||||||
|
'data': {
|
||||||
|
'aircraft_count': len(adsb_aircraft),
|
||||||
|
'wifi_networks_count': len(wifi_networks),
|
||||||
|
'wifi_clients_count': len(wifi_clients),
|
||||||
|
'bt_devices_count': len(bt_devices),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/killall', methods=['POST'])
|
@app.route('/killall', methods=['POST'])
|
||||||
def kill_all() -> Response:
|
def kill_all() -> Response:
|
||||||
"""Kill all decoder and WiFi processes."""
|
"""Kill all decoder and WiFi processes."""
|
||||||
@@ -343,6 +386,13 @@ def main() -> None:
|
|||||||
# Clean up any stale processes from previous runs
|
# Clean up any stale processes from previous runs
|
||||||
cleanup_stale_processes()
|
cleanup_stale_processes()
|
||||||
|
|
||||||
|
# Initialize database for settings storage
|
||||||
|
from utils.database import init_db
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Start automatic cleanup of stale data entries
|
||||||
|
cleanup_manager.start()
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from routes import register_blueprints
|
from routes import register_blueprints
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "1.2.0"
|
VERSION = "2.0.0"
|
||||||
|
|
||||||
|
|
||||||
def _get_env(key: str, default: str) -> str:
|
def _get_env(key: str, default: str) -> str:
|
||||||
|
|||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# INTERCEPT - Signal Intelligence Platform
|
||||||
|
# Docker Compose configuration for easy deployment
|
||||||
|
|
||||||
|
services:
|
||||||
|
intercept:
|
||||||
|
build: .
|
||||||
|
container_name: intercept
|
||||||
|
ports:
|
||||||
|
- "5050:5050"
|
||||||
|
# Privileged mode required for USB SDR device access
|
||||||
|
# Alternatively, use device mapping (see below)
|
||||||
|
privileged: true
|
||||||
|
# USB device mapping (alternative to privileged mode)
|
||||||
|
# devices:
|
||||||
|
# - /dev/bus/usb:/dev/bus/usb
|
||||||
|
volumes:
|
||||||
|
# Persist data directory
|
||||||
|
- ./data:/app/data
|
||||||
|
# Optional: mount logs directory
|
||||||
|
# - ./logs:/app/logs
|
||||||
|
environment:
|
||||||
|
- INTERCEPT_HOST=0.0.0.0
|
||||||
|
- INTERCEPT_PORT=5050
|
||||||
|
- INTERCEPT_LOG_LEVEL=INFO
|
||||||
|
# Network mode for WiFi scanning (requires host network)
|
||||||
|
# network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Optional: Add volume for persistent SQLite database
|
||||||
|
# volumes:
|
||||||
|
# intercept-data:
|
||||||
243
docs/HARDWARE.md
243
docs/HARDWARE.md
@@ -1,93 +1,75 @@
|
|||||||
# Hardware & Installation
|
# Hardware & Advanced Setup
|
||||||
|
|
||||||
## Supported SDR Hardware
|
## Supported SDR Hardware
|
||||||
|
|
||||||
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|
| Hardware | Frequency Range | Price | Notes |
|
||||||
|----------|-----------------|------------|-----|-------|-------|
|
|----------|-----------------|-------|-------|
|
||||||
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
|
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
|
||||||
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
|
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
|
||||||
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
|
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||||
|
|
||||||
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
|
INTERCEPT automatically detects connected devices.
|
||||||
|
|
||||||
## Requirements
|
---
|
||||||
|
|
||||||
### Hardware
|
## Quick Install
|
||||||
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
|
|
||||||
- **WiFi adapter** capable of monitor mode (for WiFi features)
|
|
||||||
- **Bluetooth adapter** (for Bluetooth features)
|
|
||||||
- **GPS dongle** (optional, for precise location)
|
|
||||||
|
|
||||||
### Software
|
|
||||||
- **Python 3.9+** required
|
|
||||||
- External tools (see installation below)
|
|
||||||
|
|
||||||
## Tool Installation
|
|
||||||
|
|
||||||
### Core SDR Tools
|
|
||||||
|
|
||||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
|
||||||
|------|-------|---------------|---------|
|
|
||||||
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
|
|
||||||
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
|
|
||||||
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
|
|
||||||
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
|
|
||||||
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
|
|
||||||
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
|
|
||||||
|
|
||||||
### LimeSDR / HackRF Support (Optional)
|
|
||||||
|
|
||||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
|
||||||
|------|-------|---------------|---------|
|
|
||||||
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
|
|
||||||
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
|
|
||||||
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
|
|
||||||
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
|
|
||||||
|
|
||||||
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
|
|
||||||
|
|
||||||
## Quick Install Commands
|
|
||||||
|
|
||||||
### Ubuntu/Debian
|
|
||||||
> [!NOTE]
|
|
||||||
> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
|
|
||||||
```bash
|
|
||||||
# Core tools
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
|
|
||||||
|
|
||||||
# LimeSDR (optional)
|
|
||||||
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
|
|
||||||
|
|
||||||
# HackRF (optional)
|
|
||||||
sudo apt install hackrf soapysdr-module-hackrf
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS (Homebrew)
|
### macOS (Homebrew)
|
||||||
```bash
|
|
||||||
# Core tools
|
|
||||||
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
|
|
||||||
|
|
||||||
# LimeSDR (optional)
|
```bash
|
||||||
|
# Install Homebrew if needed
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
|
# Core tools (required)
|
||||||
|
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
|
||||||
|
|
||||||
|
# ADS-B aircraft tracking
|
||||||
|
brew install dump1090-mutability
|
||||||
|
|
||||||
|
# WiFi tools (optional)
|
||||||
|
brew install aircrack-ng
|
||||||
|
|
||||||
|
# LimeSDR support (optional)
|
||||||
brew install soapysdr limesuite soapylms7
|
brew install soapysdr limesuite soapylms7
|
||||||
|
|
||||||
# HackRF (optional)
|
# HackRF support (optional)
|
||||||
brew install hackrf soapyhackrf
|
brew install hackrf soapyhackrf
|
||||||
```
|
```
|
||||||
|
|
||||||
### Arch Linux
|
### Debian / Ubuntu / Raspberry Pi OS
|
||||||
```bash
|
|
||||||
# Core tools
|
|
||||||
sudo pacman -S rtl-sdr multimon-ng
|
|
||||||
yay -S rtl_433 dump1090
|
|
||||||
|
|
||||||
# LimeSDR/HackRF (optional)
|
```bash
|
||||||
sudo pacman -S soapysdr limesuite hackrf
|
# Update package lists
|
||||||
|
sudo apt update
|
||||||
|
|
||||||
|
# Core tools (required)
|
||||||
|
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
|
||||||
|
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
|
||||||
|
|
||||||
|
# ADS-B aircraft tracking
|
||||||
|
sudo apt install -y dump1090-mutability
|
||||||
|
# Alternative: dump1090-fa (FlightAware version)
|
||||||
|
|
||||||
|
# WiFi tools (optional)
|
||||||
|
sudo apt install -y aircrack-ng
|
||||||
|
|
||||||
|
# Bluetooth tools (optional)
|
||||||
|
sudo apt install -y bluez bluetooth
|
||||||
|
|
||||||
|
# LimeSDR support (optional)
|
||||||
|
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
|
||||||
|
|
||||||
|
# HackRF support (optional)
|
||||||
|
sudo apt install -y hackrf soapysdr-module-hackrf
|
||||||
```
|
```
|
||||||
|
|
||||||
## Linux udev Rules
|
---
|
||||||
|
|
||||||
If your SDR isn't detected, add udev rules:
|
## RTL-SDR Setup (Linux)
|
||||||
|
|
||||||
|
### Add udev rules
|
||||||
|
|
||||||
|
If your RTL-SDR isn't detected, create udev rules:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||||
@@ -99,9 +81,9 @@ sudo udevadm control --reload-rules
|
|||||||
sudo udevadm trigger
|
sudo udevadm trigger
|
||||||
```
|
```
|
||||||
|
|
||||||
Then unplug and replug your device.
|
Then unplug and replug your RTL-SDR.
|
||||||
|
|
||||||
## Blacklist DVB-T Driver (Linux)
|
### Blacklist DVB-T driver
|
||||||
|
|
||||||
The default DVB-T driver conflicts with rtl-sdr:
|
The default DVB-T driver conflicts with rtl-sdr:
|
||||||
|
|
||||||
@@ -110,57 +92,120 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
|||||||
sudo modprobe -r dvb_usb_rtl28xxu
|
sudo modprobe -r dvb_usb_rtl28xxu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verify Installation
|
## Verify Installation
|
||||||
|
|
||||||
Check what's installed:
|
### Check dependencies
|
||||||
```bash
|
```bash
|
||||||
python3 intercept.py --check-deps
|
python3 intercept.py --check-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
Test SDR detection:
|
### Test SDR detection
|
||||||
```bash
|
```bash
|
||||||
# RTL-SDR
|
# RTL-SDR
|
||||||
rtl_test
|
rtl_test
|
||||||
|
|
||||||
# LimeSDR/HackRF
|
# LimeSDR/HackRF (via SoapySDR)
|
||||||
SoapySDRUtil --find
|
SoapySDRUtil --find
|
||||||
```
|
```
|
||||||
|
|
||||||
## Python Dependencies
|
---
|
||||||
|
|
||||||
### Option 1: setup.sh (Recommended)
|
## Python Environment
|
||||||
|
|
||||||
|
### Using setup.sh (Recommended)
|
||||||
```bash
|
```bash
|
||||||
./setup.sh
|
./setup.sh
|
||||||
```
|
```
|
||||||
This creates a virtual environment and installs dependencies automatically.
|
|
||||||
|
|
||||||
### Option 2: pip
|
This automatically:
|
||||||
|
- Detects your OS
|
||||||
|
- Creates a virtual environment if needed (for PEP 668 systems)
|
||||||
|
- Installs Python dependencies
|
||||||
|
- Checks for required tools
|
||||||
|
|
||||||
|
### Manual setup
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv venv
|
||||||
source .venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: uv (Fast alternative)
|
---
|
||||||
[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
|
|
||||||
|
## Running INTERCEPT
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install uv (if not already installed)
|
# Standard
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
sudo python3 intercept.py
|
||||||
|
|
||||||
# Create venv and install deps
|
# With virtual environment
|
||||||
uv venv
|
sudo venv/bin/python intercept.py
|
||||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
||||||
uv sync
|
|
||||||
|
|
||||||
# Or just install deps in existing environment
|
# Custom port
|
||||||
uv pip install -r requirements.txt
|
INTERCEPT_PORT=8080 sudo python3 intercept.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 4: pip with pyproject.toml
|
Open **http://localhost:5050** in your browser.
|
||||||
```bash
|
|
||||||
pip install . # Install as package
|
---
|
||||||
pip install -e . # Install in editable mode (for development)
|
|
||||||
pip install -e ".[dev]" # Include dev dependencies
|
## Complete Tool Reference
|
||||||
```
|
|
||||||
|
| Tool | Package (Debian) | Package (macOS) | Required For |
|
||||||
|
|------|------------------|-----------------|--------------|
|
||||||
|
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
|
||||||
|
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
|
||||||
|
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
|
||||||
|
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
|
||||||
|
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
|
||||||
|
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
|
||||||
|
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
|
||||||
|
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
|
||||||
|
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
|
||||||
|
| `hcitool` | bluez | N/A | Bluetooth scanning |
|
||||||
|
| `bluetoothctl` | bluez | N/A | Bluetooth control |
|
||||||
|
| `hciconfig` | bluez | N/A | Bluetooth config |
|
||||||
|
|
||||||
|
### Optional tools:
|
||||||
|
| Tool | Package (Debian) | Package (macOS) | Purpose |
|
||||||
|
|------|------------------|-----------------|---------|
|
||||||
|
| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
|
||||||
|
| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
|
||||||
|
| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
|
||||||
|
| `hackrf_info` | hackrf | hackrf | HackRF native tools |
|
||||||
|
|
||||||
|
### Python dependencies (requirements.txt):
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `flask` | Web server |
|
||||||
|
| `skyfield` | Satellite tracking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## dump1090 Notes
|
||||||
|
|
||||||
|
### Package names vary by distribution:
|
||||||
|
- `dump1090-mutability` - Most common
|
||||||
|
- `dump1090-fa` - FlightAware version (recommended)
|
||||||
|
- `dump1090` - Generic
|
||||||
|
|
||||||
|
### Not in repositories (Debian Trixie)?
|
||||||
|
|
||||||
|
Install FlightAware's version:
|
||||||
|
https://flightaware.com/adsb/piaware/install
|
||||||
|
|
||||||
|
Or build from source:
|
||||||
|
https://github.com/flightaware/dump1090
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
|
||||||
|
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
|
||||||
|
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ Then unplug and replug your RTL-SDR.
|
|||||||
3. Check for other applications: `lsof | grep rtl`
|
3. Check for other applications: `lsof | grep rtl`
|
||||||
|
|
||||||
### LimeSDR/HackRF not detected
|
### LimeSDR/HackRF not detected
|
||||||
|
Ensure the correct SoapySDR module for your hardware is installed first
|
||||||
|
|
||||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||||
@@ -146,21 +147,6 @@ Run with sudo or add your user to the bluetooth group:
|
|||||||
sudo usermod -a -G bluetooth $USER
|
sudo usermod -a -G bluetooth $USER
|
||||||
```
|
```
|
||||||
|
|
||||||
## GPS Issues
|
|
||||||
|
|
||||||
### GPS dongle not detected
|
|
||||||
|
|
||||||
1. Install pyserial: `pip install pyserial`
|
|
||||||
2. Check device is connected:
|
|
||||||
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
|
|
||||||
- macOS: `ls /dev/tty.usb*`
|
|
||||||
3. Add user to dialout group (Linux):
|
|
||||||
```bash
|
|
||||||
sudo usermod -a -G dialout $USER
|
|
||||||
```
|
|
||||||
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
|
|
||||||
5. GPS needs clear sky view to get a fix
|
|
||||||
|
|
||||||
## Decoding Issues
|
## Decoding Issues
|
||||||
|
|
||||||
### No messages appearing (Pager mode)
|
### No messages appearing (Pager mode)
|
||||||
@@ -170,15 +156,20 @@ sudo usermod -a -G bluetooth $USER
|
|||||||
3. Check pager services are active in your area
|
3. Check pager services are active in your area
|
||||||
4. Ensure antenna is connected
|
4. Ensure antenna is connected
|
||||||
|
|
||||||
|
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||||
|
|
||||||
|
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||||
|
|
||||||
### No aircraft appearing (ADS-B mode)
|
### No aircraft appearing (ADS-B mode)
|
||||||
|
|
||||||
1. Verify dump1090 or readsb is installed
|
1. Verify dump1090 is installed
|
||||||
2. Check antenna is connected (1090 MHz antenna recommended)
|
2. Check antenna is connected (1090 MHz antenna recommended)
|
||||||
3. Ensure clear view of sky
|
3. Ensure clear view of sky
|
||||||
4. Set correct observer location for range calculations
|
4. Set correct observer location for range calculations or use gpsd
|
||||||
|
|
||||||
### Satellite passes not calculating
|
### Satellite passes not calculating
|
||||||
|
|
||||||
1. Ensure skyfield is installed: `pip install skyfield`
|
1. Ensure skyfield is installed: `apt install python3-skyfield`
|
||||||
2. Check TLE data is valid and recent
|
2. Check TLE data is valid and recent
|
||||||
3. Verify observer location is set correctly
|
3. Verify observer location is set correctly
|
||||||
|
|
||||||
|
|||||||
BIN
instance/intercept.db
Normal file
BIN
instance/intercept.db
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "1.2.0"
|
version = "2.0.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ def register_blueprints(app):
|
|||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .satellite import satellite_bp
|
from .satellite import satellite_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
|
from .settings import settings_bp
|
||||||
|
from .correlation import correlation_bp
|
||||||
|
from .listening_post import listening_post_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -17,3 +20,6 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
app.register_blueprint(satellite_bp)
|
app.register_blueprint(satellite_bp)
|
||||||
app.register_blueprint(gps_bp)
|
app.register_blueprint(gps_bp)
|
||||||
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(correlation_bp)
|
||||||
|
app.register_blueprint(listening_post_bp)
|
||||||
|
|||||||
191
routes/adsb.py
191
routes/adsb.py
@@ -22,6 +22,20 @@ from utils.validation import (
|
|||||||
)
|
)
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.sdr import SDRFactory, SDRType
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.constants import (
|
||||||
|
ADSB_SBS_PORT,
|
||||||
|
ADSB_TERMINATE_TIMEOUT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
SBS_SOCKET_TIMEOUT,
|
||||||
|
SBS_RECONNECT_DELAY,
|
||||||
|
SOCKET_BUFFER_SIZE,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SOCKET_CONNECT_TIMEOUT,
|
||||||
|
ADSB_UPDATE_INTERVAL,
|
||||||
|
DUMP1090_START_WAIT,
|
||||||
|
)
|
||||||
|
from utils import aircraft_db
|
||||||
|
|
||||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||||
|
|
||||||
@@ -30,6 +44,14 @@ adsb_using_service = False
|
|||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
adsb_messages_received = 0
|
adsb_messages_received = 0
|
||||||
adsb_last_message_time = None
|
adsb_last_message_time = None
|
||||||
|
adsb_bytes_received = 0
|
||||||
|
adsb_lines_received = 0
|
||||||
|
|
||||||
|
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||||
|
_looked_up_icaos: set[str] = set()
|
||||||
|
|
||||||
|
# Load aircraft database at module init
|
||||||
|
aircraft_db.load_database()
|
||||||
|
|
||||||
# Common installation paths for dump1090 (when not in PATH)
|
# Common installation paths for dump1090 (when not in PATH)
|
||||||
DUMP1090_PATHS = [
|
DUMP1090_PATHS = [
|
||||||
@@ -63,22 +85,22 @@ def find_dump1090():
|
|||||||
|
|
||||||
|
|
||||||
def check_dump1090_service():
|
def check_dump1090_service():
|
||||||
"""Check if dump1090 SBS port (30003) is available."""
|
"""Check if dump1090 SBS port is available."""
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(2)
|
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
|
||||||
result = sock.connect_ex(('localhost', 30003))
|
result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
|
||||||
sock.close()
|
sock.close()
|
||||||
if result == 0:
|
if result == 0:
|
||||||
return 'localhost:30003'
|
return f'localhost:{ADSB_SBS_PORT}'
|
||||||
except Exception:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def parse_sbs_stream(service_addr):
|
def parse_sbs_stream(service_addr):
|
||||||
"""Parse SBS format data from dump1090 port 30003."""
|
"""Parse SBS format data from dump1090 SBS port."""
|
||||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
||||||
|
|
||||||
host, port = service_addr.split(':')
|
host, port = service_addr.split(':')
|
||||||
port = int(port)
|
port = int(port)
|
||||||
@@ -90,7 +112,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(5)
|
sock.settimeout(SBS_SOCKET_TIMEOUT)
|
||||||
sock.connect((host, port))
|
sock.connect((host, port))
|
||||||
adsb_connected = True
|
adsb_connected = True
|
||||||
logger.info("Connected to SBS stream")
|
logger.info("Connected to SBS stream")
|
||||||
@@ -98,12 +120,16 @@ def parse_sbs_stream(service_addr):
|
|||||||
buffer = ""
|
buffer = ""
|
||||||
last_update = time.time()
|
last_update = time.time()
|
||||||
pending_updates = set()
|
pending_updates = set()
|
||||||
|
adsb_bytes_received = 0
|
||||||
|
adsb_lines_received = 0
|
||||||
|
|
||||||
while adsb_using_service:
|
while adsb_using_service:
|
||||||
try:
|
try:
|
||||||
data = sock.recv(4096).decode('utf-8', errors='ignore')
|
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||||
if not data:
|
if not data:
|
||||||
|
logger.warning("SBS connection closed (no data)")
|
||||||
break
|
break
|
||||||
|
adsb_bytes_received += len(data)
|
||||||
buffer += data
|
buffer += data
|
||||||
|
|
||||||
while '\n' in buffer:
|
while '\n' in buffer:
|
||||||
@@ -112,8 +138,15 @@ def parse_sbs_stream(service_addr):
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
adsb_lines_received += 1
|
||||||
|
# Log first few lines for debugging
|
||||||
|
if adsb_lines_received <= 3:
|
||||||
|
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||||
|
|
||||||
parts = line.split(',')
|
parts = line.split(',')
|
||||||
if len(parts) < 11 or parts[0] != 'MSG':
|
if len(parts) < 11 or parts[0] != 'MSG':
|
||||||
|
if adsb_lines_received <= 5:
|
||||||
|
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_type = parts[1]
|
msg_type = parts[1]
|
||||||
@@ -121,7 +154,19 @@ def parse_sbs_stream(service_addr):
|
|||||||
if not icao:
|
if not icao:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
|
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||||
|
|
||||||
|
# Look up aircraft type from database (once per ICAO)
|
||||||
|
if icao not in _looked_up_icaos:
|
||||||
|
_looked_up_icaos.add(icao)
|
||||||
|
db_info = aircraft_db.lookup(icao)
|
||||||
|
if db_info:
|
||||||
|
if db_info['registration']:
|
||||||
|
aircraft['registration'] = db_info['registration']
|
||||||
|
if db_info['type_code']:
|
||||||
|
aircraft['type_code'] = db_info['type_code']
|
||||||
|
if db_info['type_desc']:
|
||||||
|
aircraft['type_desc'] = db_info['type_desc']
|
||||||
|
|
||||||
if msg_type == '1' and len(parts) > 10:
|
if msg_type == '1' and len(parts) > 10:
|
||||||
callsign = parts[10].strip()
|
callsign = parts[10].strip()
|
||||||
@@ -141,7 +186,7 @@ def parse_sbs_stream(service_addr):
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
elif msg_type == '4' and len(parts) > 13:
|
elif msg_type == '4' and len(parts) > 16:
|
||||||
if parts[12]:
|
if parts[12]:
|
||||||
try:
|
try:
|
||||||
aircraft['speed'] = int(float(parts[12]))
|
aircraft['speed'] = int(float(parts[12]))
|
||||||
@@ -152,6 +197,11 @@ def parse_sbs_stream(service_addr):
|
|||||||
aircraft['heading'] = int(float(parts[13]))
|
aircraft['heading'] = int(float(parts[13]))
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
if parts[16]:
|
||||||
|
try:
|
||||||
|
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
elif msg_type == '5' and len(parts) > 11:
|
elif msg_type == '5' and len(parts) > 11:
|
||||||
if parts[10]:
|
if parts[10]:
|
||||||
@@ -168,13 +218,13 @@ def parse_sbs_stream(service_addr):
|
|||||||
if parts[17]:
|
if parts[17]:
|
||||||
aircraft['squawk'] = parts[17]
|
aircraft['squawk'] = parts[17]
|
||||||
|
|
||||||
app_module.adsb_aircraft[icao] = aircraft
|
app_module.adsb_aircraft.set(icao, aircraft)
|
||||||
pending_updates.add(icao)
|
pending_updates.add(icao)
|
||||||
adsb_messages_received += 1
|
adsb_messages_received += 1
|
||||||
adsb_last_message_time = time.time()
|
adsb_last_message_time = time.time()
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_update >= 1.0:
|
if now - last_update >= ADSB_UPDATE_INTERVAL:
|
||||||
for update_icao in pending_updates:
|
for update_icao in pending_updates:
|
||||||
if update_icao in app_module.adsb_aircraft:
|
if update_icao in app_module.adsb_aircraft:
|
||||||
app_module.adsb_queue.put({
|
app_module.adsb_queue.put({
|
||||||
@@ -189,10 +239,10 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
sock.close()
|
sock.close()
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
except Exception as e:
|
except OSError as e:
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||||
time.sleep(2)
|
time.sleep(SBS_RECONNECT_DELAY)
|
||||||
|
|
||||||
adsb_connected = False
|
adsb_connected = False
|
||||||
logger.info("SBS stream parser stopped")
|
logger.info("SBS stream parser stopped")
|
||||||
@@ -200,25 +250,52 @@ def parse_sbs_stream(service_addr):
|
|||||||
|
|
||||||
@adsb_bp.route('/tools')
|
@adsb_bp.route('/tools')
|
||||||
def check_adsb_tools():
|
def check_adsb_tools():
|
||||||
"""Check for ADS-B decoding tools."""
|
"""Check for ADS-B decoding tools and hardware."""
|
||||||
|
# Check available decoders
|
||||||
|
has_dump1090 = find_dump1090() is not None
|
||||||
|
has_readsb = shutil.which('readsb') is not None
|
||||||
|
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||||
|
|
||||||
|
# Check what SDR hardware is detected
|
||||||
|
devices = SDRFactory.detect_devices()
|
||||||
|
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||||
|
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
||||||
|
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
||||||
|
|
||||||
|
# Determine if readsb is needed but missing
|
||||||
|
needs_readsb = has_soapy_sdr and not has_readsb
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'dump1090': find_dump1090() is not None,
|
'dump1090': has_dump1090,
|
||||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
'readsb': has_readsb,
|
||||||
|
'rtl_adsb': has_rtl_adsb,
|
||||||
|
'has_rtlsdr': has_rtlsdr,
|
||||||
|
'has_soapy_sdr': has_soapy_sdr,
|
||||||
|
'soapy_types': soapy_types,
|
||||||
|
'needs_readsb': needs_readsb
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@adsb_bp.route('/status')
|
@adsb_bp.route('/status')
|
||||||
def adsb_status():
|
def adsb_status():
|
||||||
"""Get ADS-B tracking status for debugging."""
|
"""Get ADS-B tracking status for debugging."""
|
||||||
|
# Check if dump1090 process is still running
|
||||||
|
dump1090_running = False
|
||||||
|
if app_module.adsb_process:
|
||||||
|
dump1090_running = app_module.adsb_process.poll() is None
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'tracking_active': adsb_using_service,
|
'tracking_active': adsb_using_service,
|
||||||
'connected_to_sbs': adsb_connected,
|
'connected_to_sbs': adsb_connected,
|
||||||
'messages_received': adsb_messages_received,
|
'messages_received': adsb_messages_received,
|
||||||
|
'bytes_received': adsb_bytes_received,
|
||||||
|
'lines_received': adsb_lines_received,
|
||||||
'last_message_time': adsb_last_message_time,
|
'last_message_time': adsb_last_message_time,
|
||||||
'aircraft_count': len(app_module.adsb_aircraft),
|
'aircraft_count': len(app_module.adsb_aircraft),
|
||||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||||
'queue_size': app_module.adsb_queue.qsize(),
|
'queue_size': app_module.adsb_queue.qsize(),
|
||||||
'dump1090_path': find_dump1090(),
|
'dump1090_path': find_dump1090(),
|
||||||
|
'dump1090_running': dump1090_running,
|
||||||
'port_30003_open': check_dump1090_service() is not None
|
'port_30003_open': check_dump1090_service() is not None
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -291,9 +368,12 @@ def start_adsb():
|
|||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
try:
|
try:
|
||||||
app_module.adsb_process.terminate()
|
app_module.adsb_process.terminate()
|
||||||
app_module.adsb_process.wait(timeout=2)
|
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
|
||||||
except Exception:
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
try:
|
||||||
|
app_module.adsb_process.kill()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
|
|
||||||
# Create device object and build command via abstraction layer
|
# Create device object and build command via abstraction layer
|
||||||
@@ -314,16 +394,32 @@ def start_adsb():
|
|||||||
app_module.adsb_process = subprocess.Popen(
|
app_module.adsb_process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL
|
stderr=subprocess.PIPE
|
||||||
)
|
)
|
||||||
|
|
||||||
time.sleep(3)
|
time.sleep(DUMP1090_START_WAIT)
|
||||||
|
|
||||||
if app_module.adsb_process.poll() is not None:
|
if app_module.adsb_process.poll() is not None:
|
||||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
# Process exited - try to get error message
|
||||||
|
stderr_output = ''
|
||||||
|
if app_module.adsb_process.stderr:
|
||||||
|
try:
|
||||||
|
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if sdr_type == SDRType.RTL_SDR:
|
||||||
|
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||||
|
if stderr_output:
|
||||||
|
error_msg += f' Error: {stderr_output[:200]}'
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg})
|
||||||
|
else:
|
||||||
|
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||||
|
if stderr_output:
|
||||||
|
error_msg += f' Error: {stderr_output[:200]}'
|
||||||
|
return jsonify({'status': 'error', 'message': error_msg})
|
||||||
|
|
||||||
adsb_using_service = True
|
adsb_using_service = True
|
||||||
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
|
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||||
@@ -340,13 +436,14 @@ def stop_adsb():
|
|||||||
if app_module.adsb_process:
|
if app_module.adsb_process:
|
||||||
app_module.adsb_process.terminate()
|
app_module.adsb_process.terminate()
|
||||||
try:
|
try:
|
||||||
app_module.adsb_process.wait(timeout=5)
|
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
app_module.adsb_process.kill()
|
app_module.adsb_process.kill()
|
||||||
app_module.adsb_process = None
|
app_module.adsb_process = None
|
||||||
adsb_using_service = False
|
adsb_using_service = False
|
||||||
|
|
||||||
app_module.adsb_aircraft = {}
|
app_module.adsb_aircraft.clear()
|
||||||
|
_looked_up_icaos.clear()
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
@@ -355,16 +452,15 @@ def stream_adsb():
|
|||||||
"""SSE stream for ADS-B aircraft."""
|
"""SSE stream for ADS-B aircraft."""
|
||||||
def generate():
|
def generate():
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = app_module.adsb_queue.get(timeout=1)
|
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
yield format_sse(msg)
|
yield format_sse(msg)
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - last_keepalive >= keepalive_interval:
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
yield format_sse({'type': 'keepalive'})
|
yield format_sse({'type': 'keepalive'})
|
||||||
last_keepalive = now
|
last_keepalive = now
|
||||||
|
|
||||||
@@ -378,3 +474,38 @@ def stream_adsb():
|
|||||||
def adsb_dashboard():
|
def adsb_dashboard():
|
||||||
"""Popout ADS-B dashboard."""
|
"""Popout ADS-B dashboard."""
|
||||||
return render_template('adsb_dashboard.html')
|
return render_template('adsb_dashboard.html')
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# AIRCRAFT DATABASE MANAGEMENT
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft-db/status')
|
||||||
|
def aircraft_db_status():
|
||||||
|
"""Get aircraft database status."""
|
||||||
|
return jsonify(aircraft_db.get_db_status())
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft-db/check-updates')
|
||||||
|
def aircraft_db_check_updates():
|
||||||
|
"""Check for aircraft database updates."""
|
||||||
|
result = aircraft_db.check_for_updates()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft-db/download', methods=['POST'])
|
||||||
|
def aircraft_db_download():
|
||||||
|
"""Download/update aircraft database."""
|
||||||
|
global _looked_up_icaos
|
||||||
|
result = aircraft_db.download_database()
|
||||||
|
if result.get('success'):
|
||||||
|
# Clear lookup cache so new data is used
|
||||||
|
_looked_up_icaos.clear()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@adsb_bp.route('/aircraft-db/delete', methods=['POST'])
|
||||||
|
def aircraft_db_delete():
|
||||||
|
"""Delete aircraft database."""
|
||||||
|
result = aircraft_db.delete_database()
|
||||||
|
return jsonify(result)
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
|
|||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||||
|
from utils.constants import (
|
||||||
|
BT_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
|
SERVICE_ENUM_TIMEOUT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
BT_RESET_DELAY,
|
||||||
|
BT_ADAPTER_DOWN_WAIT,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||||
|
|
||||||
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
|
|||||||
|
|
||||||
if platform.system() == 'Linux':
|
if platform.system() == 'Linux':
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||||
for block in blocks:
|
for block in blocks:
|
||||||
if block.strip():
|
if block.strip():
|
||||||
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
|
|||||||
'type': 'hci',
|
'type': 'hci',
|
||||||
'status': 'up' if is_up else 'down'
|
'status': 'up' if is_up else 'down'
|
||||||
})
|
})
|
||||||
except Exception:
|
except FileNotFoundError:
|
||||||
pass
|
logger.debug("hciconfig not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("hciconfig timed out")
|
||||||
|
except subprocess.SubprocessError as e:
|
||||||
|
logger.warning(f"Error running hciconfig: {e}")
|
||||||
|
|
||||||
elif platform.system() == 'Darwin':
|
elif platform.system() == 'Darwin':
|
||||||
interfaces.append({
|
interfaces.append({
|
||||||
|
|||||||
119
routes/correlation.py
Normal file
119
routes/correlation.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Device correlation routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
import app as app_module
|
||||||
|
from utils.correlation import get_correlations
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.correlation')
|
||||||
|
|
||||||
|
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
|
||||||
|
|
||||||
|
|
||||||
|
@correlation_bp.route('', methods=['GET'])
|
||||||
|
def get_device_correlations() -> Response:
|
||||||
|
"""
|
||||||
|
Get device correlations between WiFi and Bluetooth.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
min_confidence: Minimum confidence threshold (default 0.5)
|
||||||
|
include_historical: Include database correlations (default true)
|
||||||
|
"""
|
||||||
|
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||||
|
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current device data
|
||||||
|
wifi_devices = dict(app_module.wifi_networks)
|
||||||
|
wifi_devices.update(dict(app_module.wifi_clients))
|
||||||
|
bt_devices = dict(app_module.bt_devices)
|
||||||
|
|
||||||
|
# Calculate correlations
|
||||||
|
correlations = get_correlations(
|
||||||
|
wifi_devices=wifi_devices,
|
||||||
|
bt_devices=bt_devices,
|
||||||
|
min_confidence=min_confidence,
|
||||||
|
include_historical=include_historical
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlations': correlations,
|
||||||
|
'wifi_count': len(wifi_devices),
|
||||||
|
'bt_count': len(bt_devices)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating correlations: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@correlation_bp.route('/analyze', methods=['POST'])
|
||||||
|
def analyze_correlation() -> Response:
|
||||||
|
"""
|
||||||
|
Analyze specific device pair for correlation.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
wifi_mac: WiFi device MAC address
|
||||||
|
bt_mac: Bluetooth device MAC address
|
||||||
|
"""
|
||||||
|
data = request.json or {}
|
||||||
|
wifi_mac = data.get('wifi_mac')
|
||||||
|
bt_mac = data.get('bt_mac')
|
||||||
|
|
||||||
|
if not wifi_mac or not bt_mac:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'wifi_mac and bt_mac are required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get device data
|
||||||
|
wifi_device = app_module.wifi_networks.get(wifi_mac)
|
||||||
|
if not wifi_device:
|
||||||
|
wifi_device = app_module.wifi_clients.get(wifi_mac)
|
||||||
|
|
||||||
|
bt_device = app_module.bt_devices.get(bt_mac)
|
||||||
|
|
||||||
|
if not wifi_device:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'WiFi device {wifi_mac} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if not bt_device:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Bluetooth device {bt_mac} not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
# Calculate correlation for this specific pair
|
||||||
|
correlations = get_correlations(
|
||||||
|
wifi_devices={wifi_mac: wifi_device},
|
||||||
|
bt_devices={bt_mac: bt_device},
|
||||||
|
min_confidence=0.0, # Show even low confidence for analysis
|
||||||
|
include_historical=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if correlations:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlation': correlations[0]
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlation': None,
|
||||||
|
'message': 'No correlation detected between these devices'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error analyzing correlation: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
198
routes/gps.py
198
routes/gps.py
@@ -1,9 +1,8 @@
|
|||||||
"""GPS dongle routes for USB GPS device support."""
|
"""GPS routes for gpsd daemon support."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
from typing import Generator
|
from typing import Generator
|
||||||
|
|
||||||
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
|
|||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from utils.gps import (
|
from utils.gps import (
|
||||||
detect_gps_devices,
|
|
||||||
is_serial_available,
|
|
||||||
get_gps_reader,
|
get_gps_reader,
|
||||||
start_gps,
|
|
||||||
start_gpsd,
|
start_gpsd,
|
||||||
stop_gps,
|
stop_gps,
|
||||||
get_current_position,
|
get_current_position,
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
GPSDClient,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/available')
|
@gps_bp.route('/auto-connect', methods=['POST'])
|
||||||
def check_gps_available():
|
def auto_connect_gps():
|
||||||
"""Check if GPS dongle support is available."""
|
"""
|
||||||
return jsonify({
|
Automatically connect to gpsd if available.
|
||||||
'available': is_serial_available(),
|
|
||||||
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||||
@gps_bp.route('/gpsd/check')
|
Returns current status if already connected.
|
||||||
def check_gpsd_available():
|
"""
|
||||||
"""Check if gpsd is reachable."""
|
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
host = request.args.get('host', 'localhost')
|
# Check if already running
|
||||||
port = int(request.args.get('port', 2947))
|
reader = get_gps_reader()
|
||||||
|
if reader and reader.is_running:
|
||||||
|
position = reader.position
|
||||||
|
return jsonify({
|
||||||
|
'status': 'connected',
|
||||||
|
'source': 'gpsd',
|
||||||
|
'has_fix': position is not None,
|
||||||
|
'position': position.to_dict() if position else None
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try to connect to gpsd on localhost:2947
|
||||||
|
host = 'localhost'
|
||||||
|
port = 2947
|
||||||
|
|
||||||
|
# First check if gpsd is reachable
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(2.0)
|
sock.settimeout(1.0)
|
||||||
sock.connect((host, port))
|
sock.connect((host, port))
|
||||||
sock.close()
|
sock.close()
|
||||||
|
except Exception:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'available': True,
|
'status': 'unavailable',
|
||||||
'host': host,
|
'message': 'gpsd not running'
|
||||||
'port': port,
|
|
||||||
'message': f'gpsd reachable at {host}:{port}'
|
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
return jsonify({
|
|
||||||
'available': False,
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/devices')
|
|
||||||
def list_gps_devices():
|
|
||||||
"""List available GPS serial devices."""
|
|
||||||
if not is_serial_available():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'pyserial not installed'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
devices = detect_gps_devices()
|
|
||||||
return jsonify({
|
|
||||||
'status': 'ok',
|
|
||||||
'devices': devices
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/start', methods=['POST'])
|
|
||||||
def start_gps_reader():
|
|
||||||
"""Start GPS reader on specified device."""
|
|
||||||
if not is_serial_available():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'pyserial not installed'
|
|
||||||
}), 503
|
|
||||||
|
|
||||||
# Check if already running
|
|
||||||
reader = get_gps_reader()
|
|
||||||
if reader and reader.is_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'GPS reader already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
device_path = data.get('device')
|
|
||||||
baudrate = data.get('baudrate', 9600)
|
|
||||||
|
|
||||||
if not device_path:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Device path required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate baudrate
|
|
||||||
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
|
|
||||||
if baudrate not in valid_baudrates:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Clear the queue
|
# Clear the queue
|
||||||
while not _gps_queue.empty():
|
while not _gps_queue.empty():
|
||||||
@@ -139,80 +83,26 @@ def start_gps_reader():
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Start the GPS reader with callback pre-registered (avoids race condition)
|
# Start the gpsd client
|
||||||
success = start_gps(device_path, baudrate, callback=_position_callback)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'started',
|
|
||||||
'device': device_path,
|
|
||||||
'baudrate': baudrate,
|
|
||||||
'source': 'serial'
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
reader = get_gps_reader()
|
|
||||||
error = reader.error if reader else 'Unknown error'
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'Failed to start GPS reader: {error}'
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/gpsd/start', methods=['POST'])
|
|
||||||
def start_gpsd_client():
|
|
||||||
"""Start GPS client connected to gpsd."""
|
|
||||||
# Check if already running
|
|
||||||
reader = get_gps_reader()
|
|
||||||
if reader and reader.is_running:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'GPS reader already running'
|
|
||||||
}), 409
|
|
||||||
|
|
||||||
data = request.json or {}
|
|
||||||
host = data.get('host', 'localhost')
|
|
||||||
port = data.get('port', 2947)
|
|
||||||
|
|
||||||
# Validate port
|
|
||||||
try:
|
|
||||||
port = int(port)
|
|
||||||
if not (1 <= port <= 65535):
|
|
||||||
raise ValueError("Port out of range")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Invalid port number'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Clear the queue
|
|
||||||
while not _gps_queue.empty():
|
|
||||||
try:
|
|
||||||
_gps_queue.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Start the gpsd client with callback pre-registered
|
|
||||||
success = start_gpsd(host, port, callback=_position_callback)
|
success = start_gpsd(host, port, callback=_position_callback)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'connected',
|
||||||
'host': host,
|
'source': 'gpsd',
|
||||||
'port': port,
|
'has_fix': False,
|
||||||
'source': 'gpsd'
|
'position': None
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
reader = get_gps_reader()
|
|
||||||
error = reader.error if reader else 'Unknown error'
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'unavailable',
|
||||||
'message': f'Failed to connect to gpsd: {error}'
|
'message': 'Failed to connect to gpsd'
|
||||||
}), 500
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/stop', methods=['POST'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS reader."""
|
"""Stop GPS client."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
if reader:
|
if reader:
|
||||||
reader.remove_callback(_position_callback)
|
reader.remove_callback(_position_callback)
|
||||||
@@ -224,7 +114,7 @@ def stop_gps_reader():
|
|||||||
|
|
||||||
@gps_bp.route('/status')
|
@gps_bp.route('/status')
|
||||||
def get_gps_status():
|
def get_gps_status():
|
||||||
"""Get current GPS reader status."""
|
"""Get current GPS client status."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
|
|
||||||
if not reader:
|
if not reader:
|
||||||
@@ -233,7 +123,7 @@ def get_gps_status():
|
|||||||
'device': None,
|
'device': None,
|
||||||
'position': None,
|
'position': None,
|
||||||
'error': None,
|
'error': None,
|
||||||
'message': 'GPS reader not started'
|
'message': 'GPS client not started'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
@@ -262,7 +152,7 @@ def get_position():
|
|||||||
if not reader or not reader.is_running:
|
if not reader or not reader.is_running:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'GPS reader not running'
|
'message': 'GPS client not running'
|
||||||
}), 400
|
}), 400
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -273,22 +163,22 @@ def get_position():
|
|||||||
|
|
||||||
@gps_bp.route('/debug')
|
@gps_bp.route('/debug')
|
||||||
def debug_gps():
|
def debug_gps():
|
||||||
"""Debug endpoint showing GPS reader state."""
|
"""Debug endpoint showing GPS client state."""
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
|
|
||||||
if not reader:
|
if not reader:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'reader': None,
|
'reader': None,
|
||||||
'message': 'No GPS reader initialized'
|
'message': 'No GPS client initialized'
|
||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
'source': source,
|
'source': 'gpsd',
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'baudrate': reader.baudrate,
|
'host': reader.host,
|
||||||
|
'port': reader.port,
|
||||||
'has_position': position is not None,
|
'has_position': position is not None,
|
||||||
'position': position.to_dict() if position else None,
|
'position': position.to_dict() if position else None,
|
||||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||||
|
|||||||
768
routes/listening_post.py
Normal file
768
routes/listening_post.py
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
"""Listening Post routes for radio monitoring and frequency scanning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator, Optional, List, Dict
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.constants import (
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
PROCESS_TERMINATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.listening_post')
|
||||||
|
|
||||||
|
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# GLOBAL STATE
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# Audio demodulation state
|
||||||
|
audio_process = None
|
||||||
|
audio_rtl_process = None
|
||||||
|
audio_lock = threading.Lock()
|
||||||
|
audio_running = False
|
||||||
|
audio_frequency = 0.0
|
||||||
|
audio_modulation = 'fm'
|
||||||
|
|
||||||
|
# Scanner state
|
||||||
|
scanner_thread: Optional[threading.Thread] = None
|
||||||
|
scanner_running = False
|
||||||
|
scanner_lock = threading.Lock()
|
||||||
|
scanner_paused = False
|
||||||
|
scanner_current_freq = 0.0
|
||||||
|
scanner_config = {
|
||||||
|
'start_freq': 88.0,
|
||||||
|
'end_freq': 108.0,
|
||||||
|
'step': 0.1,
|
||||||
|
'modulation': 'wfm',
|
||||||
|
'squelch': 20,
|
||||||
|
'dwell_time': 10.0, # Seconds to stay on active frequency
|
||||||
|
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||||
|
'device': 0,
|
||||||
|
'gain': 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Activity log
|
||||||
|
activity_log: List[Dict] = []
|
||||||
|
activity_log_lock = threading.Lock()
|
||||||
|
MAX_LOG_ENTRIES = 500
|
||||||
|
|
||||||
|
# SSE queue for scanner events
|
||||||
|
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# HELPER FUNCTIONS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def find_rtl_fm() -> str | None:
|
||||||
|
"""Find rtl_fm binary."""
|
||||||
|
return shutil.which('rtl_fm')
|
||||||
|
|
||||||
|
|
||||||
|
def find_ffmpeg() -> str | None:
|
||||||
|
"""Find ffmpeg for audio encoding."""
|
||||||
|
return shutil.which('ffmpeg')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def add_activity_log(event_type: str, frequency: float, details: str = ''):
|
||||||
|
"""Add entry to activity log."""
|
||||||
|
with activity_log_lock:
|
||||||
|
entry = {
|
||||||
|
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
'type': event_type,
|
||||||
|
'frequency': frequency,
|
||||||
|
'details': details,
|
||||||
|
}
|
||||||
|
activity_log.insert(0, entry)
|
||||||
|
# Trim log
|
||||||
|
while len(activity_log) > MAX_LOG_ENTRIES:
|
||||||
|
activity_log.pop()
|
||||||
|
|
||||||
|
# Also push to SSE queue
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'log',
|
||||||
|
'entry': entry
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SCANNER IMPLEMENTATION
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
def scanner_loop():
|
||||||
|
"""Main scanner loop - scans frequencies looking for signals."""
|
||||||
|
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
|
||||||
|
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||||
|
|
||||||
|
logger.info("Scanner thread started")
|
||||||
|
add_activity_log('scanner_start', scanner_config['start_freq'],
|
||||||
|
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
|
||||||
|
|
||||||
|
rtl_fm_path = find_rtl_fm()
|
||||||
|
|
||||||
|
if not rtl_fm_path:
|
||||||
|
logger.error("rtl_fm not found")
|
||||||
|
add_activity_log('error', 0, 'rtl_fm not found')
|
||||||
|
scanner_running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
last_signal_time = 0
|
||||||
|
signal_detected = False
|
||||||
|
|
||||||
|
# Convert step from kHz to MHz
|
||||||
|
step_mhz = scanner_config['step'] / 1000.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while scanner_running:
|
||||||
|
# Check if paused
|
||||||
|
if scanner_paused:
|
||||||
|
time.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
scanner_current_freq = current_freq
|
||||||
|
|
||||||
|
# Notify clients of frequency change
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'freq_change',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'scanning': not signal_detected
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Start rtl_fm at this frequency
|
||||||
|
freq_hz = int(current_freq * 1e6)
|
||||||
|
mod = scanner_config['modulation']
|
||||||
|
|
||||||
|
# Sample rates
|
||||||
|
if mod == 'wfm':
|
||||||
|
sample_rate = 170000
|
||||||
|
resample_rate = 32000
|
||||||
|
elif mod in ['usb', 'lsb']:
|
||||||
|
sample_rate = 12000
|
||||||
|
resample_rate = 12000
|
||||||
|
else:
|
||||||
|
sample_rate = 24000
|
||||||
|
resample_rate = 24000
|
||||||
|
|
||||||
|
# Don't use squelch in rtl_fm - we want to analyze raw audio
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-M', mod,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-r', str(resample_rate),
|
||||||
|
'-g', str(scanner_config['gain']),
|
||||||
|
'-d', str(scanner_config['device']),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start rtl_fm
|
||||||
|
rtl_proc = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read audio data for analysis
|
||||||
|
audio_data = b''
|
||||||
|
|
||||||
|
# Read audio samples for a short period
|
||||||
|
sample_duration = 0.25 # 250ms - balance between speed and detection
|
||||||
|
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
|
||||||
|
|
||||||
|
while len(audio_data) < bytes_needed and scanner_running:
|
||||||
|
chunk = rtl_proc.stdout.read(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
audio_data += chunk
|
||||||
|
|
||||||
|
# Clean up rtl_fm
|
||||||
|
rtl_proc.terminate()
|
||||||
|
try:
|
||||||
|
rtl_proc.wait(timeout=1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
rtl_proc.kill()
|
||||||
|
|
||||||
|
# Analyze audio level
|
||||||
|
audio_detected = False
|
||||||
|
rms = 0
|
||||||
|
threshold = 3000
|
||||||
|
if len(audio_data) > 100:
|
||||||
|
import struct
|
||||||
|
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
||||||
|
# Calculate RMS level (root mean square)
|
||||||
|
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
|
||||||
|
|
||||||
|
# WFM (broadcast FM) has much higher audio output - needs higher threshold
|
||||||
|
# AM/NFM have lower output levels
|
||||||
|
if mod == 'wfm':
|
||||||
|
# WFM: threshold 4000-12000 based on squelch
|
||||||
|
threshold = 4000 + (scanner_config['squelch'] * 80)
|
||||||
|
else:
|
||||||
|
# AM/NFM: threshold 1500-8000 based on squelch
|
||||||
|
threshold = 1500 + (scanner_config['squelch'] * 65)
|
||||||
|
|
||||||
|
audio_detected = rms > threshold
|
||||||
|
|
||||||
|
# Send level info to clients
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'scan_update',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'level': int(rms),
|
||||||
|
'threshold': int(threshold) if 'threshold' in dir() else 0,
|
||||||
|
'detected': audio_detected
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if audio_detected and scanner_running:
|
||||||
|
if not signal_detected:
|
||||||
|
# New signal found!
|
||||||
|
signal_detected = True
|
||||||
|
last_signal_time = time.time()
|
||||||
|
add_activity_log('signal_found', current_freq,
|
||||||
|
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
|
||||||
|
logger.info(f"Signal found at {current_freq} MHz")
|
||||||
|
|
||||||
|
# Start audio streaming for user
|
||||||
|
_start_audio_stream(current_freq, mod)
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_found',
|
||||||
|
'frequency': current_freq,
|
||||||
|
'modulation': mod,
|
||||||
|
'audio_streaming': True
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for skip signal
|
||||||
|
if scanner_skip_signal:
|
||||||
|
scanner_skip_signal = False
|
||||||
|
signal_detected = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_skipped',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
# Move to next frequency (step is in kHz, convert to MHz)
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Stay on this frequency (dwell) but check periodically
|
||||||
|
dwell_start = time.time()
|
||||||
|
while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
|
||||||
|
if scanner_skip_signal:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
last_signal_time = time.time()
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No signal at this frequency
|
||||||
|
if signal_detected:
|
||||||
|
# Signal lost
|
||||||
|
duration = time.time() - last_signal_time + scanner_config['dwell_time']
|
||||||
|
add_activity_log('signal_lost', current_freq,
|
||||||
|
f'Signal lost after {duration:.1f}s')
|
||||||
|
signal_detected = False
|
||||||
|
|
||||||
|
# Stop audio
|
||||||
|
_stop_audio_stream()
|
||||||
|
|
||||||
|
try:
|
||||||
|
scanner_queue.put_nowait({
|
||||||
|
'type': 'signal_lost',
|
||||||
|
'frequency': current_freq
|
||||||
|
})
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Move to next frequency (step is in kHz, convert to MHz)
|
||||||
|
current_freq += step_mhz
|
||||||
|
if current_freq > scanner_config['end_freq']:
|
||||||
|
current_freq = scanner_config['start_freq']
|
||||||
|
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
|
||||||
|
|
||||||
|
time.sleep(scanner_config['scan_delay'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scanner error at {current_freq} MHz: {e}")
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Scanner loop error: {e}")
|
||||||
|
finally:
|
||||||
|
scanner_running = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
|
||||||
|
logger.info("Scanner thread stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def _start_audio_stream(frequency: float, modulation: str):
|
||||||
|
"""Start audio streaming at given frequency."""
|
||||||
|
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
|
||||||
|
|
||||||
|
with audio_lock:
|
||||||
|
# Stop any existing stream
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
|
||||||
|
rtl_fm_path = find_rtl_fm()
|
||||||
|
ffmpeg_path = find_ffmpeg()
|
||||||
|
|
||||||
|
if not rtl_fm_path or not ffmpeg_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
freq_hz = int(frequency * 1e6)
|
||||||
|
|
||||||
|
if modulation == 'wfm':
|
||||||
|
sample_rate = 170000
|
||||||
|
resample_rate = 32000
|
||||||
|
elif modulation in ['usb', 'lsb']:
|
||||||
|
sample_rate = 12000
|
||||||
|
resample_rate = 12000
|
||||||
|
else:
|
||||||
|
sample_rate = 24000
|
||||||
|
resample_rate = 24000
|
||||||
|
|
||||||
|
rtl_cmd = [
|
||||||
|
rtl_fm_path,
|
||||||
|
'-M', modulation,
|
||||||
|
'-f', str(freq_hz),
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
'-r', str(resample_rate),
|
||||||
|
'-g', str(scanner_config['gain']),
|
||||||
|
'-d', str(scanner_config['device']),
|
||||||
|
'-l', str(scanner_config['squelch']),
|
||||||
|
]
|
||||||
|
|
||||||
|
encoder_cmd = [
|
||||||
|
ffmpeg_path,
|
||||||
|
'-f', 's16le',
|
||||||
|
'-ar', str(resample_rate),
|
||||||
|
'-ac', '1',
|
||||||
|
'-i', 'pipe:0',
|
||||||
|
'-f', 'mp3',
|
||||||
|
'-b:a', '64k',
|
||||||
|
'-flush_packets', '1',
|
||||||
|
'pipe:1'
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting rtl_fm: {' '.join(rtl_cmd)}")
|
||||||
|
audio_rtl_process = subprocess.Popen(
|
||||||
|
rtl_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Starting ffmpeg: {' '.join(encoder_cmd)}")
|
||||||
|
audio_process = subprocess.Popen(
|
||||||
|
encoder_cmd,
|
||||||
|
stdin=audio_rtl_process.stdout,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0
|
||||||
|
)
|
||||||
|
|
||||||
|
audio_rtl_process.stdout.close()
|
||||||
|
|
||||||
|
# Brief delay to check if processes started successfully
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if audio_rtl_process.poll() is not None:
|
||||||
|
stderr = audio_rtl_process.stderr.read().decode() if audio_rtl_process.stderr else ''
|
||||||
|
logger.error(f"rtl_fm exited immediately: {stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if audio_process.poll() is not None:
|
||||||
|
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
||||||
|
logger.error(f"ffmpeg exited immediately: {stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
|
audio_running = True
|
||||||
|
audio_frequency = frequency
|
||||||
|
audio_modulation = modulation
|
||||||
|
logger.info(f"Audio stream started: {frequency} MHz ({modulation})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start audio stream: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_audio_stream():
|
||||||
|
"""Stop audio streaming."""
|
||||||
|
with audio_lock:
|
||||||
|
_stop_audio_stream_internal()
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_audio_stream_internal():
|
||||||
|
"""Internal stop (must hold lock)."""
|
||||||
|
global audio_process, audio_rtl_process, audio_running, audio_frequency
|
||||||
|
|
||||||
|
if audio_process:
|
||||||
|
try:
|
||||||
|
audio_process.terminate()
|
||||||
|
audio_process.wait(timeout=1)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
audio_process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
audio_process = None
|
||||||
|
|
||||||
|
if audio_rtl_process:
|
||||||
|
try:
|
||||||
|
audio_rtl_process.terminate()
|
||||||
|
audio_rtl_process.wait(timeout=1)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
audio_rtl_process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
audio_rtl_process = None
|
||||||
|
|
||||||
|
audio_running = False
|
||||||
|
audio_frequency = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# API ENDPOINTS
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@listening_post_bp.route('/tools')
|
||||||
|
def check_tools() -> Response:
|
||||||
|
"""Check for required tools."""
|
||||||
|
rtl_fm = find_rtl_fm()
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'rtl_fm': rtl_fm is not None,
|
||||||
|
'ffmpeg': ffmpeg is not None,
|
||||||
|
'available': rtl_fm is not None and ffmpeg is not None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/start', methods=['POST'])
|
||||||
|
def start_scanner() -> Response:
|
||||||
|
"""Start the frequency scanner."""
|
||||||
|
global scanner_thread, scanner_running, scanner_config
|
||||||
|
|
||||||
|
with scanner_lock:
|
||||||
|
if scanner_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Scanner already running'
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
# Update scanner config
|
||||||
|
try:
|
||||||
|
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
|
||||||
|
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
|
||||||
|
scanner_config['step'] = float(data.get('step', 0.1))
|
||||||
|
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
|
||||||
|
scanner_config['squelch'] = int(data.get('squelch', 20))
|
||||||
|
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
|
||||||
|
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
|
||||||
|
scanner_config['device'] = int(data.get('device', 0))
|
||||||
|
scanner_config['gain'] = int(data.get('gain', 40))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid parameter: {e}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if scanner_config['start_freq'] >= scanner_config['end_freq']:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'start_freq must be less than end_freq'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Check tools
|
||||||
|
if not find_rtl_fm():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
# Start scanner thread
|
||||||
|
scanner_running = True
|
||||||
|
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
|
||||||
|
scanner_thread.start()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'config': scanner_config
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/stop', methods=['POST'])
|
||||||
|
def stop_scanner() -> Response:
|
||||||
|
"""Stop the frequency scanner."""
|
||||||
|
global scanner_running
|
||||||
|
|
||||||
|
scanner_running = False
|
||||||
|
_stop_audio_stream()
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/pause', methods=['POST'])
|
||||||
|
def pause_scanner() -> Response:
|
||||||
|
"""Pause/resume the scanner."""
|
||||||
|
global scanner_paused
|
||||||
|
|
||||||
|
scanner_paused = not scanner_paused
|
||||||
|
|
||||||
|
if scanner_paused:
|
||||||
|
add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
|
||||||
|
else:
|
||||||
|
add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'paused' if scanner_paused else 'resumed',
|
||||||
|
'paused': scanner_paused
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Flag to trigger skip from API
|
||||||
|
scanner_skip_signal = False
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/skip', methods=['POST'])
|
||||||
|
def skip_signal() -> Response:
|
||||||
|
"""Skip current signal and continue scanning."""
|
||||||
|
global scanner_skip_signal
|
||||||
|
|
||||||
|
if not scanner_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Scanner not running'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
scanner_skip_signal = True
|
||||||
|
add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'skipped',
|
||||||
|
'frequency': scanner_current_freq
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/status')
|
||||||
|
def scanner_status() -> Response:
|
||||||
|
"""Get scanner status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': scanner_running,
|
||||||
|
'paused': scanner_paused,
|
||||||
|
'current_freq': scanner_current_freq,
|
||||||
|
'config': scanner_config,
|
||||||
|
'audio_streaming': audio_running,
|
||||||
|
'audio_frequency': audio_frequency
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/stream')
|
||||||
|
def stream_scanner_events() -> Response:
|
||||||
|
"""SSE stream for scanner events."""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(msg)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/log')
|
||||||
|
def get_activity_log() -> Response:
|
||||||
|
"""Get activity log."""
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
with activity_log_lock:
|
||||||
|
return jsonify({
|
||||||
|
'log': activity_log[:limit],
|
||||||
|
'total': len(activity_log)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
|
||||||
|
def clear_activity_log() -> Response:
|
||||||
|
"""Clear activity log."""
|
||||||
|
with activity_log_lock:
|
||||||
|
activity_log.clear()
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/presets')
|
||||||
|
def get_presets() -> Response:
|
||||||
|
"""Get scanner presets."""
|
||||||
|
presets = [
|
||||||
|
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
|
||||||
|
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
|
||||||
|
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
|
||||||
|
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
|
||||||
|
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
|
||||||
|
]
|
||||||
|
return jsonify({'presets': presets})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MANUAL AUDIO ENDPOINTS (for direct listening)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/start', methods=['POST'])
|
||||||
|
def start_audio() -> Response:
|
||||||
|
"""Start audio at specific frequency (manual mode)."""
|
||||||
|
global scanner_running
|
||||||
|
|
||||||
|
# Stop scanner if running
|
||||||
|
if scanner_running:
|
||||||
|
scanner_running = False
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
frequency = float(data.get('frequency', 0))
|
||||||
|
modulation = str(data.get('modulation', 'wfm')).lower()
|
||||||
|
squelch = int(data.get('squelch', 0))
|
||||||
|
gain = int(data.get('gain', 40))
|
||||||
|
device = int(data.get('device', 0))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid parameter: {e}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if frequency <= 0:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'frequency is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
|
||||||
|
if modulation not in valid_mods:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Update config for audio
|
||||||
|
scanner_config['squelch'] = squelch
|
||||||
|
scanner_config['gain'] = gain
|
||||||
|
scanner_config['device'] = device
|
||||||
|
|
||||||
|
_start_audio_stream(frequency, modulation)
|
||||||
|
|
||||||
|
if audio_running:
|
||||||
|
add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'frequency': frequency,
|
||||||
|
'modulation': modulation,
|
||||||
|
'stream_url': '/listening/audio/stream'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start audio. Check that rtl_fm and ffmpeg are installed, and that an SDR device is connected and not in use by another process.'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/stop', methods=['POST'])
|
||||||
|
def stop_audio() -> Response:
|
||||||
|
"""Stop audio."""
|
||||||
|
_stop_audio_stream()
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/status')
|
||||||
|
def audio_status() -> Response:
|
||||||
|
"""Get audio status."""
|
||||||
|
return jsonify({
|
||||||
|
'running': audio_running,
|
||||||
|
'frequency': audio_frequency,
|
||||||
|
'modulation': audio_modulation
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@listening_post_bp.route('/audio/stream')
|
||||||
|
def stream_audio() -> Response:
|
||||||
|
"""Stream MP3 audio."""
|
||||||
|
# Wait briefly for audio to start (handles race condition with /audio/start)
|
||||||
|
for _ in range(10):
|
||||||
|
if audio_running and audio_process:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if not audio_running or not audio_process:
|
||||||
|
# Return empty audio response instead of JSON (browser audio element can't parse JSON)
|
||||||
|
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
chunk_size = 4096
|
||||||
|
try:
|
||||||
|
while audio_running and audio_process and audio_process.poll() is None:
|
||||||
|
chunk = audio_process.stdout.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Audio stream error: {e}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
generate(),
|
||||||
|
mimetype='audio/mpeg',
|
||||||
|
headers={
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Cache-Control': 'no-cache, no-store',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
'Transfer-Encoding': 'chunked',
|
||||||
|
}
|
||||||
|
)
|
||||||
228
routes/settings.py
Normal file
228
routes/settings.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""Settings management routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
|
from utils.database import (
|
||||||
|
get_setting,
|
||||||
|
set_setting,
|
||||||
|
delete_setting,
|
||||||
|
get_all_settings,
|
||||||
|
get_signal_history,
|
||||||
|
add_signal_reading,
|
||||||
|
get_correlations,
|
||||||
|
)
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.settings')
|
||||||
|
|
||||||
|
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('', methods=['GET'])
|
||||||
|
def get_settings() -> Response:
|
||||||
|
"""Get all settings."""
|
||||||
|
try:
|
||||||
|
settings = get_all_settings()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'settings': settings
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting settings: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('', methods=['POST'])
|
||||||
|
def save_settings() -> Response:
|
||||||
|
"""Save one or more settings."""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'No settings provided'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
saved = []
|
||||||
|
for key, value in data.items():
|
||||||
|
# Validate key (alphanumeric, underscores, dots, hyphens)
|
||||||
|
if not key or not all(c.isalnum() or c in '_.-' for c in key):
|
||||||
|
continue
|
||||||
|
|
||||||
|
set_setting(key, value)
|
||||||
|
saved.append(key)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'saved': saved
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving settings: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/<key>', methods=['GET'])
|
||||||
|
def get_single_setting(key: str) -> Response:
|
||||||
|
"""Get a single setting by key."""
|
||||||
|
try:
|
||||||
|
value = get_setting(key)
|
||||||
|
if value is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'not_found',
|
||||||
|
'key': key
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting setting {key}: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/<key>', methods=['PUT'])
|
||||||
|
def update_single_setting(key: str) -> Response:
|
||||||
|
"""Update a single setting."""
|
||||||
|
data = request.json or {}
|
||||||
|
value = data.get('value')
|
||||||
|
|
||||||
|
if value is None and 'value' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Value is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_setting(key, value)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'value': value
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating setting {key}: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/<key>', methods=['DELETE'])
|
||||||
|
def delete_single_setting(key: str) -> Response:
|
||||||
|
"""Delete a setting."""
|
||||||
|
try:
|
||||||
|
deleted = delete_setting(key)
|
||||||
|
if deleted:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'key': key,
|
||||||
|
'deleted': True
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'not_found',
|
||||||
|
'key': key
|
||||||
|
}), 404
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting setting {key}: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Signal History Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
|
||||||
|
def get_device_signal_history(mode: str, device_id: str) -> Response:
|
||||||
|
"""Get signal strength history for a device."""
|
||||||
|
limit = request.args.get('limit', 100, type=int)
|
||||||
|
since_minutes = request.args.get('since', 60, type=int)
|
||||||
|
|
||||||
|
# Validate mode
|
||||||
|
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
|
||||||
|
if mode not in valid_modes:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid mode. Valid modes: {valid_modes}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
history = get_signal_history(mode, device_id, limit, since_minutes)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'mode': mode,
|
||||||
|
'device_id': device_id,
|
||||||
|
'history': history
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting signal history: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@settings_bp.route('/signal-history', methods=['POST'])
|
||||||
|
def add_signal_history() -> Response:
|
||||||
|
"""Add a signal strength reading (for internal use)."""
|
||||||
|
data = request.json or {}
|
||||||
|
|
||||||
|
mode = data.get('mode')
|
||||||
|
device_id = data.get('device_id')
|
||||||
|
signal_strength = data.get('signal_strength')
|
||||||
|
|
||||||
|
if not all([mode, device_id, signal_strength is not None]):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'mode, device_id, and signal_strength are required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
|
||||||
|
return jsonify({'status': 'success'})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding signal reading: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Correlation Endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@settings_bp.route('/correlations', methods=['GET'])
|
||||||
|
def get_device_correlations() -> Response:
|
||||||
|
"""Get device correlations between WiFi and Bluetooth."""
|
||||||
|
min_confidence = request.args.get('min_confidence', 0.5, type=float)
|
||||||
|
|
||||||
|
try:
|
||||||
|
correlations = get_correlations(min_confidence)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'correlations': correlations
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting correlations: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
@@ -16,12 +16,32 @@ from typing import Any, Generator
|
|||||||
from flask import Blueprint, jsonify, request, Response
|
from flask import Blueprint, jsonify, request, Response
|
||||||
|
|
||||||
import app as app_module
|
import app as app_module
|
||||||
from utils.dependencies import check_tool
|
from utils.dependencies import check_tool, get_tool_path
|
||||||
from utils.logging import wifi_logger as logger
|
from utils.logging import wifi_logger as logger
|
||||||
from utils.process import is_valid_mac, is_valid_channel
|
from utils.process import is_valid_mac, is_valid_channel
|
||||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||||
from utils.sse import format_sse
|
from utils.sse import format_sse
|
||||||
from data.oui import get_manufacturer
|
from data.oui import get_manufacturer
|
||||||
|
from utils.constants import (
|
||||||
|
WIFI_TERMINATE_TIMEOUT,
|
||||||
|
PMKID_TERMINATE_TIMEOUT,
|
||||||
|
SSE_KEEPALIVE_INTERVAL,
|
||||||
|
SSE_QUEUE_TIMEOUT,
|
||||||
|
WIFI_CSV_PARSE_INTERVAL,
|
||||||
|
WIFI_CSV_TIMEOUT_WARNING,
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
|
SUBPROCESS_TIMEOUT_MEDIUM,
|
||||||
|
SUBPROCESS_TIMEOUT_LONG,
|
||||||
|
DEAUTH_TIMEOUT,
|
||||||
|
MIN_DEAUTH_COUNT,
|
||||||
|
MAX_DEAUTH_COUNT,
|
||||||
|
DEFAULT_DEAUTH_COUNT,
|
||||||
|
PROCESS_START_WAIT,
|
||||||
|
MONITOR_MODE_DELAY,
|
||||||
|
WIFI_CAPTURE_PATH_PREFIX,
|
||||||
|
HANDSHAKE_CAPTURE_PATH_PREFIX,
|
||||||
|
PMKID_CAPTURE_PATH_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||||
|
|
||||||
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
|
|||||||
if platform.system() == 'Darwin': # macOS
|
if platform.system() == 'Darwin': # macOS
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['networksetup', '-listallhardwareports'],
|
result = subprocess.run(['networksetup', '-listallhardwareports'],
|
||||||
capture_output=True, text=True, timeout=5)
|
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||||
lines = result.stdout.split('\n')
|
lines = result.stdout.split('\n')
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
if 'Wi-Fi' in line or 'AirPort' in line:
|
||||||
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
|
|||||||
'status': 'up'
|
'status': 'up'
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except FileNotFoundError:
|
||||||
|
logger.debug("networksetup not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("networksetup timed out")
|
||||||
|
except subprocess.SubprocessError as e:
|
||||||
logger.error(f"Error detecting macOS interfaces: {e}")
|
logger.error(f"Error detecting macOS interfaces: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
|
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
|
||||||
capture_output=True, text=True, timeout=10)
|
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
|
||||||
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
|
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
|
||||||
interfaces.append({
|
interfaces.append({
|
||||||
'name': 'USB WiFi Adapter',
|
'name': 'USB WiFi Adapter',
|
||||||
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
|
|||||||
'monitor_capable': True,
|
'monitor_capable': True,
|
||||||
'status': 'detected'
|
'status': 'detected'
|
||||||
})
|
})
|
||||||
except Exception:
|
except FileNotFoundError:
|
||||||
pass
|
logger.debug("system_profiler not found")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.debug("system_profiler timed out")
|
||||||
|
except subprocess.SubprocessError as e:
|
||||||
|
logger.debug(f"Error running system_profiler: {e}")
|
||||||
|
|
||||||
else: # Linux
|
else: # Linux
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
|
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||||
current_iface = None
|
current_iface = None
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
|
|||||||
})
|
})
|
||||||
current_iface = None
|
current_iface = None
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
# Fall back to iwconfig if iw is not available
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if 'IEEE 802.11' in line:
|
if 'IEEE 802.11' in line:
|
||||||
iface = line.split()[0]
|
iface = line.split()[0]
|
||||||
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
|
|||||||
'monitor_capable': True,
|
'monitor_capable': True,
|
||||||
'status': 'up'
|
'status': 'up'
|
||||||
})
|
})
|
||||||
except Exception:
|
except FileNotFoundError:
|
||||||
pass
|
logger.debug("Neither iw nor iwconfig found")
|
||||||
except Exception as e:
|
except subprocess.SubprocessError as e:
|
||||||
|
logger.debug(f"Error running iwconfig: {e}")
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.warning("iw command timed out")
|
||||||
|
except subprocess.SubprocessError as e:
|
||||||
logger.error(f"Error detecting Linux interfaces: {e}")
|
logger.error(f"Error detecting Linux interfaces: {e}")
|
||||||
|
|
||||||
return interfaces
|
return interfaces
|
||||||
@@ -312,10 +345,11 @@ def toggle_monitor_mode():
|
|||||||
interfaces_before = get_wireless_interfaces()
|
interfaces_before = get_wireless_interfaces()
|
||||||
|
|
||||||
kill_processes = data.get('kill_processes', False)
|
kill_processes = data.get('kill_processes', False)
|
||||||
|
airmon_path = get_tool_path('airmon-ng')
|
||||||
if kill_processes:
|
if kill_processes:
|
||||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
|
||||||
|
|
||||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
result = subprocess.run([airmon_path, 'start', interface],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True, timeout=15)
|
||||||
|
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
@@ -396,7 +430,8 @@ def toggle_monitor_mode():
|
|||||||
else: # stop
|
else: # stop
|
||||||
if check_tool('airmon-ng'):
|
if check_tool('airmon-ng'):
|
||||||
try:
|
try:
|
||||||
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
|
airmon_path = get_tool_path('airmon-ng')
|
||||||
|
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||||
capture_output=True, text=True, timeout=15)
|
capture_output=True, text=True, timeout=15)
|
||||||
app_module.wifi_monitor_interface = None
|
app_module.wifi_monitor_interface = None
|
||||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||||
@@ -447,8 +482,9 @@ def start_wifi_scan():
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
airodump_path = get_tool_path('airodump-ng')
|
||||||
cmd = [
|
cmd = [
|
||||||
'airodump-ng',
|
airodump_path,
|
||||||
'-w', csv_path,
|
'-w', csv_path,
|
||||||
'--output-format', 'csv,pcap',
|
'--output-format', 'csv,pcap',
|
||||||
'--band', band,
|
'--band', band,
|
||||||
@@ -546,8 +582,9 @@ def send_deauth():
|
|||||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
aireplay_path = get_tool_path('aireplay-ng')
|
||||||
cmd = [
|
cmd = [
|
||||||
'aireplay-ng',
|
aireplay_path,
|
||||||
'--deauth', str(count),
|
'--deauth', str(count),
|
||||||
'-a', target_bssid,
|
'-a', target_bssid,
|
||||||
'-c', target_client,
|
'-c', target_client,
|
||||||
@@ -592,8 +629,9 @@ def capture_handshake():
|
|||||||
|
|
||||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||||
|
|
||||||
|
airodump_path = get_tool_path('airodump-ng')
|
||||||
cmd = [
|
cmd = [
|
||||||
'airodump-ng',
|
airodump_path,
|
||||||
'-c', str(channel),
|
'-c', str(channel),
|
||||||
'--bssid', target_bssid,
|
'--bssid', target_bssid,
|
||||||
'-w', capture_path,
|
'-w', capture_path,
|
||||||
@@ -631,14 +669,16 @@ def check_handshake_status():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if target_bssid and is_valid_mac(target_bssid):
|
if target_bssid and is_valid_mac(target_bssid):
|
||||||
result = subprocess.run(
|
aircrack_path = get_tool_path('aircrack-ng')
|
||||||
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
|
if aircrack_path:
|
||||||
capture_output=True, text=True, timeout=10
|
result = subprocess.run(
|
||||||
)
|
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||||
output = result.stdout + result.stderr
|
capture_output=True, text=True, timeout=10
|
||||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
)
|
||||||
if '0 handshake' not in output:
|
output = result.stdout + result.stderr
|
||||||
handshake_found = True
|
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||||
|
if '0 handshake' not in output:
|
||||||
|
handshake_found = True
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
755
setup.sh
Executable file → Normal file
755
setup.sh
Executable file → Normal file
@@ -1,18 +1,43 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
#
|
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
|
||||||
# INTERCEPT Setup Script
|
|
||||||
# Installs Python dependencies and checks for external tools
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
# ---- Force bash even if launched with sh ----
|
||||||
|
if [ -z "${BASH_VERSION:-}" ]; then
|
||||||
|
echo "[x] This script must be run with bash (not sh)."
|
||||||
|
echo " Run: bash $0"
|
||||||
|
exec bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
# Colors for output
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Ensure admin paths are searchable (many tools live here)
|
||||||
|
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Pretty output
|
||||||
|
# ----------------------------
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||||
|
fail() { echo -e "${RED}[x]${NC} $*"; }
|
||||||
|
|
||||||
|
on_error() {
|
||||||
|
local line="$1"
|
||||||
|
local cmd="${2:-unknown}"
|
||||||
|
fail "Setup failed at line ${line}: ${cmd}"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Banner
|
||||||
|
# ----------------------------
|
||||||
echo -e "${BLUE}"
|
echo -e "${BLUE}"
|
||||||
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
|
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
|
||||||
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
|
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
|
||||||
@@ -20,460 +45,356 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
|
|||||||
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
|
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
|
||||||
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
|
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
|
||||||
echo -e "${NC}"
|
echo -e "${NC}"
|
||||||
echo "Signal Intelligence Platform - Setup Script"
|
echo "INTERCEPT - Setup Script"
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
echo ""
|
echo
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Helpers
|
||||||
|
# ----------------------------
|
||||||
|
cmd_exists() {
|
||||||
|
local c="$1"
|
||||||
|
command -v "$c" >/dev/null 2>&1 && return 0
|
||||||
|
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
have_any() {
|
||||||
|
local c
|
||||||
|
for c in "$@"; do
|
||||||
|
cmd_exists "$c" && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
need_sudo() {
|
||||||
|
if [[ "$(id -u)" -eq 0 ]]; then
|
||||||
|
SUDO=""
|
||||||
|
ok "Running as root"
|
||||||
|
else
|
||||||
|
if cmd_exists sudo; then
|
||||||
|
SUDO="sudo"
|
||||||
|
else
|
||||||
|
fail "sudo is not installed and you're not root."
|
||||||
|
echo "Either run as root or install sudo first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Detect OS
|
|
||||||
detect_os() {
|
detect_os() {
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||||
OS="macos"
|
OS="macos"
|
||||||
PKG_MANAGER="brew"
|
elif [[ -f /etc/debian_version ]]; then
|
||||||
elif [[ -f /etc/debian_version ]]; then
|
OS="debian"
|
||||||
OS="debian"
|
else
|
||||||
PKG_MANAGER="apt"
|
OS="unknown"
|
||||||
elif [[ -f /etc/redhat-release ]]; then
|
fi
|
||||||
OS="redhat"
|
info "Detected OS: ${OS}"
|
||||||
PKG_MANAGER="dnf"
|
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
||||||
elif [[ -f /etc/arch-release ]]; then
|
|
||||||
OS="arch"
|
|
||||||
PKG_MANAGER="pacman"
|
|
||||||
else
|
|
||||||
OS="unknown"
|
|
||||||
PKG_MANAGER="unknown"
|
|
||||||
fi
|
|
||||||
echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if a command exists
|
# ----------------------------
|
||||||
check_cmd() {
|
# Required tool checks (with alternates)
|
||||||
command -v "$1" &> /dev/null
|
# ----------------------------
|
||||||
|
missing_required=()
|
||||||
|
|
||||||
|
check_required() {
|
||||||
|
local label="$1"; shift
|
||||||
|
local desc="$1"; shift
|
||||||
|
|
||||||
|
if have_any "$@"; then
|
||||||
|
ok "${label} - ${desc}"
|
||||||
|
else
|
||||||
|
warn "${label} - ${desc} (missing, required)"
|
||||||
|
missing_required+=("$label")
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if a package is installable (has a candidate version)
|
|
||||||
pkg_available() {
|
|
||||||
local candidate
|
|
||||||
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
|
|
||||||
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Setup sudo command (empty if running as root)
|
|
||||||
setup_sudo() {
|
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
|
||||||
SUDO=""
|
|
||||||
echo -e "${BLUE}Running as root${NC}"
|
|
||||||
elif check_cmd sudo; then
|
|
||||||
SUDO="sudo"
|
|
||||||
else
|
|
||||||
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Please either:"
|
|
||||||
echo " 1. Run this script as root: su -c './setup.sh'"
|
|
||||||
echo " 2. Install sudo: apt install sudo"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
install_python_deps() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
|
|
||||||
|
|
||||||
if ! check_cmd python3; then
|
|
||||||
echo -e "${RED}Error: Python 3 is not installed${NC}"
|
|
||||||
echo "Please install Python 3.9 or later"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check Python version (need 3.9+)
|
|
||||||
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
|
||||||
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
|
|
||||||
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
|
|
||||||
echo "Python version: $PYTHON_VERSION"
|
|
||||||
|
|
||||||
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
|
|
||||||
echo -e "${RED}Error: Python 3.9 or later is required${NC}"
|
|
||||||
echo "You have Python $PYTHON_VERSION"
|
|
||||||
echo ""
|
|
||||||
echo "Please upgrade Python:"
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
echo " Ubuntu/Debian: sudo apt install python3.11"
|
|
||||||
else
|
|
||||||
echo " Ubuntu/Debian: apt install python3.11"
|
|
||||||
fi
|
|
||||||
echo " macOS: brew install python@3.11"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if we're in a virtual environment
|
|
||||||
if [ -n "$VIRTUAL_ENV" ]; then
|
|
||||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
|
||||||
pip install -r requirements.txt
|
|
||||||
elif [ -f "venv/bin/activate" ]; then
|
|
||||||
echo "Found existing venv, activating..."
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
else
|
|
||||||
# Try direct pip install first, fall back to venv if it fails (PEP 668)
|
|
||||||
echo "Attempting to install dependencies..."
|
|
||||||
if python3 -m pip install -r requirements.txt 2>/dev/null; then
|
|
||||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If pip install failed (likely PEP 668), create a virtual environment
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
|
|
||||||
echo "Creating virtual environment..."
|
|
||||||
|
|
||||||
# Remove any incomplete venv directory from previous failed attempts
|
|
||||||
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
|
|
||||||
echo "Removing incomplete venv directory..."
|
|
||||||
rm -rf venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! python3 -m venv venv; then
|
|
||||||
echo -e "${RED}Error: Failed to create virtual environment${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "On Debian/Ubuntu, install the venv module with:"
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
echo " sudo apt install python3-venv"
|
|
||||||
else
|
|
||||||
echo " apt install python3-venv"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "Then run this setup script again."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
|
|
||||||
echo "You must activate it before running INTERCEPT:"
|
|
||||||
echo " source venv/bin/activate"
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
echo " sudo venv/bin/python intercept.py"
|
|
||||||
else
|
|
||||||
echo " venv/bin/python intercept.py"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check external tools
|
|
||||||
check_tools() {
|
check_tools() {
|
||||||
echo ""
|
info "Checking required tools..."
|
||||||
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
|
missing_required=()
|
||||||
echo ""
|
|
||||||
|
|
||||||
MISSING_TOOLS=()
|
echo
|
||||||
MISSING_CORE=false
|
info "Core SDR:"
|
||||||
MISSING_WIFI=false
|
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
||||||
MISSING_BLUETOOTH=false
|
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
||||||
|
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||||
|
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||||
|
check_required "dump1090" "ADS-B decoder" dump1090
|
||||||
|
|
||||||
# Core SDR tools
|
echo
|
||||||
echo "Core SDR Tools:"
|
info "GPS:"
|
||||||
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
|
check_required "gpsd" "GPS daemon" gpsd
|
||||||
check_tool "rtl_test" "RTL-SDR device detection" "core"
|
|
||||||
check_tool "multimon-ng" "Pager decoder" "core"
|
|
||||||
check_tool "rtl_433" "433MHz sensor decoder" "core"
|
|
||||||
check_tool "dump1090" "ADS-B decoder" "core"
|
|
||||||
|
|
||||||
echo ""
|
echo
|
||||||
echo "Additional SDR Hardware (optional):"
|
info "Audio:"
|
||||||
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional"
|
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||||
check_tool "LimeUtil" "LimeSDR tools" "optional"
|
|
||||||
check_tool "hackrf_info" "HackRF tools" "optional"
|
|
||||||
|
|
||||||
echo ""
|
echo
|
||||||
echo "WiFi Tools:"
|
info "WiFi:"
|
||||||
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
|
check_required "airmon-ng" "Monitor mode helper" airmon-ng
|
||||||
check_tool "airodump-ng" "WiFi scanner" "wifi"
|
check_required "airodump-ng" "WiFi scanner" airodump-ng
|
||||||
|
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
|
||||||
|
check_required "hcxdumptool" "PMKID capture" hcxdumptool
|
||||||
|
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
|
||||||
|
|
||||||
echo ""
|
echo
|
||||||
echo "Bluetooth Tools:"
|
info "Bluetooth:"
|
||||||
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
|
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
|
||||||
check_tool "hcitool" "Bluetooth HCI tool" "bluetooth"
|
check_required "hcitool" "Bluetooth scan utility" hcitool
|
||||||
|
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
||||||
|
|
||||||
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
echo
|
||||||
echo ""
|
info "SoapySDR:"
|
||||||
echo -e "${YELLOW}Some tools are missing.${NC}"
|
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
||||||
fi
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
check_tool() {
|
# ----------------------------
|
||||||
local cmd=$1
|
# Python venv + deps
|
||||||
local desc=$2
|
# ----------------------------
|
||||||
local category=$3
|
check_python_version() {
|
||||||
if check_cmd "$cmd"; then
|
if ! cmd_exists python3; then
|
||||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
fail "python3 not found."
|
||||||
else
|
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
|
||||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
|
||||||
MISSING_TOOLS+=("$cmd")
|
exit 1
|
||||||
case "$category" in
|
fi
|
||||||
core) MISSING_CORE=true ;;
|
|
||||||
wifi) MISSING_WIFI=true ;;
|
local ver
|
||||||
bluetooth) MISSING_BLUETOOTH=true ;;
|
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||||
esac
|
info "Python version: ${ver}"
|
||||||
fi
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import sys
|
||||||
|
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||||
|
PY
|
||||||
|
ok "Python version OK (>= 3.9)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install tools on Debian/Ubuntu
|
install_python_deps() {
|
||||||
install_debian_tools() {
|
info "Setting up Python virtual environment..."
|
||||||
echo ""
|
check_python_version
|
||||||
echo -e "${BLUE}[3/3] Installing tools...${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
if [[ ! -f requirements.txt ]]; then
|
||||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
warn "requirements.txt not found; skipping Python dependency install."
|
||||||
return
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}The following tool categories need to be installed:${NC}"
|
if [[ ! -d venv ]]; then
|
||||||
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
|
python3 -m venv venv
|
||||||
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
|
ok "Created venv/"
|
||||||
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
|
else
|
||||||
echo ""
|
ok "Using existing venv/"
|
||||||
|
fi
|
||||||
|
|
||||||
read -p "Would you like to install missing tools automatically? [Y/n] " -n 1 -r
|
# shellcheck disable=SC1091
|
||||||
echo ""
|
source venv/bin/activate
|
||||||
|
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
python -m pip install --upgrade pip setuptools wheel >/dev/null
|
||||||
echo ""
|
ok "Upgraded pip tooling"
|
||||||
echo "Updating package lists..."
|
|
||||||
$SUDO apt update
|
|
||||||
|
|
||||||
# Core SDR tools
|
info "Installing Python requirements..."
|
||||||
if $MISSING_CORE; then
|
python -m pip install -r requirements.txt
|
||||||
echo ""
|
ok "Python dependencies installed"
|
||||||
echo -e "${BLUE}Installing Core SDR tools...${NC}"
|
echo
|
||||||
|
|
||||||
# Install packages that are reliably available
|
|
||||||
$SUDO apt install -y rtl-sdr multimon-ng
|
|
||||||
|
|
||||||
# rtl-433 may be named differently or unavailable
|
|
||||||
if pkg_available rtl-433; then
|
|
||||||
$SUDO apt install -y rtl-433
|
|
||||||
elif pkg_available rtl433; then
|
|
||||||
$SUDO apt install -y rtl433
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Note: rtl-433 not found in repositories. Install manually or from source.${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# dump1090 - try available variants, not available on all Debian versions
|
|
||||||
if pkg_available dump1090-fa; then
|
|
||||||
$SUDO apt install -y dump1090-fa
|
|
||||||
elif pkg_available dump1090-mutability; then
|
|
||||||
$SUDO apt install -y dump1090-mutability
|
|
||||||
elif pkg_available dump1090; then
|
|
||||||
$SUDO apt install -y dump1090
|
|
||||||
elif ! check_cmd dump1090; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Note: dump1090 not available in your repos (e.g. Debian Trixie).${NC}"
|
|
||||||
echo " FlightAware version: https://flightaware.com/adsb/piaware/install"
|
|
||||||
echo " Or from source: https://github.com/flightaware/dump1090"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# WiFi tools
|
|
||||||
if $MISSING_WIFI; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Installing WiFi tools...${NC}"
|
|
||||||
$SUDO apt install -y aircrack-ng
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Bluetooth tools
|
|
||||||
if $MISSING_BLUETOOTH; then
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}Installing Bluetooth tools...${NC}"
|
|
||||||
$SUDO apt install -y bluez bluetooth
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}Tool installation complete!${NC}"
|
|
||||||
|
|
||||||
# Setup udev rules automatically
|
|
||||||
setup_udev_rules_auto
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "Skipping automatic installation."
|
|
||||||
show_manual_instructions
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Setup udev rules automatically (Debian)
|
# ----------------------------
|
||||||
setup_udev_rules_auto() {
|
# macOS install (Homebrew)
|
||||||
echo ""
|
# ----------------------------
|
||||||
echo -e "${BLUE}Setting up RTL-SDR udev rules...${NC}"
|
ensure_brew() {
|
||||||
|
cmd_exists brew && return 0
|
||||||
|
warn "Homebrew not found. Installing Homebrew..."
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
|
if [[ -x /opt/homebrew/bin/brew ]]; then
|
||||||
echo "udev rules already exist, skipping."
|
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||||
return
|
elif [[ -x /usr/local/bin/brew ]]; then
|
||||||
|
eval "$(/usr/local/bin/brew shellenv)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
brew_install() {
|
||||||
|
local pkg="$1"
|
||||||
|
if brew list --formula "$pkg" >/dev/null 2>&1; then
|
||||||
|
ok "brew: ${pkg} already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
info "brew: installing ${pkg}..."
|
||||||
|
brew install "$pkg"
|
||||||
|
ok "brew: installed ${pkg}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_macos_packages() {
|
||||||
|
ensure_brew
|
||||||
|
info "Installing packages via Homebrew..."
|
||||||
|
|
||||||
|
brew_install librtlsdr
|
||||||
|
brew_install multimon-ng
|
||||||
|
brew_install ffmpeg
|
||||||
|
brew_install rtl_433
|
||||||
|
|
||||||
|
# ADS-B (may not exist)
|
||||||
|
warn "Attempting dump1090 install via Homebrew (may be unavailable)..."
|
||||||
|
(brew_install dump1090-mutability) || true
|
||||||
|
|
||||||
|
brew_install aircrack-ng
|
||||||
|
brew_install hcxtools
|
||||||
|
brew_install soapysdr
|
||||||
|
brew_install gpsd
|
||||||
|
|
||||||
|
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
|
||||||
|
# ----------------------------
|
||||||
|
# Debian/Ubuntu install (APT)
|
||||||
|
# ----------------------------
|
||||||
|
apt_install() { $SUDO apt-get install -y --no-install-recommends "$@" >/dev/null; }
|
||||||
|
|
||||||
|
apt_try_install_any() {
|
||||||
|
local p
|
||||||
|
for p in "$@"; do
|
||||||
|
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||||
|
ok "apt: installed ${p}"
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
read -p "Would you like to setup RTL-SDR udev rules? [Y/n] " -n 1 -r
|
install_dump1090_from_source_debian() {
|
||||||
echo ""
|
info "dump1090 not available via APT. Building from source (required)..."
|
||||||
|
|
||||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
apt_install build-essential git pkg-config \
|
||||||
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
librtlsdr-dev libusb-1.0-0-dev \
|
||||||
|
libncurses-dev tcl-dev python3-dev
|
||||||
|
|
||||||
|
local orig_dir tmp_dir
|
||||||
|
orig_dir="$(pwd)"
|
||||||
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
||||||
|
cleanup() { cd "$orig_dir" >/dev/null 2>&1 || true; rm -rf "$tmp_dir"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
info "Cloning FlightAware dump1090..."
|
||||||
|
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
|
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dump1090"
|
||||||
|
info "Compiling FlightAware dump1090..."
|
||||||
|
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||||
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
|
ok "dump1090 installed successfully (FlightAware)."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "FlightAware build failed. Falling back to antirez/dump1090..."
|
||||||
|
rm -rf "$tmp_dir/dump1090"
|
||||||
|
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||||
|
|| { fail "Failed to clone antirez dump1090"; exit 1; }
|
||||||
|
|
||||||
|
cd "$tmp_dir/dump1090"
|
||||||
|
info "Compiling antirez dump1090..."
|
||||||
|
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
|
||||||
|
|
||||||
|
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||||
|
ok "dump1090 installed successfully (antirez)."
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_udev_rules_debian() {
|
||||||
|
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||||
|
|
||||||
|
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
|
||||||
|
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
|
||||||
|
|
||||||
|
info "Installing RTL-SDR udev rules..."
|
||||||
|
$SUDO tee "$rules_file" >/dev/null <<'EOF'
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||||
EOF'
|
EOF
|
||||||
$SUDO udevadm control --reload-rules
|
$SUDO udevadm control --reload-rules || true
|
||||||
$SUDO udevadm trigger
|
$SUDO udevadm trigger || true
|
||||||
echo -e "${GREEN}udev rules installed!${NC}"
|
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
|
||||||
echo "Please unplug and replug your RTL-SDR device."
|
echo
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show manual installation instructions
|
install_debian_packages() {
|
||||||
show_manual_instructions() {
|
need_sudo
|
||||||
echo ""
|
info "Updating APT package lists..."
|
||||||
echo -e "${BLUE}Manual installation instructions:${NC}"
|
$SUDO apt-get update -y >/dev/null
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [[ "$OS" == "macos" ]]; then
|
info "Installing required packages via APT..."
|
||||||
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
apt_install rtl-sdr
|
||||||
echo ""
|
apt_install multimon-ng
|
||||||
|
apt_install ffmpeg
|
||||||
|
|
||||||
if ! check_cmd brew; then
|
apt_try_install_any rtl-433 rtl433 || true
|
||||||
echo "First, install Homebrew:"
|
|
||||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "# Core SDR tools"
|
apt_install aircrack-ng || true
|
||||||
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
|
apt_install hcxdumptool || true
|
||||||
echo ""
|
apt_install hcxtools || true
|
||||||
echo "# LimeSDR support (optional)"
|
apt_install bluez bluetooth || true
|
||||||
echo "brew install soapysdr limesuite soapylms7"
|
apt_install soapysdr-tools || true
|
||||||
echo ""
|
apt_install gpsd gpsd-clients || true
|
||||||
echo "# HackRF support (optional)"
|
|
||||||
echo "brew install hackrf soapyhackrf"
|
|
||||||
echo ""
|
|
||||||
echo "# WiFi tools"
|
|
||||||
echo "brew install aircrack-ng"
|
|
||||||
|
|
||||||
elif [[ "$OS" == "debian" ]]; then
|
# dump1090: apt first; source fallback; hard fail inside if it can't build
|
||||||
echo -e "${YELLOW}Ubuntu/Debian:${NC}"
|
if ! cmd_exists dump1090; then
|
||||||
echo ""
|
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||||
echo "# Core SDR tools"
|
fi
|
||||||
echo "sudo apt update"
|
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433"
|
|
||||||
echo ""
|
|
||||||
echo "# dump1090 (try one of these - package name varies):"
|
|
||||||
echo "sudo apt install dump1090-fa # FlightAware version"
|
|
||||||
echo "# Or install from: https://flightaware.com/adsb/piaware/install"
|
|
||||||
echo ""
|
|
||||||
echo "# LimeSDR support (optional)"
|
|
||||||
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
|
|
||||||
echo ""
|
|
||||||
echo "# HackRF support (optional)"
|
|
||||||
echo "sudo apt install hackrf soapysdr-module-hackrf"
|
|
||||||
echo ""
|
|
||||||
echo "# WiFi tools"
|
|
||||||
echo "sudo apt install aircrack-ng"
|
|
||||||
echo ""
|
|
||||||
echo "# Bluetooth tools"
|
|
||||||
echo "sudo apt install bluez bluetooth"
|
|
||||||
|
|
||||||
elif [[ "$OS" == "arch" ]]; then
|
setup_udev_rules_debian
|
||||||
echo -e "${YELLOW}Arch Linux:${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "# Core SDR tools"
|
|
||||||
echo "sudo pacman -S rtl-sdr multimon-ng"
|
|
||||||
echo "yay -S rtl_433 dump1090"
|
|
||||||
echo ""
|
|
||||||
echo "# LimeSDR/HackRF support (optional)"
|
|
||||||
echo "sudo pacman -S soapysdr limesuite hackrf"
|
|
||||||
|
|
||||||
elif [[ "$OS" == "redhat" ]]; then
|
|
||||||
echo -e "${YELLOW}Fedora/RHEL:${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "# Core SDR tools"
|
|
||||||
echo "sudo dnf install rtl-sdr"
|
|
||||||
echo "# multimon-ng, rtl_433, dump1090 may need to be built from source"
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "Please install the following tools manually:"
|
|
||||||
for tool in "${MISSING_TOOLS[@]}"; do
|
|
||||||
echo " - $tool"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show installation instructions (decides auto vs manual)
|
# ----------------------------
|
||||||
install_or_show_instructions() {
|
# Final summary / hard fail
|
||||||
if [[ "$OS" == "debian" ]]; then
|
# ----------------------------
|
||||||
install_debian_tools
|
final_summary_and_hard_fail() {
|
||||||
else
|
check_tools
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
|
echo "============================================"
|
||||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
||||||
echo ""
|
ok "All REQUIRED tools are installed."
|
||||||
echo -e "${GREEN}All tools are installed!${NC}"
|
else
|
||||||
else
|
fail "Missing REQUIRED tools:"
|
||||||
show_manual_instructions
|
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||||
fi
|
echo
|
||||||
fi
|
fail "Exiting because required tools are missing."
|
||||||
|
echo
|
||||||
|
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
|
||||||
|
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "To start INTERCEPT:"
|
||||||
|
echo " source venv/bin/activate"
|
||||||
|
echo " sudo python intercept.py"
|
||||||
|
echo
|
||||||
|
echo "Then open http://localhost:5050 in your browser"
|
||||||
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
# RTL-SDR udev rules (Linux only)
|
# ----------------------------
|
||||||
setup_udev_rules() {
|
# MAIN
|
||||||
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
|
# ----------------------------
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "If your RTL-SDR is not detected, you may need to add udev rules:"
|
|
||||||
echo ""
|
|
||||||
echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
|
|
||||||
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
|
|
||||||
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
|
|
||||||
echo "EOF'"
|
|
||||||
echo ""
|
|
||||||
echo "sudo udevadm control --reload-rules"
|
|
||||||
echo "sudo udevadm trigger"
|
|
||||||
echo ""
|
|
||||||
echo "Then unplug and replug your RTL-SDR device."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main
|
|
||||||
main() {
|
main() {
|
||||||
detect_os
|
detect_os
|
||||||
setup_sudo
|
|
||||||
install_python_deps
|
|
||||||
check_tools
|
|
||||||
install_or_show_instructions
|
|
||||||
|
|
||||||
# Show udev rules instructions for non-Debian Linux (Debian handles it automatically)
|
if [[ "$OS" == "macos" ]]; then
|
||||||
if [[ "$OS" != "debian" ]]; then
|
install_macos_packages
|
||||||
setup_udev_rules
|
else
|
||||||
fi
|
install_debian_packages
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
install_python_deps
|
||||||
echo "============================================"
|
final_summary_and_hard_fail
|
||||||
echo -e "${GREEN}Setup complete!${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "To start INTERCEPT:"
|
|
||||||
if [ -d "venv" ]; then
|
|
||||||
echo " source venv/bin/activate"
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
echo " sudo venv/bin/python intercept.py"
|
|
||||||
else
|
|
||||||
echo " venv/bin/python intercept.py"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ -n "$SUDO" ]; then
|
|
||||||
echo " sudo python3 intercept.py"
|
|
||||||
else
|
|
||||||
echo " python3 intercept.py"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
echo "Then open http://localhost:5050 in your browser"
|
|
||||||
echo ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #0a0a0f;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0d1117;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #161b22;
|
--bg-card: #151a23;
|
||||||
--border-glow: #00ff88;
|
--border-color: #1f2937;
|
||||||
--text-primary: #e6edf3;
|
--border-glow: #4a9eff;
|
||||||
--text-secondary: #8b949e;
|
--text-primary: #e8eaed;
|
||||||
--accent-green: #00ff88;
|
--text-secondary: #9ca3af;
|
||||||
--accent-cyan: #00d4ff;
|
--text-dim: #4b5563;
|
||||||
--accent-orange: #ff9500;
|
--accent-green: #22c55e;
|
||||||
--accent-red: #ff4444;
|
--accent-cyan: #4a9eff;
|
||||||
--accent-yellow: #ffcc00;
|
--accent-orange: #f59e0b;
|
||||||
--grid-line: rgba(0, 255, 136, 0.1);
|
--accent-red: #ef4444;
|
||||||
--radar-cyan: #00ffff;
|
--accent-yellow: #eab308;
|
||||||
--radar-bg: #1a1a2e;
|
--accent-amber: #d4a853;
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
|
--radar-cyan: #4a9eff;
|
||||||
|
--radar-bg: #0f1218;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -44,18 +47,18 @@ body {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scan line effect */
|
/* Scan line effect - subtle */
|
||||||
.scanline {
|
.scanline {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 4px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
animation: scan 4s linear infinite;
|
animation: scan 6s linear infinite;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
opacity: 0.5;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scan {
|
@keyframes scan {
|
||||||
@@ -73,20 +76,20 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
|
background: var(--bg-panel);
|
||||||
border-bottom: 1px solid rgba(0, 255, 136, 0.3);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Inter', sans-serif;
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
letter-spacing: 4px;
|
letter-spacing: 3px;
|
||||||
color: var(--accent-green);
|
color: var(--text-primary);
|
||||||
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo span {
|
.logo span {
|
||||||
@@ -115,8 +118,8 @@ body {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-green);
|
background: var(--accent-cyan);
|
||||||
box-shadow: 0 0 10px var(--accent-green);
|
box-shadow: 0 0 10px var(--accent-cyan);
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +147,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
@@ -153,7 +156,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge .value {
|
.stat-badge .value {
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,15 +168,15 @@ body {
|
|||||||
.datetime {
|
.datetime {
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border: 1px solid var(--accent-green);
|
border: 1px solid var(--accent-cyan);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +195,7 @@ body {
|
|||||||
/* Panels */
|
/* Panels */
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
border: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -204,19 +207,19 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
background: rgba(0, 255, 136, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
border-bottom: 1px solid rgba(0, 255, 136, 0.1);
|
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -225,7 +228,7 @@ body {
|
|||||||
.panel-indicator {
|
.panel-indicator {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: var(--accent-green);
|
background: var(--accent-cyan);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: blink 1s ease-in-out infinite;
|
animation: blink 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -300,7 +303,7 @@ body {
|
|||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-left: 1px solid rgba(0, 255, 136, 0.2);
|
border-left: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,13 +313,13 @@ body {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-bottom: 1px solid rgba(0, 255, 136, 0.2);
|
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
@@ -330,13 +333,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.view-btn:hover {
|
.view-btn:hover {
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-cyan);
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-btn.active {
|
.view-btn.active {
|
||||||
background: var(--accent-green);
|
background: var(--accent-cyan);
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-cyan);
|
||||||
color: var(--bg-dark);
|
color: var(--bg-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,8 +358,8 @@ body {
|
|||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
text-shadow: 0 0 15px var(--accent-green);
|
text-shadow: 0 0 15px var(--accent-cyan);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -371,7 +374,7 @@ body {
|
|||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-left: 2px solid var(--accent-green);
|
border-left: 2px solid var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.telemetry-label {
|
.telemetry-label {
|
||||||
@@ -404,7 +407,7 @@ body {
|
|||||||
|
|
||||||
.aircraft-item {
|
.aircraft-item {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.15);
|
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
@@ -413,14 +416,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-item:hover {
|
.aircraft-item:hover {
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-cyan);
|
||||||
background: rgba(0, 255, 136, 0.05);
|
background: rgba(74, 158, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-item.selected {
|
.aircraft-item.selected {
|
||||||
border-color: var(--accent-green);
|
border-color: var(--accent-cyan);
|
||||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
|
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-header {
|
.aircraft-header {
|
||||||
@@ -434,14 +437,14 @@ body {
|
|||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aircraft-icao {
|
.aircraft-icao {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
@@ -475,10 +478,28 @@ body {
|
|||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
flex-wrap: nowrap;
|
||||||
padding: 10px 20px;
|
gap: 8px;
|
||||||
|
padding: 8px 15px;
|
||||||
background: var(--bg-panel);
|
background: var(--bg-panel);
|
||||||
border-top: 1px solid rgba(0, 255, 136, 0.3);
|
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar select,
|
||||||
|
.controls-bar input[type="text"],
|
||||||
|
.controls-bar input[type="number"] {
|
||||||
|
padding: 3px 5px;
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group {
|
.control-group {
|
||||||
@@ -497,15 +518,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-group input[type="checkbox"] {
|
.control-group input[type="checkbox"] {
|
||||||
accent-color: var(--accent-green);
|
accent-color: var(--accent-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group select {
|
.control-group select {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -514,9 +535,9 @@ body {
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -531,9 +552,9 @@ body {
|
|||||||
/* Start/stop button */
|
/* Start/stop button */
|
||||||
.start-btn {
|
.start-btn {
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border: 1px solid var(--accent-green);
|
border: 1px solid var(--accent-cyan);
|
||||||
background: rgba(0, 255, 136, 0.1);
|
background: rgba(74, 158, 255, 0.1);
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Orbitron', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -546,9 +567,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.start-btn:hover {
|
.start-btn:hover {
|
||||||
background: var(--accent-green);
|
background: var(--accent-cyan);
|
||||||
color: var(--bg-dark);
|
color: var(--bg-dark);
|
||||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
|
box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-btn.active {
|
.start-btn.active {
|
||||||
@@ -564,10 +585,10 @@ body {
|
|||||||
/* GPS button */
|
/* GPS button */
|
||||||
.gps-btn {
|
.gps-btn {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: rgba(0, 255, 136, 0.2);
|
background: rgba(74, 158, 255, 0.2);
|
||||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
border: 1px solid rgba(74, 158, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--accent-green);
|
color: var(--accent-cyan);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -578,10 +599,15 @@ body {
|
|||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaflet-tile-pane,
|
||||||
|
.leaflet-container .leaflet-tile-pane {
|
||||||
|
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
color: var(--accent-green) !important;
|
color: var(--accent-cyan) !important;
|
||||||
border-color: rgba(0, 255, 136, 0.3) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-attribution {
|
.leaflet-control-attribution {
|
||||||
@@ -600,7 +626,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--accent-green);
|
background: var(--accent-cyan);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +658,7 @@ body {
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid rgba(0, 255, 136, 0.2);
|
border-top: 1px solid rgba(74, 158, 255, 0.2);
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,3 +667,159 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* Airband Audio Controls */
|
||||||
|
.airband-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 0 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(74, 158, 255, 0.1);
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-btn.active {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
border-color: var(--accent-green);
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-status {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#airbandSquelch {
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Airband Audio Visualizer */
|
||||||
|
.airband-visualizer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-visualizer .signal-meter {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-visualizer .meter-bar {
|
||||||
|
height: 10px;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--accent-green) 0%,
|
||||||
|
var(--accent-green) 60%,
|
||||||
|
var(--accent-orange) 60%,
|
||||||
|
var(--accent-orange) 80%,
|
||||||
|
var(--accent-red) 80%,
|
||||||
|
var(--accent-red) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-visualizer .meter-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--accent-green) 0%,
|
||||||
|
var(--accent-green) 60%,
|
||||||
|
var(--accent-orange) 60%,
|
||||||
|
var(--accent-orange) 80%,
|
||||||
|
var(--accent-red) 80%,
|
||||||
|
var(--accent-red) 100%
|
||||||
|
);
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.05s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airband-visualizer .meter-peak {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 2px;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: left 0.05s ease-out;
|
||||||
|
left: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#airbandSpectrumCanvas {
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Indicator */
|
||||||
|
.gps-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
border: 1px solid #22c55e;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #22c55e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gps-indicator .gps-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #22c55e;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: gps-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gps-pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(0.8); }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,22 +5,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-dark: #0a0a0f;
|
--bg-dark: #0a0c10;
|
||||||
--bg-panel: #0d1117;
|
--bg-panel: #0f1218;
|
||||||
--bg-card: #161b22;
|
--bg-card: #151a23;
|
||||||
--border-glow: #00d4ff;
|
--border-color: #1f2937;
|
||||||
--text-primary: #e6edf3;
|
--border-glow: #4a9eff;
|
||||||
--text-secondary: #8b949e;
|
--text-primary: #e8eaed;
|
||||||
--accent-cyan: #00d4ff;
|
--text-secondary: #9ca3af;
|
||||||
--accent-green: #00ff88;
|
--text-dim: #4b5563;
|
||||||
--accent-orange: #ff9500;
|
--accent-cyan: #4a9eff;
|
||||||
--accent-red: #ff4444;
|
--accent-green: #22c55e;
|
||||||
|
--accent-orange: #f59e0b;
|
||||||
|
--accent-red: #ef4444;
|
||||||
--accent-purple: #a855f7;
|
--accent-purple: #a855f7;
|
||||||
--grid-line: rgba(0, 212, 255, 0.1);
|
--accent-amber: #d4a853;
|
||||||
|
--grid-line: rgba(74, 158, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -82,28 +85,28 @@ body {
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
|
background: var(--bg-panel);
|
||||||
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
font-family: 'Orbitron', monospace;
|
font-family: 'Inter', sans-serif;
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
letter-spacing: 4px;
|
letter-spacing: 3px;
|
||||||
color: var(--accent-cyan);
|
color: var(--text-primary);
|
||||||
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo span {
|
.logo span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
margin-left: 15px;
|
margin-left: 15px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats badges in header */
|
/* Stats badges in header */
|
||||||
@@ -113,7 +116,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
background: rgba(0, 212, 255, 0.1);
|
background: var(--bg-card);
|
||||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -600,10 +603,15 @@ body {
|
|||||||
background: var(--bg-dark) !important;
|
background: var(--bg-dark) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leaflet-tile-pane,
|
||||||
|
.leaflet-container .leaflet-tile-pane {
|
||||||
|
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.leaflet-control-zoom a {
|
.leaflet-control-zoom a {
|
||||||
background: var(--bg-panel) !important;
|
background: var(--bg-panel) !important;
|
||||||
color: var(--accent-cyan) !important;
|
color: var(--accent-cyan) !important;
|
||||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-control-attribution {
|
.leaflet-control-attribution {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2387
templates/index.html
2387
templates/index.html
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SATELLITE COMMAND // INTERCEPT</title>
|
<title>SATELLITE COMMAND // INTERCEPT</title>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||||
@@ -247,8 +247,8 @@
|
|||||||
worldCopyJump: true
|
worldCopyJump: true
|
||||||
});
|
});
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '©OpenStreetMap, ©CartoDB'
|
attribution: '© OpenStreetMap contributors'
|
||||||
}).addTo(groundMap);
|
}).addTo(groundMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
340
tests/test_correlation.py
Normal file
340
tests/test_correlation.py
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"""Tests for device correlation engine."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceCorrelator:
|
||||||
|
"""Tests for DeviceCorrelator class."""
|
||||||
|
|
||||||
|
def test_correlate_same_oui(self):
|
||||||
|
"""Test correlation detects same OUI."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'AA:BB:CC:11:22:33': {
|
||||||
|
'first_seen': datetime.now(),
|
||||||
|
'last_seen': datetime.now(),
|
||||||
|
'essid': 'TestNetwork',
|
||||||
|
'power': -65
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'AA:BB:CC:44:55:66': {
|
||||||
|
'first_seen': datetime.now(),
|
||||||
|
'last_seen': datetime.now(),
|
||||||
|
'name': 'TestPhone',
|
||||||
|
'rssi': -60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
assert len(correlations) >= 1
|
||||||
|
assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33'
|
||||||
|
assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66'
|
||||||
|
assert correlations[0]['confidence'] > 0
|
||||||
|
|
||||||
|
def test_correlate_timing(self):
|
||||||
|
"""Test correlation considers timing."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(time_window_seconds=30)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Devices appearing at the same time
|
||||||
|
wifi_devices = {
|
||||||
|
'11:22:33:44:55:66': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'essid': 'Network1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'77:88:99:AA:BB:CC': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'name': 'Device1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
# Should have some confidence from timing correlation
|
||||||
|
if correlations:
|
||||||
|
assert correlations[0]['confidence'] > 0
|
||||||
|
|
||||||
|
def test_correlate_no_overlap(self):
|
||||||
|
"""Test no correlation when devices don't overlap."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(
|
||||||
|
time_window_seconds=30,
|
||||||
|
min_confidence=0.6
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
old = now - timedelta(hours=1)
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'11:22:33:44:55:66': {
|
||||||
|
'first_seen': old,
|
||||||
|
'last_seen': old,
|
||||||
|
'essid': 'OldNetwork'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'77:88:99:AA:BB:CC': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'name': 'NewDevice'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
# With high min_confidence and no OUI match, should be empty
|
||||||
|
assert len(correlations) == 0
|
||||||
|
|
||||||
|
def test_correlate_manufacturer_match(self):
|
||||||
|
"""Test correlation boosts confidence for same manufacturer."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'11:22:33:44:55:66': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'manufacturer': 'Apple',
|
||||||
|
'essid': 'Network'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'77:88:99:AA:BB:CC': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'manufacturer': 'Apple',
|
||||||
|
'name': 'iPhone'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
# Should have correlation with bonus for manufacturer match
|
||||||
|
assert len(correlations) >= 1
|
||||||
|
|
||||||
|
def test_correlate_empty_inputs(self):
|
||||||
|
"""Test correlation handles empty inputs."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator()
|
||||||
|
|
||||||
|
# Empty WiFi
|
||||||
|
assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == []
|
||||||
|
|
||||||
|
# Empty Bluetooth
|
||||||
|
assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == []
|
||||||
|
|
||||||
|
# Both empty
|
||||||
|
assert correlator.correlate({}, {}) == []
|
||||||
|
|
||||||
|
def test_correlate_sorting(self):
|
||||||
|
"""Test correlations are sorted by confidence."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(
|
||||||
|
time_window_seconds=60,
|
||||||
|
min_confidence=0.0
|
||||||
|
)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'AA:BB:CC:11:11:11': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'manufacturer': 'Apple'
|
||||||
|
},
|
||||||
|
'11:22:33:44:55:66': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'AA:BB:CC:22:22:22': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now,
|
||||||
|
'manufacturer': 'Apple'
|
||||||
|
},
|
||||||
|
'77:88:99:AA:BB:CC': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
if len(correlations) >= 2:
|
||||||
|
# Should be sorted by confidence (highest first)
|
||||||
|
assert correlations[0]['confidence'] >= correlations[1]['confidence']
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCorrelations:
|
||||||
|
"""Tests for get_correlations function."""
|
||||||
|
|
||||||
|
@patch('utils.correlation.correlator')
|
||||||
|
@patch('utils.correlation.db_get_correlations')
|
||||||
|
def test_get_correlations_live(self, mock_db, mock_correlator):
|
||||||
|
"""Test get_correlations with live data."""
|
||||||
|
from utils.correlation import get_correlations
|
||||||
|
|
||||||
|
mock_correlator.correlate.return_value = [
|
||||||
|
{
|
||||||
|
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||||
|
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||||
|
'confidence': 0.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_db.return_value = []
|
||||||
|
|
||||||
|
wifi = {'AA:AA:AA:AA:AA:AA': {}}
|
||||||
|
bt = {'BB:BB:BB:BB:BB:BB': {}}
|
||||||
|
|
||||||
|
results = get_correlations(
|
||||||
|
wifi_devices=wifi,
|
||||||
|
bt_devices=bt,
|
||||||
|
include_historical=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
mock_correlator.correlate.assert_called_once()
|
||||||
|
|
||||||
|
@patch('utils.correlation.correlator')
|
||||||
|
@patch('utils.correlation.db_get_correlations')
|
||||||
|
def test_get_correlations_historical(self, mock_db, mock_correlator):
|
||||||
|
"""Test get_correlations includes historical data."""
|
||||||
|
from utils.correlation import get_correlations
|
||||||
|
|
||||||
|
mock_correlator.correlate.return_value = []
|
||||||
|
mock_db.return_value = [
|
||||||
|
{
|
||||||
|
'wifi_mac': 'CC:CC:CC:CC:CC:CC',
|
||||||
|
'bt_mac': 'DD:DD:DD:DD:DD:DD',
|
||||||
|
'confidence': 0.7,
|
||||||
|
'first_seen': '2024-01-01',
|
||||||
|
'last_seen': '2024-01-02'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
results = get_correlations(
|
||||||
|
wifi_devices={},
|
||||||
|
bt_devices={},
|
||||||
|
include_historical=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC'
|
||||||
|
|
||||||
|
@patch('utils.correlation.correlator')
|
||||||
|
@patch('utils.correlation.db_get_correlations')
|
||||||
|
def test_get_correlations_deduplication(self, mock_db, mock_correlator):
|
||||||
|
"""Test get_correlations deduplicates live and historical."""
|
||||||
|
from utils.correlation import get_correlations
|
||||||
|
|
||||||
|
# Same correlation from both sources
|
||||||
|
mock_correlator.correlate.return_value = [
|
||||||
|
{
|
||||||
|
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||||
|
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||||
|
'confidence': 0.8
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_db.return_value = [
|
||||||
|
{
|
||||||
|
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
|
||||||
|
'bt_mac': 'BB:BB:BB:BB:BB:BB',
|
||||||
|
'confidence': 0.7,
|
||||||
|
'first_seen': '2024-01-01',
|
||||||
|
'last_seen': '2024-01-02'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
wifi = {'AA:AA:AA:AA:AA:AA': {}}
|
||||||
|
bt = {'BB:BB:BB:BB:BB:BB': {}}
|
||||||
|
|
||||||
|
results = get_correlations(
|
||||||
|
wifi_devices=wifi,
|
||||||
|
bt_devices=bt,
|
||||||
|
include_historical=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should deduplicate - only one entry for the same device pair
|
||||||
|
matching = [r for r in results
|
||||||
|
if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
|
||||||
|
assert len(matching) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCorrelationReason:
|
||||||
|
"""Tests for correlation reason generation."""
|
||||||
|
|
||||||
|
def test_reason_same_oui(self):
|
||||||
|
"""Test reason includes OUI match."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator()
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'AA:BB:CC:11:22:33': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'AA:BB:CC:44:55:66': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
if correlations:
|
||||||
|
assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower()
|
||||||
|
|
||||||
|
def test_reason_timing(self):
|
||||||
|
"""Test reason includes timing information."""
|
||||||
|
from utils.correlation import DeviceCorrelator
|
||||||
|
|
||||||
|
correlator = DeviceCorrelator(time_window_seconds=60)
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
wifi_devices = {
|
||||||
|
'11:22:33:44:55:66': {
|
||||||
|
'first_seen': now,
|
||||||
|
'last_seen': now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bt_devices = {
|
||||||
|
'77:88:99:AA:BB:CC': {
|
||||||
|
'first_seen': now + timedelta(seconds=5),
|
||||||
|
'last_seen': now + timedelta(seconds=5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
correlations = correlator.correlate(wifi_devices, bt_devices)
|
||||||
|
|
||||||
|
# If correlation found, should mention timing
|
||||||
|
if correlations and correlations[0]['confidence'] > 0.3:
|
||||||
|
assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason']
|
||||||
256
tests/test_database.py
Normal file
256
tests/test_database.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
"""Tests for database utilities."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Need to patch DB_PATH before importing database module
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def temp_db():
|
||||||
|
"""Use a temporary database for each test."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
test_db_path = Path(tmpdir) / 'test_intercept.db'
|
||||||
|
test_db_dir = Path(tmpdir)
|
||||||
|
|
||||||
|
with patch('utils.database.DB_PATH', test_db_path), \
|
||||||
|
patch('utils.database.DB_DIR', test_db_dir):
|
||||||
|
# Import after patching
|
||||||
|
from utils.database import init_db, close_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
yield test_db_path
|
||||||
|
close_db()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsCRUD:
|
||||||
|
"""Tests for settings CRUD operations."""
|
||||||
|
|
||||||
|
def test_set_and_get_string(self, temp_db):
|
||||||
|
"""Test setting and getting string values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
set_setting('test_key', 'test_value')
|
||||||
|
assert get_setting('test_key') == 'test_value'
|
||||||
|
|
||||||
|
def test_set_and_get_int(self, temp_db):
|
||||||
|
"""Test setting and getting integer values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
set_setting('int_key', 42)
|
||||||
|
result = get_setting('int_key')
|
||||||
|
assert result == 42
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
def test_set_and_get_float(self, temp_db):
|
||||||
|
"""Test setting and getting float values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
set_setting('float_key', 3.14)
|
||||||
|
result = get_setting('float_key')
|
||||||
|
assert result == 3.14
|
||||||
|
assert isinstance(result, float)
|
||||||
|
|
||||||
|
def test_set_and_get_bool(self, temp_db):
|
||||||
|
"""Test setting and getting boolean values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
set_setting('bool_true', True)
|
||||||
|
set_setting('bool_false', False)
|
||||||
|
|
||||||
|
assert get_setting('bool_true') is True
|
||||||
|
assert get_setting('bool_false') is False
|
||||||
|
|
||||||
|
def test_set_and_get_dict(self, temp_db):
|
||||||
|
"""Test setting and getting dictionary values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}}
|
||||||
|
set_setting('dict_key', test_dict)
|
||||||
|
result = get_setting('dict_key')
|
||||||
|
|
||||||
|
assert result == test_dict
|
||||||
|
assert result['nested']['a'] == 1
|
||||||
|
|
||||||
|
def test_set_and_get_list(self, temp_db):
|
||||||
|
"""Test setting and getting list values."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
test_list = [1, 2, 3, 'four', {'five': 5}]
|
||||||
|
set_setting('list_key', test_list)
|
||||||
|
result = get_setting('list_key')
|
||||||
|
|
||||||
|
assert result == test_list
|
||||||
|
|
||||||
|
def test_get_nonexistent_key(self, temp_db):
|
||||||
|
"""Test getting a key that doesn't exist."""
|
||||||
|
from utils.database import get_setting
|
||||||
|
|
||||||
|
assert get_setting('nonexistent') is None
|
||||||
|
assert get_setting('nonexistent', 'default') == 'default'
|
||||||
|
|
||||||
|
def test_update_existing_setting(self, temp_db):
|
||||||
|
"""Test updating an existing setting."""
|
||||||
|
from utils.database import set_setting, get_setting
|
||||||
|
|
||||||
|
set_setting('update_key', 'original')
|
||||||
|
assert get_setting('update_key') == 'original'
|
||||||
|
|
||||||
|
set_setting('update_key', 'updated')
|
||||||
|
assert get_setting('update_key') == 'updated'
|
||||||
|
|
||||||
|
def test_delete_setting(self, temp_db):
|
||||||
|
"""Test deleting a setting."""
|
||||||
|
from utils.database import set_setting, get_setting, delete_setting
|
||||||
|
|
||||||
|
set_setting('delete_key', 'value')
|
||||||
|
assert get_setting('delete_key') == 'value'
|
||||||
|
|
||||||
|
result = delete_setting('delete_key')
|
||||||
|
assert result is True
|
||||||
|
assert get_setting('delete_key') is None
|
||||||
|
|
||||||
|
def test_delete_nonexistent_setting(self, temp_db):
|
||||||
|
"""Test deleting a setting that doesn't exist."""
|
||||||
|
from utils.database import delete_setting
|
||||||
|
|
||||||
|
result = delete_setting('nonexistent_key')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_all_settings(self, temp_db):
|
||||||
|
"""Test getting all settings."""
|
||||||
|
from utils.database import set_setting, get_all_settings
|
||||||
|
|
||||||
|
set_setting('key1', 'value1')
|
||||||
|
set_setting('key2', 42)
|
||||||
|
set_setting('key3', True)
|
||||||
|
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
|
||||||
|
assert 'key1' in all_settings
|
||||||
|
assert all_settings['key1'] == 'value1'
|
||||||
|
assert all_settings['key2'] == 42
|
||||||
|
assert all_settings['key3'] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalHistory:
|
||||||
|
"""Tests for signal history operations."""
|
||||||
|
|
||||||
|
def test_add_and_get_signal_reading(self, temp_db):
|
||||||
|
"""Test adding and retrieving signal readings."""
|
||||||
|
from utils.database import add_signal_reading, get_signal_history
|
||||||
|
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62)
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70)
|
||||||
|
|
||||||
|
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
|
||||||
|
|
||||||
|
assert len(history) == 3
|
||||||
|
# Results should be in chronological order
|
||||||
|
assert history[0]['signal'] == -65
|
||||||
|
assert history[1]['signal'] == -62
|
||||||
|
assert history[2]['signal'] == -70
|
||||||
|
|
||||||
|
def test_signal_history_with_metadata(self, temp_db):
|
||||||
|
"""Test signal readings with metadata."""
|
||||||
|
from utils.database import add_signal_reading, get_signal_history
|
||||||
|
|
||||||
|
metadata = {'channel': 6, 'ssid': 'TestNetwork'}
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata)
|
||||||
|
|
||||||
|
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
|
||||||
|
|
||||||
|
assert len(history) == 1
|
||||||
|
assert history[0]['metadata'] == metadata
|
||||||
|
|
||||||
|
def test_signal_history_limit(self, temp_db):
|
||||||
|
"""Test signal history respects limit parameter."""
|
||||||
|
from utils.database import add_signal_reading, get_signal_history
|
||||||
|
|
||||||
|
for i in range(10):
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i)
|
||||||
|
|
||||||
|
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5)
|
||||||
|
assert len(history) == 5
|
||||||
|
|
||||||
|
def test_signal_history_different_devices(self, temp_db):
|
||||||
|
"""Test signal history isolates different devices."""
|
||||||
|
from utils.database import add_signal_reading, get_signal_history
|
||||||
|
|
||||||
|
add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65)
|
||||||
|
add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70)
|
||||||
|
|
||||||
|
history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA')
|
||||||
|
history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB')
|
||||||
|
|
||||||
|
assert len(history_a) == 1
|
||||||
|
assert len(history_b) == 1
|
||||||
|
assert history_a[0]['signal'] == -65
|
||||||
|
assert history_b[0]['signal'] == -70
|
||||||
|
|
||||||
|
def test_cleanup_old_signal_history(self, temp_db):
|
||||||
|
"""Test cleanup of old signal history."""
|
||||||
|
from utils.database import add_signal_reading, cleanup_old_signal_history
|
||||||
|
|
||||||
|
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
|
||||||
|
|
||||||
|
# Cleanup with 0 hours should remove everything
|
||||||
|
deleted = cleanup_old_signal_history(max_age_hours=0)
|
||||||
|
# Note: This may or may not delete depending on timing
|
||||||
|
assert isinstance(deleted, int)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceCorrelations:
|
||||||
|
"""Tests for device correlation operations."""
|
||||||
|
|
||||||
|
def test_add_and_get_correlation(self, temp_db):
|
||||||
|
"""Test adding and retrieving correlations."""
|
||||||
|
from utils.database import add_correlation, get_correlations
|
||||||
|
|
||||||
|
add_correlation(
|
||||||
|
wifi_mac='AA:AA:AA:AA:AA:AA',
|
||||||
|
bt_mac='BB:BB:BB:BB:BB:BB',
|
||||||
|
confidence=0.85,
|
||||||
|
metadata={'reason': 'timing'}
|
||||||
|
)
|
||||||
|
|
||||||
|
correlations = get_correlations(min_confidence=0.5)
|
||||||
|
|
||||||
|
assert len(correlations) >= 1
|
||||||
|
found = next(
|
||||||
|
(c for c in correlations
|
||||||
|
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
assert found is not None
|
||||||
|
assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB'
|
||||||
|
assert found['confidence'] == 0.85
|
||||||
|
|
||||||
|
def test_correlation_confidence_filter(self, temp_db):
|
||||||
|
"""Test correlation filtering by confidence."""
|
||||||
|
from utils.database import add_correlation, get_correlations
|
||||||
|
|
||||||
|
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
|
||||||
|
add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4)
|
||||||
|
|
||||||
|
high_confidence = get_correlations(min_confidence=0.7)
|
||||||
|
all_confidence = get_correlations(min_confidence=0.3)
|
||||||
|
|
||||||
|
assert len(high_confidence) == 1
|
||||||
|
assert len(all_confidence) == 2
|
||||||
|
|
||||||
|
def test_correlation_upsert(self, temp_db):
|
||||||
|
"""Test that correlations are updated on conflict."""
|
||||||
|
from utils.database import add_correlation, get_correlations
|
||||||
|
|
||||||
|
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5)
|
||||||
|
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
|
||||||
|
|
||||||
|
correlations = get_correlations(min_confidence=0.0)
|
||||||
|
|
||||||
|
matching = [c for c in correlations
|
||||||
|
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
|
||||||
|
assert len(matching) == 1
|
||||||
|
assert matching[0]['confidence'] == 0.9
|
||||||
376
tests/test_routes.py
Normal file
376
tests/test_routes.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""Tests for Flask routes and API endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def app():
|
||||||
|
"""Create application for testing."""
|
||||||
|
import app as app_module
|
||||||
|
from routes import register_blueprints
|
||||||
|
from utils.database import init_db
|
||||||
|
|
||||||
|
app_module.app.config['TESTING'] = True
|
||||||
|
|
||||||
|
# Initialize database for settings tests
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Register blueprints only if not already registered (normally done in main())
|
||||||
|
# Check if any blueprint is already registered to avoid re-registration
|
||||||
|
if 'pager' not in app_module.app.blueprints:
|
||||||
|
register_blueprints(app_module.app)
|
||||||
|
|
||||||
|
return app_module.app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthEndpoint:
|
||||||
|
"""Tests for health check endpoint."""
|
||||||
|
|
||||||
|
def test_health_check(self, client):
|
||||||
|
"""Test health endpoint returns expected data."""
|
||||||
|
response = client.get('/health')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'healthy'
|
||||||
|
assert 'version' in data
|
||||||
|
assert 'uptime_seconds' in data
|
||||||
|
assert 'processes' in data
|
||||||
|
assert 'data' in data
|
||||||
|
|
||||||
|
def test_health_process_status(self, client):
|
||||||
|
"""Test health endpoint reports process status."""
|
||||||
|
response = client.get('/health')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
processes = data['processes']
|
||||||
|
assert 'pager' in processes
|
||||||
|
assert 'sensor' in processes
|
||||||
|
assert 'adsb' in processes
|
||||||
|
assert 'wifi' in processes
|
||||||
|
assert 'bluetooth' in processes
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevicesEndpoint:
|
||||||
|
"""Tests for devices endpoint."""
|
||||||
|
|
||||||
|
def test_get_devices(self, client):
|
||||||
|
"""Test getting device list."""
|
||||||
|
response = client.get('/devices')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
@patch('app.SDRFactory.detect_devices')
|
||||||
|
def test_devices_returns_list(self, mock_detect, client):
|
||||||
|
"""Test devices endpoint returns list format."""
|
||||||
|
mock_device = MagicMock()
|
||||||
|
mock_device.to_dict.return_value = {
|
||||||
|
'index': 0,
|
||||||
|
'name': 'Test RTL-SDR',
|
||||||
|
'sdr_type': 'rtlsdr'
|
||||||
|
}
|
||||||
|
mock_detect.return_value = [mock_device]
|
||||||
|
|
||||||
|
response = client.get('/devices')
|
||||||
|
data = json.loads(response.data)
|
||||||
|
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]['name'] == 'Test RTL-SDR'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDependenciesEndpoint:
|
||||||
|
"""Tests for dependencies endpoint."""
|
||||||
|
|
||||||
|
def test_get_dependencies(self, client):
|
||||||
|
"""Test getting dependency status."""
|
||||||
|
response = client.get('/dependencies')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'os' in data
|
||||||
|
assert 'pkg_manager' in data
|
||||||
|
assert 'modes' in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestSettingsEndpoints:
|
||||||
|
"""Tests for settings API endpoints."""
|
||||||
|
|
||||||
|
def test_get_settings(self, client):
|
||||||
|
"""Test getting all settings."""
|
||||||
|
response = client.get('/settings')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'settings' in data
|
||||||
|
|
||||||
|
def test_save_settings(self, client):
|
||||||
|
"""Test saving settings."""
|
||||||
|
response = client.post(
|
||||||
|
'/settings',
|
||||||
|
data=json.dumps({'test_key': 'test_value'}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'test_key' in data['saved']
|
||||||
|
|
||||||
|
def test_save_empty_settings(self, client):
|
||||||
|
"""Test saving empty settings returns error."""
|
||||||
|
response = client.post(
|
||||||
|
'/settings',
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_get_single_setting(self, client):
|
||||||
|
"""Test getting a single setting."""
|
||||||
|
# First save a setting
|
||||||
|
client.post(
|
||||||
|
'/settings',
|
||||||
|
data=json.dumps({'my_setting': 'my_value'}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then retrieve it
|
||||||
|
response = client.get('/settings/my_setting')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['value'] == 'my_value'
|
||||||
|
|
||||||
|
def test_get_nonexistent_setting(self, client):
|
||||||
|
"""Test getting a setting that doesn't exist."""
|
||||||
|
response = client.get('/settings/nonexistent_key_xyz')
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_update_setting(self, client):
|
||||||
|
"""Test updating a setting via PUT."""
|
||||||
|
response = client.put(
|
||||||
|
'/settings/update_test',
|
||||||
|
data=json.dumps({'value': 'updated_value'}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['value'] == 'updated_value'
|
||||||
|
|
||||||
|
def test_delete_setting(self, client):
|
||||||
|
"""Test deleting a setting."""
|
||||||
|
# First create a setting
|
||||||
|
client.post(
|
||||||
|
'/settings',
|
||||||
|
data=json.dumps({'delete_me': 'value'}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then delete it
|
||||||
|
response = client.delete('/settings/delete_me')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['deleted'] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCorrelationEndpoints:
|
||||||
|
"""Tests for correlation API endpoints."""
|
||||||
|
|
||||||
|
def test_get_correlations(self, client):
|
||||||
|
"""Test getting device correlations."""
|
||||||
|
response = client.get('/correlation')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert 'correlations' in data
|
||||||
|
assert 'wifi_count' in data
|
||||||
|
assert 'bt_count' in data
|
||||||
|
|
||||||
|
def test_correlations_with_confidence_filter(self, client):
|
||||||
|
"""Test correlation endpoint respects confidence filter."""
|
||||||
|
response = client.get('/correlation?min_confidence=0.8')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
|
||||||
|
|
||||||
|
class TestListeningPostEndpoints:
|
||||||
|
"""Tests for listening post endpoints."""
|
||||||
|
|
||||||
|
def test_tools_check(self, client):
|
||||||
|
"""Test listening post tools availability check."""
|
||||||
|
response = client.get('/listening/tools')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'rtl_fm' in data
|
||||||
|
assert 'available' in data
|
||||||
|
|
||||||
|
def test_scanner_status(self, client):
|
||||||
|
"""Test scanner status endpoint."""
|
||||||
|
response = client.get('/listening/scanner/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'running' in data
|
||||||
|
assert 'paused' in data
|
||||||
|
assert 'current_freq' in data
|
||||||
|
|
||||||
|
def test_presets(self, client):
|
||||||
|
"""Test scanner presets endpoint."""
|
||||||
|
response = client.get('/listening/presets')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'presets' in data
|
||||||
|
assert len(data['presets']) > 0
|
||||||
|
|
||||||
|
# Check preset structure
|
||||||
|
preset = data['presets'][0]
|
||||||
|
assert 'name' in preset
|
||||||
|
assert 'start' in preset
|
||||||
|
assert 'end' in preset
|
||||||
|
assert 'mod' in preset
|
||||||
|
|
||||||
|
def test_scanner_stop_when_not_running(self, client):
|
||||||
|
"""Test stopping scanner when not running."""
|
||||||
|
response = client.post('/listening/scanner/stop')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'stopped'
|
||||||
|
|
||||||
|
def test_activity_log(self, client):
|
||||||
|
"""Test getting activity log."""
|
||||||
|
response = client.get('/listening/scanner/log')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'log' in data
|
||||||
|
assert 'total' in data
|
||||||
|
|
||||||
|
def test_scanner_skip_when_not_running(self, client):
|
||||||
|
"""Test skip signal when scanner not running returns error."""
|
||||||
|
response = client.post('/listening/scanner/skip')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class TestAudioEndpoints:
|
||||||
|
"""Tests for audio demodulation endpoints."""
|
||||||
|
|
||||||
|
def test_audio_status(self, client):
|
||||||
|
"""Test audio status endpoint."""
|
||||||
|
response = client.get('/listening/audio/status')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert 'running' in data
|
||||||
|
assert 'frequency' in data
|
||||||
|
assert 'modulation' in data
|
||||||
|
|
||||||
|
def test_audio_stop_when_not_running(self, client):
|
||||||
|
"""Test stopping audio when not running."""
|
||||||
|
response = client.post('/listening/audio/stop')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'stopped'
|
||||||
|
|
||||||
|
def test_audio_start_missing_frequency(self, client):
|
||||||
|
"""Test starting audio without frequency returns error."""
|
||||||
|
response = client.post(
|
||||||
|
'/listening/audio/start',
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'frequency' in data['message'].lower()
|
||||||
|
|
||||||
|
def test_audio_start_invalid_modulation(self, client):
|
||||||
|
"""Test starting audio with invalid modulation returns error."""
|
||||||
|
response = client.post(
|
||||||
|
'/listening/audio/start',
|
||||||
|
data=json.dumps({
|
||||||
|
'frequency': 98.1,
|
||||||
|
'modulation': 'invalid_mode'
|
||||||
|
}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'modulation' in data['message'].lower()
|
||||||
|
|
||||||
|
def test_audio_stream_when_not_running(self, client):
|
||||||
|
"""Test audio stream when not running returns error."""
|
||||||
|
response = client.get('/listening/audio/stream')
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportEndpoints:
|
||||||
|
"""Tests for data export endpoints."""
|
||||||
|
|
||||||
|
def test_export_aircraft_json(self, client):
|
||||||
|
"""Test exporting aircraft data as JSON."""
|
||||||
|
response = client.get('/export/aircraft?format=json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type == 'application/json'
|
||||||
|
|
||||||
|
def test_export_aircraft_csv(self, client):
|
||||||
|
"""Test exporting aircraft data as CSV."""
|
||||||
|
response = client.get('/export/aircraft?format=csv')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'text/csv' in response.content_type
|
||||||
|
|
||||||
|
def test_export_wifi_json(self, client):
|
||||||
|
"""Test exporting WiFi data as JSON."""
|
||||||
|
response = client.get('/export/wifi?format=json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type == 'application/json'
|
||||||
|
|
||||||
|
def test_export_wifi_csv(self, client):
|
||||||
|
"""Test exporting WiFi data as CSV."""
|
||||||
|
response = client.get('/export/wifi?format=csv')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'text/csv' in response.content_type
|
||||||
|
|
||||||
|
def test_export_bluetooth_json(self, client):
|
||||||
|
"""Test exporting Bluetooth data as JSON."""
|
||||||
|
response = client.get('/export/bluetooth?format=json')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type == 'application/json'
|
||||||
|
|
||||||
|
def test_export_bluetooth_csv(self, client):
|
||||||
|
"""Test exporting Bluetooth data as CSV."""
|
||||||
|
response = client.get('/export/bluetooth?format=csv')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'text/csv' in response.content_type
|
||||||
120
tests/test_validation.py
Normal file
120
tests/test_validation.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Comprehensive tests for validation utilities."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from utils.validation import (
|
||||||
|
validate_frequency,
|
||||||
|
validate_gain,
|
||||||
|
validate_device_index,
|
||||||
|
validate_rtl_tcp_host,
|
||||||
|
validate_rtl_tcp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrequencyValidation:
|
||||||
|
"""Tests for frequency validation."""
|
||||||
|
|
||||||
|
def test_valid_frequencies(self):
|
||||||
|
"""Test valid frequency values."""
|
||||||
|
assert validate_frequency('152.0') == '152.0'
|
||||||
|
assert validate_frequency(152.0) == '152.0'
|
||||||
|
assert validate_frequency('1090') == '1090'
|
||||||
|
assert validate_frequency(433.92) == '433.92'
|
||||||
|
|
||||||
|
def test_frequency_range(self):
|
||||||
|
"""Test frequency range limits."""
|
||||||
|
# RTL-SDR typical range: 24MHz - 1766MHz
|
||||||
|
assert validate_frequency('24') == '24'
|
||||||
|
assert validate_frequency('1700') == '1700'
|
||||||
|
|
||||||
|
def test_invalid_frequencies(self):
|
||||||
|
"""Test invalid frequency values."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_frequency('')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_frequency('abc')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_frequency(-100)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_frequency(0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGainValidation:
|
||||||
|
"""Tests for gain validation."""
|
||||||
|
|
||||||
|
def test_valid_gains(self):
|
||||||
|
"""Test valid gain values."""
|
||||||
|
assert validate_gain('0') == '0'
|
||||||
|
assert validate_gain('40') == '40'
|
||||||
|
assert validate_gain(49.6) == '49.6'
|
||||||
|
assert validate_gain('auto') == 'auto'
|
||||||
|
|
||||||
|
def test_invalid_gains(self):
|
||||||
|
"""Test invalid gain values."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_gain(-10)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_gain(100)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_gain('invalid')
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceIndexValidation:
|
||||||
|
"""Tests for device index validation."""
|
||||||
|
|
||||||
|
def test_valid_indices(self):
|
||||||
|
"""Test valid device indices."""
|
||||||
|
assert validate_device_index('0') == '0'
|
||||||
|
assert validate_device_index(0) == '0'
|
||||||
|
assert validate_device_index('1') == '1'
|
||||||
|
assert validate_device_index(3) == '3'
|
||||||
|
|
||||||
|
def test_invalid_indices(self):
|
||||||
|
"""Test invalid device indices."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_device_index(-1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_device_index('abc')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_device_index(100)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRtlTcpHostValidation:
|
||||||
|
"""Tests for RTL-TCP host validation."""
|
||||||
|
|
||||||
|
def test_valid_hosts(self):
|
||||||
|
"""Test valid host values."""
|
||||||
|
assert validate_rtl_tcp_host('localhost') == 'localhost'
|
||||||
|
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
|
||||||
|
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
|
||||||
|
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
|
||||||
|
|
||||||
|
def test_invalid_hosts(self):
|
||||||
|
"""Test invalid host values."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_host('')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_host('invalid host with spaces')
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_host('host;rm -rf /')
|
||||||
|
|
||||||
|
|
||||||
|
class TestRtlTcpPortValidation:
|
||||||
|
"""Tests for RTL-TCP port validation."""
|
||||||
|
|
||||||
|
def test_valid_ports(self):
|
||||||
|
"""Test valid port values."""
|
||||||
|
assert validate_rtl_tcp_port(1234) == 1234
|
||||||
|
assert validate_rtl_tcp_port('1234') == 1234
|
||||||
|
assert validate_rtl_tcp_port(30003) == 30003
|
||||||
|
assert validate_rtl_tcp_port(65535) == 65535
|
||||||
|
|
||||||
|
def test_invalid_ports(self):
|
||||||
|
"""Test invalid port values."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_port(0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_port(-1)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_port(70000)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_rtl_tcp_port('abc')
|
||||||
268
utils/aircraft_db.py
Normal file
268
utils/aircraft_db.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""Aircraft database for ICAO hex to type/registration lookup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
from urllib.error import URLError
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.aircraft_db')
|
||||||
|
|
||||||
|
# Database file location (project root)
|
||||||
|
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
|
||||||
|
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
|
||||||
|
|
||||||
|
# Mictronics database URLs (raw GitHub)
|
||||||
|
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
|
||||||
|
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
|
||||||
|
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
|
||||||
|
|
||||||
|
# In-memory cache
|
||||||
|
_aircraft_cache: dict[str, dict[str, str]] = {}
|
||||||
|
_types_cache: dict[str, str] = {}
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_db_loaded = False
|
||||||
|
_db_version: str | None = None
|
||||||
|
_update_available: bool = False
|
||||||
|
_latest_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_status() -> dict[str, Any]:
|
||||||
|
"""Get current database status."""
|
||||||
|
exists = os.path.exists(DB_FILE)
|
||||||
|
meta = _load_meta()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'installed': exists,
|
||||||
|
'version': meta.get('version') if meta else None,
|
||||||
|
'downloaded': meta.get('downloaded') if meta else None,
|
||||||
|
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
|
||||||
|
'update_available': _update_available,
|
||||||
|
'latest_version': _latest_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meta() -> dict[str, Any] | None:
|
||||||
|
"""Load database metadata."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(DB_META_FILE):
|
||||||
|
with open(DB_META_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _save_meta(version: str) -> None:
|
||||||
|
"""Save database metadata."""
|
||||||
|
try:
|
||||||
|
meta = {
|
||||||
|
'version': version,
|
||||||
|
'downloaded': datetime.utcnow().isoformat() + 'Z',
|
||||||
|
}
|
||||||
|
with open(DB_META_FILE, 'w') as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error saving aircraft db meta: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_database() -> bool:
|
||||||
|
"""Load aircraft database into memory. Returns True if successful."""
|
||||||
|
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||||
|
|
||||||
|
if not os.path.exists(DB_FILE):
|
||||||
|
logger.info("Aircraft database not installed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _cache_lock:
|
||||||
|
with open(DB_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
_aircraft_cache = data.get('aircraft', {})
|
||||||
|
_types_cache = data.get('types', {})
|
||||||
|
_db_loaded = True
|
||||||
|
|
||||||
|
meta = _load_meta()
|
||||||
|
_db_version = meta.get('version') if meta else 'unknown'
|
||||||
|
|
||||||
|
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading aircraft database: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def lookup(icao: str) -> dict[str, str] | None:
|
||||||
|
"""
|
||||||
|
Look up aircraft by ICAO hex code.
|
||||||
|
|
||||||
|
Returns dict with keys: registration, type_code, type_desc
|
||||||
|
Or None if not found.
|
||||||
|
"""
|
||||||
|
if not _db_loaded:
|
||||||
|
return None
|
||||||
|
|
||||||
|
icao_upper = icao.upper()
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
aircraft = _aircraft_cache.get(icao_upper)
|
||||||
|
if not aircraft:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Database format is array: [registration, type_code, flags, ...]
|
||||||
|
# Handle both list format (from Mictronics) and dict format (legacy)
|
||||||
|
if isinstance(aircraft, list):
|
||||||
|
reg = aircraft[0] if len(aircraft) > 0 else ''
|
||||||
|
type_code = aircraft[1] if len(aircraft) > 1 else ''
|
||||||
|
else:
|
||||||
|
# Dict format fallback
|
||||||
|
reg = aircraft.get('r', '')
|
||||||
|
type_code = aircraft.get('t', '')
|
||||||
|
|
||||||
|
# Look up type description
|
||||||
|
type_desc = ''
|
||||||
|
if type_code and type_code in _types_cache:
|
||||||
|
type_desc = _types_cache[type_code]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'registration': reg,
|
||||||
|
'type_code': type_code,
|
||||||
|
'type_desc': type_desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_updates() -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check GitHub for database updates.
|
||||||
|
Returns status dict with update_available flag.
|
||||||
|
"""
|
||||||
|
global _update_available, _latest_version
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||||
|
with urlopen(req, timeout=10) as response:
|
||||||
|
commits = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
if commits and len(commits) > 0:
|
||||||
|
latest_sha = commits[0]['sha'][:8]
|
||||||
|
latest_date = commits[0]['commit']['committer']['date']
|
||||||
|
_latest_version = f"{latest_date[:10]}_{latest_sha}"
|
||||||
|
|
||||||
|
meta = _load_meta()
|
||||||
|
current_version = meta.get('version') if meta else None
|
||||||
|
|
||||||
|
_update_available = current_version != _latest_version
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'current_version': current_version,
|
||||||
|
'latest_version': _latest_version,
|
||||||
|
'update_available': _update_available,
|
||||||
|
}
|
||||||
|
except URLError as e:
|
||||||
|
logger.warning(f"Failed to check for updates: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error checking for updates: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
return {'success': False, 'error': 'Unknown error'}
|
||||||
|
|
||||||
|
|
||||||
|
def download_database(progress_callback=None) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Download latest aircraft database from Mictronics repo.
|
||||||
|
Returns status dict.
|
||||||
|
"""
|
||||||
|
global _update_available
|
||||||
|
|
||||||
|
try:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('Downloading aircraft database...')
|
||||||
|
|
||||||
|
# Download aircraft database
|
||||||
|
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||||
|
with urlopen(req, timeout=60) as response:
|
||||||
|
aircraft_data = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('Downloading type codes...')
|
||||||
|
|
||||||
|
# Download types database
|
||||||
|
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||||
|
with urlopen(req, timeout=30) as response:
|
||||||
|
types_data = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback('Processing database...')
|
||||||
|
|
||||||
|
# Combine into single file
|
||||||
|
combined = {
|
||||||
|
'aircraft': aircraft_data,
|
||||||
|
'types': types_data,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save to file
|
||||||
|
with open(DB_FILE, 'w') as f:
|
||||||
|
json.dump(combined, f, separators=(',', ':')) # Compact JSON
|
||||||
|
|
||||||
|
# Get version from GitHub
|
||||||
|
version = datetime.utcnow().strftime('%Y-%m-%d')
|
||||||
|
try:
|
||||||
|
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||||
|
with urlopen(req, timeout=10) as response:
|
||||||
|
commits = json.loads(response.read().decode('utf-8'))
|
||||||
|
if commits:
|
||||||
|
sha = commits[0]['sha'][:8]
|
||||||
|
date = commits[0]['commit']['committer']['date'][:10]
|
||||||
|
version = f"{date}_{sha}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_save_meta(version)
|
||||||
|
_update_available = False
|
||||||
|
|
||||||
|
# Reload into memory
|
||||||
|
load_database()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
|
||||||
|
'version': version,
|
||||||
|
}
|
||||||
|
|
||||||
|
except URLError as e:
|
||||||
|
logger.error(f"Download failed: {e}")
|
||||||
|
return {'success': False, 'error': f'Download failed: {e}'}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error downloading database: {e}")
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def delete_database() -> dict[str, Any]:
|
||||||
|
"""Delete local database files."""
|
||||||
|
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _cache_lock:
|
||||||
|
_aircraft_cache = {}
|
||||||
|
_types_cache = {}
|
||||||
|
_db_loaded = False
|
||||||
|
_db_version = None
|
||||||
|
|
||||||
|
if os.path.exists(DB_FILE):
|
||||||
|
os.remove(DB_FILE)
|
||||||
|
if os.path.exists(DB_META_FILE):
|
||||||
|
os.remove(DB_META_FILE)
|
||||||
|
|
||||||
|
return {'success': True, 'message': 'Database deleted'}
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': str(e)}
|
||||||
@@ -99,6 +99,23 @@ class DataStore:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return key in self.data
|
return key in self.data
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
"""Get an entry using subscript notation."""
|
||||||
|
with self._lock:
|
||||||
|
return self.data[key]
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: Any) -> None:
|
||||||
|
"""Set an entry using subscript notation."""
|
||||||
|
with self._lock:
|
||||||
|
self.data[key] = value
|
||||||
|
self.timestamps[key] = time.time()
|
||||||
|
|
||||||
|
def __delitem__(self, key: str) -> None:
|
||||||
|
"""Delete an entry using subscript notation."""
|
||||||
|
with self._lock:
|
||||||
|
del self.data[key]
|
||||||
|
del self.timestamps[key]
|
||||||
|
|
||||||
def cleanup(self) -> int:
|
def cleanup(self) -> int:
|
||||||
"""
|
"""
|
||||||
Remove entries older than max_age.
|
Remove entries older than max_age.
|
||||||
|
|||||||
213
utils/constants.py
Normal file
213
utils/constants.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
INTERCEPT - Constants and Magic Numbers
|
||||||
|
|
||||||
|
Centralized location for all hardcoded values used throughout the application.
|
||||||
|
This improves maintainability and makes the codebase self-documenting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# NETWORK PORTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ADS-B SBS data output port (dump1090 default)
|
||||||
|
ADSB_SBS_PORT = 30003
|
||||||
|
|
||||||
|
# GPS daemon port (gpsd default)
|
||||||
|
GPSD_PORT = 2947
|
||||||
|
|
||||||
|
# RTL-TCP server port (rtl_tcp default)
|
||||||
|
RTL_TCP_PORT = 1234
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROCESS TIMEOUTS (seconds)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# General process termination timeout
|
||||||
|
PROCESS_TERMINATE_TIMEOUT = 2
|
||||||
|
|
||||||
|
# ADS-B process termination (dump1090 needs longer)
|
||||||
|
ADSB_TERMINATE_TIMEOUT = 5
|
||||||
|
|
||||||
|
# WiFi process termination (airodump-ng)
|
||||||
|
WIFI_TERMINATE_TIMEOUT = 3
|
||||||
|
|
||||||
|
# Bluetooth process termination
|
||||||
|
BT_TERMINATE_TIMEOUT = 3
|
||||||
|
|
||||||
|
# PMKID process termination
|
||||||
|
PMKID_TERMINATE_TIMEOUT = 5
|
||||||
|
|
||||||
|
# Socket connection timeout
|
||||||
|
SOCKET_CONNECT_TIMEOUT = 2
|
||||||
|
|
||||||
|
# SBS stream socket timeout
|
||||||
|
SBS_SOCKET_TIMEOUT = 5
|
||||||
|
|
||||||
|
# Subprocess command timeout (short operations)
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT = 5
|
||||||
|
|
||||||
|
# Subprocess command timeout (medium operations)
|
||||||
|
SUBPROCESS_TIMEOUT_MEDIUM = 10
|
||||||
|
|
||||||
|
# Subprocess command timeout (long operations like airmon-ng)
|
||||||
|
SUBPROCESS_TIMEOUT_LONG = 15
|
||||||
|
|
||||||
|
# External HTTP request timeout (TLE fetching, etc.)
|
||||||
|
HTTP_REQUEST_TIMEOUT = 10
|
||||||
|
|
||||||
|
# Deauth command timeout
|
||||||
|
DEAUTH_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Service enumeration timeout (sdptool browse)
|
||||||
|
SERVICE_ENUM_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SSE (Server-Sent Events) SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Keepalive interval for SSE streams (seconds)
|
||||||
|
SSE_KEEPALIVE_INTERVAL = 30.0
|
||||||
|
|
||||||
|
# Queue get timeout for SSE generators (seconds)
|
||||||
|
SSE_QUEUE_TIMEOUT = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATA RETENTION / CLEANUP (seconds)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum age for aircraft data before cleanup
|
||||||
|
MAX_AIRCRAFT_AGE_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Maximum age for WiFi network data before cleanup
|
||||||
|
MAX_WIFI_NETWORK_AGE_SECONDS = 600 # 10 minutes
|
||||||
|
|
||||||
|
# Maximum age for Bluetooth device data before cleanup
|
||||||
|
MAX_BT_DEVICE_AGE_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
# ADS-B queue batch update interval
|
||||||
|
ADSB_UPDATE_INTERVAL = 1.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# QUEUE LIMITS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum queue size for all data queues
|
||||||
|
QUEUE_MAX_SIZE = 1000
|
||||||
|
|
||||||
|
# GPS queue size (smaller, more frequent updates)
|
||||||
|
GPS_QUEUE_MAX_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATA PARSING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# WiFi CSV parse interval (seconds)
|
||||||
|
WIFI_CSV_PARSE_INTERVAL = 2.0
|
||||||
|
|
||||||
|
# Minimum time before warning about no CSV data
|
||||||
|
WIFI_CSV_TIMEOUT_WARNING = 5.0
|
||||||
|
|
||||||
|
# Socket receive buffer size
|
||||||
|
SOCKET_BUFFER_SIZE = 4096
|
||||||
|
|
||||||
|
# PTY read buffer size
|
||||||
|
PTY_BUFFER_SIZE = 1024
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# EXTERNAL SERVICE LIMITS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maximum response size for external HTTP requests (bytes)
|
||||||
|
MAX_HTTP_RESPONSE_SIZE = 1024 * 1024 # 1 MB
|
||||||
|
|
||||||
|
# Deauth packet count limits
|
||||||
|
MIN_DEAUTH_COUNT = 1
|
||||||
|
MAX_DEAUTH_COUNT = 100
|
||||||
|
DEFAULT_DEAUTH_COUNT = 5
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# VALIDATION LIMITS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Squelch range
|
||||||
|
MIN_SQUELCH = 0
|
||||||
|
MAX_SQUELCH = 1000
|
||||||
|
|
||||||
|
# Valid GPS baudrates
|
||||||
|
VALID_GPS_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||||
|
|
||||||
|
# Port range
|
||||||
|
MIN_PORT = 1
|
||||||
|
MAX_PORT = 65535
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SATELLITE TRACKING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default observer location (London)
|
||||||
|
DEFAULT_LATITUDE = 51.5074
|
||||||
|
DEFAULT_LONGITUDE = -0.1278
|
||||||
|
|
||||||
|
# Allowed TLE hosts for security
|
||||||
|
ALLOWED_TLE_HOSTS = [
|
||||||
|
'celestrak.org',
|
||||||
|
'celestrak.com',
|
||||||
|
'www.celestrak.org',
|
||||||
|
'www.celestrak.com'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Earth radius (km) - WGS84 mean
|
||||||
|
EARTH_RADIUS_KM = 6371
|
||||||
|
|
||||||
|
# Trajectory calculation points
|
||||||
|
TRAJECTORY_POINTS = 30
|
||||||
|
GROUND_TRACK_POINTS = 60
|
||||||
|
ORBIT_TRACK_RANGE_MINUTES = 45
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SLEEP/DELAY TIMES (seconds)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Wait after starting process before checking status
|
||||||
|
PROCESS_START_WAIT = 0.5
|
||||||
|
|
||||||
|
# Wait after dump1090 start before connecting
|
||||||
|
DUMP1090_START_WAIT = 3.0
|
||||||
|
|
||||||
|
# Delay between monitor mode operations
|
||||||
|
MONITOR_MODE_DELAY = 1.0
|
||||||
|
|
||||||
|
# Bluetooth adapter reset delays
|
||||||
|
BT_RESET_DELAY = 0.5
|
||||||
|
BT_ADAPTER_DOWN_WAIT = 1.0
|
||||||
|
|
||||||
|
# SBS reconnection delay on error
|
||||||
|
SBS_RECONNECT_DELAY = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FILE PATHS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default pager log file
|
||||||
|
DEFAULT_PAGER_LOG_FILE = 'pager_messages.log'
|
||||||
|
|
||||||
|
# WiFi capture temp path prefix
|
||||||
|
WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi'
|
||||||
|
|
||||||
|
# Handshake capture path prefix
|
||||||
|
HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
|
||||||
|
|
||||||
|
# PMKID capture path prefix
|
||||||
|
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
|
||||||
313
utils/correlation.py
Normal file
313
utils/correlation.py
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Device correlation engine for matching WiFi and Bluetooth devices.
|
||||||
|
|
||||||
|
Uses timing-based correlation to identify when WiFi and Bluetooth
|
||||||
|
signals likely belong to the same physical device.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.database import add_correlation, get_correlations as db_get_correlations
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.correlation')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceObservation:
|
||||||
|
"""A single observation of a device."""
|
||||||
|
mac: str
|
||||||
|
first_seen: datetime
|
||||||
|
last_seen: datetime
|
||||||
|
rssi: int | None = None
|
||||||
|
name: str | None = None
|
||||||
|
manufacturer: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCorrelator:
|
||||||
|
"""
|
||||||
|
Correlates WiFi and Bluetooth devices based on timing patterns.
|
||||||
|
|
||||||
|
Devices are considered potentially correlated if:
|
||||||
|
1. They appear within a short time window of each other
|
||||||
|
2. They have similar signal strength patterns (optional)
|
||||||
|
3. They share the same OUI/manufacturer (bonus confidence)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
time_window_seconds: int = 30,
|
||||||
|
min_confidence: float = 0.5,
|
||||||
|
rssi_threshold: int = 20
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize correlator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_window_seconds: Max time difference for correlation (default 30s)
|
||||||
|
min_confidence: Minimum confidence score to report (default 0.5)
|
||||||
|
rssi_threshold: Max RSSI difference for signal-based correlation
|
||||||
|
"""
|
||||||
|
self.time_window = timedelta(seconds=time_window_seconds)
|
||||||
|
self.min_confidence = min_confidence
|
||||||
|
self.rssi_threshold = rssi_threshold
|
||||||
|
|
||||||
|
def correlate(
|
||||||
|
self,
|
||||||
|
wifi_devices: dict[str, dict[str, Any]],
|
||||||
|
bt_devices: dict[str, dict[str, Any]]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Find correlations between WiFi and Bluetooth devices.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wifi_devices: Dict of WiFi devices keyed by MAC
|
||||||
|
bt_devices: Dict of Bluetooth devices keyed by MAC
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of correlation results with confidence scores
|
||||||
|
"""
|
||||||
|
correlations = []
|
||||||
|
|
||||||
|
for wifi_mac, wifi_data in wifi_devices.items():
|
||||||
|
wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi')
|
||||||
|
if not wifi_obs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for bt_mac, bt_data in bt_devices.items():
|
||||||
|
bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth')
|
||||||
|
if not bt_obs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
confidence = self._calculate_confidence(wifi_obs, bt_obs)
|
||||||
|
|
||||||
|
if confidence >= self.min_confidence:
|
||||||
|
correlations.append({
|
||||||
|
'wifi_mac': wifi_mac,
|
||||||
|
'wifi_name': wifi_obs.name,
|
||||||
|
'bt_mac': bt_mac,
|
||||||
|
'bt_name': bt_obs.name,
|
||||||
|
'confidence': round(confidence, 2),
|
||||||
|
'reason': self._get_correlation_reason(wifi_obs, bt_obs)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Persist high-confidence correlations
|
||||||
|
if confidence >= 0.7:
|
||||||
|
try:
|
||||||
|
add_correlation(
|
||||||
|
wifi_mac=wifi_mac,
|
||||||
|
bt_mac=bt_mac,
|
||||||
|
confidence=confidence,
|
||||||
|
metadata={
|
||||||
|
'wifi_name': wifi_obs.name,
|
||||||
|
'bt_name': bt_obs.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to persist correlation: {e}")
|
||||||
|
|
||||||
|
# Sort by confidence (highest first)
|
||||||
|
correlations.sort(key=lambda x: x['confidence'], reverse=True)
|
||||||
|
|
||||||
|
return correlations
|
||||||
|
|
||||||
|
def _to_observation(
|
||||||
|
self,
|
||||||
|
mac: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
device_type: str
|
||||||
|
) -> DeviceObservation | None:
|
||||||
|
"""Convert device dict to observation."""
|
||||||
|
try:
|
||||||
|
# Handle different timestamp formats
|
||||||
|
first_seen = data.get('first_seen') or data.get('firstSeen')
|
||||||
|
last_seen = data.get('last_seen') or data.get('lastSeen')
|
||||||
|
|
||||||
|
if isinstance(first_seen, str):
|
||||||
|
first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00'))
|
||||||
|
elif isinstance(first_seen, (int, float)):
|
||||||
|
first_seen = datetime.fromtimestamp(first_seen / 1000)
|
||||||
|
else:
|
||||||
|
first_seen = datetime.now()
|
||||||
|
|
||||||
|
if isinstance(last_seen, str):
|
||||||
|
last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
|
||||||
|
elif isinstance(last_seen, (int, float)):
|
||||||
|
last_seen = datetime.fromtimestamp(last_seen / 1000)
|
||||||
|
else:
|
||||||
|
last_seen = datetime.now()
|
||||||
|
|
||||||
|
# Get RSSI (different field names)
|
||||||
|
rssi = data.get('rssi') or data.get('power') or data.get('signal')
|
||||||
|
if rssi is not None:
|
||||||
|
rssi = int(rssi)
|
||||||
|
|
||||||
|
# Get name
|
||||||
|
name = data.get('name') or data.get('essid') or data.get('ssid')
|
||||||
|
|
||||||
|
# Get manufacturer
|
||||||
|
manufacturer = data.get('manufacturer') or data.get('vendor')
|
||||||
|
|
||||||
|
return DeviceObservation(
|
||||||
|
mac=mac,
|
||||||
|
first_seen=first_seen,
|
||||||
|
last_seen=last_seen,
|
||||||
|
rssi=rssi,
|
||||||
|
name=name,
|
||||||
|
manufacturer=manufacturer
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to parse device {mac}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _calculate_confidence(
|
||||||
|
self,
|
||||||
|
wifi: DeviceObservation,
|
||||||
|
bt: DeviceObservation
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calculate correlation confidence score.
|
||||||
|
|
||||||
|
Score components:
|
||||||
|
- Timing overlap: 0.0-0.5 (primary factor)
|
||||||
|
- Same manufacturer: +0.2
|
||||||
|
- Similar RSSI: +0.1
|
||||||
|
- Both named: +0.1
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confidence score 0.0-1.0
|
||||||
|
"""
|
||||||
|
confidence = 0.0
|
||||||
|
|
||||||
|
# Timing correlation (most important)
|
||||||
|
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
|
||||||
|
if time_diff <= self.time_window.total_seconds():
|
||||||
|
# Linear decay from 0.5 to 0.0 as time difference increases
|
||||||
|
timing_score = 0.5 * (1 - time_diff / self.time_window.total_seconds())
|
||||||
|
confidence += timing_score
|
||||||
|
else:
|
||||||
|
# Check if observation windows overlap at all
|
||||||
|
wifi_end = wifi.last_seen
|
||||||
|
bt_end = bt.last_seen
|
||||||
|
|
||||||
|
# If observation periods overlap
|
||||||
|
if wifi.first_seen <= bt_end and bt.first_seen <= wifi_end:
|
||||||
|
confidence += 0.25 # Partial credit for overlapping presence
|
||||||
|
|
||||||
|
# Manufacturer match
|
||||||
|
if wifi.manufacturer and bt.manufacturer:
|
||||||
|
wifi_mfg = wifi.manufacturer.lower()
|
||||||
|
bt_mfg = bt.manufacturer.lower()
|
||||||
|
if wifi_mfg == bt_mfg:
|
||||||
|
confidence += 0.2
|
||||||
|
elif wifi_mfg[:5] == bt_mfg[:5]: # Partial match
|
||||||
|
confidence += 0.1
|
||||||
|
|
||||||
|
# OUI match (first 3 octets of MAC)
|
||||||
|
wifi_oui = wifi.mac[:8].upper()
|
||||||
|
bt_oui = bt.mac[:8].upper()
|
||||||
|
if wifi_oui == bt_oui:
|
||||||
|
confidence += 0.15
|
||||||
|
|
||||||
|
# RSSI similarity
|
||||||
|
if wifi.rssi is not None and bt.rssi is not None:
|
||||||
|
rssi_diff = abs(wifi.rssi - bt.rssi)
|
||||||
|
if rssi_diff <= self.rssi_threshold:
|
||||||
|
rssi_score = 0.1 * (1 - rssi_diff / self.rssi_threshold)
|
||||||
|
confidence += rssi_score
|
||||||
|
|
||||||
|
# Both have names (suggests user device)
|
||||||
|
if wifi.name and bt.name:
|
||||||
|
confidence += 0.05
|
||||||
|
|
||||||
|
return min(confidence, 1.0)
|
||||||
|
|
||||||
|
def _get_correlation_reason(
|
||||||
|
self,
|
||||||
|
wifi: DeviceObservation,
|
||||||
|
bt: DeviceObservation
|
||||||
|
) -> str:
|
||||||
|
"""Generate human-readable reason for correlation."""
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
|
||||||
|
if time_diff <= self.time_window.total_seconds():
|
||||||
|
reasons.append(f"appeared within {int(time_diff)}s")
|
||||||
|
|
||||||
|
wifi_oui = wifi.mac[:8].upper()
|
||||||
|
bt_oui = bt.mac[:8].upper()
|
||||||
|
if wifi_oui == bt_oui:
|
||||||
|
reasons.append("same OUI")
|
||||||
|
|
||||||
|
if wifi.manufacturer and bt.manufacturer:
|
||||||
|
if wifi.manufacturer.lower() == bt.manufacturer.lower():
|
||||||
|
reasons.append(f"same manufacturer ({wifi.manufacturer})")
|
||||||
|
|
||||||
|
if wifi.rssi is not None and bt.rssi is not None:
|
||||||
|
rssi_diff = abs(wifi.rssi - bt.rssi)
|
||||||
|
if rssi_diff <= self.rssi_threshold:
|
||||||
|
reasons.append("similar signal strength")
|
||||||
|
|
||||||
|
return "; ".join(reasons) if reasons else "timing overlap"
|
||||||
|
|
||||||
|
|
||||||
|
# Global correlator instance
|
||||||
|
correlator = DeviceCorrelator()
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlations(
|
||||||
|
wifi_devices: dict[str, dict] | None = None,
|
||||||
|
bt_devices: dict[str, dict] | None = None,
|
||||||
|
min_confidence: float = 0.5,
|
||||||
|
include_historical: bool = True
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get device correlations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wifi_devices: Current WiFi devices (or None to use only historical)
|
||||||
|
bt_devices: Current Bluetooth devices (or None to use only historical)
|
||||||
|
min_confidence: Minimum confidence threshold
|
||||||
|
include_historical: Include correlations from database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of correlations sorted by confidence
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Get live correlations
|
||||||
|
if wifi_devices and bt_devices:
|
||||||
|
correlator.min_confidence = min_confidence
|
||||||
|
results.extend(correlator.correlate(wifi_devices, bt_devices))
|
||||||
|
|
||||||
|
# Get historical correlations from database
|
||||||
|
if include_historical:
|
||||||
|
try:
|
||||||
|
historical = db_get_correlations(min_confidence)
|
||||||
|
for h in historical:
|
||||||
|
# Avoid duplicates
|
||||||
|
existing = next(
|
||||||
|
(r for r in results
|
||||||
|
if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
results.append({
|
||||||
|
'wifi_mac': h['wifi_mac'],
|
||||||
|
'bt_mac': h['bt_mac'],
|
||||||
|
'confidence': h['confidence'],
|
||||||
|
'reason': 'historical correlation',
|
||||||
|
'first_seen': h['first_seen'],
|
||||||
|
'last_seen': h['last_seen']
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get historical correlations: {e}")
|
||||||
|
|
||||||
|
# Sort by confidence
|
||||||
|
results.sort(key=lambda x: x['confidence'], reverse=True)
|
||||||
|
|
||||||
|
return results
|
||||||
351
utils/database.py
Normal file
351
utils/database.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
SQLite database utilities for persistent settings storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.database')
|
||||||
|
|
||||||
|
# Database file location
|
||||||
|
DB_DIR = Path(__file__).parent.parent / 'instance'
|
||||||
|
DB_PATH = DB_DIR / 'intercept.db'
|
||||||
|
|
||||||
|
# Thread-local storage for connections
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_path() -> Path:
|
||||||
|
"""Get the database file path, creating directory if needed."""
|
||||||
|
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
"""Get a thread-local database connection."""
|
||||||
|
if not hasattr(_local, 'connection') or _local.connection is None:
|
||||||
|
db_path = get_db_path()
|
||||||
|
_local.connection = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
_local.connection.row_factory = sqlite3.Row
|
||||||
|
# Enable foreign keys
|
||||||
|
_local.connection.execute('PRAGMA foreign_keys = ON')
|
||||||
|
return _local.connection
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
"""Context manager for database operations."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Initialize the database schema."""
|
||||||
|
db_path = get_db_path()
|
||||||
|
logger.info(f"Initializing database at {db_path}")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
# Settings table for key-value storage
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type TEXT DEFAULT 'string',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Signal history table for graphs
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS signal_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
signal_strength REAL,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create index for faster queries
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device
|
||||||
|
ON signal_history(mode, device_id, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Device correlation table
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS device_correlations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wifi_mac TEXT,
|
||||||
|
bt_mac TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
metadata TEXT,
|
||||||
|
UNIQUE(wifi_mac, bt_mac)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def close_db() -> None:
|
||||||
|
"""Close the thread-local database connection."""
|
||||||
|
if hasattr(_local, 'connection') and _local.connection is not None:
|
||||||
|
_local.connection.close()
|
||||||
|
_local.connection = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Settings Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_setting(key: str, default: Any = None) -> Any:
|
||||||
|
"""
|
||||||
|
Get a setting value by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key
|
||||||
|
default: Default value if not found
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Setting value (auto-converted from JSON for complex types)
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT value, value_type FROM settings WHERE key = ?',
|
||||||
|
(key,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return default
|
||||||
|
|
||||||
|
value, value_type = row['value'], row['value_type']
|
||||||
|
|
||||||
|
# Convert based on type
|
||||||
|
if value_type == 'json':
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return default
|
||||||
|
elif value_type == 'int':
|
||||||
|
return int(value)
|
||||||
|
elif value_type == 'float':
|
||||||
|
return float(value)
|
||||||
|
elif value_type == 'bool':
|
||||||
|
return value.lower() in ('true', '1', 'yes')
|
||||||
|
else:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(key: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Set a setting value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key
|
||||||
|
value: Setting value (will be JSON-encoded for complex types)
|
||||||
|
"""
|
||||||
|
# Determine value type and string representation
|
||||||
|
if isinstance(value, bool):
|
||||||
|
value_type = 'bool'
|
||||||
|
str_value = 'true' if value else 'false'
|
||||||
|
elif isinstance(value, int):
|
||||||
|
value_type = 'int'
|
||||||
|
str_value = str(value)
|
||||||
|
elif isinstance(value, float):
|
||||||
|
value_type = 'float'
|
||||||
|
str_value = str(value)
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
value_type = 'json'
|
||||||
|
str_value = json.dumps(value)
|
||||||
|
else:
|
||||||
|
value_type = 'string'
|
||||||
|
str_value = str(value)
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO settings (key, value, value_type, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
value_type = excluded.value_type,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
''', (key, str_value, value_type))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_setting(key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a setting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Setting key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if setting was deleted, False if not found
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,))
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_settings() -> dict[str, Any]:
|
||||||
|
"""Get all settings as a dictionary."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('SELECT key, value, value_type FROM settings')
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
for row in cursor:
|
||||||
|
key, value, value_type = row['key'], row['value'], row['value_type']
|
||||||
|
|
||||||
|
if value_type == 'json':
|
||||||
|
try:
|
||||||
|
settings[key] = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
settings[key] = value
|
||||||
|
elif value_type == 'int':
|
||||||
|
settings[key] = int(value)
|
||||||
|
elif value_type == 'float':
|
||||||
|
settings[key] = float(value)
|
||||||
|
elif value_type == 'bool':
|
||||||
|
settings[key] = value.lower() in ('true', '1', 'yes')
|
||||||
|
else:
|
||||||
|
settings[key] = value
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Signal History Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def add_signal_reading(
|
||||||
|
mode: str,
|
||||||
|
device_id: str,
|
||||||
|
signal_strength: float,
|
||||||
|
metadata: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Add a signal strength reading."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO signal_history (mode, device_id, signal_strength, metadata)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None))
|
||||||
|
|
||||||
|
|
||||||
|
def get_signal_history(
|
||||||
|
mode: str,
|
||||||
|
device_id: str,
|
||||||
|
limit: int = 100,
|
||||||
|
since_minutes: int = 60
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get signal history for a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Mode (wifi, bluetooth, adsb, etc.)
|
||||||
|
device_id: Device identifier (MAC, ICAO, etc.)
|
||||||
|
limit: Maximum number of readings
|
||||||
|
since_minutes: Only get readings from last N minutes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of signal readings with timestamp
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT signal_strength, timestamp, metadata
|
||||||
|
FROM signal_history
|
||||||
|
WHERE mode = ? AND device_id = ?
|
||||||
|
AND timestamp > datetime('now', ?)
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
''', (mode, device_id, f'-{since_minutes} minutes', limit))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor:
|
||||||
|
results.append({
|
||||||
|
'signal': row['signal_strength'],
|
||||||
|
'timestamp': row['timestamp'],
|
||||||
|
'metadata': json.loads(row['metadata']) if row['metadata'] else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return list(reversed(results)) # Return in chronological order
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_old_signal_history(max_age_hours: int = 24) -> int:
|
||||||
|
"""
|
||||||
|
Remove old signal history entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_hours: Maximum age in hours
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of deleted entries
|
||||||
|
"""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
DELETE FROM signal_history
|
||||||
|
WHERE timestamp < datetime('now', ?)
|
||||||
|
''', (f'-{max_age_hours} hours',))
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Device Correlation Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def add_correlation(
|
||||||
|
wifi_mac: str,
|
||||||
|
bt_mac: str,
|
||||||
|
confidence: float,
|
||||||
|
metadata: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Add or update a device correlation."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET
|
||||||
|
confidence = excluded.confidence,
|
||||||
|
last_seen = CURRENT_TIMESTAMP,
|
||||||
|
metadata = excluded.metadata
|
||||||
|
''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None))
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlations(min_confidence: float = 0.5) -> list[dict]:
|
||||||
|
"""Get all device correlations above minimum confidence."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata
|
||||||
|
FROM device_correlations
|
||||||
|
WHERE confidence >= ?
|
||||||
|
ORDER BY confidence DESC
|
||||||
|
''', (min_confidence,))
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor:
|
||||||
|
results.append({
|
||||||
|
'wifi_mac': row['wifi_mac'],
|
||||||
|
'bt_mac': row['bt_mac'],
|
||||||
|
'confidence': row['confidence'],
|
||||||
|
'first_seen': row['first_seen'],
|
||||||
|
'last_seen': row['last_seen'],
|
||||||
|
'metadata': json.loads(row['metadata']) if row['metadata'] else None
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -1,15 +1,35 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.dependencies')
|
logger = logging.getLogger('intercept.dependencies')
|
||||||
|
|
||||||
|
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||||
|
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||||
|
|
||||||
|
|
||||||
def check_tool(name: str) -> bool:
|
def check_tool(name: str) -> bool:
|
||||||
"""Check if a tool is installed."""
|
"""Check if a tool is installed."""
|
||||||
return shutil.which(name) is not None
|
return get_tool_path(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_path(name: str) -> str | None:
|
||||||
|
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||||
|
# First check standard PATH
|
||||||
|
path = shutil.which(name)
|
||||||
|
if path:
|
||||||
|
return path
|
||||||
|
|
||||||
|
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||||
|
for extra_path in EXTRA_TOOL_PATHS:
|
||||||
|
full_path = os.path.join(extra_path, name)
|
||||||
|
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Comprehensive tool dependency definitions
|
# Comprehensive tool dependency definitions
|
||||||
|
|||||||
511
utils/gps.py
511
utils/gps.py
@@ -1,32 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
GPS dongle support for INTERCEPT.
|
GPS support for INTERCEPT via gpsd daemon.
|
||||||
|
|
||||||
Provides detection and reading of USB GPS dongles via serial port.
|
Provides GPS location data by connecting to the gpsd daemon.
|
||||||
Parses NMEA sentences to extract location data.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import glob
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Callable, Union
|
from typing import Optional, Callable
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.gps')
|
logger = logging.getLogger('intercept.gps')
|
||||||
|
|
||||||
# Try to import serial, but don't fail if not available
|
|
||||||
try:
|
|
||||||
import serial
|
|
||||||
SERIAL_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
SERIAL_AVAILABLE = False
|
|
||||||
logger.warning("pyserial not installed - GPS dongle support disabled")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GPSPosition:
|
class GPSPosition:
|
||||||
@@ -34,10 +22,10 @@ class GPSPosition:
|
|||||||
latitude: float
|
latitude: float
|
||||||
longitude: float
|
longitude: float
|
||||||
altitude: Optional[float] = None
|
altitude: Optional[float] = None
|
||||||
speed: Optional[float] = None # knots
|
speed: Optional[float] = None # m/s
|
||||||
heading: Optional[float] = None # degrees
|
heading: Optional[float] = None # degrees
|
||||||
satellites: Optional[int] = None
|
satellites: Optional[int] = None
|
||||||
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
|
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||||
timestamp: Optional[datetime] = None
|
timestamp: Optional[datetime] = None
|
||||||
device: Optional[str] = None
|
device: Optional[str] = None
|
||||||
|
|
||||||
@@ -56,407 +44,6 @@ class GPSPosition:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def detect_gps_devices() -> list[dict]:
|
|
||||||
"""
|
|
||||||
Detect potential GPS serial devices.
|
|
||||||
|
|
||||||
Returns a list of device info dictionaries.
|
|
||||||
"""
|
|
||||||
devices = []
|
|
||||||
|
|
||||||
# Common GPS device patterns by platform
|
|
||||||
patterns = []
|
|
||||||
|
|
||||||
if os.name == 'posix':
|
|
||||||
# Linux
|
|
||||||
patterns.extend([
|
|
||||||
'/dev/ttyUSB*', # USB serial adapters
|
|
||||||
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
|
|
||||||
'/dev/gps*', # gpsd symlinks
|
|
||||||
])
|
|
||||||
# macOS
|
|
||||||
patterns.extend([
|
|
||||||
'/dev/tty.usbserial*',
|
|
||||||
'/dev/tty.usbmodem*',
|
|
||||||
'/dev/cu.usbserial*',
|
|
||||||
'/dev/cu.usbmodem*',
|
|
||||||
])
|
|
||||||
|
|
||||||
for pattern in patterns:
|
|
||||||
for path in glob.glob(pattern):
|
|
||||||
# Try to get device info
|
|
||||||
device_info = {
|
|
||||||
'path': path,
|
|
||||||
'name': os.path.basename(path),
|
|
||||||
'type': 'serial',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if it's readable
|
|
||||||
if os.access(path, os.R_OK):
|
|
||||||
device_info['accessible'] = True
|
|
||||||
else:
|
|
||||||
device_info['accessible'] = False
|
|
||||||
device_info['error'] = 'Permission denied'
|
|
||||||
|
|
||||||
devices.append(device_info)
|
|
||||||
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Parse NMEA coordinate format to decimal degrees.
|
|
||||||
|
|
||||||
NMEA format: DDDMM.MMMM or DDMM.MMMM
|
|
||||||
"""
|
|
||||||
if not coord or not direction:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Find the decimal point
|
|
||||||
dot_pos = coord.index('.')
|
|
||||||
|
|
||||||
# Degrees are everything before the last 2 digits before decimal
|
|
||||||
degrees = int(coord[:dot_pos - 2])
|
|
||||||
minutes = float(coord[dot_pos - 2:])
|
|
||||||
|
|
||||||
result = degrees + (minutes / 60.0)
|
|
||||||
|
|
||||||
# Apply direction
|
|
||||||
if direction in ('S', 'W'):
|
|
||||||
result = -result
|
|
||||||
|
|
||||||
return result
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
|
|
||||||
"""
|
|
||||||
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
|
|
||||||
|
|
||||||
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
|
|
||||||
"""
|
|
||||||
if len(parts) < 10:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
fix_quality = int(parts[6]) if parts[6] else 0
|
|
||||||
|
|
||||||
# No fix
|
|
||||||
if fix_quality == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
lat = parse_nmea_coordinate(parts[2], parts[3])
|
|
||||||
lon = parse_nmea_coordinate(parts[4], parts[5])
|
|
||||||
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Parse optional fields
|
|
||||||
satellites = int(parts[7]) if parts[7] else None
|
|
||||||
altitude = float(parts[9]) if parts[9] else None
|
|
||||||
|
|
||||||
# Parse time (HHMMSS.sss)
|
|
||||||
timestamp = None
|
|
||||||
if parts[1]:
|
|
||||||
try:
|
|
||||||
time_str = parts[1].split('.')[0]
|
|
||||||
if len(time_str) >= 6:
|
|
||||||
now = datetime.utcnow()
|
|
||||||
timestamp = now.replace(
|
|
||||||
hour=int(time_str[0:2]),
|
|
||||||
minute=int(time_str[2:4]),
|
|
||||||
second=int(time_str[4:6]),
|
|
||||||
microsecond=0
|
|
||||||
)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return GPSPosition(
|
|
||||||
latitude=lat,
|
|
||||||
longitude=lon,
|
|
||||||
altitude=altitude,
|
|
||||||
satellites=satellites,
|
|
||||||
fix_quality=fix_quality,
|
|
||||||
timestamp=timestamp,
|
|
||||||
)
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
logger.debug(f"GGA parse error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
|
|
||||||
"""
|
|
||||||
Parse GPRMC/GNRMC sentence (Recommended Minimum).
|
|
||||||
|
|
||||||
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
|
|
||||||
"""
|
|
||||||
if len(parts) < 8:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check status (A=active/valid, V=void/invalid)
|
|
||||||
if parts[2] != 'A':
|
|
||||||
return None
|
|
||||||
|
|
||||||
lat = parse_nmea_coordinate(parts[3], parts[4])
|
|
||||||
lon = parse_nmea_coordinate(parts[5], parts[6])
|
|
||||||
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Parse optional fields
|
|
||||||
speed = float(parts[7]) if parts[7] else None # knots
|
|
||||||
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
|
|
||||||
|
|
||||||
# Parse timestamp
|
|
||||||
timestamp = None
|
|
||||||
if parts[1] and len(parts) > 9 and parts[9]:
|
|
||||||
try:
|
|
||||||
time_str = parts[1].split('.')[0]
|
|
||||||
date_str = parts[9]
|
|
||||||
if len(time_str) >= 6 and len(date_str) >= 6:
|
|
||||||
timestamp = datetime(
|
|
||||||
year=2000 + int(date_str[4:6]),
|
|
||||||
month=int(date_str[2:4]),
|
|
||||||
day=int(date_str[0:2]),
|
|
||||||
hour=int(time_str[0:2]),
|
|
||||||
minute=int(time_str[2:4]),
|
|
||||||
second=int(time_str[4:6]),
|
|
||||||
)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return GPSPosition(
|
|
||||||
latitude=lat,
|
|
||||||
longitude=lon,
|
|
||||||
speed=speed,
|
|
||||||
heading=heading,
|
|
||||||
timestamp=timestamp,
|
|
||||||
fix_quality=1, # RMC with A status means valid fix
|
|
||||||
)
|
|
||||||
except (ValueError, IndexError) as e:
|
|
||||||
logger.debug(f"RMC parse error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
|
|
||||||
"""
|
|
||||||
Parse an NMEA sentence and extract position data.
|
|
||||||
|
|
||||||
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
|
|
||||||
"""
|
|
||||||
sentence = sentence.strip()
|
|
||||||
|
|
||||||
# Validate checksum if present
|
|
||||||
if '*' in sentence:
|
|
||||||
data, checksum = sentence.rsplit('*', 1)
|
|
||||||
if data.startswith('$'):
|
|
||||||
data = data[1:]
|
|
||||||
|
|
||||||
# Calculate checksum
|
|
||||||
calc_checksum = 0
|
|
||||||
for char in data:
|
|
||||||
calc_checksum ^= ord(char)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if int(checksum, 16) != calc_checksum:
|
|
||||||
logger.debug(f"Checksum mismatch: {sentence}")
|
|
||||||
return None
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove $ prefix if present
|
|
||||||
if sentence.startswith('$'):
|
|
||||||
sentence = sentence[1:]
|
|
||||||
|
|
||||||
# Remove checksum for parsing
|
|
||||||
if '*' in sentence:
|
|
||||||
sentence = sentence.split('*')[0]
|
|
||||||
|
|
||||||
parts = sentence.split(',')
|
|
||||||
if not parts:
|
|
||||||
return None
|
|
||||||
|
|
||||||
msg_type = parts[0]
|
|
||||||
|
|
||||||
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
|
|
||||||
if msg_type.endswith('GGA'):
|
|
||||||
return parse_gga(parts)
|
|
||||||
elif msg_type.endswith('RMC'):
|
|
||||||
return parse_rmc(parts)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class GPSReader:
|
|
||||||
"""
|
|
||||||
Reads GPS data from a serial device.
|
|
||||||
|
|
||||||
Runs in a background thread and maintains current position.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device_path: str, baudrate: int = 9600):
|
|
||||||
self.device_path = device_path
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self._position: Optional[GPSPosition] = None
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._running = False
|
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
self._serial: Optional['serial.Serial'] = None
|
|
||||||
self._last_update: Optional[datetime] = None
|
|
||||||
self._error: Optional[str] = None
|
|
||||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def position(self) -> Optional[GPSPosition]:
|
|
||||||
"""Get the current GPS position."""
|
|
||||||
with self._lock:
|
|
||||||
return self._position
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_running(self) -> bool:
|
|
||||||
"""Check if the reader is running."""
|
|
||||||
return self._running
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_update(self) -> Optional[datetime]:
|
|
||||||
"""Get the time of the last position update."""
|
|
||||||
with self._lock:
|
|
||||||
return self._last_update
|
|
||||||
|
|
||||||
@property
|
|
||||||
def error(self) -> Optional[str]:
|
|
||||||
"""Get any error message."""
|
|
||||||
with self._lock:
|
|
||||||
return self._error
|
|
||||||
|
|
||||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
|
||||||
"""Add a callback to be called on position updates."""
|
|
||||||
self._callbacks.append(callback)
|
|
||||||
|
|
||||||
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
|
||||||
"""Remove a position update callback."""
|
|
||||||
if callback in self._callbacks:
|
|
||||||
self._callbacks.remove(callback)
|
|
||||||
|
|
||||||
def start(self) -> bool:
|
|
||||||
"""Start reading GPS data in a background thread."""
|
|
||||||
if not SERIAL_AVAILABLE:
|
|
||||||
self._error = "pyserial not installed"
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self._running:
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._serial = serial.Serial(
|
|
||||||
self.device_path,
|
|
||||||
baudrate=self.baudrate,
|
|
||||||
timeout=1.0
|
|
||||||
)
|
|
||||||
self._running = True
|
|
||||||
self._error = None
|
|
||||||
|
|
||||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
logger.info(f"Started GPS reader on {self.device_path}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except serial.SerialException as e:
|
|
||||||
self._error = str(e)
|
|
||||||
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Stop reading GPS data."""
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
if self._serial:
|
|
||||||
try:
|
|
||||||
self._serial.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._serial = None
|
|
||||||
|
|
||||||
if self._thread:
|
|
||||||
self._thread.join(timeout=2.0)
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
logger.info(f"Stopped GPS reader on {self.device_path}")
|
|
||||||
|
|
||||||
def _read_loop(self) -> None:
|
|
||||||
"""Background thread loop for reading GPS data."""
|
|
||||||
buffer = ""
|
|
||||||
sentence_count = 0
|
|
||||||
bytes_read = 0
|
|
||||||
|
|
||||||
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
|
|
||||||
|
|
||||||
while self._running and self._serial:
|
|
||||||
try:
|
|
||||||
# Read available data
|
|
||||||
waiting = self._serial.in_waiting
|
|
||||||
if waiting:
|
|
||||||
data = self._serial.read(waiting)
|
|
||||||
bytes_read += len(data)
|
|
||||||
if bytes_read <= 500 or bytes_read % 1000 == 0:
|
|
||||||
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
|
|
||||||
buffer += data.decode('ascii', errors='ignore')
|
|
||||||
|
|
||||||
# Process complete lines
|
|
||||||
while '\n' in buffer:
|
|
||||||
line, buffer = buffer.split('\n', 1)
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
if line.startswith('$'):
|
|
||||||
sentence_count += 1
|
|
||||||
# Log first few sentences and periodically after that
|
|
||||||
if sentence_count <= 10 or sentence_count % 50 == 0:
|
|
||||||
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
|
|
||||||
|
|
||||||
position = parse_nmea_sentence(line)
|
|
||||||
if position:
|
|
||||||
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
|
|
||||||
position.device = self.device_path
|
|
||||||
self._update_position(position)
|
|
||||||
else:
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
except serial.SerialException as e:
|
|
||||||
logger.error(f"GPS read error: {e}")
|
|
||||||
with self._lock:
|
|
||||||
self._error = str(e)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"GPS parse error: {e}")
|
|
||||||
|
|
||||||
def _update_position(self, position: GPSPosition) -> None:
|
|
||||||
"""Update the current position and notify callbacks."""
|
|
||||||
with self._lock:
|
|
||||||
# Merge data from different sentence types
|
|
||||||
if self._position:
|
|
||||||
# Keep altitude from GGA if RMC doesn't have it
|
|
||||||
if position.altitude is None and self._position.altitude:
|
|
||||||
position.altitude = self._position.altitude
|
|
||||||
# Keep satellites from GGA
|
|
||||||
if position.satellites is None and self._position.satellites:
|
|
||||||
position.satellites = self._position.satellites
|
|
||||||
|
|
||||||
self._position = position
|
|
||||||
self._last_update = datetime.utcnow()
|
|
||||||
self._error = None
|
|
||||||
|
|
||||||
# Notify callbacks
|
|
||||||
for callback in self._callbacks:
|
|
||||||
try:
|
|
||||||
callback(position)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"GPS callback error: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
class GPSDClient:
|
class GPSDClient:
|
||||||
"""
|
"""
|
||||||
Connects to gpsd daemon for GPS data.
|
Connects to gpsd daemon for GPS data.
|
||||||
@@ -506,14 +93,9 @@ class GPSDClient:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def device_path(self) -> str:
|
def device_path(self) -> str:
|
||||||
"""Return gpsd connection info (for compatibility with GPSReader)."""
|
"""Return gpsd connection info."""
|
||||||
return f"gpsd://{self.host}:{self.port}"
|
return f"gpsd://{self.host}:{self.port}"
|
||||||
|
|
||||||
@property
|
|
||||||
def baudrate(self) -> int:
|
|
||||||
"""Return 0 for gpsd (for compatibility with GPSReader)."""
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||||
"""Add a callback to be called on position updates."""
|
"""Add a callback to be called on position updates."""
|
||||||
self._callbacks.append(callback)
|
self._callbacks.append(callback)
|
||||||
@@ -667,7 +249,7 @@ class GPSDClient:
|
|||||||
latitude=lat,
|
latitude=lat,
|
||||||
longitude=lon,
|
longitude=lon,
|
||||||
altitude=msg.get('alt'),
|
altitude=msg.get('alt'),
|
||||||
speed=msg.get('speed'), # m/s in gpsd (not knots)
|
speed=msg.get('speed'), # m/s in gpsd
|
||||||
heading=msg.get('track'),
|
heading=msg.get('track'),
|
||||||
fix_quality=mode,
|
fix_quality=mode,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
@@ -692,47 +274,15 @@ class GPSDClient:
|
|||||||
logger.error(f"GPS callback error: {e}")
|
logger.error(f"GPS callback error: {e}")
|
||||||
|
|
||||||
|
|
||||||
# Type alias for GPS source (either serial reader or gpsd client)
|
# Global GPS client instance
|
||||||
GPSSource = Union[GPSReader, GPSDClient]
|
_gps_client: Optional[GPSDClient] = None
|
||||||
|
|
||||||
# Global GPS reader instance
|
|
||||||
_gps_reader: Optional[GPSSource] = None
|
|
||||||
_gps_lock = threading.Lock()
|
_gps_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_gps_reader() -> Optional[GPSSource]:
|
def get_gps_reader() -> Optional[GPSDClient]:
|
||||||
"""Get the global GPS reader/client instance."""
|
"""Get the global GPS client instance."""
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
return _gps_reader
|
return _gps_client
|
||||||
|
|
||||||
|
|
||||||
def start_gps(device_path: str, baudrate: int = 9600,
|
|
||||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
|
||||||
"""
|
|
||||||
Start the global GPS reader.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
device_path: Path to the GPS serial device
|
|
||||||
baudrate: Serial baudrate (default 9600)
|
|
||||||
callback: Optional callback for position updates (registered before start to avoid race condition)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if started successfully
|
|
||||||
"""
|
|
||||||
global _gps_reader
|
|
||||||
|
|
||||||
with _gps_lock:
|
|
||||||
# Stop existing reader if any
|
|
||||||
if _gps_reader:
|
|
||||||
_gps_reader.stop()
|
|
||||||
|
|
||||||
_gps_reader = GPSReader(device_path, baudrate)
|
|
||||||
|
|
||||||
# Register callback BEFORE starting to avoid race condition
|
|
||||||
if callback:
|
|
||||||
_gps_reader.add_callback(callback)
|
|
||||||
|
|
||||||
return _gps_reader.start()
|
|
||||||
|
|
||||||
|
|
||||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||||
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
|||||||
Returns:
|
Returns:
|
||||||
True if started successfully
|
True if started successfully
|
||||||
"""
|
"""
|
||||||
global _gps_reader
|
global _gps_client
|
||||||
|
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
# Stop existing reader if any
|
# Stop existing client if any
|
||||||
if _gps_reader:
|
if _gps_client:
|
||||||
_gps_reader.stop()
|
_gps_client.stop()
|
||||||
|
|
||||||
_gps_reader = GPSDClient(host, port)
|
_gps_client = GPSDClient(host, port)
|
||||||
|
|
||||||
# Register callback BEFORE starting to avoid race condition
|
# Register callback BEFORE starting to avoid race condition
|
||||||
if callback:
|
if callback:
|
||||||
_gps_reader.add_callback(callback)
|
_gps_client.add_callback(callback)
|
||||||
|
|
||||||
return _gps_reader.start()
|
return _gps_client.start()
|
||||||
|
|
||||||
|
|
||||||
def stop_gps() -> None:
|
def stop_gps() -> None:
|
||||||
"""Stop the global GPS reader/client."""
|
"""Stop the global GPS client."""
|
||||||
global _gps_reader
|
global _gps_client
|
||||||
|
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
if _gps_reader:
|
if _gps_client:
|
||||||
_gps_reader.stop()
|
_gps_client.stop()
|
||||||
_gps_reader = None
|
_gps_client = None
|
||||||
|
|
||||||
|
|
||||||
def get_current_position() -> Optional[GPSPosition]:
|
def get_current_position() -> Optional[GPSPosition]:
|
||||||
"""Get the current GPS position from the global reader."""
|
"""Get the current GPS position from the global client."""
|
||||||
reader = get_gps_reader()
|
client = get_gps_reader()
|
||||||
if reader:
|
if client:
|
||||||
return reader.position
|
return client.position
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def is_serial_available() -> bool:
|
|
||||||
"""Check if pyserial is available."""
|
|
||||||
return SERIAL_AVAILABLE
|
|
||||||
|
|||||||
@@ -144,6 +144,15 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def _find_soapy_util() -> str | None:
|
||||||
|
"""Find SoapySDR utility command (name varies by distribution)."""
|
||||||
|
# Try different command names used across distributions
|
||||||
|
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
|
||||||
|
if _check_tool(cmd):
|
||||||
|
return cmd
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||||
"""
|
"""
|
||||||
Detect SDR devices via SoapySDR.
|
Detect SDR devices via SoapySDR.
|
||||||
@@ -156,13 +165,14 @@ def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRD
|
|||||||
devices: list[SDRDevice] = []
|
devices: list[SDRDevice] = []
|
||||||
skip_types = skip_types or set()
|
skip_types = skip_types or set()
|
||||||
|
|
||||||
if not _check_tool('SoapySDRUtil'):
|
soapy_cmd = _find_soapy_util()
|
||||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
if not soapy_cmd:
|
||||||
|
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
['SoapySDRUtil', '--find'],
|
[soapy_cmd, '--find'],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10
|
||||||
|
|||||||
Reference in New Issue
Block a user