Add Listening Post, improve setup and documentation

- Add Listening Post mode with frequency scanner and audio monitoring
- Add dependency warning for aircraft dashboard listen feature
- Auto-restart audio when switching frequencies
- Fix toolbar overflow on aircraft dashboard custom frequency
- Update setup script with full macOS/Debian support
- Clean up README and documentation for clarity
- Add sox and dump1090 to Dockerfile
- Add comprehensive tool reference to HARDWARE.md
- Add correlation, settings, and database utilities
- Add new test files for routes, validation, correlation, database

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-06 17:34:53 +00:00
parent 68e179bfd2
commit b5547d3fa9
27 changed files with 6961 additions and 1051 deletions
+34 -3
View File
@@ -3,24 +3,47 @@
FROM python:3.11-slim
LABEL maintainer="INTERCEPT Project"
LABEL description="Signal Intelligence Platform for SDR monitoring"
# Set working directory
WORKDIR /app
# Install system dependencies for RTL-SDR tools
# Install system dependencies for SDR tools
RUN apt-get update && apt-get install -y --no-install-recommends \
# RTL-SDR tools
rtl-sdr \
librtlsdr-dev \
libusb-1.0-0-dev \
# 433MHz decoder
rtl-433 \
# Pager decoder
multimon-ng \
# Audio tools for Listening Post
sox \
libsox-fmt-all \
# WiFi tools (aircrack-ng suite)
aircrack-ng \
iw \
wireless-tools \
# Bluetooth tools
bluez \
# Cleanup
bluetooth \
# GPS support
gpsd-clients \
# Utilities
curl \
procps \
&& rm -rf /var/lib/apt/lists/*
# Install dump1090 for ADS-B (package name varies by distribution)
RUN apt-get update && \
(apt-get install -y --no-install-recommends dump1090-mutability || \
apt-get install -y --no-install-recommends dump1090-fa || \
apt-get install -y --no-install-recommends dump1090 || \
echo "Note: dump1090 not available in repos, ADS-B features limited") && \
rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
@@ -28,13 +51,21 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create data directory for persistence
RUN mkdir -p /app/data
# Expose web interface port
EXPOSE 5050
# Environment variables with defaults
ENV INTERCEPT_HOST=0.0.0.0 \
INTERCEPT_PORT=5050 \
INTERCEPT_LOG_LEVEL=INFO
INTERCEPT_LOG_LEVEL=INFO \
PYTHONUNBUFFERED=1
# Health check using the new endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -sf http://localhost:5050/health || exit 1
# Run the application
CMD ["python", "intercept.py"]
+133 -76
View File
@@ -8,7 +8,7 @@
<p align="center">
<strong>Signal Intelligence Platform</strong><br>
A web-based front-end for signal intelligence tools.
A web-based interface for software-defined radio tools.
</p>
<p align="center">
@@ -17,19 +17,141 @@
---
## What is INTERCEPT?
INTERCEPT provides a unified web interface for signal intelligence tools:
## Features
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
- **433MHz Sensors** - Weather stations, TPMS, IoT devices via rtl_433
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map and radar
- **Listening Post** - Frequency scanner with audio monitoring
- **Satellite Tracking** - Pass prediction using TLE data
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
- **WiFi Scanning** - Monitor mode reconnaissance via aircrack-ng
- **Bluetooth Scanning** - Device discovery and tracker detection
---
## Installation
### macOS
**1. Install Homebrew** (if not already installed):
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
**2. Install dependencies:**
```bash
# Required
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
# For ADS-B aircraft tracking
brew install dump1090-mutability
# For WiFi scanning (optional)
brew install aircrack-ng
```
**3. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
### Debian / Ubuntu / Raspberry Pi OS
**1. Install dependencies:**
```bash
sudo apt update
sudo apt install -y python3 python3-pip python3-venv git
# Required SDR tools
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
# For ADS-B aircraft tracking (package name varies)
sudo apt install -y dump1090-mutability # or dump1090-fa
# For WiFi scanning (optional)
sudo apt install -y aircrack-ng
# For Bluetooth scanning (optional)
sudo apt install -y bluez bluetooth
```
**2. Clone and run:**
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
> **Note:** On Raspberry Pi or headless systems, you may need to run `sudo venv/bin/python intercept.py` if a virtual environment was created.
### Docker (Alternative)
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
docker-compose up -d
```
> **Note:** Docker requires privileged mode for USB SDR access. See `docker-compose.yml` for configuration options.
### Open the Interface
After starting, open **http://localhost:5050** in your browser.
---
## Hardware Requirements
| Hardware | Purpose | Price |
|----------|---------|-------|
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
---
## Troubleshooting
### RTL-SDR not detected (Linux)
Add udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
EOF'
sudo udevadm control --reload-rules && sudo udevadm trigger
```
Then unplug and replug your RTL-SDR.
### "externally-managed-environment" error (Ubuntu 23.04+)
The setup script handles this automatically by creating a virtual environment. Run:
```bash
./setup.sh
source venv/bin/activate
sudo venv/bin/python intercept.py
```
### dump1090 not available (Debian Trixie)
On newer Debian versions, dump1090 may not be in repositories. Install from FlightAware:
- https://flightaware.com/adsb/piaware/install
### Verify installation
```bash
python3 intercept.py --check-deps
```
---
## Community
<p align="center">
@@ -38,75 +160,20 @@ INTERCEPT provides a unified web interface for signal intelligence tools:
---
## Quick Start
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
./setup.sh
sudo python3 intercept.py
```
Open http://localhost:5050 in your browser.
<details>
<summary><strong>Alternative: Install with uv</strong></summary>
```bash
git clone https://github.com/smittix/intercept.git
cd intercept
uv venv
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).
---
## Requirements
- **Python 3.9+**
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
Quick install (Ubuntu/Debian):
```bash
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
```
See [Hardware & Installation](docs/HARDWARE.md) for full details.
---
## Documentation
| Document | Description |
|----------|-------------|
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
---
## Development
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
Contributions and improvements welcome.
- [Usage Guide](docs/USAGE.md) - Detailed instructions for each mode
- [Hardware Guide](docs/HARDWARE.md) - SDR hardware and advanced setup
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issues and solutions
---
## Disclaimer
**This software is for educational purposes only.**
**This software is for educational and authorized testing purposes only.**
- Only use with proper authorization
- Intercepting communications without consent may be illegal
- WiFi/Bluetooth attacks require explicit permission
- You are responsible for compliance with applicable laws
---
@@ -118,13 +185,3 @@ MIT License - see [LICENSE](LICENSE)
## Author
Created by **smittix** - [GitHub](https://github.com/smittix)
## Acknowledgments
[rtl-sdr](https://osmocom.org/projects/rtl-sdr/wiki) |
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
[rtl_433](https://github.com/merbanan/rtl_433) |
[dump1090](https://github.com/flightaware/dump1090) |
[aircrack-ng](https://www.aircrack-ng.org/) |
[Leaflet.js](https://leafletjs.com/) |
[Celestrak](https://celestrak.org/)
+94 -44
View File
@@ -29,6 +29,17 @@ from config import VERSION
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
from utils.process import cleanup_stale_processes
from utils.sdr import SDRFactory
from utils.cleanup import DataStore, cleanup_manager
from utils.constants import (
MAX_AIRCRAFT_AGE_SECONDS,
MAX_WIFI_NETWORK_AGE_SECONDS,
MAX_BT_DEVICE_AGE_SECONDS,
QUEUE_MAX_SIZE,
)
# Track application start time for uptime calculation
import time as _time
_app_start_time = _time.time()
# Create Flask app
@@ -40,32 +51,32 @@ app = Flask(__name__)
# Pager decoder
current_process = None
output_queue = queue.Queue()
output_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
process_lock = threading.Lock()
# RTL_433 sensor
sensor_process = None
sensor_queue = queue.Queue()
sensor_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
sensor_lock = threading.Lock()
# WiFi
wifi_process = None
wifi_queue = queue.Queue()
wifi_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
wifi_lock = threading.Lock()
# Bluetooth
bt_process = None
bt_queue = queue.Queue()
bt_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
bt_lock = threading.Lock()
# ADS-B aircraft
adsb_process = None
adsb_queue = queue.Queue()
adsb_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
adsb_lock = threading.Lock()
# Satellite/Iridium
satellite_process = None
satellite_queue = queue.Queue()
satellite_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
satellite_lock = threading.Lock()
# ============================================
@@ -76,23 +87,30 @@ satellite_lock = threading.Lock()
logging_enabled = False
log_file_path = 'pager_messages.log'
# WiFi state
# WiFi state - using DataStore for automatic cleanup
wifi_monitor_interface = None
wifi_networks = {} # BSSID -> network info
wifi_clients = {} # Client MAC -> client info
wifi_handshakes = [] # Captured handshakes
wifi_networks = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_networks')
wifi_clients = DataStore(max_age_seconds=MAX_WIFI_NETWORK_AGE_SECONDS, name='wifi_clients')
wifi_handshakes = [] # Captured handshakes (list, not auto-cleaned)
# Bluetooth state
# Bluetooth state - using DataStore for automatic cleanup
bt_interface = None
bt_devices = {} # MAC -> device info
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
bt_services = {} # MAC -> list of services
bt_devices = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_devices')
bt_beacons = DataStore(max_age_seconds=MAX_BT_DEVICE_AGE_SECONDS, name='bt_beacons')
bt_services = {} # MAC -> list of services (not auto-cleaned, user-requested)
# Aircraft (ADS-B) state
adsb_aircraft = {} # ICAO hex -> aircraft info
# Aircraft (ADS-B) state - using DataStore for automatic cleanup
adsb_aircraft = DataStore(max_age_seconds=MAX_AIRCRAFT_AGE_SECONDS, name='adsb_aircraft')
# Satellite state
satellite_passes = [] # Predicted satellite passes
satellite_passes = [] # Predicted satellite passes (not auto-cleaned, calculated)
# Register data stores with cleanup manager
cleanup_manager.register(wifi_networks)
cleanup_manager.register(wifi_clients)
cleanup_manager.register(bt_devices)
cleanup_manager.register(bt_beacons)
cleanup_manager.register(adsb_aircraft)
# ============================================
@@ -130,15 +148,16 @@ def get_dependencies() -> Response:
# Determine OS for install instructions
system = platform.system().lower()
if system == 'darwin':
install_method = 'brew'
pkg_manager = 'brew'
elif system == 'linux':
install_method = 'apt'
pkg_manager = 'apt'
else:
install_method = 'manual'
pkg_manager = 'manual'
return jsonify({
'status': 'success',
'os': system,
'install_method': install_method,
'pkg_manager': pkg_manager,
'modes': results
})
@@ -159,14 +178,14 @@ def export_aircraft() -> Response:
for icao, ac in adsb_aircraft.items():
writer.writerow([
icao,
ac.get('callsign', ''),
ac.get('altitude', ''),
ac.get('speed', ''),
ac.get('heading', ''),
ac.get('lat', ''),
ac.get('lon', ''),
ac.get('squawk', ''),
ac.get('lastSeen', '')
ac.get('callsign', '') if isinstance(ac, dict) else '',
ac.get('altitude', '') if isinstance(ac, dict) else '',
ac.get('speed', '') if isinstance(ac, dict) else '',
ac.get('heading', '') if isinstance(ac, dict) else '',
ac.get('lat', '') if isinstance(ac, dict) else '',
ac.get('lon', '') if isinstance(ac, dict) else '',
ac.get('squawk', '') if isinstance(ac, dict) else '',
ac.get('lastSeen', '') if isinstance(ac, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -175,7 +194,7 @@ def export_aircraft() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'aircraft': list(adsb_aircraft.values())
'aircraft': adsb_aircraft.values()
})
@@ -195,11 +214,11 @@ def export_wifi() -> Response:
for bssid, net in wifi_networks.items():
writer.writerow([
bssid,
net.get('ssid', ''),
net.get('channel', ''),
net.get('signal', ''),
net.get('encryption', ''),
net.get('clients', 0)
net.get('ssid', '') if isinstance(net, dict) else '',
net.get('channel', '') if isinstance(net, dict) else '',
net.get('signal', '') if isinstance(net, dict) else '',
net.get('encryption', '') if isinstance(net, dict) else '',
net.get('clients', 0) if isinstance(net, dict) else 0
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -208,8 +227,8 @@ def export_wifi() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'networks': list(wifi_networks.values()),
'clients': list(wifi_clients.values())
'networks': wifi_networks.values(),
'clients': wifi_clients.values()
})
@@ -229,11 +248,11 @@ def export_bluetooth() -> Response:
for mac, dev in bt_devices.items():
writer.writerow([
mac,
dev.get('name', ''),
dev.get('rssi', ''),
dev.get('type', ''),
dev.get('manufacturer', ''),
dev.get('lastSeen', '')
dev.get('name', '') if isinstance(dev, dict) else '',
dev.get('rssi', '') if isinstance(dev, dict) else '',
dev.get('type', '') if isinstance(dev, dict) else '',
dev.get('manufacturer', '') if isinstance(dev, dict) else '',
dev.get('lastSeen', '') if isinstance(dev, dict) else ''
])
response = Response(output.getvalue(), mimetype='text/csv')
@@ -242,11 +261,35 @@ def export_bluetooth() -> Response:
else:
return jsonify({
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
'devices': list(bt_devices.values()),
'beacons': list(bt_beacons.values())
'devices': bt_devices.values(),
'beacons': bt_beacons.values()
})
@app.route('/health')
def health_check() -> Response:
"""Health check endpoint for monitoring."""
import time
return jsonify({
'status': 'healthy',
'version': VERSION,
'uptime_seconds': round(time.time() - _app_start_time, 2),
'processes': {
'pager': current_process is not None and (current_process.poll() is None if current_process else False),
'sensor': sensor_process is not None and (sensor_process.poll() is None if sensor_process else False),
'adsb': adsb_process is not None and (adsb_process.poll() is None if adsb_process else False),
'wifi': wifi_process is not None and (wifi_process.poll() is None if wifi_process else False),
'bluetooth': bt_process is not None and (bt_process.poll() is None if bt_process else False),
},
'data': {
'aircraft_count': len(adsb_aircraft),
'wifi_networks_count': len(wifi_networks),
'wifi_clients_count': len(wifi_clients),
'bt_devices_count': len(bt_devices),
}
})
@app.route('/killall', methods=['POST'])
def kill_all() -> Response:
"""Kill all decoder and WiFi processes."""
@@ -343,6 +386,13 @@ def main() -> None:
# Clean up any stale processes from previous runs
cleanup_stale_processes()
# Initialize database for settings storage
from utils.database import init_db
init_db()
# Start automatic cleanup of stale data entries
cleanup_manager.start()
# Register blueprints
from routes import register_blueprints
register_blueprints(app)
+37
View File
@@ -0,0 +1,37 @@
# INTERCEPT - Signal Intelligence Platform
# Docker Compose configuration for easy deployment
services:
intercept:
build: .
container_name: intercept
ports:
- "5050:5050"
# Privileged mode required for USB SDR device access
# Alternatively, use device mapping (see below)
privileged: true
# USB device mapping (alternative to privileged mode)
# devices:
# - /dev/bus/usb:/dev/bus/usb
volumes:
# Persist data directory
- ./data:/app/data
# Optional: mount logs directory
# - ./logs:/app/logs
environment:
- INTERCEPT_HOST=0.0.0.0
- INTERCEPT_PORT=5050
- INTERCEPT_LOG_LEVEL=INFO
# Network mode for WiFi scanning (requires host network)
# network_mode: host
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:5050/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Optional: Add volume for persistent SQLite database
# volumes:
# intercept-data:
+144 -99
View File
@@ -1,93 +1,75 @@
# Hardware & Installation
# Hardware & Advanced Setup
## Supported SDR Hardware
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|----------|-----------------|------------|-----|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
| Hardware | Frequency Range | Price | Notes |
|----------|-----------------|-------|-------|
| **RTL-SDR** | 24 - 1766 MHz | ~$25-35 | Recommended for beginners |
| **LimeSDR** | 0.1 - 3800 MHz | ~$300 | Wide range, requires SoapySDR |
| **HackRF** | 1 - 6000 MHz | ~$300 | Ultra-wide range, requires SoapySDR |
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
INTERCEPT automatically detects connected devices.
## Requirements
---
### Hardware
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
- **WiFi adapter** capable of monitor mode (for WiFi features)
- **Bluetooth adapter** (for Bluetooth features)
- **GPS dongle** (optional, for precise location)
### Software
- **Python 3.9+** required
- External tools (see installation below)
## Tool Installation
### Core SDR Tools
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
### LimeSDR / HackRF Support (Optional)
| Tool | macOS | Ubuntu/Debian | Purpose |
|------|-------|---------------|---------|
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
## Quick Install Commands
### Ubuntu/Debian
> [!NOTE]
> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
```bash
# Core tools
sudo apt update
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
# LimeSDR (optional)
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
# HackRF (optional)
sudo apt install hackrf soapysdr-module-hackrf
```
## Quick Install
### macOS (Homebrew)
```bash
# Core tools
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
# LimeSDR (optional)
```bash
# Install Homebrew if needed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Core tools (required)
brew install python@3.11 librtlsdr multimon-ng rtl_433 sox
# ADS-B aircraft tracking
brew install dump1090-mutability
# WiFi tools (optional)
brew install aircrack-ng
# LimeSDR support (optional)
brew install soapysdr limesuite soapylms7
# HackRF (optional)
# HackRF support (optional)
brew install hackrf soapyhackrf
```
### Arch Linux
```bash
# Core tools
sudo pacman -S rtl-sdr multimon-ng
yay -S rtl_433 dump1090
### Debian / Ubuntu / Raspberry Pi OS
# LimeSDR/HackRF (optional)
sudo pacman -S soapysdr limesuite hackrf
```bash
# Update package lists
sudo apt update
# Core tools (required)
sudo apt install -y python3 python3-pip python3-venv
sudo apt install -y rtl-sdr multimon-ng rtl-433 sox
# ADS-B aircraft tracking
sudo apt install -y dump1090-mutability
# Alternative: dump1090-fa (FlightAware version)
# WiFi tools (optional)
sudo apt install -y aircrack-ng
# Bluetooth tools (optional)
sudo apt install -y bluez bluetooth
# LimeSDR support (optional)
sudo apt install -y soapysdr-tools limesuite soapysdr-module-lms7
# HackRF support (optional)
sudo apt install -y hackrf soapysdr-module-hackrf
```
## Linux udev Rules
---
If your SDR isn't detected, add udev rules:
## RTL-SDR Setup (Linux)
### Add udev rules
If your RTL-SDR isn't detected, create udev rules:
```bash
sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
@@ -99,9 +81,9 @@ sudo udevadm control --reload-rules
sudo udevadm trigger
```
Then unplug and replug your device.
Then unplug and replug your RTL-SDR.
## Blacklist DVB-T Driver (Linux)
### Blacklist DVB-T driver
The default DVB-T driver conflicts with rtl-sdr:
@@ -110,57 +92,120 @@ echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
sudo modprobe -r dvb_usb_rtl28xxu
```
---
## Verify Installation
Check what's installed:
### Check dependencies
```bash
python3 intercept.py --check-deps
```
Test SDR detection:
### Test SDR detection
```bash
# RTL-SDR
rtl_test
# LimeSDR/HackRF
# LimeSDR/HackRF (via SoapySDR)
SoapySDRUtil --find
```
## Python Dependencies
---
### Option 1: setup.sh (Recommended)
## Python Environment
### Using setup.sh (Recommended)
```bash
./setup.sh
```
This creates a virtual environment and installs dependencies automatically.
### Option 2: pip
This automatically:
- Detects your OS
- Creates a virtual environment if needed (for PEP 668 systems)
- Installs Python dependencies
- Checks for required tools
### Manual setup
```bash
python3 -m venv .venv
source .venv/bin/activate
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
### Option 3: uv (Fast alternative)
[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
---
## Running INTERCEPT
After installation:
```bash
# Install uv (if not already installed)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Standard
sudo python3 intercept.py
# Create venv and install deps
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
# With virtual environment
sudo venv/bin/python intercept.py
# Or just install deps in existing environment
uv pip install -r requirements.txt
# Custom port
INTERCEPT_PORT=8080 sudo python3 intercept.py
```
### Option 4: pip with pyproject.toml
```bash
pip install . # Install as package
pip install -e . # Install in editable mode (for development)
pip install -e ".[dev]" # Include dev dependencies
```
Open **http://localhost:5050** in your browser.
---
## Complete Tool Reference
| Tool | Package (Debian) | Package (macOS) | Required For |
|------|------------------|-----------------|--------------|
| `rtl_fm` | rtl-sdr | librtlsdr | Pager, Listening Post |
| `rtl_test` | rtl-sdr | librtlsdr | SDR detection |
| `multimon-ng` | multimon-ng | multimon-ng | Pager decoding |
| `rtl_433` | rtl-433 | rtl_433 | 433MHz sensors |
| `dump1090` | dump1090-mutability | dump1090-mutability | ADS-B tracking |
| `sox` | sox | sox | Listening Post audio |
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
| `hcitool` | bluez | N/A | Bluetooth scanning |
| `bluetoothctl` | bluez | N/A | Bluetooth control |
| `hciconfig` | bluez | N/A | Bluetooth config |
### Optional tools:
| Tool | Package (Debian) | Package (macOS) | Purpose |
|------|------------------|-----------------|---------|
| `ffmpeg` | ffmpeg | ffmpeg | Alternative audio encoder |
| `SoapySDRUtil` | soapysdr-tools | soapysdr | LimeSDR/HackRF support |
| `LimeUtil` | limesuite | limesuite | LimeSDR native tools |
| `hackrf_info` | hackrf | hackrf | HackRF native tools |
### Python dependencies (requirements.txt):
| Package | Purpose |
|---------|---------|
| `flask` | Web server |
| `skyfield` | Satellite tracking (optional) |
| `pyserial` | USB GPS dongle support (optional) |
---
## dump1090 Notes
### Package names vary by distribution:
- `dump1090-mutability` - Most common
- `dump1090-fa` - FlightAware version (recommended)
- `dump1090` - Generic
### Not in repositories (Debian Trixie)?
Install FlightAware's version:
https://flightaware.com/adsb/piaware/install
Or build from source:
https://github.com/flightaware/dump1090
---
## Notes
- **Bluetooth on macOS**: Uses native CoreBluetooth, bluez tools not needed
- **WiFi on macOS**: Monitor mode has limited support, full functionality on Linux
- **System tools**: `iw`, `iwconfig`, `rfkill`, `ip` are pre-installed on most Linux systems
Binary file not shown.
+6
View File
@@ -9,6 +9,9 @@ def register_blueprints(app):
from .adsb import adsb_bp
from .satellite import satellite_bp
from .gps import gps_bp
from .settings import settings_bp
from .correlation import correlation_bp
from .listening_post import listening_post_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -17,3 +20,6 @@ def register_blueprints(app):
app.register_blueprint(adsb_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(gps_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(correlation_bp)
app.register_blueprint(listening_post_bp)
+38 -23
View File
@@ -22,6 +22,19 @@ from utils.validation import (
)
from utils.sse import format_sse
from utils.sdr import SDRFactory, SDRType
from utils.constants import (
ADSB_SBS_PORT,
ADSB_TERMINATE_TIMEOUT,
PROCESS_TERMINATE_TIMEOUT,
SBS_SOCKET_TIMEOUT,
SBS_RECONNECT_DELAY,
SOCKET_BUFFER_SIZE,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SOCKET_CONNECT_TIMEOUT,
ADSB_UPDATE_INTERVAL,
DUMP1090_START_WAIT,
)
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
@@ -63,21 +76,21 @@ def find_dump1090():
def check_dump1090_service():
"""Check if dump1090 SBS port (30003) is available."""
"""Check if dump1090 SBS port is available."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', 30003))
sock.settimeout(SOCKET_CONNECT_TIMEOUT)
result = sock.connect_ex(('localhost', ADSB_SBS_PORT))
sock.close()
if result == 0:
return 'localhost:30003'
except Exception:
return f'localhost:{ADSB_SBS_PORT}'
except OSError:
pass
return None
def parse_sbs_stream(service_addr):
"""Parse SBS format data from dump1090 port 30003."""
"""Parse SBS format data from dump1090 SBS port."""
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
host, port = service_addr.split(':')
@@ -90,7 +103,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service:
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
sock.settimeout(SBS_SOCKET_TIMEOUT)
sock.connect((host, port))
adsb_connected = True
logger.info("Connected to SBS stream")
@@ -101,7 +114,7 @@ def parse_sbs_stream(service_addr):
while adsb_using_service:
try:
data = sock.recv(4096).decode('utf-8', errors='ignore')
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
if not data:
break
buffer += data
@@ -121,7 +134,7 @@ def parse_sbs_stream(service_addr):
if not icao:
continue
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
if msg_type == '1' and len(parts) > 10:
callsign = parts[10].strip()
@@ -168,13 +181,13 @@ def parse_sbs_stream(service_addr):
if parts[17]:
aircraft['squawk'] = parts[17]
app_module.adsb_aircraft[icao] = aircraft
app_module.adsb_aircraft.set(icao, aircraft)
pending_updates.add(icao)
adsb_messages_received += 1
adsb_last_message_time = time.time()
now = time.time()
if now - last_update >= 1.0:
if now - last_update >= ADSB_UPDATE_INTERVAL:
for update_icao in pending_updates:
if update_icao in app_module.adsb_aircraft:
app_module.adsb_queue.put({
@@ -189,10 +202,10 @@ def parse_sbs_stream(service_addr):
sock.close()
adsb_connected = False
except Exception as e:
except OSError as e:
adsb_connected = False
logger.warning(f"SBS connection error: {e}, reconnecting...")
time.sleep(2)
time.sleep(SBS_RECONNECT_DELAY)
adsb_connected = False
logger.info("SBS stream parser stopped")
@@ -291,9 +304,12 @@ def start_adsb():
if app_module.adsb_process:
try:
app_module.adsb_process.terminate()
app_module.adsb_process.wait(timeout=2)
except Exception:
pass
app_module.adsb_process.wait(timeout=PROCESS_TERMINATE_TIMEOUT)
except (subprocess.TimeoutExpired, OSError):
try:
app_module.adsb_process.kill()
except OSError:
pass
app_module.adsb_process = None
# Create device object and build command via abstraction layer
@@ -317,13 +333,13 @@ def start_adsb():
stderr=subprocess.DEVNULL
)
time.sleep(3)
time.sleep(DUMP1090_START_WAIT)
if app_module.adsb_process.poll() is not None:
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
adsb_using_service = True
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
thread.start()
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
@@ -340,13 +356,13 @@ def stop_adsb():
if app_module.adsb_process:
app_module.adsb_process.terminate()
try:
app_module.adsb_process.wait(timeout=5)
app_module.adsb_process.wait(timeout=ADSB_TERMINATE_TIMEOUT)
except subprocess.TimeoutExpired:
app_module.adsb_process.kill()
app_module.adsb_process = None
adsb_using_service = False
app_module.adsb_aircraft = {}
app_module.adsb_aircraft.clear()
return jsonify({'status': 'stopped'})
@@ -355,16 +371,15 @@ def stream_adsb():
"""SSE stream for ADS-B aircraft."""
def generate():
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
msg = app_module.adsb_queue.get(timeout=1)
msg = app_module.adsb_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
+18 -3
View File
@@ -23,6 +23,17 @@ from utils.logging import bluetooth_logger as logger
from utils.sse import format_sse
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
from utils.constants import (
BT_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
SUBPROCESS_TIMEOUT_SHORT,
SERVICE_ENUM_TIMEOUT,
PROCESS_START_WAIT,
BT_RESET_DELAY,
BT_ADAPTER_DOWN_WAIT,
PROCESS_TERMINATE_TIMEOUT,
)
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
@@ -113,7 +124,7 @@ def detect_bt_interfaces():
if platform.system() == 'Linux':
try:
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
for block in blocks:
if block.strip():
@@ -127,8 +138,12 @@ def detect_bt_interfaces():
'type': 'hci',
'status': 'up' if is_up else 'down'
})
except Exception:
pass
except FileNotFoundError:
logger.debug("hciconfig not found")
except subprocess.TimeoutExpired:
logger.warning("hciconfig timed out")
except subprocess.SubprocessError as e:
logger.warning(f"Error running hciconfig: {e}")
elif platform.system() == 'Darwin':
interfaces.append({
+119
View File
@@ -0,0 +1,119 @@
"""Device correlation routes."""
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
import app as app_module
from utils.correlation import get_correlations
from utils.logging import get_logger
logger = get_logger('intercept.correlation')
correlation_bp = Blueprint('correlation', __name__, url_prefix='/correlation')
@correlation_bp.route('', methods=['GET'])
def get_device_correlations() -> Response:
"""
Get device correlations between WiFi and Bluetooth.
Query params:
min_confidence: Minimum confidence threshold (default 0.5)
include_historical: Include database correlations (default true)
"""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
include_historical = request.args.get('include_historical', 'true').lower() == 'true'
try:
# Get current device data
wifi_devices = dict(app_module.wifi_networks)
wifi_devices.update(dict(app_module.wifi_clients))
bt_devices = dict(app_module.bt_devices)
# Calculate correlations
correlations = get_correlations(
wifi_devices=wifi_devices,
bt_devices=bt_devices,
min_confidence=min_confidence,
include_historical=include_historical
)
return jsonify({
'status': 'success',
'correlations': correlations,
'wifi_count': len(wifi_devices),
'bt_count': len(bt_devices)
})
except Exception as e:
logger.error(f"Error calculating correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@correlation_bp.route('/analyze', methods=['POST'])
def analyze_correlation() -> Response:
"""
Analyze specific device pair for correlation.
Request body:
wifi_mac: WiFi device MAC address
bt_mac: Bluetooth device MAC address
"""
data = request.json or {}
wifi_mac = data.get('wifi_mac')
bt_mac = data.get('bt_mac')
if not wifi_mac or not bt_mac:
return jsonify({
'status': 'error',
'message': 'wifi_mac and bt_mac are required'
}), 400
try:
# Get device data
wifi_device = app_module.wifi_networks.get(wifi_mac)
if not wifi_device:
wifi_device = app_module.wifi_clients.get(wifi_mac)
bt_device = app_module.bt_devices.get(bt_mac)
if not wifi_device:
return jsonify({
'status': 'error',
'message': f'WiFi device {wifi_mac} not found'
}), 404
if not bt_device:
return jsonify({
'status': 'error',
'message': f'Bluetooth device {bt_mac} not found'
}), 404
# Calculate correlation for this specific pair
correlations = get_correlations(
wifi_devices={wifi_mac: wifi_device},
bt_devices={bt_mac: bt_device},
min_confidence=0.0, # Show even low confidence for analysis
include_historical=True
)
if correlations:
return jsonify({
'status': 'success',
'correlation': correlations[0]
})
else:
return jsonify({
'status': 'success',
'correlation': None,
'message': 'No correlation detected between these devices'
})
except Exception as e:
logger.error(f"Error analyzing correlation: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
+754
View File
@@ -0,0 +1,754 @@
"""Listening Post routes for radio monitoring and frequency scanning."""
from __future__ import annotations
import json
import os
import queue
import shutil
import subprocess
import threading
import time
from datetime import datetime
from typing import Generator, Optional, List, Dict
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.constants import (
SSE_QUEUE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
PROCESS_TERMINATE_TIMEOUT,
)
logger = get_logger('intercept.listening_post')
listening_post_bp = Blueprint('listening_post', __name__, url_prefix='/listening')
# ============================================
# GLOBAL STATE
# ============================================
# Audio demodulation state
audio_process = None
audio_rtl_process = None
audio_lock = threading.Lock()
audio_running = False
audio_frequency = 0.0
audio_modulation = 'fm'
# Scanner state
scanner_thread: Optional[threading.Thread] = None
scanner_running = False
scanner_lock = threading.Lock()
scanner_paused = False
scanner_current_freq = 0.0
scanner_config = {
'start_freq': 88.0,
'end_freq': 108.0,
'step': 0.1,
'modulation': 'wfm',
'squelch': 20,
'dwell_time': 10.0, # Seconds to stay on active frequency
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
'device': 0,
'gain': 40,
}
# Activity log
activity_log: List[Dict] = []
activity_log_lock = threading.Lock()
MAX_LOG_ENTRIES = 500
# SSE queue for scanner events
scanner_queue: queue.Queue = queue.Queue(maxsize=100)
# ============================================
# HELPER FUNCTIONS
# ============================================
def find_rtl_fm() -> str | None:
"""Find rtl_fm binary."""
return shutil.which('rtl_fm')
def find_ffmpeg() -> str | None:
"""Find ffmpeg for audio encoding."""
return shutil.which('ffmpeg')
def find_sox() -> str | None:
"""Find sox for audio encoding."""
return shutil.which('sox')
def add_activity_log(event_type: str, frequency: float, details: str = ''):
"""Add entry to activity log."""
with activity_log_lock:
entry = {
'timestamp': datetime.utcnow().isoformat() + 'Z',
'type': event_type,
'frequency': frequency,
'details': details,
}
activity_log.insert(0, entry)
# Trim log
while len(activity_log) > MAX_LOG_ENTRIES:
activity_log.pop()
# Also push to SSE queue
try:
scanner_queue.put_nowait({
'type': 'log',
'entry': entry
})
except queue.Full:
pass
# ============================================
# SCANNER IMPLEMENTATION
# ============================================
def scanner_loop():
"""Main scanner loop - scans frequencies looking for signals."""
global scanner_running, scanner_paused, scanner_current_freq, scanner_skip_signal
global audio_process, audio_rtl_process, audio_running, audio_frequency
logger.info("Scanner thread started")
add_activity_log('scanner_start', scanner_config['start_freq'],
f"Scanning {scanner_config['start_freq']}-{scanner_config['end_freq']} MHz")
rtl_fm_path = find_rtl_fm()
if not rtl_fm_path:
logger.error("rtl_fm not found")
add_activity_log('error', 0, 'rtl_fm not found')
scanner_running = False
return
current_freq = scanner_config['start_freq']
last_signal_time = 0
signal_detected = False
# Convert step from kHz to MHz
step_mhz = scanner_config['step'] / 1000.0
try:
while scanner_running:
# Check if paused
if scanner_paused:
time.sleep(0.1)
continue
scanner_current_freq = current_freq
# Notify clients of frequency change
try:
scanner_queue.put_nowait({
'type': 'freq_change',
'frequency': current_freq,
'scanning': not signal_detected
})
except queue.Full:
pass
# Start rtl_fm at this frequency
freq_hz = int(current_freq * 1e6)
mod = scanner_config['modulation']
# Sample rates
if mod == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif mod in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
# Don't use squelch in rtl_fm - we want to analyze raw audio
rtl_cmd = [
rtl_fm_path,
'-M', mod,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
]
try:
# Start rtl_fm
rtl_proc = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
# Read audio data for analysis
audio_data = b''
# Read audio samples for a short period
sample_duration = 0.25 # 250ms - balance between speed and detection
bytes_needed = int(resample_rate * 2 * sample_duration) # 16-bit mono
while len(audio_data) < bytes_needed and scanner_running:
chunk = rtl_proc.stdout.read(4096)
if not chunk:
break
audio_data += chunk
# Clean up rtl_fm
rtl_proc.terminate()
try:
rtl_proc.wait(timeout=1)
except subprocess.TimeoutExpired:
rtl_proc.kill()
# Analyze audio level
audio_detected = False
rms = 0
threshold = 3000
if len(audio_data) > 100:
import struct
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
# Calculate RMS level (root mean square)
rms = (sum(s*s for s in samples) / len(samples)) ** 0.5
# WFM (broadcast FM) has much higher audio output - needs higher threshold
# AM/NFM have lower output levels
if mod == 'wfm':
# WFM: threshold 4000-12000 based on squelch
threshold = 4000 + (scanner_config['squelch'] * 80)
else:
# AM/NFM: threshold 1500-8000 based on squelch
threshold = 1500 + (scanner_config['squelch'] * 65)
audio_detected = rms > threshold
# Send level info to clients
try:
scanner_queue.put_nowait({
'type': 'scan_update',
'frequency': current_freq,
'level': int(rms),
'threshold': int(threshold) if 'threshold' in dir() else 0,
'detected': audio_detected
})
except queue.Full:
pass
if audio_detected and scanner_running:
if not signal_detected:
# New signal found!
signal_detected = True
last_signal_time = time.time()
add_activity_log('signal_found', current_freq,
f'Signal detected on {current_freq:.3f} MHz ({mod.upper()})')
logger.info(f"Signal found at {current_freq} MHz")
# Start audio streaming for user
_start_audio_stream(current_freq, mod)
try:
scanner_queue.put_nowait({
'type': 'signal_found',
'frequency': current_freq,
'modulation': mod,
'audio_streaming': True
})
except queue.Full:
pass
# Check for skip signal
if scanner_skip_signal:
scanner_skip_signal = False
signal_detected = False
_stop_audio_stream()
try:
scanner_queue.put_nowait({
'type': 'signal_skipped',
'frequency': current_freq
})
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
continue
# Stay on this frequency (dwell) but check periodically
dwell_start = time.time()
while (time.time() - dwell_start) < scanner_config['dwell_time'] and scanner_running:
if scanner_skip_signal:
break
time.sleep(0.2)
last_signal_time = time.time()
else:
# No signal at this frequency
if signal_detected:
# Signal lost
duration = time.time() - last_signal_time + scanner_config['dwell_time']
add_activity_log('signal_lost', current_freq,
f'Signal lost after {duration:.1f}s')
signal_detected = False
# Stop audio
_stop_audio_stream()
try:
scanner_queue.put_nowait({
'type': 'signal_lost',
'frequency': current_freq
})
except queue.Full:
pass
# Move to next frequency (step is in kHz, convert to MHz)
current_freq += step_mhz
if current_freq > scanner_config['end_freq']:
current_freq = scanner_config['start_freq']
add_activity_log('scan_cycle', current_freq, 'Scan cycle complete')
time.sleep(scanner_config['scan_delay'])
except Exception as e:
logger.error(f"Scanner error at {current_freq} MHz: {e}")
time.sleep(0.5)
except Exception as e:
logger.error(f"Scanner loop error: {e}")
finally:
scanner_running = False
_stop_audio_stream()
add_activity_log('scanner_stop', scanner_current_freq, 'Scanner stopped')
logger.info("Scanner thread stopped")
def _start_audio_stream(frequency: float, modulation: str):
"""Start audio streaming at given frequency."""
global audio_process, audio_rtl_process, audio_running, audio_frequency, audio_modulation
with audio_lock:
# Stop any existing stream
_stop_audio_stream_internal()
rtl_fm_path = find_rtl_fm()
ffmpeg_path = find_ffmpeg()
if not rtl_fm_path or not ffmpeg_path:
return
freq_hz = int(frequency * 1e6)
if modulation == 'wfm':
sample_rate = 170000
resample_rate = 32000
elif modulation in ['usb', 'lsb']:
sample_rate = 12000
resample_rate = 12000
else:
sample_rate = 24000
resample_rate = 24000
rtl_cmd = [
rtl_fm_path,
'-M', modulation,
'-f', str(freq_hz),
'-s', str(sample_rate),
'-r', str(resample_rate),
'-g', str(scanner_config['gain']),
'-d', str(scanner_config['device']),
'-l', str(scanner_config['squelch']),
]
encoder_cmd = [
ffmpeg_path,
'-f', 's16le',
'-ar', str(resample_rate),
'-ac', '1',
'-i', 'pipe:0',
'-f', 'mp3',
'-b:a', '64k',
'-flush_packets', '1',
'pipe:1'
]
try:
audio_rtl_process = subprocess.Popen(
rtl_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL
)
audio_process = subprocess.Popen(
encoder_cmd,
stdin=audio_rtl_process.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
bufsize=0
)
audio_rtl_process.stdout.close()
audio_running = True
audio_frequency = frequency
audio_modulation = modulation
except Exception as e:
logger.error(f"Failed to start audio stream: {e}")
def _stop_audio_stream():
"""Stop audio streaming."""
with audio_lock:
_stop_audio_stream_internal()
def _stop_audio_stream_internal():
"""Internal stop (must hold lock)."""
global audio_process, audio_rtl_process, audio_running, audio_frequency
if audio_process:
try:
audio_process.terminate()
audio_process.wait(timeout=1)
except:
try:
audio_process.kill()
except:
pass
audio_process = None
if audio_rtl_process:
try:
audio_rtl_process.terminate()
audio_rtl_process.wait(timeout=1)
except:
try:
audio_rtl_process.kill()
except:
pass
audio_rtl_process = None
audio_running = False
audio_frequency = 0.0
# ============================================
# API ENDPOINTS
# ============================================
@listening_post_bp.route('/tools')
def check_tools() -> Response:
"""Check for required tools."""
rtl_fm = find_rtl_fm()
ffmpeg = find_ffmpeg()
sox = find_sox()
can_stream = ffmpeg is not None or sox is not None
return jsonify({
'rtl_fm': rtl_fm is not None,
'ffmpeg': ffmpeg is not None,
'sox': sox is not None,
'can_stream': can_stream,
'available': rtl_fm is not None and can_stream
})
@listening_post_bp.route('/scanner/start', methods=['POST'])
def start_scanner() -> Response:
"""Start the frequency scanner."""
global scanner_thread, scanner_running, scanner_config
with scanner_lock:
if scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner already running'
}), 409
data = request.json or {}
# Update scanner config
try:
scanner_config['start_freq'] = float(data.get('start_freq', 88.0))
scanner_config['end_freq'] = float(data.get('end_freq', 108.0))
scanner_config['step'] = float(data.get('step', 0.1))
scanner_config['modulation'] = str(data.get('modulation', 'wfm')).lower()
scanner_config['squelch'] = int(data.get('squelch', 20))
scanner_config['dwell_time'] = float(data.get('dwell_time', 3.0))
scanner_config['scan_delay'] = float(data.get('scan_delay', 0.5))
scanner_config['device'] = int(data.get('device', 0))
scanner_config['gain'] = int(data.get('gain', 40))
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
# Validate
if scanner_config['start_freq'] >= scanner_config['end_freq']:
return jsonify({
'status': 'error',
'message': 'start_freq must be less than end_freq'
}), 400
# Check tools
if not find_rtl_fm():
return jsonify({
'status': 'error',
'message': 'rtl_fm not found. Install rtl-sdr tools.'
}), 503
# Start scanner thread
scanner_running = True
scanner_thread = threading.Thread(target=scanner_loop, daemon=True)
scanner_thread.start()
return jsonify({
'status': 'started',
'config': scanner_config
})
@listening_post_bp.route('/scanner/stop', methods=['POST'])
def stop_scanner() -> Response:
"""Stop the frequency scanner."""
global scanner_running
scanner_running = False
_stop_audio_stream()
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/scanner/pause', methods=['POST'])
def pause_scanner() -> Response:
"""Pause/resume the scanner."""
global scanner_paused
scanner_paused = not scanner_paused
if scanner_paused:
add_activity_log('scanner_pause', scanner_current_freq, 'Scanner paused')
else:
add_activity_log('scanner_resume', scanner_current_freq, 'Scanner resumed')
return jsonify({
'status': 'paused' if scanner_paused else 'resumed',
'paused': scanner_paused
})
# Flag to trigger skip from API
scanner_skip_signal = False
@listening_post_bp.route('/scanner/skip', methods=['POST'])
def skip_signal() -> Response:
"""Skip current signal and continue scanning."""
global scanner_skip_signal
if not scanner_running:
return jsonify({
'status': 'error',
'message': 'Scanner not running'
}), 400
scanner_skip_signal = True
add_activity_log('signal_skip', scanner_current_freq, f'Skipped signal at {scanner_current_freq:.3f} MHz')
return jsonify({
'status': 'skipped',
'frequency': scanner_current_freq
})
@listening_post_bp.route('/scanner/status')
def scanner_status() -> Response:
"""Get scanner status."""
return jsonify({
'running': scanner_running,
'paused': scanner_paused,
'current_freq': scanner_current_freq,
'config': scanner_config,
'audio_streaming': audio_running,
'audio_frequency': audio_frequency
})
@listening_post_bp.route('/scanner/stream')
def stream_scanner_events() -> Response:
"""SSE stream for scanner events."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
while True:
try:
msg = scanner_queue.get(timeout=SSE_QUEUE_TIMEOUT)
last_keepalive = time.time()
yield format_sse(msg)
except queue.Empty:
now = time.time()
if now - last_keepalive >= SSE_KEEPALIVE_INTERVAL:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
return response
@listening_post_bp.route('/scanner/log')
def get_activity_log() -> Response:
"""Get activity log."""
limit = request.args.get('limit', 100, type=int)
with activity_log_lock:
return jsonify({
'log': activity_log[:limit],
'total': len(activity_log)
})
@listening_post_bp.route('/scanner/log/clear', methods=['POST'])
def clear_activity_log() -> Response:
"""Clear activity log."""
with activity_log_lock:
activity_log.clear()
return jsonify({'status': 'cleared'})
@listening_post_bp.route('/presets')
def get_presets() -> Response:
"""Get scanner presets."""
presets = [
{'name': 'FM Broadcast', 'start': 88.0, 'end': 108.0, 'step': 0.2, 'mod': 'wfm'},
{'name': 'Air Band', 'start': 118.0, 'end': 137.0, 'step': 0.025, 'mod': 'am'},
{'name': 'Marine VHF', 'start': 156.0, 'end': 163.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'Amateur 2m', 'start': 144.0, 'end': 148.0, 'step': 0.0125, 'mod': 'fm'},
{'name': 'Amateur 70cm', 'start': 430.0, 'end': 440.0, 'step': 0.025, 'mod': 'fm'},
{'name': 'PMR446', 'start': 446.0, 'end': 446.2, 'step': 0.0125, 'mod': 'fm'},
{'name': 'FRS/GMRS', 'start': 462.5, 'end': 467.7, 'step': 0.025, 'mod': 'fm'},
{'name': 'Weather Radio', 'start': 162.4, 'end': 162.55, 'step': 0.025, 'mod': 'fm'},
]
return jsonify({'presets': presets})
# ============================================
# MANUAL AUDIO ENDPOINTS (for direct listening)
# ============================================
@listening_post_bp.route('/audio/start', methods=['POST'])
def start_audio() -> Response:
"""Start audio at specific frequency (manual mode)."""
global scanner_running
# Stop scanner if running
if scanner_running:
scanner_running = False
time.sleep(0.5)
data = request.json or {}
try:
frequency = float(data.get('frequency', 0))
modulation = str(data.get('modulation', 'wfm')).lower()
squelch = int(data.get('squelch', 0))
gain = int(data.get('gain', 40))
device = int(data.get('device', 0))
except (ValueError, TypeError) as e:
return jsonify({
'status': 'error',
'message': f'Invalid parameter: {e}'
}), 400
if frequency <= 0:
return jsonify({
'status': 'error',
'message': 'frequency is required'
}), 400
valid_mods = ['fm', 'wfm', 'am', 'usb', 'lsb']
if modulation not in valid_mods:
return jsonify({
'status': 'error',
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
}), 400
# Update config for audio
scanner_config['squelch'] = squelch
scanner_config['gain'] = gain
scanner_config['device'] = device
_start_audio_stream(frequency, modulation)
if audio_running:
add_activity_log('manual_tune', frequency, f'Manual tune to {frequency} MHz ({modulation.upper()})')
return jsonify({
'status': 'started',
'frequency': frequency,
'modulation': modulation,
'stream_url': '/listening/audio/stream'
})
else:
return jsonify({
'status': 'error',
'message': 'Failed to start audio'
}), 500
@listening_post_bp.route('/audio/stop', methods=['POST'])
def stop_audio() -> Response:
"""Stop audio."""
_stop_audio_stream()
return jsonify({'status': 'stopped'})
@listening_post_bp.route('/audio/status')
def audio_status() -> Response:
"""Get audio status."""
return jsonify({
'running': audio_running,
'frequency': audio_frequency,
'modulation': audio_modulation
})
@listening_post_bp.route('/audio/stream')
def stream_audio() -> Response:
"""Stream MP3 audio."""
if not audio_running or not audio_process:
return jsonify({
'status': 'error',
'message': 'Audio not running'
}), 400
def generate():
chunk_size = 4096
try:
while audio_running and audio_process and audio_process.poll() is None:
chunk = audio_process.stdout.read(chunk_size)
if not chunk:
break
yield chunk
except Exception as e:
logger.error(f"Audio stream error: {e}")
return Response(
generate(),
mimetype='audio/mpeg',
headers={
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-cache, no-store',
'X-Accel-Buffering': 'no',
'Transfer-Encoding': 'chunked',
}
)
+228
View File
@@ -0,0 +1,228 @@
"""Settings management routes."""
from __future__ import annotations
from flask import Blueprint, jsonify, request, Response
from utils.database import (
get_setting,
set_setting,
delete_setting,
get_all_settings,
get_signal_history,
add_signal_reading,
get_correlations,
)
from utils.logging import get_logger
logger = get_logger('intercept.settings')
settings_bp = Blueprint('settings', __name__, url_prefix='/settings')
@settings_bp.route('', methods=['GET'])
def get_settings() -> Response:
"""Get all settings."""
try:
settings = get_all_settings()
return jsonify({
'status': 'success',
'settings': settings
})
except Exception as e:
logger.error(f"Error getting settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('', methods=['POST'])
def save_settings() -> Response:
"""Save one or more settings."""
data = request.json or {}
if not data:
return jsonify({
'status': 'error',
'message': 'No settings provided'
}), 400
try:
saved = []
for key, value in data.items():
# Validate key (alphanumeric, underscores, dots, hyphens)
if not key or not all(c.isalnum() or c in '_.-' for c in key):
continue
set_setting(key, value)
saved.append(key)
return jsonify({
'status': 'success',
'saved': saved
})
except Exception as e:
logger.error(f"Error saving settings: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['GET'])
def get_single_setting(key: str) -> Response:
"""Get a single setting by key."""
try:
value = get_setting(key)
if value is None:
return jsonify({
'status': 'not_found',
'key': key
}), 404
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error getting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['PUT'])
def update_single_setting(key: str) -> Response:
"""Update a single setting."""
data = request.json or {}
value = data.get('value')
if value is None and 'value' not in data:
return jsonify({
'status': 'error',
'message': 'Value is required'
}), 400
try:
set_setting(key, value)
return jsonify({
'status': 'success',
'key': key,
'value': value
})
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/<key>', methods=['DELETE'])
def delete_single_setting(key: str) -> Response:
"""Delete a setting."""
try:
deleted = delete_setting(key)
if deleted:
return jsonify({
'status': 'success',
'key': key,
'deleted': True
})
else:
return jsonify({
'status': 'not_found',
'key': key
}), 404
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Signal History Endpoints
# =============================================================================
@settings_bp.route('/signal-history/<mode>/<device_id>', methods=['GET'])
def get_device_signal_history(mode: str, device_id: str) -> Response:
"""Get signal strength history for a device."""
limit = request.args.get('limit', 100, type=int)
since_minutes = request.args.get('since', 60, type=int)
# Validate mode
valid_modes = ['wifi', 'bluetooth', 'adsb', 'pager', 'sensor']
if mode not in valid_modes:
return jsonify({
'status': 'error',
'message': f'Invalid mode. Valid modes: {valid_modes}'
}), 400
try:
history = get_signal_history(mode, device_id, limit, since_minutes)
return jsonify({
'status': 'success',
'mode': mode,
'device_id': device_id,
'history': history
})
except Exception as e:
logger.error(f"Error getting signal history: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@settings_bp.route('/signal-history', methods=['POST'])
def add_signal_history() -> Response:
"""Add a signal strength reading (for internal use)."""
data = request.json or {}
mode = data.get('mode')
device_id = data.get('device_id')
signal_strength = data.get('signal_strength')
if not all([mode, device_id, signal_strength is not None]):
return jsonify({
'status': 'error',
'message': 'mode, device_id, and signal_strength are required'
}), 400
try:
add_signal_reading(mode, device_id, signal_strength, data.get('metadata'))
return jsonify({'status': 'success'})
except Exception as e:
logger.error(f"Error adding signal reading: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
# =============================================================================
# Device Correlation Endpoints
# =============================================================================
@settings_bp.route('/correlations', methods=['GET'])
def get_device_correlations() -> Response:
"""Get device correlations between WiFi and Bluetooth."""
min_confidence = request.args.get('min_confidence', 0.5, type=float)
try:
correlations = get_correlations(min_confidence)
return jsonify({
'status': 'success',
'correlations': correlations
})
except Exception as e:
logger.error(f"Error getting correlations: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
+43 -10
View File
@@ -22,6 +22,26 @@ from utils.process import is_valid_mac, is_valid_channel
from utils.validation import validate_wifi_channel, validate_mac_address
from utils.sse import format_sse
from data.oui import get_manufacturer
from utils.constants import (
WIFI_TERMINATE_TIMEOUT,
PMKID_TERMINATE_TIMEOUT,
SSE_KEEPALIVE_INTERVAL,
SSE_QUEUE_TIMEOUT,
WIFI_CSV_PARSE_INTERVAL,
WIFI_CSV_TIMEOUT_WARNING,
SUBPROCESS_TIMEOUT_SHORT,
SUBPROCESS_TIMEOUT_MEDIUM,
SUBPROCESS_TIMEOUT_LONG,
DEAUTH_TIMEOUT,
MIN_DEAUTH_COUNT,
MAX_DEAUTH_COUNT,
DEFAULT_DEAUTH_COUNT,
PROCESS_START_WAIT,
MONITOR_MODE_DELAY,
WIFI_CAPTURE_PATH_PREFIX,
HANDSHAKE_CAPTURE_PATH_PREFIX,
PMKID_CAPTURE_PATH_PREFIX,
)
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
@@ -37,7 +57,7 @@ def detect_wifi_interfaces():
if platform.system() == 'Darwin': # macOS
try:
result = subprocess.run(['networksetup', '-listallhardwareports'],
capture_output=True, text=True, timeout=5)
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
if 'Wi-Fi' in line or 'AirPort' in line:
@@ -51,12 +71,16 @@ def detect_wifi_interfaces():
'status': 'up'
})
break
except Exception as e:
except FileNotFoundError:
logger.debug("networksetup not found")
except subprocess.TimeoutExpired:
logger.warning("networksetup timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting macOS interfaces: {e}")
try:
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
capture_output=True, text=True, timeout=10)
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_MEDIUM)
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
interfaces.append({
'name': 'USB WiFi Adapter',
@@ -64,12 +88,16 @@ def detect_wifi_interfaces():
'monitor_capable': True,
'status': 'detected'
})
except Exception:
pass
except FileNotFoundError:
logger.debug("system_profiler not found")
except subprocess.TimeoutExpired:
logger.debug("system_profiler timed out")
except subprocess.SubprocessError as e:
logger.debug(f"Error running system_profiler: {e}")
else: # Linux
try:
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
current_iface = None
for line in result.stdout.split('\n'):
line = line.strip()
@@ -85,8 +113,9 @@ def detect_wifi_interfaces():
})
current_iface = None
except FileNotFoundError:
# Fall back to iwconfig if iw is not available
try:
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT)
for line in result.stdout.split('\n'):
if 'IEEE 802.11' in line:
iface = line.split()[0]
@@ -96,9 +125,13 @@ def detect_wifi_interfaces():
'monitor_capable': True,
'status': 'up'
})
except Exception:
pass
except Exception as e:
except FileNotFoundError:
logger.debug("Neither iw nor iwconfig found")
except subprocess.SubprocessError as e:
logger.debug(f"Error running iwconfig: {e}")
except subprocess.TimeoutExpired:
logger.warning("iw command timed out")
except subprocess.SubprocessError as e:
logger.error(f"Error detecting Linux interfaces: {e}")
return interfaces
+247 -232
View File
@@ -1,7 +1,7 @@
#!/bin/bash
#
# INTERCEPT Setup Script
# Installs Python dependencies and checks for external tools
# Installs dependencies for macOS and Debian/Ubuntu
#
set -e
@@ -32,17 +32,11 @@ detect_os() {
elif [[ -f /etc/debian_version ]]; then
OS="debian"
PKG_MANAGER="apt"
elif [[ -f /etc/redhat-release ]]; then
OS="redhat"
PKG_MANAGER="dnf"
elif [[ -f /etc/arch-release ]]; then
OS="arch"
PKG_MANAGER="pacman"
else
OS="unknown"
PKG_MANAGER="unknown"
fi
echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
echo -e "${BLUE}Detected OS:${NC} $OS"
}
# Check if a command exists
@@ -50,14 +44,14 @@ check_cmd() {
command -v "$1" &> /dev/null
}
# Check if a package is installable (has a candidate version)
# Check if a package is installable (Debian)
pkg_available() {
local candidate
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
}
# Setup sudo command (empty if running as root)
# Setup sudo command
setup_sudo() {
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
@@ -66,46 +60,44 @@ setup_sudo() {
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
# ============================================
# PYTHON DEPENDENCIES
# ============================================
install_python_deps() {
echo ""
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
if ! check_cmd python3; then
echo -e "${RED}Error: Python 3 is not installed${NC}"
echo "Please install Python 3.9 or later"
if [[ "$OS" == "macos" ]]; then
echo "Install with: brew install python@3.11"
else
echo "Install with: sudo apt install python3"
fi
exit 1
fi
# Check Python version (need 3.9+)
# Check Python version
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
echo "Python version: $PYTHON_VERSION"
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
echo -e "${RED}Error: Python 3.9 or later is required${NC}"
echo "You have Python $PYTHON_VERSION"
echo ""
echo "Please upgrade Python:"
if [ -n "$SUDO" ]; then
echo " Ubuntu/Debian: sudo apt install python3.11"
echo -e "${RED}Error: Python 3.9 or later is required (you have $PYTHON_VERSION)${NC}"
if [[ "$OS" == "macos" ]]; then
echo "Upgrade with: brew install python@3.11"
else
echo " Ubuntu/Debian: apt install python3.11"
echo "Upgrade with: sudo apt install python3.11"
fi
echo " macOS: brew install python@3.11"
exit 1
fi
# Check if we're in a virtual environment
# Install dependencies
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
@@ -114,54 +106,59 @@ install_python_deps() {
source venv/bin/activate
pip install -r requirements.txt
else
# Try direct pip install first, fall back to venv if it fails (PEP 668)
echo "Attempting to install dependencies..."
# Try pip install, fall back to venv if needed (PEP 668)
if python3 -m pip install -r requirements.txt 2>/dev/null; then
echo -e "${GREEN}Python dependencies installed successfully${NC}"
echo -e "${GREEN}Python dependencies installed${NC}"
return
fi
# If pip install failed (likely PEP 668), create a virtual environment
echo ""
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
echo "Creating virtual environment..."
# Remove any incomplete venv directory from previous failed attempts
echo -e "${YELLOW}Creating virtual environment...${NC}"
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"
if [[ "$OS" == "debian" ]]; then
echo "Install with: sudo 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
echo -e "${YELLOW}NOTE: Virtual environment created.${NC}"
echo "Activate with: source venv/bin/activate"
fi
echo -e "${GREEN}Python dependencies installed successfully${NC}"
echo -e "${GREEN}Python dependencies installed${NC}"
}
# ============================================
# TOOL CHECKING
# ============================================
check_tool() {
local cmd=$1
local desc=$2
local category=$3
if check_cmd "$cmd"; then
echo -e " ${GREEN}${NC} $cmd - $desc"
return 0
else
echo -e " ${RED}${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
MISSING_TOOLS+=("$cmd")
case "$category" in
core) MISSING_CORE=true ;;
audio) MISSING_AUDIO=true ;;
wifi) MISSING_WIFI=true ;;
bluetooth) MISSING_BLUETOOTH=true ;;
esac
return 1
fi
}
# Check external tools
check_tools() {
echo ""
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
@@ -169,10 +166,10 @@ check_tools() {
MISSING_TOOLS=()
MISSING_CORE=false
MISSING_AUDIO=false
MISSING_WIFI=false
MISSING_BLUETOOTH=false
# Core SDR tools
echo "Core SDR Tools:"
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
check_tool "rtl_test" "RTL-SDR device detection" "core"
@@ -181,48 +178,51 @@ check_tools() {
check_tool "dump1090" "ADS-B decoder" "core"
echo ""
echo "Additional SDR Hardware (optional):"
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional"
check_tool "LimeUtil" "LimeSDR tools" "optional"
check_tool "hackrf_info" "HackRF tools" "optional"
echo "Audio Tools:"
check_tool "sox" "Audio player/processor" "audio"
# ffmpeg is optional alternative to sox
if check_cmd ffmpeg; then
echo -e " ${GREEN}${NC} ffmpeg - Audio encoder (optional)"
fi
echo ""
echo "WiFi Tools:"
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
check_tool "airodump-ng" "WiFi scanner" "wifi"
# aireplay-ng is optional (for deauth)
if check_cmd aireplay-ng; then
echo -e " ${GREEN}${NC} aireplay-ng - Deauthentication (optional)"
fi
echo ""
echo "Bluetooth Tools:"
check_tool "hcitool" "Bluetooth scanner" "bluetooth"
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
check_tool "hcitool" "Bluetooth HCI tool" "bluetooth"
check_tool "hciconfig" "Bluetooth adapter config" "bluetooth"
echo ""
echo "Optional (LimeSDR/HackRF):"
if check_cmd SoapySDRUtil; then
echo -e " ${GREEN}${NC} SoapySDRUtil - SoapySDR support"
else
echo -e " ${YELLOW}-${NC} SoapySDRUtil - Not installed (optional)"
fi
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
echo ""
echo -e "${YELLOW}Some tools are missing.${NC}"
fi
}
check_tool() {
local cmd=$1
local desc=$2
local category=$3
if check_cmd "$cmd"; then
echo -e " ${GREEN}${NC} $cmd - $desc"
else
echo -e " ${RED}${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
MISSING_TOOLS+=("$cmd")
case "$category" in
core) MISSING_CORE=true ;;
wifi) MISSING_WIFI=true ;;
bluetooth) MISSING_BLUETOOTH=true ;;
esac
echo ""
echo -e "${GREEN}All tools installed!${NC}"
fi
}
# Install tools on Debian/Ubuntu
install_debian_tools() {
# ============================================
# macOS INSTALLATION
# ============================================
install_macos_tools() {
echo ""
echo -e "${BLUE}[3/3] Installing tools...${NC}"
echo -e "${BLUE}[3/3] Installing tools (macOS)...${NC}"
echo ""
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
@@ -230,17 +230,114 @@ install_debian_tools() {
return
fi
echo -e "${YELLOW}The following tool categories need to be installed:${NC}"
# Check for Homebrew
if ! check_cmd brew; then
echo -e "${YELLOW}Homebrew is not installed.${NC}"
echo ""
read -p "Would you like to install Homebrew? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Add brew to PATH for this session
if [[ -f /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
else
echo ""
echo "Skipping tool installation. Install manually with:"
show_macos_manual
return
fi
fi
echo ""
echo -e "${YELLOW}The following will be installed:${NC}"
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl_433, dump1090)"
$MISSING_AUDIO && echo " - Audio tools (sox)"
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
echo ""
read -p "Install missing tools? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
# Core SDR tools
if $MISSING_CORE; then
echo ""
echo -e "${BLUE}Installing Core SDR tools...${NC}"
brew install librtlsdr multimon-ng rtl_433 2>/dev/null || true
# dump1090
if ! check_cmd dump1090; then
brew install dump1090-mutability 2>/dev/null || \
echo -e "${YELLOW}Note: dump1090 may need manual installation${NC}"
fi
fi
# Audio tools
if $MISSING_AUDIO; then
echo ""
echo -e "${BLUE}Installing Audio tools...${NC}"
brew install sox
fi
# WiFi tools
if $MISSING_WIFI; then
echo ""
echo -e "${BLUE}Installing WiFi tools...${NC}"
brew install aircrack-ng
fi
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
else
show_macos_manual
fi
}
show_macos_manual() {
echo ""
echo -e "${BLUE}Manual installation (macOS):${NC}"
echo ""
echo "# Required tools"
echo "brew install librtlsdr multimon-ng rtl_433 sox"
echo ""
echo "# ADS-B tracking"
echo "brew install dump1090-mutability"
echo ""
echo "# WiFi scanning (optional)"
echo "brew install aircrack-ng"
}
# ============================================
# DEBIAN INSTALLATION
# ============================================
install_debian_tools() {
echo ""
echo -e "${BLUE}[3/3] Installing tools (Debian/Ubuntu)...${NC}"
echo ""
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo -e "${GREEN}All tools are already installed!${NC}"
return
fi
echo -e "${YELLOW}The following will be installed:${NC}"
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
$MISSING_AUDIO && echo " - Audio tools (sox)"
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
echo ""
read -p "Would you like to install missing tools automatically? [Y/n] " -n 1 -r
read -p "Install missing tools? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo ""
echo "Updating package lists..."
$SUDO apt update
@@ -248,34 +345,40 @@ install_debian_tools() {
if $MISSING_CORE; then
echo ""
echo -e "${BLUE}Installing Core SDR tools...${NC}"
$SUDO apt install -y rtl-sdr multimon-ng 2>/dev/null || true
# Install packages that are reliably available
$SUDO apt install -y rtl-sdr multimon-ng
# rtl-433 may be named differently or unavailable
# rtl-433 (package name varies)
if pkg_available rtl-433; then
$SUDO apt install -y rtl-433
elif pkg_available rtl433; then
$SUDO apt install -y rtl433
else
echo -e "${YELLOW}Note: rtl-433 not found in repositories. Install manually or from source.${NC}"
echo -e "${YELLOW}Note: rtl-433 not in repositories, install manually${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"
# dump1090 (package varies by distribution)
if ! check_cmd dump1090; then
if pkg_available dump1090-fa; then
$SUDO apt install -y dump1090-fa
elif pkg_available dump1090-mutability; then
$SUDO apt install -y dump1090-mutability
elif pkg_available dump1090; then
$SUDO apt install -y dump1090
else
echo ""
echo -e "${YELLOW}Note: dump1090 not in repositories.${NC}"
echo "Install from: https://flightaware.com/adsb/piaware/install"
fi
fi
fi
# Audio tools
if $MISSING_AUDIO; then
echo ""
echo -e "${BLUE}Installing Audio tools...${NC}"
$SUDO apt install -y sox
fi
# WiFi tools
if $MISSING_WIFI; then
echo ""
@@ -293,26 +396,37 @@ install_debian_tools() {
echo ""
echo -e "${GREEN}Tool installation complete!${NC}"
# Setup udev rules automatically
setup_udev_rules_auto
# Setup udev rules
setup_udev_rules
else
echo ""
echo "Skipping automatic installation."
show_manual_instructions
show_debian_manual
fi
}
# Setup udev rules automatically (Debian)
setup_udev_rules_auto() {
show_debian_manual() {
echo ""
echo -e "${BLUE}Setting up RTL-SDR udev rules...${NC}"
echo -e "${BLUE}Manual installation (Debian/Ubuntu):${NC}"
echo ""
echo "# Required tools"
echo "sudo apt install rtl-sdr multimon-ng rtl-433 sox"
echo ""
echo "# ADS-B tracking"
echo "sudo apt install dump1090-mutability # or dump1090-fa"
echo ""
echo "# WiFi scanning (optional)"
echo "sudo apt install aircrack-ng"
echo ""
echo "# Bluetooth scanning (optional)"
echo "sudo apt install bluez bluetooth"
}
setup_udev_rules() {
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
echo "udev rules already exist, skipping."
return
fi
read -p "Would you like to setup RTL-SDR udev rules? [Y/n] " -n 1 -r
echo ""
read -p "Setup RTL-SDR udev rules? [Y/n] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
@@ -323,133 +437,32 @@ EOF'
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger
echo -e "${GREEN}udev rules installed!${NC}"
echo "Please unplug and replug your RTL-SDR device."
echo "Please unplug and replug your RTL-SDR."
fi
}
# Show manual installation instructions
show_manual_instructions() {
echo ""
echo -e "${BLUE}Manual installation instructions:${NC}"
echo ""
if [[ "$OS" == "macos" ]]; then
echo -e "${YELLOW}macOS (Homebrew):${NC}"
echo ""
if ! check_cmd brew; then
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"
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
echo ""
echo "# LimeSDR support (optional)"
echo "brew install soapysdr limesuite soapylms7"
echo ""
echo "# HackRF support (optional)"
echo "brew install hackrf soapyhackrf"
echo ""
echo "# WiFi tools"
echo "brew install aircrack-ng"
elif [[ "$OS" == "debian" ]]; then
echo -e "${YELLOW}Ubuntu/Debian:${NC}"
echo ""
echo "# Core SDR tools"
echo "sudo apt update"
echo "sudo apt install rtl-sdr multimon-ng rtl-433"
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
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() {
if [[ "$OS" == "debian" ]]; then
install_debian_tools
else
echo ""
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo ""
echo -e "${GREEN}All tools are installed!${NC}"
else
show_manual_instructions
fi
fi
}
# RTL-SDR udev rules (Linux only)
setup_udev_rules() {
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
echo ""
echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
echo ""
echo "If your RTL-SDR is not detected, you may need to add udev rules:"
echo ""
echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
echo "EOF'"
echo ""
echo "sudo udevadm control --reload-rules"
echo "sudo udevadm trigger"
echo ""
echo "Then unplug and replug your RTL-SDR device."
fi
}
# Main
# ============================================
# MAIN
# ============================================
main() {
detect_os
setup_sudo
if [[ "$OS" == "unknown" ]]; then
echo -e "${RED}Unsupported OS. This script supports macOS and Debian/Ubuntu.${NC}"
exit 1
fi
if [[ "$OS" == "debian" ]]; then
setup_sudo
fi
install_python_deps
check_tools
install_or_show_instructions
# Show udev rules instructions for non-Debian Linux (Debian handles it automatically)
if [[ "$OS" != "debian" ]]; then
setup_udev_rules
if [[ "$OS" == "macos" ]]; then
install_macos_tools
else
install_debian_tools
fi
echo ""
@@ -457,20 +470,22 @@ main() {
echo -e "${GREEN}Setup complete!${NC}"
echo ""
echo "To start INTERCEPT:"
if [ -d "venv" ]; then
echo " source venv/bin/activate"
if [ -n "$SUDO" ]; then
if [[ "$OS" == "debian" ]]; then
echo " sudo venv/bin/python intercept.py"
else
echo " venv/bin/python intercept.py"
echo " sudo python3 intercept.py"
fi
else
if [ -n "$SUDO" ]; then
if [[ "$OS" == "debian" ]]; then
echo " sudo python3 intercept.py"
else
echo " python3 intercept.py"
echo " sudo python3 intercept.py"
fi
fi
echo ""
echo "Then open http://localhost:5050 in your browser"
echo ""
+214 -80
View File
@@ -5,24 +5,27 @@
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00ff88;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-green: #00ff88;
--accent-cyan: #00d4ff;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--accent-yellow: #ffcc00;
--grid-line: rgba(0, 255, 136, 0.1);
--radar-cyan: #00ffff;
--radar-bg: #1a1a2e;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-green: #22c55e;
--accent-cyan: #4a9eff;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
--radar-cyan: #4a9eff;
--radar-bg: #0f1218;
}
body {
font-family: 'Rajdhani', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -44,18 +47,18 @@ body {
z-index: 0;
}
/* Scan line effect */
/* Scan line effect - subtle */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
animation: scan 4s linear infinite;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 6s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.5;
opacity: 0.3;
}
@keyframes scan {
@@ -73,20 +76,20 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 255, 136, 0.3);
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-green);
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
font-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo span {
@@ -115,8 +118,8 @@ body {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
animation: pulse 2s ease-in-out infinite;
}
@@ -144,8 +147,8 @@ body {
}
.stat-badge {
background: rgba(0, 255, 136, 0.1);
border: 1px solid rgba(0, 255, 136, 0.3);
background: rgba(74, 158, 255, 0.1);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace;
@@ -153,7 +156,7 @@ body {
}
.stat-badge .value {
color: var(--accent-green);
color: var(--accent-cyan);
font-weight: 600;
}
@@ -165,15 +168,15 @@ body {
.datetime {
font-family: 'Orbitron', monospace;
font-size: 12px;
color: var(--accent-green);
color: var(--accent-cyan);
}
.back-link {
color: var(--accent-green);
color: var(--accent-cyan);
text-decoration: none;
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--accent-green);
border: 1px solid var(--accent-cyan);
border-radius: 4px;
}
@@ -192,7 +195,7 @@ body {
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(0, 255, 136, 0.2);
border: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
position: relative;
}
@@ -204,19 +207,19 @@ body {
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.panel-header {
padding: 10px 15px;
background: rgba(0, 255, 136, 0.05);
border-bottom: 1px solid rgba(0, 255, 136, 0.1);
background: rgba(74, 158, 255, 0.05);
border-bottom: 1px solid rgba(74, 158, 255, 0.1);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-green);
color: var(--accent-cyan);
display: flex;
justify-content: space-between;
align-items: center;
@@ -225,7 +228,7 @@ body {
.panel-indicator {
width: 6px;
height: 6px;
background: var(--accent-green);
background: var(--accent-cyan);
border-radius: 50%;
animation: blink 1s ease-in-out infinite;
}
@@ -300,7 +303,7 @@ body {
grid-row: 1;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(0, 255, 136, 0.2);
border-left: 1px solid rgba(74, 158, 255, 0.2);
overflow: hidden;
}
@@ -310,13 +313,13 @@ body {
padding: 10px;
gap: 8px;
background: var(--bg-panel);
border-bottom: 1px solid rgba(0, 255, 136, 0.2);
border-bottom: 1px solid rgba(74, 158, 255, 0.2);
}
.view-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
background: transparent;
color: var(--text-secondary);
font-family: 'Orbitron', monospace;
@@ -330,13 +333,13 @@ body {
}
.view-btn:hover {
border-color: var(--accent-green);
color: var(--accent-green);
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.view-btn.active {
background: var(--accent-green);
border-color: var(--accent-green);
background: var(--accent-cyan);
border-color: var(--accent-cyan);
color: var(--bg-dark);
}
@@ -355,8 +358,8 @@ body {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-weight: 700;
color: var(--accent-green);
text-shadow: 0 0 15px var(--accent-green);
color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan);
text-align: center;
margin-bottom: 12px;
}
@@ -371,7 +374,7 @@ body {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
padding: 8px;
border-left: 2px solid var(--accent-green);
border-left: 2px solid var(--accent-cyan);
}
.telemetry-label {
@@ -404,7 +407,7 @@ body {
.aircraft-item {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.15);
border: 1px solid rgba(74, 158, 255, 0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
@@ -413,14 +416,14 @@ body {
}
.aircraft-item:hover {
border-color: var(--accent-green);
background: rgba(0, 255, 136, 0.05);
border-color: var(--accent-cyan);
background: rgba(74, 158, 255, 0.05);
}
.aircraft-item.selected {
border-color: var(--accent-green);
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
background: rgba(0, 255, 136, 0.1);
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(74, 158, 255, 0.2);
background: rgba(74, 158, 255, 0.1);
}
.aircraft-header {
@@ -434,14 +437,14 @@ body {
font-family: 'Orbitron', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-green);
color: var(--accent-cyan);
}
.aircraft-icao {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
background: rgba(0, 255, 136, 0.1);
background: rgba(74, 158, 255, 0.1);
padding: 2px 5px;
border-radius: 3px;
}
@@ -475,10 +478,11 @@ body {
grid-row: 2;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
gap: 10px 20px;
padding: 10px 20px;
background: var(--bg-panel);
border-top: 1px solid rgba(0, 255, 136, 0.3);
border-top: 1px solid rgba(74, 158, 255, 0.3);
}
.control-group {
@@ -497,15 +501,15 @@ body {
}
.control-group input[type="checkbox"] {
accent-color: var(--accent-green);
accent-color: var(--accent-cyan);
}
.control-group select {
padding: 6px 10px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -514,9 +518,9 @@ body {
width: 80px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(0, 255, 136, 0.3);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
@@ -531,9 +535,9 @@ body {
/* Start/stop button */
.start-btn {
padding: 8px 20px;
border: 1px solid var(--accent-green);
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
border: 1px solid var(--accent-cyan);
background: rgba(74, 158, 255, 0.1);
color: var(--accent-cyan);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
@@ -546,9 +550,9 @@ body {
}
.start-btn:hover {
background: var(--accent-green);
background: var(--accent-cyan);
color: var(--bg-dark);
box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
box-shadow: 0 0 20px rgba(74, 158, 255, 0.3);
}
.start-btn.active {
@@ -564,10 +568,10 @@ body {
/* GPS button */
.gps-btn {
padding: 6px 10px;
background: rgba(0, 255, 136, 0.2);
border: 1px solid rgba(0, 255, 136, 0.3);
background: rgba(74, 158, 255, 0.2);
border: 1px solid rgba(74, 158, 255, 0.3);
border-radius: 4px;
color: var(--accent-green);
color: var(--accent-cyan);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
@@ -578,10 +582,15 @@ body {
background: var(--bg-dark) !important;
}
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-green) !important;
border-color: rgba(0, 255, 136, 0.3) !important;
color: var(--accent-cyan) !important;
border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
@@ -600,7 +609,7 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--accent-green);
background: var(--accent-cyan);
border-radius: 3px;
}
@@ -632,7 +641,7 @@ body {
grid-column: 1;
grid-row: 2;
border-left: none;
border-top: 1px solid rgba(0, 255, 136, 0.2);
border-top: 1px solid rgba(74, 158, 255, 0.2);
max-height: 300px;
}
@@ -640,4 +649,129 @@ body {
grid-row: 3;
flex-wrap: wrap;
}
}
}
/* Airband Audio Controls */
.airband-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 10px;
}
.airband-controls {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
}
.airband-btn {
padding: 6px 12px;
background: rgba(74, 158, 255, 0.1);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s;
flex-shrink: 0;
white-space: nowrap;
}
.airband-btn:hover {
background: rgba(74, 158, 255, 0.2);
}
.airband-btn.active {
background: rgba(34, 197, 94, 0.2);
border-color: var(--accent-green);
color: var(--accent-green);
}
.airband-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.airband-icon {
font-size: 10px;
}
.airband-status {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
padding: 0 8px;
}
#airbandSquelch {
accent-color: var(--accent-cyan);
}
/* Airband Audio Visualizer */
.airband-visualizer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-left: 1px solid var(--border-color);
margin-left: 5px;
}
.airband-visualizer .signal-meter {
width: 80px;
}
.airband-visualizer .meter-bar {
height: 10px;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
position: relative;
overflow: hidden;
opacity: 0.3;
}
.airband-visualizer .meter-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg,
var(--accent-green) 0%,
var(--accent-green) 60%,
var(--accent-orange) 60%,
var(--accent-orange) 80%,
var(--accent-red) 80%,
var(--accent-red) 100%
);
border-radius: 3px;
width: 0%;
transition: width 0.05s ease-out;
}
.airband-visualizer .meter-peak {
position: absolute;
top: 0;
height: 100%;
width: 2px;
background: #fff;
opacity: 0.8;
transition: left 0.05s ease-out;
left: 0%;
}
#airbandSpectrumCanvas {
border-radius: 3px;
background: rgba(0, 0, 0, 0.4);
}
+650 -312
View File
File diff suppressed because it is too large Load Diff
+32 -24
View File
@@ -5,22 +5,25 @@
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00d4ff;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--bg-dark: #0a0c10;
--bg-panel: #0f1218;
--bg-card: #151a23;
--border-color: #1f2937;
--border-glow: #4a9eff;
--text-primary: #e8eaed;
--text-secondary: #9ca3af;
--text-dim: #4b5563;
--accent-cyan: #4a9eff;
--accent-green: #22c55e;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--accent-purple: #a855f7;
--grid-line: rgba(0, 212, 255, 0.1);
--accent-amber: #d4a853;
--grid-line: rgba(74, 158, 255, 0.08);
}
body {
font-family: 'Rajdhani', sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
@@ -82,28 +85,28 @@ body {
position: relative;
z-index: 10;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-cyan);
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
font-family: 'Inter', sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 3px;
color: var(--text-primary);
text-transform: uppercase;
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 14px;
font-size: 12px;
margin-left: 15px;
letter-spacing: 2px;
letter-spacing: 1px;
}
/* Stats badges in header */
@@ -113,7 +116,7 @@ body {
}
.stat-badge {
background: rgba(0, 212, 255, 0.1);
background: var(--bg-card);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 4px;
padding: 4px 10px;
@@ -600,10 +603,15 @@ body {
background: var(--bg-dark) !important;
}
.leaflet-tile-pane,
.leaflet-container .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.8) contrast(1.2) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-cyan) !important;
border-color: rgba(0, 212, 255, 0.3) !important;
border-color: var(--border-color) !important;
}
.leaflet-control-attribution {
+359 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
@@ -179,6 +179,50 @@
<input type="number" id="remoteSbsPort" value="30003" min="1" max="65535" style="width: 55px; font-size: 10px;">
</div>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
<div class="airband-divider"></div>
<div class="control-group airband-controls">
<span class="control-label" style="color: var(--accent-cyan);">AIRBAND:</span>
<select id="airbandFreqSelect" onchange="updateAirbandFreq()">
<option value="121.5">121.5 MHz (Guard)</option>
<option value="118.0">118.0 MHz</option>
<option value="119.1">119.1 MHz</option>
<option value="120.5">120.5 MHz</option>
<option value="123.45">123.45 MHz (Air-Air)</option>
<option value="127.85">127.85 MHz</option>
<option value="128.825">128.825 MHz</option>
<option value="132.0">132.0 MHz</option>
<option value="134.725">134.725 MHz</option>
<option value="custom">Custom...</option>
</select>
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" style="width: 70px; display: none;">
</div>
<div class="control-group airband-controls">
<span class="control-label">SDR:</span>
<select id="airbandDeviceSelect" style="width: 80px;">
<option value="0">Dev 0</option>
</select>
</div>
<div class="control-group airband-controls">
<span class="control-label">SQ:</span>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" style="width: 60px;">
</div>
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">
<span class="airband-icon"></span> LISTEN
</button>
<div class="airband-status">
<span id="airbandStatus" style="color: var(--text-muted);">OFF</span>
</div>
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
<!-- Airband Visualizer (compact) -->
<div class="airband-visualizer" id="airbandVisualizerContainer" style="display: none;">
<div class="signal-meter">
<div class="meter-bar">
<div class="meter-fill" id="airbandSignalMeter"></div>
<div class="meter-peak" id="airbandSignalPeak"></div>
</div>
</div>
<canvas id="airbandSpectrumCanvas" width="120" height="30"></canvas>
</div>
</div>
</main>
@@ -1117,8 +1161,8 @@
maxZoom: 15
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap, ©CartoDB'
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(radarMap);
if (navigator.geolocation) {
@@ -1554,6 +1598,318 @@
scheduleUIUpdate();
}
}
// ============================================
// AIRBAND AUDIO
// ============================================
let isAirbandPlaying = false;
// Web Audio API for airband visualization
let airbandAudioContext = null;
let airbandAnalyser = null;
let airbandSource = null;
let airbandVisualizerId = null;
let airbandPeakLevel = 0;
function initAirbandVisualizer() {
const audioPlayer = document.getElementById('airbandPlayer');
if (!airbandAudioContext) {
airbandAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (airbandAudioContext.state === 'suspended') {
airbandAudioContext.resume();
}
if (!airbandSource) {
try {
airbandSource = airbandAudioContext.createMediaElementSource(audioPlayer);
airbandAnalyser = airbandAudioContext.createAnalyser();
airbandAnalyser.fftSize = 128;
airbandAnalyser.smoothingTimeConstant = 0.7;
airbandSource.connect(airbandAnalyser);
airbandAnalyser.connect(airbandAudioContext.destination);
} catch (e) {
console.warn('Could not create airband audio source:', e);
return;
}
}
document.getElementById('airbandVisualizerContainer').style.display = 'flex';
drawAirbandVisualizer();
}
function drawAirbandVisualizer() {
if (!airbandAnalyser) return;
const canvas = document.getElementById('airbandSpectrumCanvas');
const ctx = canvas.getContext('2d');
const bufferLength = airbandAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
airbandVisualizerId = requestAnimationFrame(draw);
airbandAnalyser.getByteFrequencyData(dataArray);
// Signal meter
let sum = 0;
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
const average = sum / bufferLength;
const levelPercent = (average / 255) * 100;
if (levelPercent > airbandPeakLevel) {
airbandPeakLevel = levelPercent;
} else {
airbandPeakLevel *= 0.95;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = levelPercent + '%';
if (meterPeak) meterPeak.style.left = Math.min(airbandPeakLevel, 100) + '%';
// Draw spectrum
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / bufferLength * 2;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const hue = 200 - (i / bufferLength) * 60;
ctx.fillStyle = `hsl(${hue}, 80%, ${40 + (dataArray[i] / 255) * 30}%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
}
draw();
}
function stopAirbandVisualizer() {
if (airbandVisualizerId) {
cancelAnimationFrame(airbandVisualizerId);
airbandVisualizerId = null;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = '0%';
if (meterPeak) meterPeak.style.left = '0%';
airbandPeakLevel = 0;
const container = document.getElementById('airbandVisualizerContainer');
if (container) container.style.display = 'none';
}
function initAirband() {
// Populate device selector
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR</option>';
} else {
devices.forEach((dev, i) => {
const opt = document.createElement('option');
opt.value = dev.index || i;
opt.textContent = `Dev ${dev.index || i}`;
select.appendChild(opt);
});
}
})
.catch(() => {});
// Check if audio tools are available
fetch('/listening/tools')
.then(r => r.json())
.then(data => {
const missingTools = [];
if (!data.rtl_fm) missingTools.push('rtl_fm');
if (!data.sox) missingTools.push('sox (audio player)');
if (missingTools.length > 0) {
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
// Show warning banner
showAirbandWarning(missingTools);
}
})
.catch(() => {
// Endpoint not available, disable airband
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function showAirbandWarning(missingTools) {
const warning = document.createElement('div');
warning.id = 'airbandWarning';
warning.style.cssText = `
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 400px;
text-align: center;
`;
const toolList = missingTools.join(', ');
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">⚠️ Airband Listen Unavailable</div>
<div>Missing required tools: <strong>${toolList}</strong></div>
<div style="margin-top: 8px; font-size: 10px; opacity: 0.9;">
Install with: <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">sudo apt install rtl-sdr sox</code> (Debian) or <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">brew install librtlsdr sox</code> (macOS)
</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 5px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
document.body.appendChild(warning);
// Auto-dismiss after 15 seconds
setTimeout(() => {
if (warning.parentElement) {
warning.style.opacity = '0';
warning.style.transition = 'opacity 0.3s';
setTimeout(() => warning.remove(), 300);
}
}, 15000);
}
function updateAirbandFreq() {
const select = document.getElementById('airbandFreqSelect');
const customInput = document.getElementById('airbandCustomFreq');
if (select.value === 'custom') {
customInput.style.display = 'inline-block';
} else {
customInput.style.display = 'none';
// If audio is playing, restart on new frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
}
}
// Handle custom frequency input changes
document.addEventListener('DOMContentLoaded', () => {
const customInput = document.getElementById('airbandCustomFreq');
if (customInput) {
customInput.addEventListener('change', () => {
// If audio is playing, restart on new custom frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
});
}
});
function getAirbandFrequency() {
const select = document.getElementById('airbandFreqSelect');
if (select.value === 'custom') {
return parseFloat(document.getElementById('airbandCustomFreq').value) || 121.5;
}
return parseFloat(select.value);
}
function toggleAirband() {
if (isAirbandPlaying) {
stopAirband();
} else {
startAirband();
}
}
function startAirband() {
const frequency = getAirbandFrequency();
const device = parseInt(document.getElementById('airbandDeviceSelect').value);
const squelch = parseInt(document.getElementById('airbandSquelch').value);
document.getElementById('airbandStatus').textContent = 'STARTING...';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
fetch('/spectrum/audio/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
frequency: frequency,
modulation: 'am', // Airband uses AM
squelch: squelch,
gain: 40,
device: device
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAirbandPlaying = true;
// Start browser audio playback
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.src = '/spectrum/audio/stream?' + Date.now();
// Initialize visualizer before playing
initAirbandVisualizer();
audioPlayer.play().catch(e => {
console.warn('Audio autoplay blocked:', e);
});
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon"></span> STOP';
document.getElementById('airbandBtn').classList.add('active');
document.getElementById('airbandStatus').textContent = frequency.toFixed(3) + ' MHz';
document.getElementById('airbandStatus').style.color = 'var(--accent-green)';
} else {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
alert('Airband Error: ' + (data.message || 'Failed to start'));
}
})
.catch(err => {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function stopAirband() {
// Stop visualizer
stopAirbandVisualizer();
// Stop browser audio
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.pause();
audioPlayer.src = '';
fetch('/spectrum/audio/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAirbandPlaying = false;
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon"></span> LISTEN';
document.getElementById('airbandBtn').classList.remove('active');
document.getElementById('airbandStatus').textContent = 'OFF';
document.getElementById('airbandStatus').style.color = 'var(--text-muted)';
})
.catch(() => {});
}
// Initialize airband on page load
document.addEventListener('DOMContentLoaded', initAirband);
</script>
</body>
</html>
+1839 -139
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
@@ -247,8 +247,8 @@
worldCopyJump: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap, ©CartoDB'
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(groundMap);
}
+340
View File
@@ -0,0 +1,340 @@
"""Tests for device correlation engine."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
class TestDeviceCorrelator:
"""Tests for DeviceCorrelator class."""
def test_correlate_same_oui(self):
"""Test correlation detects same OUI."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'essid': 'TestNetwork',
'power': -65
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': datetime.now(),
'last_seen': datetime.now(),
'name': 'TestPhone',
'rssi': -60
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
assert len(correlations) >= 1
assert correlations[0]['wifi_mac'] == 'AA:BB:CC:11:22:33'
assert correlations[0]['bt_mac'] == 'AA:BB:CC:44:55:66'
assert correlations[0]['confidence'] > 0
def test_correlate_timing(self):
"""Test correlation considers timing."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=30)
now = datetime.now()
# Devices appearing at the same time
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'essid': 'Network1'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'Device1'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have some confidence from timing correlation
if correlations:
assert correlations[0]['confidence'] > 0
def test_correlate_no_overlap(self):
"""Test no correlation when devices don't overlap."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=30,
min_confidence=0.6
)
now = datetime.now()
old = now - timedelta(hours=1)
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': old,
'last_seen': old,
'essid': 'OldNetwork'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'name': 'NewDevice'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# With high min_confidence and no OUI match, should be empty
assert len(correlations) == 0
def test_correlate_manufacturer_match(self):
"""Test correlation boosts confidence for same manufacturer."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'essid': 'Network'
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple',
'name': 'iPhone'
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# Should have correlation with bonus for manufacturer match
assert len(correlations) >= 1
def test_correlate_empty_inputs(self):
"""Test correlation handles empty inputs."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
# Empty WiFi
assert correlator.correlate({}, {'AA:BB:CC:DD:EE:FF': {}}) == []
# Empty Bluetooth
assert correlator.correlate({'AA:BB:CC:DD:EE:FF': {}}, {}) == []
# Both empty
assert correlator.correlate({}, {}) == []
def test_correlate_sorting(self):
"""Test correlations are sorted by confidence."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(
time_window_seconds=60,
min_confidence=0.0
)
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:11:11': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:22:22:22': {
'first_seen': now,
'last_seen': now,
'manufacturer': 'Apple'
},
'77:88:99:AA:BB:CC': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if len(correlations) >= 2:
# Should be sorted by confidence (highest first)
assert correlations[0]['confidence'] >= correlations[1]['confidence']
class TestGetCorrelations:
"""Tests for get_correlations function."""
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_live(self, mock_db, mock_correlator):
"""Test get_correlations with live data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = []
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=False
)
assert len(results) == 1
mock_correlator.correlate.assert_called_once()
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_historical(self, mock_db, mock_correlator):
"""Test get_correlations includes historical data."""
from utils.correlation import get_correlations
mock_correlator.correlate.return_value = []
mock_db.return_value = [
{
'wifi_mac': 'CC:CC:CC:CC:CC:CC',
'bt_mac': 'DD:DD:DD:DD:DD:DD',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
results = get_correlations(
wifi_devices={},
bt_devices={},
include_historical=True
)
assert len(results) == 1
assert results[0]['wifi_mac'] == 'CC:CC:CC:CC:CC:CC'
@patch('utils.correlation.correlator')
@patch('utils.correlation.db_get_correlations')
def test_get_correlations_deduplication(self, mock_db, mock_correlator):
"""Test get_correlations deduplicates live and historical."""
from utils.correlation import get_correlations
# Same correlation from both sources
mock_correlator.correlate.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.8
}
]
mock_db.return_value = [
{
'wifi_mac': 'AA:AA:AA:AA:AA:AA',
'bt_mac': 'BB:BB:BB:BB:BB:BB',
'confidence': 0.7,
'first_seen': '2024-01-01',
'last_seen': '2024-01-02'
}
]
wifi = {'AA:AA:AA:AA:AA:AA': {}}
bt = {'BB:BB:BB:BB:BB:BB': {}}
results = get_correlations(
wifi_devices=wifi,
bt_devices=bt,
include_historical=True
)
# Should deduplicate - only one entry for the same device pair
matching = [r for r in results
if r['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
class TestCorrelationReason:
"""Tests for correlation reason generation."""
def test_reason_same_oui(self):
"""Test reason includes OUI match."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator()
now = datetime.now()
wifi_devices = {
'AA:BB:CC:11:22:33': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'AA:BB:CC:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
if correlations:
assert 'OUI' in correlations[0]['reason'] or 'same' in correlations[0]['reason'].lower()
def test_reason_timing(self):
"""Test reason includes timing information."""
from utils.correlation import DeviceCorrelator
correlator = DeviceCorrelator(time_window_seconds=60)
now = datetime.now()
wifi_devices = {
'11:22:33:44:55:66': {
'first_seen': now,
'last_seen': now
}
}
bt_devices = {
'77:88:99:AA:BB:CC': {
'first_seen': now + timedelta(seconds=5),
'last_seen': now + timedelta(seconds=5)
}
}
correlations = correlator.correlate(wifi_devices, bt_devices)
# If correlation found, should mention timing
if correlations and correlations[0]['confidence'] > 0.3:
assert 'appeared' in correlations[0]['reason'] or 'timing' in correlations[0]['reason']
+256
View File
@@ -0,0 +1,256 @@
"""Tests for database utilities."""
import os
import tempfile
import pytest
from pathlib import Path
from unittest.mock import patch
# Need to patch DB_PATH before importing database module
@pytest.fixture(autouse=True)
def temp_db():
"""Use a temporary database for each test."""
with tempfile.TemporaryDirectory() as tmpdir:
test_db_path = Path(tmpdir) / 'test_intercept.db'
test_db_dir = Path(tmpdir)
with patch('utils.database.DB_PATH', test_db_path), \
patch('utils.database.DB_DIR', test_db_dir):
# Import after patching
from utils.database import init_db, close_db
init_db()
yield test_db_path
close_db()
class TestSettingsCRUD:
"""Tests for settings CRUD operations."""
def test_set_and_get_string(self, temp_db):
"""Test setting and getting string values."""
from utils.database import set_setting, get_setting
set_setting('test_key', 'test_value')
assert get_setting('test_key') == 'test_value'
def test_set_and_get_int(self, temp_db):
"""Test setting and getting integer values."""
from utils.database import set_setting, get_setting
set_setting('int_key', 42)
result = get_setting('int_key')
assert result == 42
assert isinstance(result, int)
def test_set_and_get_float(self, temp_db):
"""Test setting and getting float values."""
from utils.database import set_setting, get_setting
set_setting('float_key', 3.14)
result = get_setting('float_key')
assert result == 3.14
assert isinstance(result, float)
def test_set_and_get_bool(self, temp_db):
"""Test setting and getting boolean values."""
from utils.database import set_setting, get_setting
set_setting('bool_true', True)
set_setting('bool_false', False)
assert get_setting('bool_true') is True
assert get_setting('bool_false') is False
def test_set_and_get_dict(self, temp_db):
"""Test setting and getting dictionary values."""
from utils.database import set_setting, get_setting
test_dict = {'name': 'test', 'value': 123, 'nested': {'a': 1}}
set_setting('dict_key', test_dict)
result = get_setting('dict_key')
assert result == test_dict
assert result['nested']['a'] == 1
def test_set_and_get_list(self, temp_db):
"""Test setting and getting list values."""
from utils.database import set_setting, get_setting
test_list = [1, 2, 3, 'four', {'five': 5}]
set_setting('list_key', test_list)
result = get_setting('list_key')
assert result == test_list
def test_get_nonexistent_key(self, temp_db):
"""Test getting a key that doesn't exist."""
from utils.database import get_setting
assert get_setting('nonexistent') is None
assert get_setting('nonexistent', 'default') == 'default'
def test_update_existing_setting(self, temp_db):
"""Test updating an existing setting."""
from utils.database import set_setting, get_setting
set_setting('update_key', 'original')
assert get_setting('update_key') == 'original'
set_setting('update_key', 'updated')
assert get_setting('update_key') == 'updated'
def test_delete_setting(self, temp_db):
"""Test deleting a setting."""
from utils.database import set_setting, get_setting, delete_setting
set_setting('delete_key', 'value')
assert get_setting('delete_key') == 'value'
result = delete_setting('delete_key')
assert result is True
assert get_setting('delete_key') is None
def test_delete_nonexistent_setting(self, temp_db):
"""Test deleting a setting that doesn't exist."""
from utils.database import delete_setting
result = delete_setting('nonexistent_key')
assert result is False
def test_get_all_settings(self, temp_db):
"""Test getting all settings."""
from utils.database import set_setting, get_all_settings
set_setting('key1', 'value1')
set_setting('key2', 42)
set_setting('key3', True)
all_settings = get_all_settings()
assert 'key1' in all_settings
assert all_settings['key1'] == 'value1'
assert all_settings['key2'] == 42
assert all_settings['key3'] is True
class TestSignalHistory:
"""Tests for signal history operations."""
def test_add_and_get_signal_reading(self, temp_db):
"""Test adding and retrieving signal readings."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -62)
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -70)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 3
# Results should be in chronological order
assert history[0]['signal'] == -65
assert history[1]['signal'] == -62
assert history[2]['signal'] == -70
def test_signal_history_with_metadata(self, temp_db):
"""Test signal readings with metadata."""
from utils.database import add_signal_reading, get_signal_history
metadata = {'channel': 6, 'ssid': 'TestNetwork'}
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65, metadata)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF')
assert len(history) == 1
assert history[0]['metadata'] == metadata
def test_signal_history_limit(self, temp_db):
"""Test signal history respects limit parameter."""
from utils.database import add_signal_reading, get_signal_history
for i in range(10):
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -60 - i)
history = get_signal_history('wifi', 'AA:BB:CC:DD:EE:FF', limit=5)
assert len(history) == 5
def test_signal_history_different_devices(self, temp_db):
"""Test signal history isolates different devices."""
from utils.database import add_signal_reading, get_signal_history
add_signal_reading('wifi', 'AA:AA:AA:AA:AA:AA', -65)
add_signal_reading('wifi', 'BB:BB:BB:BB:BB:BB', -70)
history_a = get_signal_history('wifi', 'AA:AA:AA:AA:AA:AA')
history_b = get_signal_history('wifi', 'BB:BB:BB:BB:BB:BB')
assert len(history_a) == 1
assert len(history_b) == 1
assert history_a[0]['signal'] == -65
assert history_b[0]['signal'] == -70
def test_cleanup_old_signal_history(self, temp_db):
"""Test cleanup of old signal history."""
from utils.database import add_signal_reading, cleanup_old_signal_history
add_signal_reading('wifi', 'AA:BB:CC:DD:EE:FF', -65)
# Cleanup with 0 hours should remove everything
deleted = cleanup_old_signal_history(max_age_hours=0)
# Note: This may or may not delete depending on timing
assert isinstance(deleted, int)
class TestDeviceCorrelations:
"""Tests for device correlation operations."""
def test_add_and_get_correlation(self, temp_db):
"""Test adding and retrieving correlations."""
from utils.database import add_correlation, get_correlations
add_correlation(
wifi_mac='AA:AA:AA:AA:AA:AA',
bt_mac='BB:BB:BB:BB:BB:BB',
confidence=0.85,
metadata={'reason': 'timing'}
)
correlations = get_correlations(min_confidence=0.5)
assert len(correlations) >= 1
found = next(
(c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA'),
None
)
assert found is not None
assert found['bt_mac'] == 'BB:BB:BB:BB:BB:BB'
assert found['confidence'] == 0.85
def test_correlation_confidence_filter(self, temp_db):
"""Test correlation filtering by confidence."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
add_correlation('CC:CC:CC:CC:CC:CC', 'DD:DD:DD:DD:DD:DD', 0.4)
high_confidence = get_correlations(min_confidence=0.7)
all_confidence = get_correlations(min_confidence=0.3)
assert len(high_confidence) == 1
assert len(all_confidence) == 2
def test_correlation_upsert(self, temp_db):
"""Test that correlations are updated on conflict."""
from utils.database import add_correlation, get_correlations
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.5)
add_correlation('AA:AA:AA:AA:AA:AA', 'BB:BB:BB:BB:BB:BB', 0.9)
correlations = get_correlations(min_confidence=0.0)
matching = [c for c in correlations
if c['wifi_mac'] == 'AA:AA:AA:AA:AA:AA']
assert len(matching) == 1
assert matching[0]['confidence'] == 0.9
+376
View File
@@ -0,0 +1,376 @@
"""Tests for Flask routes and API endpoints."""
import json
import pytest
from unittest.mock import patch, MagicMock
@pytest.fixture(scope='session')
def app():
"""Create application for testing."""
import app as app_module
from routes import register_blueprints
from utils.database import init_db
app_module.app.config['TESTING'] = True
# Initialize database for settings tests
init_db()
# Register blueprints only if not already registered (normally done in main())
# Check if any blueprint is already registered to avoid re-registration
if 'pager' not in app_module.app.blueprints:
register_blueprints(app_module.app)
return app_module.app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
class TestHealthEndpoint:
"""Tests for health check endpoint."""
def test_health_check(self, client):
"""Test health endpoint returns expected data."""
response = client.get('/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'healthy'
assert 'version' in data
assert 'uptime_seconds' in data
assert 'processes' in data
assert 'data' in data
def test_health_process_status(self, client):
"""Test health endpoint reports process status."""
response = client.get('/health')
data = json.loads(response.data)
processes = data['processes']
assert 'pager' in processes
assert 'sensor' in processes
assert 'adsb' in processes
assert 'wifi' in processes
assert 'bluetooth' in processes
class TestDevicesEndpoint:
"""Tests for devices endpoint."""
def test_get_devices(self, client):
"""Test getting device list."""
response = client.get('/devices')
assert response.status_code == 200
data = json.loads(response.data)
assert isinstance(data, list)
@patch('app.SDRFactory.detect_devices')
def test_devices_returns_list(self, mock_detect, client):
"""Test devices endpoint returns list format."""
mock_device = MagicMock()
mock_device.to_dict.return_value = {
'index': 0,
'name': 'Test RTL-SDR',
'sdr_type': 'rtlsdr'
}
mock_detect.return_value = [mock_device]
response = client.get('/devices')
data = json.loads(response.data)
assert len(data) == 1
assert data[0]['name'] == 'Test RTL-SDR'
class TestDependenciesEndpoint:
"""Tests for dependencies endpoint."""
def test_get_dependencies(self, client):
"""Test getting dependency status."""
response = client.get('/dependencies')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'os' in data
assert 'pkg_manager' in data
assert 'modes' in data
class TestSettingsEndpoints:
"""Tests for settings API endpoints."""
def test_get_settings(self, client):
"""Test getting all settings."""
response = client.get('/settings')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'settings' in data
def test_save_settings(self, client):
"""Test saving settings."""
response = client.post(
'/settings',
data=json.dumps({'test_key': 'test_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'test_key' in data['saved']
def test_save_empty_settings(self, client):
"""Test saving empty settings returns error."""
response = client.post(
'/settings',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
def test_get_single_setting(self, client):
"""Test getting a single setting."""
# First save a setting
client.post(
'/settings',
data=json.dumps({'my_setting': 'my_value'}),
content_type='application/json'
)
# Then retrieve it
response = client.get('/settings/my_setting')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'my_value'
def test_get_nonexistent_setting(self, client):
"""Test getting a setting that doesn't exist."""
response = client.get('/settings/nonexistent_key_xyz')
assert response.status_code == 404
def test_update_setting(self, client):
"""Test updating a setting via PUT."""
response = client.put(
'/settings/update_test',
data=json.dumps({'value': 'updated_value'}),
content_type='application/json'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['value'] == 'updated_value'
def test_delete_setting(self, client):
"""Test deleting a setting."""
# First create a setting
client.post(
'/settings',
data=json.dumps({'delete_me': 'value'}),
content_type='application/json'
)
# Then delete it
response = client.delete('/settings/delete_me')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert data['deleted'] is True
class TestCorrelationEndpoints:
"""Tests for correlation API endpoints."""
def test_get_correlations(self, client):
"""Test getting device correlations."""
response = client.get('/correlation')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
assert 'correlations' in data
assert 'wifi_count' in data
assert 'bt_count' in data
def test_correlations_with_confidence_filter(self, client):
"""Test correlation endpoint respects confidence filter."""
response = client.get('/correlation?min_confidence=0.8')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'success'
class TestListeningPostEndpoints:
"""Tests for listening post endpoints."""
def test_tools_check(self, client):
"""Test listening post tools availability check."""
response = client.get('/listening/tools')
assert response.status_code == 200
data = json.loads(response.data)
assert 'rtl_fm' in data
assert 'available' in data
def test_scanner_status(self, client):
"""Test scanner status endpoint."""
response = client.get('/listening/scanner/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'paused' in data
assert 'current_freq' in data
def test_presets(self, client):
"""Test scanner presets endpoint."""
response = client.get('/listening/presets')
assert response.status_code == 200
data = json.loads(response.data)
assert 'presets' in data
assert len(data['presets']) > 0
# Check preset structure
preset = data['presets'][0]
assert 'name' in preset
assert 'start' in preset
assert 'end' in preset
assert 'mod' in preset
def test_scanner_stop_when_not_running(self, client):
"""Test stopping scanner when not running."""
response = client.post('/listening/scanner/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_activity_log(self, client):
"""Test getting activity log."""
response = client.get('/listening/scanner/log')
assert response.status_code == 200
data = json.loads(response.data)
assert 'log' in data
assert 'total' in data
def test_scanner_skip_when_not_running(self, client):
"""Test skip signal when scanner not running returns error."""
response = client.post('/listening/scanner/skip')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestAudioEndpoints:
"""Tests for audio demodulation endpoints."""
def test_audio_status(self, client):
"""Test audio status endpoint."""
response = client.get('/listening/audio/status')
assert response.status_code == 200
data = json.loads(response.data)
assert 'running' in data
assert 'frequency' in data
assert 'modulation' in data
def test_audio_stop_when_not_running(self, client):
"""Test stopping audio when not running."""
response = client.post('/listening/audio/stop')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'stopped'
def test_audio_start_missing_frequency(self, client):
"""Test starting audio without frequency returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'frequency' in data['message'].lower()
def test_audio_start_invalid_modulation(self, client):
"""Test starting audio with invalid modulation returns error."""
response = client.post(
'/listening/audio/start',
data=json.dumps({
'frequency': 98.1,
'modulation': 'invalid_mode'
}),
content_type='application/json'
)
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
assert 'modulation' in data['message'].lower()
def test_audio_stream_when_not_running(self, client):
"""Test audio stream when not running returns error."""
response = client.get('/listening/audio/stream')
assert response.status_code == 400
data = json.loads(response.data)
assert data['status'] == 'error'
class TestExportEndpoints:
"""Tests for data export endpoints."""
def test_export_aircraft_json(self, client):
"""Test exporting aircraft data as JSON."""
response = client.get('/export/aircraft?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_aircraft_csv(self, client):
"""Test exporting aircraft data as CSV."""
response = client.get('/export/aircraft?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_wifi_json(self, client):
"""Test exporting WiFi data as JSON."""
response = client.get('/export/wifi?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_wifi_csv(self, client):
"""Test exporting WiFi data as CSV."""
response = client.get('/export/wifi?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
def test_export_bluetooth_json(self, client):
"""Test exporting Bluetooth data as JSON."""
response = client.get('/export/bluetooth?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
def test_export_bluetooth_csv(self, client):
"""Test exporting Bluetooth data as CSV."""
response = client.get('/export/bluetooth?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
+120
View File
@@ -0,0 +1,120 @@
"""Comprehensive tests for validation utilities."""
import pytest
from utils.validation import (
validate_frequency,
validate_gain,
validate_device_index,
validate_rtl_tcp_host,
validate_rtl_tcp_port,
)
class TestFrequencyValidation:
"""Tests for frequency validation."""
def test_valid_frequencies(self):
"""Test valid frequency values."""
assert validate_frequency('152.0') == '152.0'
assert validate_frequency(152.0) == '152.0'
assert validate_frequency('1090') == '1090'
assert validate_frequency(433.92) == '433.92'
def test_frequency_range(self):
"""Test frequency range limits."""
# RTL-SDR typical range: 24MHz - 1766MHz
assert validate_frequency('24') == '24'
assert validate_frequency('1700') == '1700'
def test_invalid_frequencies(self):
"""Test invalid frequency values."""
with pytest.raises(ValueError):
validate_frequency('')
with pytest.raises(ValueError):
validate_frequency('abc')
with pytest.raises(ValueError):
validate_frequency(-100)
with pytest.raises(ValueError):
validate_frequency(0)
class TestGainValidation:
"""Tests for gain validation."""
def test_valid_gains(self):
"""Test valid gain values."""
assert validate_gain('0') == '0'
assert validate_gain('40') == '40'
assert validate_gain(49.6) == '49.6'
assert validate_gain('auto') == 'auto'
def test_invalid_gains(self):
"""Test invalid gain values."""
with pytest.raises(ValueError):
validate_gain(-10)
with pytest.raises(ValueError):
validate_gain(100)
with pytest.raises(ValueError):
validate_gain('invalid')
class TestDeviceIndexValidation:
"""Tests for device index validation."""
def test_valid_indices(self):
"""Test valid device indices."""
assert validate_device_index('0') == '0'
assert validate_device_index(0) == '0'
assert validate_device_index('1') == '1'
assert validate_device_index(3) == '3'
def test_invalid_indices(self):
"""Test invalid device indices."""
with pytest.raises(ValueError):
validate_device_index(-1)
with pytest.raises(ValueError):
validate_device_index('abc')
with pytest.raises(ValueError):
validate_device_index(100)
class TestRtlTcpHostValidation:
"""Tests for RTL-TCP host validation."""
def test_valid_hosts(self):
"""Test valid host values."""
assert validate_rtl_tcp_host('localhost') == 'localhost'
assert validate_rtl_tcp_host('127.0.0.1') == '127.0.0.1'
assert validate_rtl_tcp_host('192.168.1.1') == '192.168.1.1'
assert validate_rtl_tcp_host('server.example.com') == 'server.example.com'
def test_invalid_hosts(self):
"""Test invalid host values."""
with pytest.raises(ValueError):
validate_rtl_tcp_host('')
with pytest.raises(ValueError):
validate_rtl_tcp_host('invalid host with spaces')
with pytest.raises(ValueError):
validate_rtl_tcp_host('host;rm -rf /')
class TestRtlTcpPortValidation:
"""Tests for RTL-TCP port validation."""
def test_valid_ports(self):
"""Test valid port values."""
assert validate_rtl_tcp_port(1234) == 1234
assert validate_rtl_tcp_port('1234') == 1234
assert validate_rtl_tcp_port(30003) == 30003
assert validate_rtl_tcp_port(65535) == 65535
def test_invalid_ports(self):
"""Test invalid port values."""
with pytest.raises(ValueError):
validate_rtl_tcp_port(0)
with pytest.raises(ValueError):
validate_rtl_tcp_port(-1)
with pytest.raises(ValueError):
validate_rtl_tcp_port(70000)
with pytest.raises(ValueError):
validate_rtl_tcp_port('abc')
+213
View File
@@ -0,0 +1,213 @@
"""
INTERCEPT - Constants and Magic Numbers
Centralized location for all hardcoded values used throughout the application.
This improves maintainability and makes the codebase self-documenting.
"""
from __future__ import annotations
# =============================================================================
# NETWORK PORTS
# =============================================================================
# ADS-B SBS data output port (dump1090 default)
ADSB_SBS_PORT = 30003
# GPS daemon port (gpsd default)
GPSD_PORT = 2947
# RTL-TCP server port (rtl_tcp default)
RTL_TCP_PORT = 1234
# =============================================================================
# PROCESS TIMEOUTS (seconds)
# =============================================================================
# General process termination timeout
PROCESS_TERMINATE_TIMEOUT = 2
# ADS-B process termination (dump1090 needs longer)
ADSB_TERMINATE_TIMEOUT = 5
# WiFi process termination (airodump-ng)
WIFI_TERMINATE_TIMEOUT = 3
# Bluetooth process termination
BT_TERMINATE_TIMEOUT = 3
# PMKID process termination
PMKID_TERMINATE_TIMEOUT = 5
# Socket connection timeout
SOCKET_CONNECT_TIMEOUT = 2
# SBS stream socket timeout
SBS_SOCKET_TIMEOUT = 5
# Subprocess command timeout (short operations)
SUBPROCESS_TIMEOUT_SHORT = 5
# Subprocess command timeout (medium operations)
SUBPROCESS_TIMEOUT_MEDIUM = 10
# Subprocess command timeout (long operations like airmon-ng)
SUBPROCESS_TIMEOUT_LONG = 15
# External HTTP request timeout (TLE fetching, etc.)
HTTP_REQUEST_TIMEOUT = 10
# Deauth command timeout
DEAUTH_TIMEOUT = 30
# Service enumeration timeout (sdptool browse)
SERVICE_ENUM_TIMEOUT = 30
# =============================================================================
# SSE (Server-Sent Events) SETTINGS
# =============================================================================
# Keepalive interval for SSE streams (seconds)
SSE_KEEPALIVE_INTERVAL = 30.0
# Queue get timeout for SSE generators (seconds)
SSE_QUEUE_TIMEOUT = 1.0
# =============================================================================
# DATA RETENTION / CLEANUP (seconds)
# =============================================================================
# Maximum age for aircraft data before cleanup
MAX_AIRCRAFT_AGE_SECONDS = 300 # 5 minutes
# Maximum age for WiFi network data before cleanup
MAX_WIFI_NETWORK_AGE_SECONDS = 600 # 10 minutes
# Maximum age for Bluetooth device data before cleanup
MAX_BT_DEVICE_AGE_SECONDS = 300 # 5 minutes
# ADS-B queue batch update interval
ADSB_UPDATE_INTERVAL = 1.0 # seconds
# =============================================================================
# QUEUE LIMITS
# =============================================================================
# Maximum queue size for all data queues
QUEUE_MAX_SIZE = 1000
# GPS queue size (smaller, more frequent updates)
GPS_QUEUE_MAX_SIZE = 100
# =============================================================================
# DATA PARSING
# =============================================================================
# WiFi CSV parse interval (seconds)
WIFI_CSV_PARSE_INTERVAL = 2.0
# Minimum time before warning about no CSV data
WIFI_CSV_TIMEOUT_WARNING = 5.0
# Socket receive buffer size
SOCKET_BUFFER_SIZE = 4096
# PTY read buffer size
PTY_BUFFER_SIZE = 1024
# =============================================================================
# EXTERNAL SERVICE LIMITS
# =============================================================================
# Maximum response size for external HTTP requests (bytes)
MAX_HTTP_RESPONSE_SIZE = 1024 * 1024 # 1 MB
# Deauth packet count limits
MIN_DEAUTH_COUNT = 1
MAX_DEAUTH_COUNT = 100
DEFAULT_DEAUTH_COUNT = 5
# =============================================================================
# VALIDATION LIMITS
# =============================================================================
# Squelch range
MIN_SQUELCH = 0
MAX_SQUELCH = 1000
# Valid GPS baudrates
VALID_GPS_BAUDRATES = [4800, 9600, 19200, 38400, 57600, 115200]
# Port range
MIN_PORT = 1
MAX_PORT = 65535
# =============================================================================
# SATELLITE TRACKING
# =============================================================================
# Default observer location (London)
DEFAULT_LATITUDE = 51.5074
DEFAULT_LONGITUDE = -0.1278
# Allowed TLE hosts for security
ALLOWED_TLE_HOSTS = [
'celestrak.org',
'celestrak.com',
'www.celestrak.org',
'www.celestrak.com'
]
# Earth radius (km) - WGS84 mean
EARTH_RADIUS_KM = 6371
# Trajectory calculation points
TRAJECTORY_POINTS = 30
GROUND_TRACK_POINTS = 60
ORBIT_TRACK_RANGE_MINUTES = 45
# =============================================================================
# SLEEP/DELAY TIMES (seconds)
# =============================================================================
# Wait after starting process before checking status
PROCESS_START_WAIT = 0.5
# Wait after dump1090 start before connecting
DUMP1090_START_WAIT = 3.0
# Delay between monitor mode operations
MONITOR_MODE_DELAY = 1.0
# Bluetooth adapter reset delays
BT_RESET_DELAY = 0.5
BT_ADAPTER_DOWN_WAIT = 1.0
# SBS reconnection delay on error
SBS_RECONNECT_DELAY = 2.0
# =============================================================================
# FILE PATHS
# =============================================================================
# Default pager log file
DEFAULT_PAGER_LOG_FILE = 'pager_messages.log'
# WiFi capture temp path prefix
WIFI_CAPTURE_PATH_PREFIX = '/tmp/intercept_wifi'
# Handshake capture path prefix
HANDSHAKE_CAPTURE_PATH_PREFIX = '/tmp/intercept_handshake_'
# PMKID capture path prefix
PMKID_CAPTURE_PATH_PREFIX = '/tmp/intercept_pmkid_'
+313
View File
@@ -0,0 +1,313 @@
"""
Device correlation engine for matching WiFi and Bluetooth devices.
Uses timing-based correlation to identify when WiFi and Bluetooth
signals likely belong to the same physical device.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from utils.database import add_correlation, get_correlations as db_get_correlations
logger = logging.getLogger('intercept.correlation')
@dataclass
class DeviceObservation:
"""A single observation of a device."""
mac: str
first_seen: datetime
last_seen: datetime
rssi: int | None = None
name: str | None = None
manufacturer: str | None = None
class DeviceCorrelator:
"""
Correlates WiFi and Bluetooth devices based on timing patterns.
Devices are considered potentially correlated if:
1. They appear within a short time window of each other
2. They have similar signal strength patterns (optional)
3. They share the same OUI/manufacturer (bonus confidence)
"""
def __init__(
self,
time_window_seconds: int = 30,
min_confidence: float = 0.5,
rssi_threshold: int = 20
):
"""
Initialize correlator.
Args:
time_window_seconds: Max time difference for correlation (default 30s)
min_confidence: Minimum confidence score to report (default 0.5)
rssi_threshold: Max RSSI difference for signal-based correlation
"""
self.time_window = timedelta(seconds=time_window_seconds)
self.min_confidence = min_confidence
self.rssi_threshold = rssi_threshold
def correlate(
self,
wifi_devices: dict[str, dict[str, Any]],
bt_devices: dict[str, dict[str, Any]]
) -> list[dict]:
"""
Find correlations between WiFi and Bluetooth devices.
Args:
wifi_devices: Dict of WiFi devices keyed by MAC
bt_devices: Dict of Bluetooth devices keyed by MAC
Returns:
List of correlation results with confidence scores
"""
correlations = []
for wifi_mac, wifi_data in wifi_devices.items():
wifi_obs = self._to_observation(wifi_mac, wifi_data, 'wifi')
if not wifi_obs:
continue
for bt_mac, bt_data in bt_devices.items():
bt_obs = self._to_observation(bt_mac, bt_data, 'bluetooth')
if not bt_obs:
continue
confidence = self._calculate_confidence(wifi_obs, bt_obs)
if confidence >= self.min_confidence:
correlations.append({
'wifi_mac': wifi_mac,
'wifi_name': wifi_obs.name,
'bt_mac': bt_mac,
'bt_name': bt_obs.name,
'confidence': round(confidence, 2),
'reason': self._get_correlation_reason(wifi_obs, bt_obs)
})
# Persist high-confidence correlations
if confidence >= 0.7:
try:
add_correlation(
wifi_mac=wifi_mac,
bt_mac=bt_mac,
confidence=confidence,
metadata={
'wifi_name': wifi_obs.name,
'bt_name': bt_obs.name
}
)
except Exception as e:
logger.debug(f"Failed to persist correlation: {e}")
# Sort by confidence (highest first)
correlations.sort(key=lambda x: x['confidence'], reverse=True)
return correlations
def _to_observation(
self,
mac: str,
data: dict[str, Any],
device_type: str
) -> DeviceObservation | None:
"""Convert device dict to observation."""
try:
# Handle different timestamp formats
first_seen = data.get('first_seen') or data.get('firstSeen')
last_seen = data.get('last_seen') or data.get('lastSeen')
if isinstance(first_seen, str):
first_seen = datetime.fromisoformat(first_seen.replace('Z', '+00:00'))
elif isinstance(first_seen, (int, float)):
first_seen = datetime.fromtimestamp(first_seen / 1000)
else:
first_seen = datetime.now()
if isinstance(last_seen, str):
last_seen = datetime.fromisoformat(last_seen.replace('Z', '+00:00'))
elif isinstance(last_seen, (int, float)):
last_seen = datetime.fromtimestamp(last_seen / 1000)
else:
last_seen = datetime.now()
# Get RSSI (different field names)
rssi = data.get('rssi') or data.get('power') or data.get('signal')
if rssi is not None:
rssi = int(rssi)
# Get name
name = data.get('name') or data.get('essid') or data.get('ssid')
# Get manufacturer
manufacturer = data.get('manufacturer') or data.get('vendor')
return DeviceObservation(
mac=mac,
first_seen=first_seen,
last_seen=last_seen,
rssi=rssi,
name=name,
manufacturer=manufacturer
)
except Exception as e:
logger.debug(f"Failed to parse device {mac}: {e}")
return None
def _calculate_confidence(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> float:
"""
Calculate correlation confidence score.
Score components:
- Timing overlap: 0.0-0.5 (primary factor)
- Same manufacturer: +0.2
- Similar RSSI: +0.1
- Both named: +0.1
Returns:
Confidence score 0.0-1.0
"""
confidence = 0.0
# Timing correlation (most important)
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
# Linear decay from 0.5 to 0.0 as time difference increases
timing_score = 0.5 * (1 - time_diff / self.time_window.total_seconds())
confidence += timing_score
else:
# Check if observation windows overlap at all
wifi_end = wifi.last_seen
bt_end = bt.last_seen
# If observation periods overlap
if wifi.first_seen <= bt_end and bt.first_seen <= wifi_end:
confidence += 0.25 # Partial credit for overlapping presence
# Manufacturer match
if wifi.manufacturer and bt.manufacturer:
wifi_mfg = wifi.manufacturer.lower()
bt_mfg = bt.manufacturer.lower()
if wifi_mfg == bt_mfg:
confidence += 0.2
elif wifi_mfg[:5] == bt_mfg[:5]: # Partial match
confidence += 0.1
# OUI match (first 3 octets of MAC)
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
confidence += 0.15
# RSSI similarity
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
rssi_score = 0.1 * (1 - rssi_diff / self.rssi_threshold)
confidence += rssi_score
# Both have names (suggests user device)
if wifi.name and bt.name:
confidence += 0.05
return min(confidence, 1.0)
def _get_correlation_reason(
self,
wifi: DeviceObservation,
bt: DeviceObservation
) -> str:
"""Generate human-readable reason for correlation."""
reasons = []
time_diff = abs((wifi.first_seen - bt.first_seen).total_seconds())
if time_diff <= self.time_window.total_seconds():
reasons.append(f"appeared within {int(time_diff)}s")
wifi_oui = wifi.mac[:8].upper()
bt_oui = bt.mac[:8].upper()
if wifi_oui == bt_oui:
reasons.append("same OUI")
if wifi.manufacturer and bt.manufacturer:
if wifi.manufacturer.lower() == bt.manufacturer.lower():
reasons.append(f"same manufacturer ({wifi.manufacturer})")
if wifi.rssi is not None and bt.rssi is not None:
rssi_diff = abs(wifi.rssi - bt.rssi)
if rssi_diff <= self.rssi_threshold:
reasons.append("similar signal strength")
return "; ".join(reasons) if reasons else "timing overlap"
# Global correlator instance
correlator = DeviceCorrelator()
def get_correlations(
wifi_devices: dict[str, dict] | None = None,
bt_devices: dict[str, dict] | None = None,
min_confidence: float = 0.5,
include_historical: bool = True
) -> list[dict]:
"""
Get device correlations.
Args:
wifi_devices: Current WiFi devices (or None to use only historical)
bt_devices: Current Bluetooth devices (or None to use only historical)
min_confidence: Minimum confidence threshold
include_historical: Include correlations from database
Returns:
List of correlations sorted by confidence
"""
results = []
# Get live correlations
if wifi_devices and bt_devices:
correlator.min_confidence = min_confidence
results.extend(correlator.correlate(wifi_devices, bt_devices))
# Get historical correlations from database
if include_historical:
try:
historical = db_get_correlations(min_confidence)
for h in historical:
# Avoid duplicates
existing = next(
(r for r in results
if r['wifi_mac'] == h['wifi_mac'] and r['bt_mac'] == h['bt_mac']),
None
)
if not existing:
results.append({
'wifi_mac': h['wifi_mac'],
'bt_mac': h['bt_mac'],
'confidence': h['confidence'],
'reason': 'historical correlation',
'first_seen': h['first_seen'],
'last_seen': h['last_seen']
})
except Exception as e:
logger.debug(f"Failed to get historical correlations: {e}")
# Sort by confidence
results.sort(key=lambda x: x['confidence'], reverse=True)
return results
+351
View File
@@ -0,0 +1,351 @@
"""
SQLite database utilities for persistent settings storage.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger('intercept.database')
# Database file location
DB_DIR = Path(__file__).parent.parent / 'instance'
DB_PATH = DB_DIR / 'intercept.db'
# Thread-local storage for connections
_local = threading.local()
def get_db_path() -> Path:
"""Get the database file path, creating directory if needed."""
DB_DIR.mkdir(parents=True, exist_ok=True)
return DB_PATH
def get_connection() -> sqlite3.Connection:
"""Get a thread-local database connection."""
if not hasattr(_local, 'connection') or _local.connection is None:
db_path = get_db_path()
_local.connection = sqlite3.connect(str(db_path), check_same_thread=False)
_local.connection.row_factory = sqlite3.Row
# Enable foreign keys
_local.connection.execute('PRAGMA foreign_keys = ON')
return _local.connection
@contextmanager
def get_db():
"""Context manager for database operations."""
conn = get_connection()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def init_db() -> None:
"""Initialize the database schema."""
db_path = get_db_path()
logger.info(f"Initializing database at {db_path}")
with get_db() as conn:
# Settings table for key-value storage
conn.execute('''
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
value_type TEXT DEFAULT 'string',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Signal history table for graphs
conn.execute('''
CREATE TABLE IF NOT EXISTS signal_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT NOT NULL,
device_id TEXT NOT NULL,
signal_strength REAL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT
)
''')
# Create index for faster queries
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_signal_history_mode_device
ON signal_history(mode, device_id, timestamp)
''')
# Device correlation table
conn.execute('''
CREATE TABLE IF NOT EXISTS device_correlations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wifi_mac TEXT,
bt_mac TEXT,
confidence REAL,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
metadata TEXT,
UNIQUE(wifi_mac, bt_mac)
)
''')
logger.info("Database initialized successfully")
def close_db() -> None:
"""Close the thread-local database connection."""
if hasattr(_local, 'connection') and _local.connection is not None:
_local.connection.close()
_local.connection = None
# =============================================================================
# Settings Functions
# =============================================================================
def get_setting(key: str, default: Any = None) -> Any:
"""
Get a setting value by key.
Args:
key: Setting key
default: Default value if not found
Returns:
Setting value (auto-converted from JSON for complex types)
"""
with get_db() as conn:
cursor = conn.execute(
'SELECT value, value_type FROM settings WHERE key = ?',
(key,)
)
row = cursor.fetchone()
if row is None:
return default
value, value_type = row['value'], row['value_type']
# Convert based on type
if value_type == 'json':
try:
return json.loads(value)
except json.JSONDecodeError:
return default
elif value_type == 'int':
return int(value)
elif value_type == 'float':
return float(value)
elif value_type == 'bool':
return value.lower() in ('true', '1', 'yes')
else:
return value
def set_setting(key: str, value: Any) -> None:
"""
Set a setting value.
Args:
key: Setting key
value: Setting value (will be JSON-encoded for complex types)
"""
# Determine value type and string representation
if isinstance(value, bool):
value_type = 'bool'
str_value = 'true' if value else 'false'
elif isinstance(value, int):
value_type = 'int'
str_value = str(value)
elif isinstance(value, float):
value_type = 'float'
str_value = str(value)
elif isinstance(value, (dict, list)):
value_type = 'json'
str_value = json.dumps(value)
else:
value_type = 'string'
str_value = str(value)
with get_db() as conn:
conn.execute('''
INSERT INTO settings (key, value, value_type, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
value_type = excluded.value_type,
updated_at = CURRENT_TIMESTAMP
''', (key, str_value, value_type))
def delete_setting(key: str) -> bool:
"""
Delete a setting.
Args:
key: Setting key
Returns:
True if setting was deleted, False if not found
"""
with get_db() as conn:
cursor = conn.execute('DELETE FROM settings WHERE key = ?', (key,))
return cursor.rowcount > 0
def get_all_settings() -> dict[str, Any]:
"""Get all settings as a dictionary."""
with get_db() as conn:
cursor = conn.execute('SELECT key, value, value_type FROM settings')
settings = {}
for row in cursor:
key, value, value_type = row['key'], row['value'], row['value_type']
if value_type == 'json':
try:
settings[key] = json.loads(value)
except json.JSONDecodeError:
settings[key] = value
elif value_type == 'int':
settings[key] = int(value)
elif value_type == 'float':
settings[key] = float(value)
elif value_type == 'bool':
settings[key] = value.lower() in ('true', '1', 'yes')
else:
settings[key] = value
return settings
# =============================================================================
# Signal History Functions
# =============================================================================
def add_signal_reading(
mode: str,
device_id: str,
signal_strength: float,
metadata: dict | None = None
) -> None:
"""Add a signal strength reading."""
with get_db() as conn:
conn.execute('''
INSERT INTO signal_history (mode, device_id, signal_strength, metadata)
VALUES (?, ?, ?, ?)
''', (mode, device_id, signal_strength, json.dumps(metadata) if metadata else None))
def get_signal_history(
mode: str,
device_id: str,
limit: int = 100,
since_minutes: int = 60
) -> list[dict]:
"""
Get signal history for a device.
Args:
mode: Mode (wifi, bluetooth, adsb, etc.)
device_id: Device identifier (MAC, ICAO, etc.)
limit: Maximum number of readings
since_minutes: Only get readings from last N minutes
Returns:
List of signal readings with timestamp
"""
with get_db() as conn:
cursor = conn.execute('''
SELECT signal_strength, timestamp, metadata
FROM signal_history
WHERE mode = ? AND device_id = ?
AND timestamp > datetime('now', ?)
ORDER BY timestamp DESC
LIMIT ?
''', (mode, device_id, f'-{since_minutes} minutes', limit))
results = []
for row in cursor:
results.append({
'signal': row['signal_strength'],
'timestamp': row['timestamp'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
return list(reversed(results)) # Return in chronological order
def cleanup_old_signal_history(max_age_hours: int = 24) -> int:
"""
Remove old signal history entries.
Args:
max_age_hours: Maximum age in hours
Returns:
Number of deleted entries
"""
with get_db() as conn:
cursor = conn.execute('''
DELETE FROM signal_history
WHERE timestamp < datetime('now', ?)
''', (f'-{max_age_hours} hours',))
return cursor.rowcount
# =============================================================================
# Device Correlation Functions
# =============================================================================
def add_correlation(
wifi_mac: str,
bt_mac: str,
confidence: float,
metadata: dict | None = None
) -> None:
"""Add or update a device correlation."""
with get_db() as conn:
conn.execute('''
INSERT INTO device_correlations (wifi_mac, bt_mac, confidence, metadata, last_seen)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(wifi_mac, bt_mac) DO UPDATE SET
confidence = excluded.confidence,
last_seen = CURRENT_TIMESTAMP,
metadata = excluded.metadata
''', (wifi_mac, bt_mac, confidence, json.dumps(metadata) if metadata else None))
def get_correlations(min_confidence: float = 0.5) -> list[dict]:
"""Get all device correlations above minimum confidence."""
with get_db() as conn:
cursor = conn.execute('''
SELECT wifi_mac, bt_mac, confidence, first_seen, last_seen, metadata
FROM device_correlations
WHERE confidence >= ?
ORDER BY confidence DESC
''', (min_confidence,))
results = []
for row in cursor:
results.append({
'wifi_mac': row['wifi_mac'],
'bt_mac': row['bt_mac'],
'confidence': row['confidence'],
'first_seen': row['first_seen'],
'last_seen': row['last_seen'],
'metadata': json.loads(row['metadata']) if row['metadata'] else None
})
return results