mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 08:13:32 -07:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 007400d2a7 | |||
| 1f60e64217 | |||
| 69de7e4afd | |||
| 29025059af | |||
| 6229c25872 | |||
| 73ac74a9d6 | |||
| ebb1e233d8 | |||
| e719e32c73 | |||
| 46ab5fe78d | |||
| dc467aef91 | |||
| 0bc915fe1f | |||
| b7f9ad786a | |||
| 6c80521cf8 | |||
| a174884269 | |||
| f3b1865a79 | |||
| 6c99651ac9 | |||
| 0aaf888dd1 | |||
| d947ce17a3 | |||
| 97c957b70f | |||
| 82830c86ac | |||
| d8e4189100 | |||
| 6bcde56525 | |||
| 88ebe3c337 | |||
| 5f4d1b05a8 | |||
| 370c46bddb | |||
| 47b5e03bbb | |||
| 556ca59a99 | |||
| 81c5af474d | |||
| cdaee3f62f | |||
| aab4288f67 | |||
| bab49e4442 | |||
| 7608aca681 | |||
| 58907bdc4d | |||
| 8dfd92082c | |||
| e39304da90 | |||
| 31fd3f3f63 | |||
| e1ab24b36b | |||
| f5b92ddcf9 | |||
| d9ee87d4b4 | |||
| 5e83db54ac | |||
| de7b12a759 | |||
| 1236011174 | |||
| b60f2cdf81 | |||
| 0c310ab068 | |||
| a87f66cc0c | |||
| c05756357f | |||
| f4b4b5febd | |||
| 805290b17f | |||
| fecc2237b8 | |||
| 471cc1ee94 | |||
| 41ebf59964 | |||
| a5e9a3e1ce | |||
| 23689d9fe1 | |||
| 601d432fbf | |||
| a21e9c508e | |||
| 55b0c0509d | |||
| 563c6b79fa | |||
| 8d9e5f9d56 | |||
| c0f6ccaf2a | |||
| 9b3e4ec7fb | |||
| 9d45eb21a4 | |||
| bcf8fe59f5 | |||
| 5b411456c7 | |||
| 4432816934 | |||
| 5277537445 | |||
| e73ce8cd8f | |||
| 120015d133 | |||
| f85cf61019 | |||
| 41226d173a | |||
| 83244c85fe | |||
| 27dd868d97 | |||
| 45b35ea5b0 | |||
| ac8b9f82cd | |||
| 9d0e417f2a | |||
| 40369ccb7b | |||
| 61ef3f7bdd | |||
| bcb1a825d3 | |||
| 1f7a3fe664 | |||
| dcd855896e | |||
| 4778134ab6 | |||
| 300b19d1d6 | |||
| 945ae33361 | |||
| dbbcb6c5cc | |||
| 016959ad7c | |||
| 7a9599786c | |||
| fa537390c5 | |||
| bb24bdb06c | |||
| b55100d5c3 | |||
| 02cb9c751a | |||
| 8555938f52 | |||
| a2a3ea62f1 | |||
| 0d5310eb4b | |||
| a5a2692a5f |
@@ -8,6 +8,7 @@ env/
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
uv.lock
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -28,3 +29,6 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Package manager lock files
|
||||
uv.lock
|
||||
|
||||
+40
-9
@@ -1,19 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to INTERCEPT will be documented in this file.
|
||||
All notable changes to iNTERCEPT will be documented in this file.
|
||||
|
||||
## [2.0.0] - 2025-01-06
|
||||
## [2.9.0] - 2026-01-10
|
||||
|
||||
### Added
|
||||
- **Landing Page** - Animated welcome screen with logo reveal and "See the Invisible" tagline
|
||||
- **New Branding** - Redesigned logo featuring 'i' with signal wave brackets
|
||||
- **Logo Assets** - Full-size SVG logos in `/static/img/` for external use
|
||||
- **Instagram Promo** - Animated HTML promo video template in `/promo/` directory
|
||||
- **Listening Post Scanner** - Fully functional frequency scanning with signal detection
|
||||
- Scan button toggles between start/stop states
|
||||
- Signal hits logged with Listen button to tune directly
|
||||
- Proper 4-column display (Time, Frequency, Modulation, Action)
|
||||
|
||||
### Changed
|
||||
- **Rebranding** - Application renamed from "INTERCEPT" to "iNTERCEPT"
|
||||
- **Updated Tagline** - "Signal Intelligence & Counter Surveillance Platform"
|
||||
- **Setup Script** - Now installs Python packages via apt first (more reliable on Debian/Ubuntu)
|
||||
- Uses `--system-site-packages` for venv to leverage apt packages
|
||||
- Added fallback logic when pip fails
|
||||
- **Troubleshooting Docs** - Added sections for pip install issues and apt alternatives
|
||||
|
||||
### Fixed
|
||||
- **Tuning Dial Audio** - Fixed audio stopping when using tuning knob
|
||||
- Added restart prevention flags to avoid overlapping restarts
|
||||
- Increased debounce time for smoother operation
|
||||
- Added silent mode for programmatic value changes
|
||||
- **Scanner Signal Hits** - Fixed table column count and colspan
|
||||
- **Favicon** - Updated to new 'i' logo design
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-01-06
|
||||
|
||||
### Added
|
||||
- **Listening Post Mode** - New frequency scanner with automatic signal detection
|
||||
- Scans frequency ranges and stops on detected signals
|
||||
- Real-time audio monitoring with sox integration
|
||||
- Real-time audio monitoring with ffmpeg integration
|
||||
- Skip button to continue scanning after signal detection
|
||||
- Configurable dwell time, squelch, and step size
|
||||
- Preset frequency bands (FM broadcast, Air band, Marine, etc.)
|
||||
- Activity log of detected signals
|
||||
- **Aircraft Dashboard Improvements**
|
||||
- Dependency warning when rtl_fm or sox not installed
|
||||
- Dependency warning when rtl_fm or ffmpeg not installed
|
||||
- Auto-restart audio when switching frequencies
|
||||
- Fixed toolbar overflow with custom frequency input
|
||||
- **Device Correlation** - Match WiFi and Bluetooth devices by manufacturer
|
||||
@@ -29,10 +59,10 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
- **Setup Script Rewrite**
|
||||
- Full macOS support with Homebrew auto-installation
|
||||
- Improved Debian/Ubuntu package detection
|
||||
- Added sox to tool checks
|
||||
- Added ffmpeg to tool checks
|
||||
- Better error messages with platform-specific install commands
|
||||
- **Dockerfile Updated**
|
||||
- Added sox and libsox-fmt-all for Listening Post audio
|
||||
- Added ffmpeg for Listening Post audio encoding
|
||||
- Added dump1090 with fallback for different package names
|
||||
|
||||
### Fixed
|
||||
@@ -50,7 +80,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] - 2024-12-XX
|
||||
## [1.2.0] - 2026-12-29
|
||||
|
||||
### Added
|
||||
- Airspy SDR support
|
||||
@@ -62,7 +92,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2024-XX-XX
|
||||
## [1.1.0] - 2026-12-18
|
||||
|
||||
### Added
|
||||
- Satellite tracking with TLE data
|
||||
@@ -71,7 +101,7 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2024-XX-XX
|
||||
## [1.0.0] - 2026-12-15
|
||||
|
||||
### Initial Release
|
||||
- Pager decoding (POCSAG/FLEX)
|
||||
@@ -80,3 +110,4 @@ All notable changes to INTERCEPT will be documented in this file.
|
||||
- WiFi reconnaissance
|
||||
- Bluetooth scanning
|
||||
- Multi-SDR support (RTL-SDR, LimeSDR, HackRF)
|
||||
|
||||
|
||||
+1
-2
@@ -20,8 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Pager decoder
|
||||
multimon-ng \
|
||||
# Audio tools for Listening Post
|
||||
sox \
|
||||
libsox-fmt-all \
|
||||
ffmpeg \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
iw \
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="static/images/screenshots/screenshot_main.png" alt="Screenshot">
|
||||
<img src="static/images/screenshots/logo-banner.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -29,28 +29,11 @@
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
## Installation / Debian / Ubuntu / MacOS
|
||||
|
||||
### 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:**
|
||||
**1. Clone and run:**
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
@@ -58,36 +41,6 @@ cd intercept
|
||||
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
|
||||
@@ -109,50 +62,23 @@ After starting, open **http://localhost:5050** in your browser.
|
||||
| Hardware | Purpose | Price |
|
||||
|----------|---------|-------|
|
||||
| **RTL-SDR** | Required for all SDR features | ~$25-35 |
|
||||
| **WiFi adapter** | Monitor mode scanning (optional) | ~$20-40 |
|
||||
| **WiFi adapter** | Must support promiscuous (monitor) mode | ~$20-40 |
|
||||
| **Bluetooth adapter** | Device scanning (usually built-in) | - |
|
||||
| **GPS** | Any Linux supported GPS Unit | ~10 |
|
||||
|
||||
Most features work with a basic RTL-SDR dongle (RTL2832U + R820T2).
|
||||
|
||||
---
|
||||
| :exclamation: Not using an RTL-SDR Device? |
|
||||
|-----------------------------------------------
|
||||
|Intercept supports any device that SoapySDR supports. You must however have the correct module for your device installed! For example if you have an SDRPlay device you'd need to install soapysdr-module-sdrplay.
|
||||
|
||||
## 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
|
||||
```
|
||||
| :exclamation: GPS Usage |
|
||||
|-----------------------------------------------
|
||||
|gpsd is needed for real time location. Intercept automatically checks to see if you're running gpsd in the background when any maps are rendered.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
## Discord Server
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
@@ -165,11 +91,14 @@ python3 intercept.py --check-deps
|
||||
- [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
|
||||
- [Security](docs/SECURITY.md) - Network security and best practices
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This project was developed using AI as a coding partner, combining human direction with AI-assisted implementation. The goal: make Software Defined Radio more accessible by providing a clean, unified interface for common SDR tools.
|
||||
|
||||
**This software is for educational and authorized testing purposes only.**
|
||||
|
||||
- Only use with proper authorization
|
||||
@@ -195,3 +124,7 @@ Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[Celestrak](https://celestrak.org/)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "2026-01-04_e27bf619",
|
||||
"downloaded": "2026-01-07T14:55:20.680977Z"
|
||||
}
|
||||
@@ -45,6 +45,30 @@ _app_start_time = _time.time()
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# Disable Werkzeug debugger PIN (not needed for local development tool)
|
||||
os.environ['WERKZEUG_DEBUG_PIN'] = 'off'
|
||||
|
||||
|
||||
# ============================================
|
||||
# SECURITY HEADERS
|
||||
# ============================================
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
# Prevent MIME type sniffing
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
# Prevent clickjacking
|
||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
||||
# Enable XSS filter
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
# Referrer policy
|
||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||
# Permissions policy (disable unnecessary features)
|
||||
response.headers['Permissions-Policy'] = 'geolocation=(self), microphone=()'
|
||||
return response
|
||||
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
@@ -397,6 +421,14 @@ def main() -> None:
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Initialize WebSocket for audio streaming
|
||||
try:
|
||||
from routes.audio_websocket import init_audio_websocket
|
||||
init_audio_websocket(app)
|
||||
print("WebSocket audio streaming enabled")
|
||||
except ImportError as e:
|
||||
print(f"WebSocket audio disabled (install flask-sock): {e}")
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "2.0.0"
|
||||
VERSION = "2.9.0"
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
|
||||
+6
-6
@@ -21,7 +21,7 @@ INTERCEPT automatically detects connected devices.
|
||||
/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
|
||||
brew install python@3.11 librtlsdr multimon-ng rtl_433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
brew install dump1090-mutability
|
||||
@@ -43,8 +43,8 @@ brew install hackrf soapyhackrf
|
||||
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
|
||||
sudo apt install -y python3 python3-pip python3-venv python3-skyfield
|
||||
sudo apt install -y rtl-sdr multimon-ng rtl-433 ffmpeg
|
||||
|
||||
# ADS-B aircraft tracking
|
||||
sudo apt install -y dump1090-mutability
|
||||
@@ -162,7 +162,7 @@ Open **http://localhost:5050** in your browser.
|
||||
| `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 |
|
||||
| `ffmpeg` | ffmpeg | ffmpeg | Listening Post audio |
|
||||
| `airmon-ng` | aircrack-ng | aircrack-ng | WiFi monitor mode |
|
||||
| `airodump-ng` | aircrack-ng | aircrack-ng | WiFi scanning |
|
||||
| `aireplay-ng` | aircrack-ng | aircrack-ng | WiFi deauth (optional) |
|
||||
@@ -182,8 +182,7 @@ Open **http://localhost:5050** in your browser.
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `flask` | Web server |
|
||||
| `skyfield` | Satellite tracking (optional) |
|
||||
| `pyserial` | USB GPS dongle support (optional) |
|
||||
| `skyfield` | Satellite tracking |
|
||||
|
||||
---
|
||||
|
||||
@@ -209,3 +208,4 @@ https://github.com/flightaware/dump1090
|
||||
- **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
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Security Considerations
|
||||
|
||||
INTERCEPT is designed as a **local signal intelligence tool** for personal use on trusted networks. This document outlines security considerations and best practices.
|
||||
|
||||
## Network Binding
|
||||
|
||||
By default, INTERCEPT binds to `0.0.0.0:5050`, making it accessible from any network interface. This is convenient for accessing the web UI from other devices on your local network, but has security implications:
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. **Firewall Rules**: If you don't need remote access, configure your firewall to block external access to port 5050:
|
||||
```bash
|
||||
# Linux (iptables)
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -s 127.0.0.1 -j ACCEPT
|
||||
sudo iptables -A INPUT -p tcp --dport 5050 -j DROP
|
||||
|
||||
# macOS (pf)
|
||||
echo "block in on en0 proto tcp from any to any port 5050" | sudo pfctl -ef -
|
||||
```
|
||||
|
||||
2. **Bind to Localhost**: For local-only access, set the host environment variable:
|
||||
```bash
|
||||
export INTERCEPT_HOST=127.0.0.1
|
||||
python intercept.py
|
||||
```
|
||||
|
||||
3. **Trusted Networks Only**: Only run INTERCEPT on networks you trust. The application has no authentication mechanism.
|
||||
|
||||
## Authentication
|
||||
|
||||
INTERCEPT does **not** include authentication. This is by design for ease of use as a personal tool. If you need to expose INTERCEPT to untrusted networks:
|
||||
|
||||
1. Use a reverse proxy (nginx, Caddy) with authentication
|
||||
2. Use a VPN to access your home network
|
||||
3. Use SSH port forwarding: `ssh -L 5050:localhost:5050 your-server`
|
||||
|
||||
## Security Headers
|
||||
|
||||
INTERCEPT includes the following security headers on all responses:
|
||||
|
||||
| Header | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
|
||||
| `X-Frame-Options` | `SAMEORIGIN` | Prevent clickjacking |
|
||||
| `X-XSS-Protection` | `1; mode=block` | Enable browser XSS filter |
|
||||
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Control referrer information |
|
||||
| `Permissions-Policy` | `geolocation=(self), microphone=()` | Restrict browser features |
|
||||
|
||||
## Input Validation
|
||||
|
||||
All user inputs are validated before use:
|
||||
|
||||
- **Network interface names**: Validated against strict regex pattern
|
||||
- **Bluetooth interface names**: Must match `hciX` format
|
||||
- **MAC addresses**: Validated format
|
||||
- **Frequencies**: Validated range and format
|
||||
- **File paths**: Protected against directory traversal
|
||||
- **HTML output**: All user-provided content is escaped
|
||||
|
||||
## Subprocess Execution
|
||||
|
||||
INTERCEPT executes external tools (rtl_fm, airodump-ng, etc.) via subprocess. Security measures:
|
||||
|
||||
- **No shell execution**: All subprocess calls use list arguments, not shell strings
|
||||
- **Input validation**: All user-provided arguments are validated before use
|
||||
- **Process isolation**: Each tool runs in its own process with limited permissions
|
||||
|
||||
## Debug Mode
|
||||
|
||||
Debug mode is **disabled by default**. If enabled via `INTERCEPT_DEBUG=true`:
|
||||
|
||||
- The Werkzeug debugger PIN is disabled (not needed for local tool)
|
||||
- Additional logging is enabled
|
||||
- Stack traces are shown on errors
|
||||
|
||||
**Never run in debug mode on untrusted networks.**
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability, please report it by:
|
||||
|
||||
1. Opening a GitHub issue (for non-sensitive issues)
|
||||
2. Emailing the maintainer directly (for sensitive issues)
|
||||
|
||||
Please include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
+235
-27
@@ -14,6 +14,37 @@ pip install -r requirements.txt
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### pip install fails for flask or skyfield
|
||||
|
||||
On newer Debian/Ubuntu systems, pip may fail with permission errors or dependency conflicts. **Use apt instead:**
|
||||
|
||||
```bash
|
||||
# Install Python packages via apt (recommended for Debian/Ubuntu)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
|
||||
# Then create venv with system packages
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "error: externally-managed-environment" (pip blocked)
|
||||
|
||||
This is PEP 668 protection on Ubuntu 23.04+, Debian 12+, and similar systems. Solutions:
|
||||
|
||||
```bash
|
||||
# Option 1: Use apt packages (recommended)
|
||||
sudo apt install python3-flask python3-requests python3-serial python3-skyfield
|
||||
python3 -m venv --system-site-packages venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Option 2: Use pipx for isolated install
|
||||
pipx install flask
|
||||
|
||||
# Option 3: Force pip (not recommended)
|
||||
pip install --break-system-packages flask
|
||||
```
|
||||
|
||||
### "TypeError: 'type' object is not subscriptable"
|
||||
|
||||
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
|
||||
@@ -33,18 +64,12 @@ pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
|
||||
### Alternative: Use the setup script
|
||||
|
||||
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
|
||||
The setup script handles all installation automatically, including apt packages:
|
||||
|
||||
```bash
|
||||
# Option 1: Virtual environment (recommended)
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
|
||||
# Option 2: Use the setup script (auto-creates venv if needed)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
@@ -101,11 +126,204 @@ Then unplug and replug your RTL-SDR.
|
||||
3. Check for other applications: `lsof | grep rtl`
|
||||
|
||||
### LimeSDR/HackRF not detected
|
||||
Ensure the correct SoapySDR module for your hardware is installed first
|
||||
|
||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||
3. May need udev rules or run as root
|
||||
|
||||
### Using HackRF/Airspy/LimeSDR with ADS-B
|
||||
|
||||
For non-RTL-SDR devices, ADS-B requires `readsb` compiled with SoapySDR support (standard dump1090 won't work).
|
||||
|
||||
**Option 1: Run readsb separately and connect via Remote mode**
|
||||
|
||||
1. Start readsb with your device:
|
||||
```bash
|
||||
# HackRF
|
||||
readsb --device-type soapysdr --device driver=hackrf --net --quiet
|
||||
|
||||
# Airspy
|
||||
readsb --device-type soapysdr --device driver=airspy --net --quiet
|
||||
|
||||
# LimeSDR
|
||||
readsb --device-type soapysdr --device driver=lime --net --quiet
|
||||
```
|
||||
|
||||
2. In Intercept's ADS-B dashboard:
|
||||
- Check the **"Remote"** checkbox
|
||||
- Enter Host: `localhost` and Port: `30003`
|
||||
- Click **START**
|
||||
|
||||
3. Intercept will connect to readsb's SBS output on port 30003
|
||||
|
||||
**Option 2: Install readsb with SoapySDR support**
|
||||
|
||||
On Debian/Ubuntu:
|
||||
```bash
|
||||
# Install dependencies
|
||||
sudo apt install build-essential debhelper librtlsdr-dev pkg-config \
|
||||
libncurses5-dev libbladerf-dev libhackrf-dev liblimesuite-dev libsoapysdr-dev
|
||||
|
||||
# Clone and build
|
||||
git clone https://github.com/wiedehopf/readsb.git
|
||||
cd readsb
|
||||
dpkg-buildpackage -b --no-sign
|
||||
sudo dpkg -i ../readsb_*.deb
|
||||
```
|
||||
|
||||
### Using HackRF/Airspy with Listening Post
|
||||
|
||||
The Listening Post requires `rx_fm` from SoapySDR utilities for non-RTL-SDR devices.
|
||||
|
||||
```bash
|
||||
# Install SoapySDR utilities (includes rx_fm)
|
||||
sudo apt install soapysdr-tools
|
||||
|
||||
# Verify rx_fm is available
|
||||
which rx_fm
|
||||
```
|
||||
|
||||
If `rx_fm` is installed, select your device from the SDR dropdown in the Listening Post - HackRF, Airspy, LimeSDR, and SDRPlay are all supported.
|
||||
|
||||
### Setting up Icecast for Listening Post Audio
|
||||
|
||||
The Listening Post uses Icecast for low-latency audio streaming (2-10 second latency). Intercept will automatically start Icecast when you begin listening, but you must install and configure it first.
|
||||
|
||||
**Install Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install icecast2
|
||||
|
||||
# macOS
|
||||
brew install icecast
|
||||
```
|
||||
|
||||
**Configure Icecast:**
|
||||
|
||||
During installation on Debian/Ubuntu, you'll be prompted to configure. Otherwise, edit `/etc/icecast2/icecast.xml`:
|
||||
|
||||
```xml
|
||||
<icecast>
|
||||
<authentication>
|
||||
<!-- Source password - used by ffmpeg to send audio -->
|
||||
<source-password>hackme</source-password>
|
||||
<!-- Admin password for web interface -->
|
||||
<admin-password>your-admin-password</admin-password>
|
||||
</authentication>
|
||||
<hostname>localhost</hostname>
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
</listen-socket>
|
||||
</icecast>
|
||||
```
|
||||
|
||||
**Start Icecast:**
|
||||
```bash
|
||||
# Ubuntu/Debian (as service)
|
||||
sudo systemctl enable icecast2
|
||||
sudo systemctl start icecast2
|
||||
|
||||
# Or run directly
|
||||
icecast -c /etc/icecast2/icecast.xml
|
||||
|
||||
# macOS
|
||||
brew services start icecast
|
||||
# Or: icecast -c /usr/local/etc/icecast.xml
|
||||
```
|
||||
|
||||
**Verify Icecast is running:**
|
||||
- Open http://localhost:8000 in your browser
|
||||
- You should see the Icecast status page
|
||||
|
||||
**Configure Intercept (optional):**
|
||||
|
||||
The default configuration expects Icecast on `127.0.0.1:8000` with source password `hackme` and mount point `/listen.mp3`. To change these, modify the scanner config in your API calls or update the defaults in `routes/listening_post.py`:
|
||||
|
||||
```python
|
||||
scanner_config = {
|
||||
# ... other settings ...
|
||||
'icecast_host': '127.0.0.1',
|
||||
'icecast_port': 8000,
|
||||
'icecast_mount': '/listen.mp3',
|
||||
'icecast_source_password': 'hackme',
|
||||
}
|
||||
```
|
||||
|
||||
**Troubleshooting Icecast:**
|
||||
|
||||
- **"Connection refused" errors**: Ensure Icecast is running on the configured port
|
||||
- **"Authentication failed"**: Check the source password matches between Icecast config and Intercept
|
||||
- **No audio playing**: Check Icecast status page (http://localhost:8000) to verify the mount point is active
|
||||
- **High latency**: Ensure nginx/reverse proxy isn't buffering - add `proxy_buffering off;` to nginx config
|
||||
|
||||
### Audio Streaming Issues - Detailed Debugging
|
||||
|
||||
If the Listening Post shows "Icecast mount not active" errors or audio doesn't play:
|
||||
|
||||
**1. Check the console output for errors**
|
||||
|
||||
Intercept now logs detailed error output. Look for lines starting with `[AUDIO]`:
|
||||
```
|
||||
[AUDIO] SDR errors: ... # Problems with rtl_fm/rx_fm (SDR not connected, device busy)
|
||||
[AUDIO] FFmpeg errors: ... # Problems with ffmpeg (wrong password, codec issues)
|
||||
```
|
||||
|
||||
**2. Verify SDR is connected and working**
|
||||
```bash
|
||||
# For RTL-SDR
|
||||
rtl_test -t
|
||||
|
||||
# You should see: "Found 1 device(s)"
|
||||
# If not, check USB connection and drivers
|
||||
```
|
||||
|
||||
**3. Check Icecast password (macOS Homebrew)**
|
||||
|
||||
On macOS with Homebrew, the Icecast config is at `/opt/homebrew/etc/icecast.xml`. Check the source password:
|
||||
```bash
|
||||
grep source-password /opt/homebrew/etc/icecast.xml
|
||||
```
|
||||
|
||||
If it's different from `hackme`, update it in the Listening Post Icecast config panel, or change the Icecast config and restart:
|
||||
```bash
|
||||
brew services restart icecast
|
||||
```
|
||||
|
||||
**4. Verify ffmpeg has required codecs**
|
||||
```bash
|
||||
# Check MP3 encoder is available
|
||||
ffmpeg -encoders 2>/dev/null | grep mp3
|
||||
|
||||
# Should show: libmp3lame
|
||||
# If not, reinstall ffmpeg with all codecs:
|
||||
# macOS: brew reinstall ffmpeg
|
||||
# Linux: sudo apt install ffmpeg
|
||||
```
|
||||
|
||||
**5. Test the pipeline manually**
|
||||
|
||||
Try running the audio pipeline directly to see errors:
|
||||
```bash
|
||||
# Test rtl_fm (should produce raw audio data)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>&1 | head -c 1000 | xxd | head
|
||||
|
||||
# Test ffmpeg to Icecast (replace PASSWORD with your source password)
|
||||
rtl_fm -M am -f 118000000 -s 24000 -r 24000 -g 40 2>/dev/null | \
|
||||
ffmpeg -f s16le -ar 24000 -ac 1 -i pipe:0 -c:a libmp3lame -b:a 64k \
|
||||
-f mp3 -content_type audio/mpeg icecast://source:PASSWORD@127.0.0.1:8000/listen.mp3
|
||||
```
|
||||
|
||||
**6. Common error messages and solutions**
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| `No supported devices found` | SDR not connected | Plug in SDR, check USB |
|
||||
| `Device or resource busy` | Another process using SDR | Click "Kill All Processes" |
|
||||
| `401 Unauthorized` | Wrong Icecast password | Check password in Icecast config |
|
||||
| `Connection refused` | Icecast not running | Start Icecast service |
|
||||
| `Encoder libmp3lame not found` | ffmpeg missing codec | Reinstall ffmpeg with codecs |
|
||||
|
||||
## WiFi Issues
|
||||
|
||||
### Monitor mode fails
|
||||
@@ -146,21 +364,6 @@ Run with sudo or add your user to the bluetooth group:
|
||||
sudo usermod -a -G bluetooth $USER
|
||||
```
|
||||
|
||||
## GPS Issues
|
||||
|
||||
### GPS dongle not detected
|
||||
|
||||
1. Install pyserial: `pip install pyserial`
|
||||
2. Check device is connected:
|
||||
- Linux: `ls /dev/ttyUSB* /dev/ttyACM*`
|
||||
- macOS: `ls /dev/tty.usb*`
|
||||
3. Add user to dialout group (Linux):
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
```
|
||||
4. Most GPS dongles use 9600 baud (default in INTERCEPT)
|
||||
5. GPS needs clear sky view to get a fix
|
||||
|
||||
## Decoding Issues
|
||||
|
||||
### No messages appearing (Pager mode)
|
||||
@@ -170,15 +373,20 @@ sudo usermod -a -G bluetooth $USER
|
||||
3. Check pager services are active in your area
|
||||
4. Ensure antenna is connected
|
||||
|
||||
### Cannot install dump1090 in Debian (ADS-B mode)
|
||||
|
||||
On newer Debian versions, dump1090 may not be in repositories. The recommended action is to build from source or use the setup.sh script which will do it for you.
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
1. Verify dump1090 or readsb is installed
|
||||
1. Verify dump1090 is installed
|
||||
2. Check antenna is connected (1090 MHz antenna recommended)
|
||||
3. Ensure clear view of sky
|
||||
4. Set correct observer location for range calculations
|
||||
4. Set correct observer location for range calculations or use gpsd
|
||||
|
||||
### Satellite passes not calculating
|
||||
|
||||
1. Ensure skyfield is installed: `pip install skyfield`
|
||||
1. Ensure skyfield is installed: `apt install python3-skyfield`
|
||||
2. Check TLE data is valid and recent
|
||||
3. Verify observer location is set correctly
|
||||
|
||||
|
||||
+18
-6
@@ -1,8 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#000"/>
|
||||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="3" fill="none"/>
|
||||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="50" r="4" fill="#00d4ff"/>
|
||||
<!-- Background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Signal brackets - left side -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="4" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="3.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter -->
|
||||
<circle cx="50" cy="22" r="7" fill="#00ff88"/>
|
||||
<rect x="43" y="35" width="14" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="36" y="35" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
<rect x="36" y="75" width="28" height="5" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
@@ -0,0 +1,898 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>iNTERCEPT Promo</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--cyan: #00d4ff;
|
||||
--green: #00ff88;
|
||||
--red: #ff3366;
|
||||
--purple: #a855f7;
|
||||
--orange: #ff9500;
|
||||
--bg: #0a0a0f;
|
||||
--bg-secondary: #12121a;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Container maintains 9:16 aspect ratio and scales to fit */
|
||||
.video-frame {
|
||||
position: relative;
|
||||
width: min(100vw, calc(100vh * 9 / 16));
|
||||
height: min(100vh, calc(100vw * 16 / 9));
|
||||
max-width: 1080px;
|
||||
max-height: 1920px;
|
||||
background: var(--bg);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
/* Scale font size based on container width */
|
||||
font-size: min(16px, calc(100vw * 16 / 1080));
|
||||
}
|
||||
|
||||
/* Animated background grid */
|
||||
.grid-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 212, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 212, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(30px, 30px); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
.scanline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
opacity: 0.7;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0; }
|
||||
100% { top: 100%; }
|
||||
}
|
||||
|
||||
/* Glowing orbs background */
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(50px);
|
||||
opacity: 0.25;
|
||||
animation: orbFloat 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--cyan);
|
||||
top: 10%;
|
||||
left: -10%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
background: var(--purple);
|
||||
bottom: 20%;
|
||||
right: -5%;
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: var(--green);
|
||||
bottom: 40%;
|
||||
left: 20%;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
@keyframes orbFloat {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(30px, -30px) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Main content container */
|
||||
.container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Scene management */
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.8s ease, visibility 0.8s ease;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Scene 1: Logo reveal */
|
||||
.logo-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 20px;
|
||||
filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.5));
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave {
|
||||
opacity: 0;
|
||||
animation: signalReveal 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.logo-svg .signal-wave-1 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-2 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-3 { animation-delay: 0.9s; }
|
||||
.logo-svg .signal-wave-4 { animation-delay: 0.5s; }
|
||||
.logo-svg .signal-wave-5 { animation-delay: 0.7s; }
|
||||
.logo-svg .signal-wave-6 { animation-delay: 0.9s; }
|
||||
|
||||
@keyframes signalReveal {
|
||||
0% { opacity: 0; transform: scale(0.8); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-i {
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.8s ease forwards 0.2s;
|
||||
}
|
||||
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.logo-svg .logo-dot {
|
||||
animation: dotPulse 1.5s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 5px rgba(0, 255, 136, 0.5)); }
|
||||
50% { filter: drop-shadow(0 0 25px rgba(0, 255, 136, 1)); }
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0;
|
||||
animation: titleReveal 1s ease forwards 1.2s;
|
||||
}
|
||||
|
||||
@keyframes titleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); letter-spacing: 0.3em; }
|
||||
100% { opacity: 1; transform: translateY(0); letter-spacing: 0.15em; }
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0;
|
||||
animation: taglineReveal 0.8s ease forwards 1.8s;
|
||||
}
|
||||
|
||||
@keyframes taglineReveal {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0;
|
||||
animation: subtitleReveal 0.8s ease forwards 2.2s;
|
||||
}
|
||||
|
||||
@keyframes subtitleReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Scene 2: Features */
|
||||
.features-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 24px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
animation: featureReveal 0.6s ease forwards;
|
||||
}
|
||||
|
||||
.feature-card:nth-child(1) { animation-delay: 0.2s; }
|
||||
.feature-card:nth-child(2) { animation-delay: 0.4s; }
|
||||
.feature-card:nth-child(3) { animation-delay: 0.6s; }
|
||||
.feature-card:nth-child(4) { animation-delay: 0.8s; }
|
||||
|
||||
@keyframes featureReveal {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Scene 3: Modes showcase */
|
||||
.modes-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mode-showcase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 3px solid var(--cyan);
|
||||
padding: 10px 15px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
animation: modeSlide 0.5s ease forwards;
|
||||
}
|
||||
|
||||
.mode-item:nth-child(1) { animation-delay: 0.1s; border-color: var(--cyan); }
|
||||
.mode-item:nth-child(2) { animation-delay: 0.2s; border-color: var(--green); }
|
||||
.mode-item:nth-child(3) { animation-delay: 0.3s; border-color: var(--purple); }
|
||||
.mode-item:nth-child(4) { animation-delay: 0.4s; border-color: var(--orange); }
|
||||
.mode-item:nth-child(5) { animation-delay: 0.5s; border-color: var(--red); }
|
||||
.mode-item:nth-child(6) { animation-delay: 0.6s; border-color: #00ffcc; }
|
||||
.mode-item:nth-child(7) { animation-delay: 0.7s; border-color: #ff66cc; }
|
||||
|
||||
@keyframes modeSlide {
|
||||
0% { opacity: 0; transform: translateX(-30px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 22px;
|
||||
width: 35px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Scene 4: UI Preview */
|
||||
.ui-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-preview {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 60px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-header {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.ui-logo-small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.ui-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ui-card-header {
|
||||
font-size: 8px;
|
||||
color: var(--cyan);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ui-stat {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.ui-stat.cyan { color: var(--cyan); }
|
||||
.ui-stat.orange { color: var(--orange); }
|
||||
|
||||
.ui-console {
|
||||
grid-column: span 3;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.console-line {
|
||||
margin-bottom: 4px;
|
||||
opacity: 0;
|
||||
animation: consoleLine 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.console-line:nth-child(1) { animation-delay: 0.5s; }
|
||||
.console-line:nth-child(2) { animation-delay: 0.8s; }
|
||||
.console-line:nth-child(3) { animation-delay: 1.1s; }
|
||||
.console-line:nth-child(4) { animation-delay: 1.4s; }
|
||||
.console-line:nth-child(5) { animation-delay: 1.7s; }
|
||||
|
||||
@keyframes consoleLine {
|
||||
0% { opacity: 0; transform: translateX(-10px); }
|
||||
100% { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.console-time { color: #666; }
|
||||
.console-type { color: var(--cyan); }
|
||||
.console-msg { color: var(--green); }
|
||||
.console-freq { color: var(--orange); }
|
||||
|
||||
/* Scene 5: CTA */
|
||||
.cta-scene {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 20px;
|
||||
animation: ctaLogoPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaLogoPulse {
|
||||
0%, 100% { filter: drop-shadow(0 0 20px rgba(0, 212, 255, 0.5)); transform: scale(1); }
|
||||
50% { filter: drop-shadow(0 0 40px rgba(0, 212, 255, 0.8)); transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cta-tagline {
|
||||
font-size: 18px;
|
||||
color: var(--cyan);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.cta-btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
background: var(--cyan);
|
||||
border-radius: 30px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
animation: ctaBtnPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ctaBtnPulse {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(0, 212, 255, 0.5); }
|
||||
50% { box-shadow: 0 0 40px rgba(0, 212, 255, 0.8); }
|
||||
}
|
||||
|
||||
.cta-url {
|
||||
margin-top: 20px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Typing cursor effect */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--cyan);
|
||||
margin-left: 5px;
|
||||
animation: blink 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 10px var(--cyan);
|
||||
}
|
||||
|
||||
/* Decorative elements */
|
||||
.corner-decoration {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.corner-tl {
|
||||
top: 15px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-tr {
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.corner-bl {
|
||||
bottom: 50px;
|
||||
left: 15px;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.corner-br {
|
||||
bottom: 50px;
|
||||
right: 15px;
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="video-frame">
|
||||
<!-- Background elements -->
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
|
||||
<!-- Corner decorations -->
|
||||
<div class="corner-decoration corner-tl"></div>
|
||||
<div class="corner-decoration corner-tr"></div>
|
||||
<div class="corner-decoration corner-bl"></div>
|
||||
<div class="corner-decoration corner-br"></div>
|
||||
|
||||
<!-- Scene 1: Logo Reveal -->
|
||||
<div class="scene active" id="scene1">
|
||||
<div class="logo-container">
|
||||
<svg class="logo-svg" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Signal brackets - left side -->
|
||||
<path class="signal-wave signal-wave-1" d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-2" d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-3" d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- Signal brackets - right side -->
|
||||
<path class="signal-wave signal-wave-4" d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path class="signal-wave signal-wave-5" d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path class="signal-wave signal-wave-6" d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<!-- The 'i' letter -->
|
||||
<g class="logo-i">
|
||||
<circle class="logo-dot" cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 class="title">iNTERCEPT</h1>
|
||||
<p class="tagline">// See the Invisible</p>
|
||||
<p class="subtitle">Signal Intelligence & Counter Surveillance</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 2: Features Grid -->
|
||||
<div class="scene" id="scene2">
|
||||
<div class="features-scene">
|
||||
<h2 class="feature-title">Capabilities</h2>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📡</div>
|
||||
<div class="feature-name">SDR Scanning</div>
|
||||
<div class="feature-desc">Multi-band reception</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔐</div>
|
||||
<div class="feature-name">Decryption</div>
|
||||
<div class="feature-desc">Signal analysis</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛰️</div>
|
||||
<div class="feature-name">Tracking</div>
|
||||
<div class="feature-desc">Real-time monitoring</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔍</div>
|
||||
<div class="feature-name">Detection</div>
|
||||
<div class="feature-desc">Counter surveillance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 3: Modes List -->
|
||||
<div class="scene" id="scene3">
|
||||
<div class="modes-scene">
|
||||
<div class="mode-showcase">
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📟</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">PAGER</div>
|
||||
<div class="mode-desc">POCSAG & FLEX decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">✈️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">ADS-B</div>
|
||||
<div class="mode-desc">Aircraft tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📻</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">LISTENING POST</div>
|
||||
<div class="mode-desc">RF monitoring & scanning</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">📶</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">WiFi</div>
|
||||
<div class="mode-desc">Network reconnaissance</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🔵</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">BLUETOOTH</div>
|
||||
<div class="mode-desc">Device & tracker detection</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🌡️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SENSORS</div>
|
||||
<div class="mode-desc">433MHz IoT decoding</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-item">
|
||||
<div class="mode-icon">🛰️</div>
|
||||
<div class="mode-info">
|
||||
<div class="mode-name">SATELLITE</div>
|
||||
<div class="mode-desc">Pass prediction & tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 4: UI Preview -->
|
||||
<div class="scene" id="scene4">
|
||||
<div class="ui-scene">
|
||||
<div class="ui-preview">
|
||||
<div class="ui-header">
|
||||
<svg class="ui-logo-small" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<span class="ui-title">iNTERCEPT</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Messages</div>
|
||||
<div class="ui-stat">2,847</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Aircraft</div>
|
||||
<div class="ui-stat cyan">42</div>
|
||||
</div>
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-header">Devices</div>
|
||||
<div class="ui-stat orange">156</div>
|
||||
</div>
|
||||
<div class="ui-console">
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:07]</span>
|
||||
<span class="console-type"> POCSAG </span>
|
||||
<span class="console-msg">Signal intercepted</span>
|
||||
<span class="console-freq"> 153.350 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:09]</span>
|
||||
<span class="console-type"> ADS-B </span>
|
||||
<span class="console-msg">Aircraft detected: BA284</span>
|
||||
<span class="console-freq"> FL350</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:11]</span>
|
||||
<span class="console-type"> BT </span>
|
||||
<span class="console-msg">AirTag detected nearby</span>
|
||||
<span class="console-freq"> -42 dBm</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:14]</span>
|
||||
<span class="console-type"> SENSOR </span>
|
||||
<span class="console-msg">Temperature: 22.4C</span>
|
||||
<span class="console-freq"> 433.92 MHz</span>
|
||||
</div>
|
||||
<div class="console-line">
|
||||
<span class="console-time">[14:32:16]</span>
|
||||
<span class="console-type"> SCAN </span>
|
||||
<span class="console-msg">Signal found</span>
|
||||
<span class="console-freq"> 145.500 MHz</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scene 5: CTA -->
|
||||
<div class="scene" id="scene5">
|
||||
<div class="cta-scene">
|
||||
<svg class="cta-logo" viewBox="0 0 100 100" fill="none">
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
<h2 class="cta-title">iNTERCEPT</h2>
|
||||
<p class="cta-tagline">See the Invisible</p>
|
||||
<div class="cta-btn">Open Source</div>
|
||||
<p class="cta-url">github.com/yourrepo/intercept</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="progress-bar">
|
||||
<div class="progress-dot active" data-scene="1"></div>
|
||||
<div class="progress-dot" data-scene="2"></div>
|
||||
<div class="progress-dot" data-scene="3"></div>
|
||||
<div class="progress-dot" data-scene="4"></div>
|
||||
<div class="progress-dot" data-scene="5"></div>
|
||||
</div>
|
||||
</div><!-- end video-frame -->
|
||||
|
||||
<script>
|
||||
// Scene timing (in milliseconds)
|
||||
const sceneTiming = [
|
||||
{ scene: 1, duration: 4000 }, // Logo reveal
|
||||
{ scene: 2, duration: 4000 }, // Features
|
||||
{ scene: 3, duration: 5000 }, // Modes
|
||||
{ scene: 4, duration: 5000 }, // UI Preview
|
||||
{ scene: 5, duration: 4000 }, // CTA
|
||||
];
|
||||
|
||||
let currentScene = 0;
|
||||
|
||||
function showScene(index) {
|
||||
// Hide all scenes
|
||||
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
|
||||
document.querySelectorAll('.progress-dot').forEach(d => d.classList.remove('active'));
|
||||
|
||||
// Show current scene
|
||||
const scene = document.getElementById(`scene${index + 1}`);
|
||||
if (scene) {
|
||||
scene.classList.add('active');
|
||||
document.querySelector(`.progress-dot[data-scene="${index + 1}"]`).classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function nextScene() {
|
||||
currentScene++;
|
||||
if (currentScene >= sceneTiming.length) {
|
||||
currentScene = 0; // Loop back to start
|
||||
}
|
||||
showScene(currentScene);
|
||||
setTimeout(nextScene, sceneTiming[currentScene].duration);
|
||||
}
|
||||
|
||||
// Start the animation sequence
|
||||
setTimeout(nextScene, sceneTiming[0].duration);
|
||||
|
||||
// Keyboard controls for manual navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
currentScene = (currentScene + 1) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
currentScene = (currentScene - 1 + sceneTiming.length) % sceneTiming.length;
|
||||
showScene(currentScene);
|
||||
} else if (e.key === ' ') {
|
||||
// Spacebar to pause/resume could be added here
|
||||
}
|
||||
});
|
||||
|
||||
// Click on progress dots to jump to scene
|
||||
document.querySelectorAll('.progress-dot').forEach(dot => {
|
||||
dot.addEventListener('click', () => {
|
||||
currentScene = parseInt(dot.dataset.scene) - 1;
|
||||
showScene(currentScene);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,6 +40,7 @@ Issues = "https://github.com/smittix/intercept/issues"
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.15.1",
|
||||
"ruff>=0.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.15.1
|
||||
|
||||
# Code quality
|
||||
ruff>=0.1.0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Core dependencies
|
||||
flask>=2.0.0
|
||||
requests>=2.28.0
|
||||
|
||||
# Satellite tracking (optional - only needed for satellite features)
|
||||
skyfield>=1.45
|
||||
@@ -13,3 +14,4 @@ pyserial>=3.5
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
flask-sock
|
||||
|
||||
+162
-8
@@ -35,6 +35,7 @@ from utils.constants import (
|
||||
ADSB_UPDATE_INTERVAL,
|
||||
DUMP1090_START_WAIT,
|
||||
)
|
||||
from utils import aircraft_db
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
@@ -43,6 +44,14 @@ adsb_using_service = False
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
# Track ICAOs already looked up in aircraft database (avoid repeated lookups)
|
||||
_looked_up_icaos: set[str] = set()
|
||||
|
||||
# Load aircraft database at module init
|
||||
aircraft_db.load_database()
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
@@ -91,7 +100,7 @@ def check_dump1090_service():
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 SBS port."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time, adsb_bytes_received, adsb_lines_received
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
@@ -111,12 +120,16 @@ def parse_sbs_stream(service_addr):
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
adsb_bytes_received = 0
|
||||
adsb_lines_received = 0
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(SOCKET_BUFFER_SIZE).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
logger.warning("SBS connection closed (no data)")
|
||||
break
|
||||
adsb_bytes_received += len(data)
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
@@ -125,8 +138,15 @@ def parse_sbs_stream(service_addr):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
adsb_lines_received += 1
|
||||
# Log first few lines for debugging
|
||||
if adsb_lines_received <= 3:
|
||||
logger.info(f"SBS line {adsb_lines_received}: {line[:100]}")
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
if adsb_lines_received <= 5:
|
||||
logger.debug(f"Skipping non-MSG line: {line[:50]}")
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
@@ -136,6 +156,18 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao) or {'icao': icao}
|
||||
|
||||
# Look up aircraft type from database (once per ICAO)
|
||||
if icao not in _looked_up_icaos:
|
||||
_looked_up_icaos.add(icao)
|
||||
db_info = aircraft_db.lookup(icao)
|
||||
if db_info:
|
||||
if db_info['registration']:
|
||||
aircraft['registration'] = db_info['registration']
|
||||
if db_info['type_code']:
|
||||
aircraft['type_code'] = db_info['type_code']
|
||||
if db_info['type_desc']:
|
||||
aircraft['type_desc'] = db_info['type_desc']
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
@@ -154,7 +186,7 @@ def parse_sbs_stream(service_addr):
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '4' and len(parts) > 13:
|
||||
elif msg_type == '4' and len(parts) > 16:
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
@@ -165,6 +197,11 @@ def parse_sbs_stream(service_addr):
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[16]:
|
||||
try:
|
||||
aircraft['vertical_rate'] = int(float(parts[16]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '5' and len(parts) > 11:
|
||||
if parts[10]:
|
||||
@@ -213,25 +250,52 @@ def parse_sbs_stream(service_addr):
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools."""
|
||||
"""Check for ADS-B decoding tools and hardware."""
|
||||
# Check available decoders
|
||||
has_dump1090 = find_dump1090() is not None
|
||||
has_readsb = shutil.which('readsb') is not None
|
||||
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||
|
||||
# Check what SDR hardware is detected
|
||||
devices = SDRFactory.detect_devices()
|
||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
||||
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
||||
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
||||
|
||||
# Determine if readsb is needed but missing
|
||||
needs_readsb = has_soapy_sdr and not has_readsb
|
||||
|
||||
return jsonify({
|
||||
'dump1090': find_dump1090() is not None,
|
||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
||||
'dump1090': has_dump1090,
|
||||
'readsb': has_readsb,
|
||||
'rtl_adsb': has_rtl_adsb,
|
||||
'has_rtlsdr': has_rtlsdr,
|
||||
'has_soapy_sdr': has_soapy_sdr,
|
||||
'soapy_types': soapy_types,
|
||||
'needs_readsb': needs_readsb
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
# Check if dump1090 process is still running
|
||||
dump1090_running = False
|
||||
if app_module.adsb_process:
|
||||
dump1090_running = app_module.adsb_process.poll() is None
|
||||
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_received,
|
||||
'bytes_received': adsb_bytes_received,
|
||||
'lines_received': adsb_lines_received,
|
||||
'last_message_time': adsb_last_message_time,
|
||||
'aircraft_count': len(app_module.adsb_aircraft),
|
||||
'aircraft': dict(app_module.adsb_aircraft), # Full aircraft data
|
||||
'queue_size': app_module.adsb_queue.qsize(),
|
||||
'dump1090_path': find_dump1090(),
|
||||
'dump1090_running': dump1090_running,
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
@@ -317,9 +381,11 @@ def start_adsb():
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build ADS-B decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain)
|
||||
gain=float(gain),
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
# For RTL-SDR, ensure we use the found dump1090 path
|
||||
@@ -330,13 +396,29 @@ def start_adsb():
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
time.sleep(DUMP1090_START_WAIT)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
||||
# Process exited - try to get error message
|
||||
stderr_output = ''
|
||||
if app_module.adsb_process.stderr:
|
||||
try:
|
||||
stderr_output = app_module.adsb_process.stderr.read().decode('utf-8', errors='ignore').strip()
|
||||
except Exception:
|
||||
pass
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
error_msg = 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
else:
|
||||
error_msg = f'ADS-B decoder failed to start for {sdr_type.value}. Ensure readsb is installed with SoapySDR support and the device is connected.'
|
||||
if stderr_output:
|
||||
error_msg += f' Error: {stderr_output[:200]}'
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(f'localhost:{ADSB_SBS_PORT}',), daemon=True)
|
||||
@@ -363,6 +445,7 @@ def stop_adsb():
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft.clear()
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@@ -393,3 +476,74 @@ def stream_adsb():
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
|
||||
|
||||
# ============================================
|
||||
# AIRCRAFT DATABASE MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
@adsb_bp.route('/aircraft-db/status')
|
||||
def aircraft_db_status():
|
||||
"""Get aircraft database status."""
|
||||
return jsonify(aircraft_db.get_db_status())
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/check-updates')
|
||||
def aircraft_db_check_updates():
|
||||
"""Check for aircraft database updates."""
|
||||
result = aircraft_db.check_for_updates()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/download', methods=['POST'])
|
||||
def aircraft_db_download():
|
||||
"""Download/update aircraft database."""
|
||||
global _looked_up_icaos
|
||||
result = aircraft_db.download_database()
|
||||
if result.get('success'):
|
||||
# Clear lookup cache so new data is used
|
||||
_looked_up_icaos.clear()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-db/delete', methods=['POST'])
|
||||
def aircraft_db_delete():
|
||||
"""Delete aircraft database."""
|
||||
result = aircraft_db.delete_database()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@adsb_bp.route('/aircraft-photo/<registration>')
|
||||
def aircraft_photo(registration: str):
|
||||
"""Fetch aircraft photo from Planespotters.net API."""
|
||||
import requests
|
||||
|
||||
# Validate registration format (alphanumeric with dashes)
|
||||
if not registration or not all(c.isalnum() or c == '-' for c in registration):
|
||||
return jsonify({'error': 'Invalid registration'}), 400
|
||||
|
||||
try:
|
||||
# Planespotters.net public API
|
||||
url = f'https://api.planespotters.net/pub/photos/reg/{registration}'
|
||||
resp = requests.get(url, timeout=5, headers={
|
||||
'User-Agent': 'INTERCEPT-ADS-B/1.0'
|
||||
})
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get('photos') and len(data['photos']) > 0:
|
||||
photo = data['photos'][0]
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'thumbnail': photo.get('thumbnail_large', {}).get('src'),
|
||||
'link': photo.get('link'),
|
||||
'photographer': photo.get('photographer')
|
||||
})
|
||||
|
||||
return jsonify({'success': False, 'error': 'No photo found'})
|
||||
|
||||
except requests.Timeout:
|
||||
return jsonify({'success': False, 'error': 'Request timeout'}), 504
|
||||
except Exception as e:
|
||||
logger.debug(f"Error fetching aircraft photo: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""WebSocket-based audio streaming for SDR."""
|
||||
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import shutil
|
||||
import json
|
||||
from flask import Flask
|
||||
|
||||
# Try to import flask-sock
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
Sock = None
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger('intercept.audio_ws')
|
||||
|
||||
# Global state
|
||||
audio_process = None
|
||||
rtl_process = None
|
||||
process_lock = threading.Lock()
|
||||
current_config = {
|
||||
'frequency': 118.0,
|
||||
'modulation': 'am',
|
||||
'squelch': 0,
|
||||
'gain': 40,
|
||||
'device': 0
|
||||
}
|
||||
|
||||
|
||||
def find_rtl_fm():
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_ffmpeg():
|
||||
return shutil.which('ffmpeg')
|
||||
|
||||
|
||||
def kill_audio_processes():
|
||||
"""Kill any running audio processes."""
|
||||
global audio_process, rtl_process
|
||||
|
||||
if audio_process:
|
||||
try:
|
||||
audio_process.terminate()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
audio_process.kill()
|
||||
except:
|
||||
pass
|
||||
audio_process = None
|
||||
|
||||
if rtl_process:
|
||||
try:
|
||||
rtl_process.terminate()
|
||||
rtl_process.wait(timeout=0.5)
|
||||
except:
|
||||
try:
|
||||
rtl_process.kill()
|
||||
except:
|
||||
pass
|
||||
rtl_process = None
|
||||
|
||||
# Kill any orphaned processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'rtl_fm'], capture_output=True, timeout=1)
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def start_audio_stream(config):
|
||||
"""Start rtl_fm + ffmpeg pipeline, return the ffmpeg process."""
|
||||
global audio_process, rtl_process, current_config
|
||||
|
||||
kill_audio_processes()
|
||||
|
||||
rtl_fm = find_rtl_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
if not rtl_fm or not ffmpeg:
|
||||
logger.error("rtl_fm or ffmpeg not found")
|
||||
return None
|
||||
|
||||
current_config.update(config)
|
||||
|
||||
freq = config.get('frequency', 118.0)
|
||||
mod = config.get('modulation', 'am')
|
||||
squelch = config.get('squelch', 0)
|
||||
gain = config.get('gain', 40)
|
||||
device = config.get('device', 0)
|
||||
|
||||
# Sample rates based on modulation
|
||||
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
|
||||
|
||||
freq_hz = int(freq * 1e6)
|
||||
|
||||
rtl_cmd = [
|
||||
rtl_fm,
|
||||
'-M', mod,
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
'-l', str(squelch),
|
||||
]
|
||||
|
||||
# Encode to MP3 for browser compatibility
|
||||
ffmpeg_cmd = [
|
||||
ffmpeg,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-f', 'mp3',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
logger.info(f"Starting rtl_fm: {freq} MHz, {mod}")
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
audio_process = subprocess.Popen(
|
||||
ffmpeg_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
)
|
||||
|
||||
rtl_process.stdout.close()
|
||||
|
||||
# Check processes started
|
||||
time.sleep(0.2)
|
||||
if rtl_process.poll() is not None or audio_process.poll() is not None:
|
||||
logger.error("Audio process failed to start")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
return audio_process
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio: {e}")
|
||||
kill_audio_processes()
|
||||
return None
|
||||
|
||||
|
||||
def init_audio_websocket(app: Flask):
|
||||
"""Initialize WebSocket audio streaming."""
|
||||
if not WEBSOCKET_AVAILABLE:
|
||||
logger.warning("flask-sock not installed, WebSocket audio disabled")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route('/ws/audio')
|
||||
def audio_stream(ws):
|
||||
"""WebSocket endpoint for audio streaming."""
|
||||
logger.info("WebSocket audio client connected")
|
||||
|
||||
proc = None
|
||||
streaming = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Check for messages from client (non-blocking with timeout)
|
||||
try:
|
||||
msg = ws.receive(timeout=0.01)
|
||||
if msg:
|
||||
data = json.loads(msg)
|
||||
cmd = data.get('cmd')
|
||||
|
||||
if cmd == 'start':
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Starting audio: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'started'}))
|
||||
else:
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to start'}))
|
||||
|
||||
elif cmd == 'stop':
|
||||
logger.info("Stopping audio")
|
||||
streaming = False
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
proc = None
|
||||
ws.send(json.dumps({'status': 'stopped'}))
|
||||
|
||||
elif cmd == 'tune':
|
||||
# Change frequency/modulation - restart stream
|
||||
config = data.get('config', {})
|
||||
logger.info(f"Retuning: {config}")
|
||||
with process_lock:
|
||||
proc = start_audio_stream(config)
|
||||
if proc:
|
||||
streaming = True
|
||||
ws.send(json.dumps({'status': 'tuned'}))
|
||||
else:
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Failed to tune'}))
|
||||
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
if "timed out" not in str(e).lower():
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
|
||||
# Stream audio data if active
|
||||
if streaming and proc and proc.poll() is None:
|
||||
try:
|
||||
chunk = proc.stdout.read(4096)
|
||||
if chunk:
|
||||
ws.send(chunk)
|
||||
except Exception as e:
|
||||
logger.error(f"Audio read error: {e}")
|
||||
streaming = False
|
||||
elif streaming:
|
||||
# Process died
|
||||
streaming = False
|
||||
ws.send(json.dumps({'status': 'error', 'message': 'Audio process died'}))
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.info(f"WebSocket closed: {e}")
|
||||
finally:
|
||||
with process_lock:
|
||||
kill_audio_processes()
|
||||
logger.info("WebSocket audio client disconnected")
|
||||
+80
-10
@@ -21,6 +21,7 @@ import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from utils.validation import validate_bluetooth_interface
|
||||
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 (
|
||||
@@ -43,42 +44,76 @@ def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
# Audio devices - check name patterns first
|
||||
audio_patterns = [
|
||||
'airpod', 'earbud', 'headphone', 'headset', 'speaker', 'audio', 'beats', 'bose',
|
||||
'jbl', 'sony wh', 'sony wf', 'sennheiser', 'jabra', 'soundcore', 'anker', 'buds',
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen'
|
||||
'earphone', 'pod', 'soundbar', 'skullcandy', 'marshall', 'b&o', 'bang', 'olufsen',
|
||||
'powerbeats', 'soundlink', 'soundsport', 'quietcomfort', 'qc35', 'qc45', 'nc700',
|
||||
'wh-1000', 'wf-1000', 'linkbuds', 'freebuds', 'galaxy buds', 'pixel buds',
|
||||
'echo dot', 'homepod', 'sonos', 'ue boom', 'flip', 'charge', 'xtreme', 'pulse'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
# Wearables
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire', 'fenix',
|
||||
'forerunner', 'venu', 'vivoactive', 'instinct', 'apple watch', 'gt 2', 'gt2'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
# Phones - check name patterns
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi',
|
||||
'redmi', 'poco', 'realme', 'oppo', 'vivo', 'motorola', 'nokia', 'lg-', 'sm-',
|
||||
'moto g', 'moto e', 'note', 'ultra', 'pro max', 's21', 's22', 's23', 's24'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
# Trackers
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my', 'findmy']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
# Input devices
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote', 'trackpad',
|
||||
'magic keyboard', 'magic mouse', 'magic trackpad', 'mx master', 'mx keys',
|
||||
'logitech k', 'logitech m', 'razer', 'dualshock', 'dualsense', 'xbox']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
# Computers/laptops
|
||||
computer_patterns = ['macbook', 'imac', 'mac pro', 'mac mini', 'dell', 'hp ', 'lenovo',
|
||||
'thinkpad', 'surface', 'chromebook', 'laptop', 'desktop', 'pc']
|
||||
if any(x in name_lower for x in computer_patterns):
|
||||
return 'computer'
|
||||
|
||||
# Check manufacturer for device type inference
|
||||
audio_manufacturers = ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats',
|
||||
'bang & olufsen', 'audio-technica', 'skullcandy', 'anker', 'plantronics']
|
||||
if mfr_lower in audio_manufacturers:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
|
||||
wearable_manufacturers = ['fitbit', 'garmin']
|
||||
if mfr_lower in wearable_manufacturers:
|
||||
return 'wearable'
|
||||
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
phone_manufacturers = ['samsung', 'xiaomi', 'huawei', 'oneplus', 'google', 'oppo', 'vivo', 'realme']
|
||||
if mfr_lower in phone_manufacturers:
|
||||
return 'phone'
|
||||
|
||||
computer_manufacturers = ['dell', 'hp', 'lenovo', 'microsoft', 'intel']
|
||||
if mfr_lower in computer_manufacturers:
|
||||
return 'computer'
|
||||
|
||||
# Check device class if available
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
@@ -218,18 +253,43 @@ def stream_bt_scan(process, scan_mode):
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
# Check for RSSI update: [CHG] Device XX:XX:XX RSSI: -65
|
||||
rssi_match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}).*RSSI:\s*(-?\d+)', line)
|
||||
if rssi_match:
|
||||
mac = rssi_match.group(1).upper()
|
||||
rssi = int(rssi_match.group(2))
|
||||
if mac in app_module.bt_devices:
|
||||
app_module.bt_devices[mac]['rssi'] = rssi
|
||||
app_module.bt_devices[mac]['last_seen'] = time.time()
|
||||
# Send RSSI update
|
||||
app_module.bt_queue.put({
|
||||
**app_module.bt_devices[mac],
|
||||
'type': 'device',
|
||||
'device_type': app_module.bt_devices[mac].get('type', 'other'),
|
||||
'action': 'update',
|
||||
})
|
||||
continue
|
||||
|
||||
# Check for new device: [NEW] Device XX:XX:XX Name
|
||||
match = re.search(r'([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s*(.*)', line)
|
||||
if match:
|
||||
mac = match.group(1).upper()
|
||||
name = match.group(2).strip()
|
||||
|
||||
# Extract RSSI from name if present
|
||||
rssi_in_name = re.search(r'RSSI:\s*(-?\d+)', name)
|
||||
initial_rssi = int(rssi_in_name.group(1)) if rssi_in_name else None
|
||||
|
||||
# Remove "RSSI: -XX" from name
|
||||
name = re.sub(r'\s*RSSI:\s*-?\d+\s*', '', name).strip()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'rssi': initial_rssi,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
@@ -304,9 +364,14 @@ def start_bt_scan():
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
@@ -388,7 +453,12 @@ def stop_bt_scan():
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
# Validate Bluetooth interface name
|
||||
try:
|
||||
interface = validate_bluetooth_interface(data.get('interface', 'hci0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
|
||||
+44
-154
@@ -1,9 +1,8 @@
|
||||
"""GPS dongle routes for USB GPS device support."""
|
||||
"""GPS routes for gpsd daemon support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
|
||||
from utils.logging import get_logger
|
||||
from utils.sse import format_sse
|
||||
from utils.gps import (
|
||||
detect_gps_devices,
|
||||
is_serial_available,
|
||||
get_gps_reader,
|
||||
start_gps,
|
||||
start_gpsd,
|
||||
stop_gps,
|
||||
get_current_position,
|
||||
GPSPosition,
|
||||
GPSDClient,
|
||||
)
|
||||
|
||||
logger = get_logger('intercept.gps')
|
||||
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@gps_bp.route('/available')
|
||||
def check_gps_available():
|
||||
"""Check if GPS dongle support is available."""
|
||||
return jsonify({
|
||||
'available': is_serial_available(),
|
||||
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
|
||||
})
|
||||
@gps_bp.route('/auto-connect', methods=['POST'])
|
||||
def auto_connect_gps():
|
||||
"""
|
||||
Automatically connect to gpsd if available.
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/check')
|
||||
def check_gpsd_available():
|
||||
"""Check if gpsd is reachable."""
|
||||
Called on page load to seamlessly enable GPS if gpsd is running.
|
||||
Returns current status if already connected.
|
||||
"""
|
||||
import socket
|
||||
|
||||
host = request.args.get('host', 'localhost')
|
||||
port = int(request.args.get('port', 2947))
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': position is not None,
|
||||
'position': position.to_dict() if position else None
|
||||
})
|
||||
|
||||
# Try to connect to gpsd on localhost:2947
|
||||
host = 'localhost'
|
||||
port = 2947
|
||||
|
||||
# First check if gpsd is reachable
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.settimeout(1.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
except Exception:
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'gpsd reachable at {host}:{port}'
|
||||
'status': 'unavailable',
|
||||
'message': 'gpsd not running'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'available': False,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List available GPS serial devices."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
devices = detect_gps_devices()
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'devices': devices
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/start', methods=['POST'])
|
||||
def start_gps_reader():
|
||||
"""Start GPS reader on specified device."""
|
||||
if not is_serial_available():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'pyserial not installed'
|
||||
}), 503
|
||||
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
device_path = data.get('device')
|
||||
baudrate = data.get('baudrate', 9600)
|
||||
|
||||
if not device_path:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Device path required'
|
||||
}), 400
|
||||
|
||||
# Validate baudrate
|
||||
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
|
||||
if baudrate not in valid_baudrates:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
@@ -139,80 +83,26 @@ def start_gps_reader():
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the GPS reader with callback pre-registered (avoids race condition)
|
||||
success = start_gps(device_path, baudrate, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'device': device_path,
|
||||
'baudrate': baudrate,
|
||||
'source': 'serial'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to start GPS reader: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/gpsd/start', methods=['POST'])
|
||||
def start_gpsd_client():
|
||||
"""Start GPS client connected to gpsd."""
|
||||
# Check if already running
|
||||
reader = get_gps_reader()
|
||||
if reader and reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader already running'
|
||||
}), 409
|
||||
|
||||
data = request.json or {}
|
||||
host = data.get('host', 'localhost')
|
||||
port = data.get('port', 2947)
|
||||
|
||||
# Validate port
|
||||
try:
|
||||
port = int(port)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("Port out of range")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Invalid port number'
|
||||
}), 400
|
||||
|
||||
# Clear the queue
|
||||
while not _gps_queue.empty():
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Start the gpsd client with callback pre-registered
|
||||
# Start the gpsd client
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'source': 'gpsd'
|
||||
'status': 'connected',
|
||||
'source': 'gpsd',
|
||||
'has_fix': False,
|
||||
'position': None
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to connect to gpsd: {error}'
|
||||
}), 500
|
||||
'status': 'unavailable',
|
||||
'message': 'Failed to connect to gpsd'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
"""Stop GPS client."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
@@ -224,7 +114,7 @@ def stop_gps_reader():
|
||||
|
||||
@gps_bp.route('/status')
|
||||
def get_gps_status():
|
||||
"""Get current GPS reader status."""
|
||||
"""Get current GPS client status."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
@@ -233,7 +123,7 @@ def get_gps_status():
|
||||
'device': None,
|
||||
'position': None,
|
||||
'error': None,
|
||||
'message': 'GPS reader not started'
|
||||
'message': 'GPS client not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
@@ -262,7 +152,7 @@ def get_position():
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader not running'
|
||||
'message': 'GPS client not running'
|
||||
}), 400
|
||||
else:
|
||||
return jsonify({
|
||||
@@ -273,22 +163,22 @@ def get_position():
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS reader state."""
|
||||
"""Debug endpoint showing GPS client state."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS reader initialized'
|
||||
'message': 'No GPS client initialized'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': source,
|
||||
'source': 'gpsd',
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'host': reader.host,
|
||||
'port': reader.port,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
|
||||
+231
-90
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import select
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -21,6 +23,7 @@ from utils.constants import (
|
||||
SSE_KEEPALIVE_INTERVAL,
|
||||
PROCESS_TERMINATE_TIMEOUT,
|
||||
)
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
logger = get_logger('intercept.listening_post')
|
||||
|
||||
@@ -54,6 +57,8 @@ scanner_config = {
|
||||
'scan_delay': 0.1, # Seconds between frequency hops (keep low for fast scanning)
|
||||
'device': 0,
|
||||
'gain': 40,
|
||||
'bias_t': False, # Bias-T power for external LNA
|
||||
'sdr_type': 'rtlsdr', # SDR type: rtlsdr, hackrf, airspy, limesdr, sdrplay
|
||||
}
|
||||
|
||||
# Activity log
|
||||
@@ -74,14 +79,16 @@ def find_rtl_fm() -> str | None:
|
||||
return shutil.which('rtl_fm')
|
||||
|
||||
|
||||
def find_rx_fm() -> str | None:
|
||||
"""Find rx_fm binary (SoapySDR FM demodulator for HackRF/Airspy/LimeSDR)."""
|
||||
return shutil.which('rx_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 = ''):
|
||||
@@ -133,9 +140,6 @@ def scanner_loop():
|
||||
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
|
||||
@@ -143,6 +147,13 @@ def scanner_loop():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
|
||||
# Read config values on each iteration (allows live updates)
|
||||
step_mhz = scanner_config['step'] / 1000.0
|
||||
squelch = scanner_config['squelch']
|
||||
mod = scanner_config['modulation']
|
||||
gain = scanner_config['gain']
|
||||
device = scanner_config['device']
|
||||
|
||||
scanner_current_freq = current_freq
|
||||
|
||||
# Notify clients of frequency change
|
||||
@@ -157,7 +168,6 @@ def scanner_loop():
|
||||
|
||||
# Start rtl_fm at this frequency
|
||||
freq_hz = int(current_freq * 1e6)
|
||||
mod = scanner_config['modulation']
|
||||
|
||||
# Sample rates
|
||||
if mod == 'wfm':
|
||||
@@ -177,9 +187,12 @@ def scanner_loop():
|
||||
'-f', str(freq_hz),
|
||||
'-s', str(sample_rate),
|
||||
'-r', str(resample_rate),
|
||||
'-g', str(scanner_config['gain']),
|
||||
'-d', str(scanner_config['device']),
|
||||
'-g', str(gain),
|
||||
'-d', str(device),
|
||||
]
|
||||
# Add bias-t flag if enabled (for external LNA power)
|
||||
if scanner_config.get('bias_t', False):
|
||||
rtl_cmd.append('-T')
|
||||
|
||||
try:
|
||||
# Start rtl_fm
|
||||
@@ -212,21 +225,22 @@ def scanner_loop():
|
||||
# Analyze audio level
|
||||
audio_detected = False
|
||||
rms = 0
|
||||
threshold = 3000
|
||||
threshold = 500
|
||||
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
|
||||
# Threshold based on squelch setting
|
||||
# Lower squelch = more sensitive (lower threshold)
|
||||
# squelch 0 = very sensitive, squelch 100 = only strong signals
|
||||
if mod == 'wfm':
|
||||
# WFM: threshold 4000-12000 based on squelch
|
||||
threshold = 4000 + (scanner_config['squelch'] * 80)
|
||||
# WFM: threshold 500-10000 based on squelch
|
||||
threshold = 500 + (squelch * 95)
|
||||
else:
|
||||
# AM/NFM: threshold 1500-8000 based on squelch
|
||||
threshold = 1500 + (scanner_config['squelch'] * 65)
|
||||
# AM/NFM: threshold 300-6500 based on squelch
|
||||
threshold = 300 + (squelch * 62)
|
||||
|
||||
audio_detected = rms > threshold
|
||||
|
||||
@@ -340,14 +354,19 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
# 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:
|
||||
if not ffmpeg_path:
|
||||
logger.error("ffmpeg not found")
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
# Determine SDR type and build appropriate command
|
||||
sdr_type_str = scanner_config.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Set sample rates based on modulation
|
||||
if modulation == 'wfm':
|
||||
sample_rate = 170000
|
||||
resample_rate = 32000
|
||||
@@ -358,48 +377,93 @@ def _start_audio_stream(frequency: float, modulation: str):
|
||||
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']),
|
||||
]
|
||||
# Build the SDR command based on device type
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
# Use rtl_fm for RTL-SDR devices
|
||||
rtl_fm_path = find_rtl_fm()
|
||||
if not rtl_fm_path:
|
||||
logger.error("rtl_fm not found")
|
||||
return
|
||||
|
||||
freq_hz = int(frequency * 1e6)
|
||||
sdr_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']),
|
||||
]
|
||||
if scanner_config.get('bias_t', False):
|
||||
sdr_cmd.append('-T')
|
||||
else:
|
||||
# Use SDR abstraction layer for HackRF, Airspy, LimeSDR, SDRPlay
|
||||
rx_fm_path = find_rx_fm()
|
||||
if not rx_fm_path:
|
||||
logger.error(f"rx_fm not found - required for {sdr_type.value}. Install SoapySDR utilities.")
|
||||
return
|
||||
|
||||
# Create device and get command builder
|
||||
device = SDRFactory.create_default_device(sdr_type, index=scanner_config['device'])
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build FM demod command
|
||||
sdr_cmd = builder.build_fm_demod_command(
|
||||
device=device,
|
||||
frequency_mhz=frequency,
|
||||
sample_rate=resample_rate,
|
||||
gain=float(scanner_config['gain']),
|
||||
modulation=modulation,
|
||||
squelch=scanner_config['squelch'],
|
||||
bias_t=scanner_config.get('bias_t', False)
|
||||
)
|
||||
# Ensure we use the found rx_fm path
|
||||
sdr_cmd[0] = rx_fm_path
|
||||
|
||||
encoder_cmd = [
|
||||
ffmpeg_path,
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 's16le',
|
||||
'-ar', str(resample_rate),
|
||||
'-ac', '1',
|
||||
'-i', 'pipe:0',
|
||||
'-acodec', 'libmp3lame',
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100',
|
||||
'-f', 'mp3',
|
||||
'-b:a', '64k',
|
||||
'-flush_packets', '1',
|
||||
'pipe:1'
|
||||
]
|
||||
|
||||
try:
|
||||
audio_rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
# Use shell pipe for reliable streaming (Python subprocess piping can be unreliable)
|
||||
shell_cmd = f"{' '.join(sdr_cmd)} 2>/dev/null | {' '.join(encoder_cmd)}"
|
||||
logger.info(f"Starting audio pipeline: {shell_cmd}")
|
||||
|
||||
audio_rtl_process = None # Not used in shell mode
|
||||
audio_process = subprocess.Popen(
|
||||
encoder_cmd,
|
||||
stdin=audio_rtl_process.stdout,
|
||||
shell_cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
bufsize=0
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=0,
|
||||
start_new_session=True # Create new process group for clean shutdown
|
||||
)
|
||||
|
||||
audio_rtl_process.stdout.close()
|
||||
# Brief delay to check if process started successfully
|
||||
time.sleep(0.3)
|
||||
|
||||
if audio_process.poll() is not None:
|
||||
stderr = audio_process.stderr.read().decode() if audio_process.stderr else ''
|
||||
logger.error(f"Audio pipeline exited immediately: {stderr}")
|
||||
return
|
||||
|
||||
audio_running = True
|
||||
audio_frequency = frequency
|
||||
audio_modulation = modulation
|
||||
logger.info(f"Audio stream started: {frequency} MHz ({modulation}) via {sdr_type.value}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio stream: {e}")
|
||||
@@ -415,31 +479,38 @@ 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
|
||||
|
||||
# Set flag first to stop any streaming
|
||||
audio_running = False
|
||||
audio_frequency = 0.0
|
||||
|
||||
# Kill the shell process and its children
|
||||
if audio_process:
|
||||
try:
|
||||
# Kill entire process group (rtl_fm, ffmpeg, shell)
|
||||
try:
|
||||
os.killpg(os.getpgid(audio_process.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
audio_process.kill()
|
||||
audio_process.wait(timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
audio_process = None
|
||||
audio_rtl_process = None
|
||||
|
||||
# Kill any orphaned rtl_fm and ffmpeg processes
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', 'rtl_fm'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', '-f', 'ffmpeg.*pipe:0'], capture_output=True, timeout=0.5)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Pause for SDR device to be released (important for frequency/modulation changes)
|
||||
time.sleep(0.7)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
@@ -449,16 +520,23 @@ def _stop_audio_stream_internal():
|
||||
def check_tools() -> Response:
|
||||
"""Check for required tools."""
|
||||
rtl_fm = find_rtl_fm()
|
||||
rx_fm = find_rx_fm()
|
||||
ffmpeg = find_ffmpeg()
|
||||
sox = find_sox()
|
||||
can_stream = ffmpeg is not None or sox is not None
|
||||
|
||||
# Determine which SDR types are supported
|
||||
supported_sdr_types = []
|
||||
if rtl_fm:
|
||||
supported_sdr_types.append('rtlsdr')
|
||||
if rx_fm:
|
||||
# rx_fm from SoapySDR supports these types
|
||||
supported_sdr_types.extend(['hackrf', 'airspy', 'limesdr', 'sdrplay'])
|
||||
|
||||
return jsonify({
|
||||
'rtl_fm': rtl_fm is not None,
|
||||
'rx_fm': rx_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
|
||||
'available': (rtl_fm is not None or rx_fm is not None) and ffmpeg is not None,
|
||||
'supported_sdr_types': supported_sdr_types
|
||||
})
|
||||
|
||||
|
||||
@@ -487,6 +565,8 @@ def start_scanner() -> Response:
|
||||
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))
|
||||
scanner_config['bias_t'] = bool(data.get('bias_t', False))
|
||||
scanner_config['sdr_type'] = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -500,12 +580,20 @@ def start_scanner() -> Response:
|
||||
'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
|
||||
# Check tools based on SDR type
|
||||
sdr_type = scanner_config['sdr_type']
|
||||
if sdr_type == 'rtlsdr':
|
||||
if not find_rtl_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'rtl_fm not found. Install rtl-sdr tools.'
|
||||
}), 503
|
||||
else:
|
||||
if not find_rx_fm():
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'rx_fm not found. Install SoapySDR utilities for {sdr_type}.'
|
||||
}), 503
|
||||
|
||||
# Start scanner thread
|
||||
scanner_running = True
|
||||
@@ -571,6 +659,42 @@ def skip_signal() -> Response:
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/config', methods=['POST'])
|
||||
def update_scanner_config() -> Response:
|
||||
"""Update scanner config while running (step, squelch, gain, dwell)."""
|
||||
data = request.json or {}
|
||||
|
||||
updated = []
|
||||
|
||||
if 'step' in data:
|
||||
scanner_config['step'] = float(data['step'])
|
||||
updated.append(f"step={data['step']}kHz")
|
||||
|
||||
if 'squelch' in data:
|
||||
scanner_config['squelch'] = int(data['squelch'])
|
||||
updated.append(f"squelch={data['squelch']}")
|
||||
|
||||
if 'gain' in data:
|
||||
scanner_config['gain'] = int(data['gain'])
|
||||
updated.append(f"gain={data['gain']}")
|
||||
|
||||
if 'dwell_time' in data:
|
||||
scanner_config['dwell_time'] = int(data['dwell_time'])
|
||||
updated.append(f"dwell={data['dwell_time']}s")
|
||||
|
||||
if 'modulation' in data:
|
||||
scanner_config['modulation'] = str(data['modulation']).lower()
|
||||
updated.append(f"mod={data['modulation']}")
|
||||
|
||||
if updated:
|
||||
logger.info(f"Scanner config updated: {', '.join(updated)}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'updated',
|
||||
'config': scanner_config
|
||||
})
|
||||
|
||||
|
||||
@listening_post_bp.route('/scanner/status')
|
||||
def scanner_status() -> Response:
|
||||
"""Get scanner status."""
|
||||
@@ -651,6 +775,8 @@ def start_audio() -> Response:
|
||||
"""Start audio at specific frequency (manual mode)."""
|
||||
global scanner_running
|
||||
|
||||
logger.info("Audio start request received")
|
||||
|
||||
# Stop scanner if running
|
||||
if scanner_running:
|
||||
scanner_running = False
|
||||
@@ -664,6 +790,7 @@ def start_audio() -> Response:
|
||||
squelch = int(data.get('squelch', 0))
|
||||
gain = int(data.get('gain', 40))
|
||||
device = int(data.get('device', 0))
|
||||
sdr_type = str(data.get('sdr_type', 'rtlsdr')).lower()
|
||||
except (ValueError, TypeError) as e:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
@@ -683,25 +810,31 @@ def start_audio() -> Response:
|
||||
'message': f'Invalid modulation. Use: {", ".join(valid_mods)}'
|
||||
}), 400
|
||||
|
||||
valid_sdr_types = ['rtlsdr', 'hackrf', 'airspy', 'limesdr', 'sdrplay']
|
||||
if sdr_type not in valid_sdr_types:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Invalid sdr_type. Use: {", ".join(valid_sdr_types)}'
|
||||
}), 400
|
||||
|
||||
# Update config for audio
|
||||
scanner_config['squelch'] = squelch
|
||||
scanner_config['gain'] = gain
|
||||
scanner_config['device'] = device
|
||||
scanner_config['sdr_type'] = sdr_type
|
||||
|
||||
_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'
|
||||
'modulation': modulation
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Failed to start audio'
|
||||
'message': 'Failed to start audio. Check SDR device.'
|
||||
}), 500
|
||||
|
||||
|
||||
@@ -725,22 +858,30 @@ def audio_status() -> Response:
|
||||
@listening_post_bp.route('/audio/stream')
|
||||
def stream_audio() -> Response:
|
||||
"""Stream MP3 audio."""
|
||||
# Wait for audio to be ready (up to 2 seconds for modulation/squelch changes)
|
||||
for _ in range(40):
|
||||
if audio_running and audio_process:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if not audio_running or not audio_process:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'Audio not running'
|
||||
}), 400
|
||||
return Response(b'', mimetype='audio/mpeg', status=204)
|
||||
|
||||
def generate():
|
||||
chunk_size = 4096
|
||||
try:
|
||||
while audio_running and audio_process and audio_process.poll() is None:
|
||||
chunk = audio_process.stdout.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
logger.error(f"Audio stream error: {e}")
|
||||
# Use select to avoid blocking forever
|
||||
ready, _, _ = select.select([audio_process.stdout], [], [], 2.0)
|
||||
if ready:
|
||||
chunk = audio_process.stdout.read(4096)
|
||||
if chunk:
|
||||
yield chunk
|
||||
else:
|
||||
break
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
|
||||
+3
-1
@@ -233,6 +233,7 @@ def start_decoding() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
bias_t = data.get('bias_t', False)
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
@@ -240,7 +241,8 @@ def start_decoding() -> Response:
|
||||
gain=float(gain) if gain and gain != '0' else None,
|
||||
ppm=int(ppm) if ppm and ppm != '0' else None,
|
||||
modulation='fm',
|
||||
squelch=squelch if squelch and squelch != 0 else None
|
||||
squelch=squelch if squelch and squelch != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
+4
-3
@@ -114,11 +114,13 @@ def start_sensor() -> Response:
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
bias_t = data.get('bias_t', False)
|
||||
cmd = builder.build_ism_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
gain=float(gain) if gain and gain != 0 else None,
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None
|
||||
ppm=int(ppm) if ppm and ppm != 0 else None,
|
||||
bias_t=bias_t
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
@@ -128,8 +130,7 @@ def start_sensor() -> Response:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
|
||||
@@ -9,8 +9,6 @@ from utils.database import (
|
||||
set_setting,
|
||||
delete_setting,
|
||||
get_all_settings,
|
||||
get_signal_history,
|
||||
add_signal_reading,
|
||||
get_correlations,
|
||||
)
|
||||
from utils.logging import get_logger
|
||||
@@ -145,66 +143,6 @@ def delete_single_setting(key: str) -> Response:
|
||||
}), 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
|
||||
# =============================================================================
|
||||
|
||||
+310
-31
@@ -16,10 +16,10 @@ from typing import Any, Generator
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.dependencies import check_tool
|
||||
from utils.dependencies import check_tool, get_tool_path
|
||||
from utils.logging import wifi_logger as logger
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address
|
||||
from utils.validation import validate_wifi_channel, validate_mac_address, validate_network_interface
|
||||
from utils.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
from utils.constants import (
|
||||
@@ -105,12 +105,18 @@ def detect_wifi_interfaces():
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and 'type' in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': current_iface,
|
||||
'type': iface_type,
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
# Get additional interface details
|
||||
iface_info.update(_get_interface_details(current_iface))
|
||||
interfaces.append(iface_info)
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
# Fall back to iwconfig if iw is not available
|
||||
@@ -119,12 +125,17 @@ def detect_wifi_interfaces():
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
interfaces.append({
|
||||
iface_info = {
|
||||
'name': iface,
|
||||
'type': 'managed',
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
'status': 'up',
|
||||
'driver': '',
|
||||
'chipset': '',
|
||||
'mac': ''
|
||||
}
|
||||
iface_info.update(_get_interface_details(iface))
|
||||
interfaces.append(iface_info)
|
||||
except FileNotFoundError:
|
||||
logger.debug("Neither iw nor iwconfig found")
|
||||
except subprocess.SubprocessError as e:
|
||||
@@ -137,6 +148,101 @@ def detect_wifi_interfaces():
|
||||
return interfaces
|
||||
|
||||
|
||||
def _get_interface_details(iface_name):
|
||||
"""Get additional details about a WiFi interface (driver, chipset, MAC)."""
|
||||
import os
|
||||
details = {'driver': '', 'chipset': '', 'mac': ''}
|
||||
|
||||
# Get MAC address
|
||||
try:
|
||||
mac_path = f'/sys/class/net/{iface_name}/address'
|
||||
with open(mac_path, 'r') as f:
|
||||
details['mac'] = f.read().strip().upper()
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# Get driver name
|
||||
try:
|
||||
driver_link = f'/sys/class/net/{iface_name}/device/driver'
|
||||
if os.path.islink(driver_link):
|
||||
driver_path = os.readlink(driver_link)
|
||||
details['driver'] = os.path.basename(driver_path)
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
# Try airmon-ng first for chipset info (most reliable for WiFi adapters)
|
||||
try:
|
||||
result = subprocess.run(['airmon-ng'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
# airmon-ng output format: PHY Interface Driver Chipset
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 4:
|
||||
if parts[1].strip() == iface_name or parts[1].strip().startswith(iface_name):
|
||||
if parts[2].strip():
|
||||
details['driver'] = parts[2].strip()
|
||||
if parts[3].strip():
|
||||
details['chipset'] = parts[3].strip()
|
||||
break
|
||||
# Also try space-separated format
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
if parts[1] == iface_name or parts[1].startswith(iface_name):
|
||||
details['driver'] = parts[2]
|
||||
details['chipset'] = ' '.join(parts[3:])
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
|
||||
pass
|
||||
|
||||
# Fallback: Get chipset info from USB or PCI sysfs
|
||||
if not details['chipset']:
|
||||
try:
|
||||
device_path = f'/sys/class/net/{iface_name}/device'
|
||||
if os.path.exists(device_path):
|
||||
# Try to get USB product name
|
||||
for usb_path in [f'{device_path}/product', f'{device_path}/../product']:
|
||||
try:
|
||||
with open(usb_path, 'r') as f:
|
||||
details['chipset'] = f.read().strip()
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
|
||||
# If no USB product, try lsusb for USB devices
|
||||
if not details['chipset']:
|
||||
try:
|
||||
# Get USB bus/device info
|
||||
uevent_path = f'{device_path}/uevent'
|
||||
with open(uevent_path, 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('PRODUCT='):
|
||||
# PRODUCT format: vendor/product/bcdDevice
|
||||
product = line.split('=')[1].strip()
|
||||
parts = product.split('/')
|
||||
if len(parts) >= 2:
|
||||
vid = parts[0].zfill(4)
|
||||
pid = parts[1].zfill(4)
|
||||
# Try lsusb to get device name
|
||||
try:
|
||||
lsusb = subprocess.run(
|
||||
['lsusb', '-d', f'{vid}:{pid}'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if lsusb.stdout:
|
||||
# Format: Bus XXX Device YYY: ID vid:pid Name
|
||||
usb_parts = lsusb.stdout.split(f'{vid}:{pid}')
|
||||
if len(usb_parts) > 1:
|
||||
details['chipset'] = usb_parts[1].strip()
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
break
|
||||
except (FileNotFoundError, IOError):
|
||||
pass
|
||||
except (FileNotFoundError, IOError, OSError):
|
||||
pass
|
||||
|
||||
return details
|
||||
|
||||
|
||||
def parse_airodump_csv(csv_path):
|
||||
"""Parse airodump-ng CSV output file."""
|
||||
networks = {}
|
||||
@@ -253,6 +359,20 @@ def stream_airodump_output(process, csv_path):
|
||||
'action': 'new',
|
||||
**client
|
||||
})
|
||||
else:
|
||||
# Send update if probes changed or signal changed significantly
|
||||
old_client = app_module.wifi_clients[mac]
|
||||
old_probes = old_client.get('probes', '')
|
||||
new_probes = client.get('probes', '')
|
||||
old_power = int(old_client.get('power', -100) or -100)
|
||||
new_power = int(client.get('power', -100) or -100)
|
||||
|
||||
if new_probes != old_probes or abs(new_power - old_power) >= 5:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'client',
|
||||
'action': 'update',
|
||||
**client
|
||||
})
|
||||
|
||||
app_module.wifi_networks = networks
|
||||
app_module.wifi_clients = clients
|
||||
@@ -303,11 +423,13 @@ def get_wifi_interfaces():
|
||||
def toggle_monitor_mode():
|
||||
"""Enable or disable monitor mode on an interface."""
|
||||
data = request.json
|
||||
interface = data.get('interface')
|
||||
action = data.get('action', 'start')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No interface specified'})
|
||||
# Validate interface name to prevent command injection
|
||||
try:
|
||||
interface = validate_network_interface(data.get('interface'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
@@ -345,10 +467,11 @@ def toggle_monitor_mode():
|
||||
interfaces_before = get_wireless_interfaces()
|
||||
|
||||
kill_processes = data.get('kill_processes', False)
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
if kill_processes:
|
||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
||||
subprocess.run([airmon_path, 'check', 'kill'], capture_output=True, timeout=10)
|
||||
|
||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
||||
result = subprocess.run([airmon_path, 'start', interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
@@ -405,8 +528,35 @@ def toggle_monitor_mode():
|
||||
if not monitor_iface:
|
||||
monitor_iface = interface + 'mon'
|
||||
|
||||
# Verify the interface actually exists
|
||||
def interface_exists(iface_name):
|
||||
return os.path.exists(f'/sys/class/net/{iface_name}')
|
||||
|
||||
if not interface_exists(monitor_iface):
|
||||
# Try common naming patterns
|
||||
candidates = [
|
||||
interface + 'mon',
|
||||
interface.replace('wlan', 'wlan') + 'mon',
|
||||
'wlan0mon', 'wlan1mon',
|
||||
interface # Maybe it stayed the same but in monitor mode
|
||||
]
|
||||
for candidate in candidates:
|
||||
if interface_exists(candidate):
|
||||
monitor_iface = candidate
|
||||
break
|
||||
else:
|
||||
# List all wireless interfaces to help debug
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
logger.error(f"Monitor interface not found. Tried: {monitor_iface}. Available: {all_wireless}")
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Monitor interface not created. airmon-ng output: {output[:500]}. Available interfaces: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_monitor_interface = monitor_iface
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Monitor mode enabled on {app_module.wifi_monitor_interface}'})
|
||||
logger.info(f"Monitor mode enabled on {monitor_iface}")
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
@@ -429,7 +579,8 @@ def toggle_monitor_mode():
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
subprocess.run(['airmon-ng', 'stop', app_module.wifi_monitor_interface or interface],
|
||||
airmon_path = get_tool_path('airmon-ng')
|
||||
subprocess.run([airmon_path, 'stop', app_module.wifi_monitor_interface or interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
@@ -456,13 +607,31 @@ def start_wifi_scan():
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
|
||||
data = request.json
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
channel = data.get('channel')
|
||||
band = data.get('band', 'abg')
|
||||
|
||||
# Use provided interface or fall back to stored monitor interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
# Verify interface exists
|
||||
if not os.path.exists(f'/sys/class/net/{interface}'):
|
||||
all_wireless = [f for f in os.listdir('/sys/class/net')
|
||||
if os.path.exists(f'/sys/class/net/{f}/wireless') or 'mon' in f or f.startswith('wl')]
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Interface "{interface}" does not exist. Available: {all_wireless}'
|
||||
})
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
|
||||
@@ -480,8 +649,9 @@ def start_wifi_scan():
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
@@ -512,11 +682,12 @@ def start_wifi_scan():
|
||||
error_msg = re.sub(r'\x1b\[[0-9;]*m', '', error_msg)
|
||||
|
||||
if 'No such device' in error_msg or 'No such interface' in error_msg:
|
||||
error_msg = f'Interface "{interface}" not found.'
|
||||
error_msg = f'Interface "{interface}" not found. Make sure monitor mode is enabled.'
|
||||
elif 'Operation not permitted' in error_msg:
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
logger.error(f"airodump-ng failed for interface '{interface}': {error_msg}")
|
||||
return jsonify({'status': 'error', 'message': error_msg, 'interface': interface})
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
@@ -554,7 +725,16 @@ def send_deauth():
|
||||
target_bssid = data.get('bssid')
|
||||
target_client = data.get('client', 'FF:FF:FF:FF:FF:FF')
|
||||
count = data.get('count', 5)
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
@@ -579,8 +759,9 @@ def send_deauth():
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
|
||||
try:
|
||||
aireplay_path = get_tool_path('aireplay-ng')
|
||||
cmd = [
|
||||
'aireplay-ng',
|
||||
aireplay_path,
|
||||
'--deauth', str(count),
|
||||
'-a', target_bssid,
|
||||
'-c', target_client,
|
||||
@@ -608,7 +789,16 @@ def capture_handshake():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
@@ -625,8 +815,9 @@ def capture_handshake():
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
airodump_path = get_tool_path('airodump-ng')
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
airodump_path,
|
||||
'-c', str(channel),
|
||||
'--bssid', target_bssid,
|
||||
'-w', capture_path,
|
||||
@@ -664,14 +855,16 @@ def check_handshake_status():
|
||||
|
||||
try:
|
||||
if target_bssid and is_valid_mac(target_bssid):
|
||||
result = subprocess.run(
|
||||
['aircrack-ng', '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if aircrack_path:
|
||||
result = subprocess.run(
|
||||
[aircrack_path, '-a', '2', '-b', target_bssid, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if '1 handshake' in output or ('handshake' in output.lower() and 'wpa' in output.lower()):
|
||||
if '0 handshake' not in output:
|
||||
handshake_found = True
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
@@ -694,7 +887,16 @@ def capture_pmkid():
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
# Validate interface
|
||||
interface = data.get('interface')
|
||||
if interface:
|
||||
try:
|
||||
interface = validate_network_interface(interface)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
else:
|
||||
interface = app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
@@ -785,6 +987,83 @@ def stop_pmkid():
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/crack', methods=['POST'])
|
||||
def crack_handshake():
|
||||
"""Crack a captured handshake using aircrack-ng."""
|
||||
data = request.json
|
||||
capture_file = data.get('capture_file', '')
|
||||
target_bssid = data.get('bssid', '')
|
||||
wordlist = data.get('wordlist', '')
|
||||
|
||||
# Validate paths to prevent path traversal
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'}), 400
|
||||
|
||||
if '..' in wordlist:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid wordlist path'}), 400
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'status': 'error', 'message': 'Capture file not found'}), 404
|
||||
|
||||
if not os.path.exists(wordlist):
|
||||
return jsonify({'status': 'error', 'message': 'Wordlist file not found'}), 404
|
||||
|
||||
if target_bssid and not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'}), 400
|
||||
|
||||
aircrack_path = get_tool_path('aircrack-ng')
|
||||
if not aircrack_path:
|
||||
return jsonify({'status': 'error', 'message': 'aircrack-ng not found'}), 500
|
||||
|
||||
try:
|
||||
cmd = [aircrack_path, '-a', '2', '-w', wordlist]
|
||||
if target_bssid:
|
||||
cmd.extend(['-b', target_bssid])
|
||||
cmd.append(capture_file)
|
||||
|
||||
logger.info(f"Starting aircrack-ng: {' '.join(cmd)}")
|
||||
|
||||
# Run aircrack-ng with a timeout (this could take a while)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300 # 5 minute timeout
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
# Check if password was found
|
||||
# Aircrack-ng outputs "KEY FOUND! [ password ]" when successful
|
||||
if 'KEY FOUND!' in output:
|
||||
# Extract the password
|
||||
import re
|
||||
match = re.search(r'KEY FOUND!\s*\[\s*(.+?)\s*\]', output)
|
||||
if match:
|
||||
password = match.group(1)
|
||||
logger.info(f"Password cracked for {target_bssid}: {password}")
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'password': password,
|
||||
'bssid': target_bssid
|
||||
})
|
||||
|
||||
# Password not found
|
||||
return jsonify({
|
||||
'status': 'not_found',
|
||||
'message': 'Password not in wordlist'
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({
|
||||
'status': 'timeout',
|
||||
'message': 'Cracking timed out after 5 minutes. Try a smaller wordlist or use hashcat.'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Crack error: {e}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
def get_wifi_networks():
|
||||
"""Get current list of discovered networks."""
|
||||
|
||||
@@ -1,18 +1,60 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# INTERCEPT Setup Script
|
||||
# Installs dependencies for macOS and Debian/Ubuntu
|
||||
#
|
||||
#!/usr/bin/env bash
|
||||
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
|
||||
|
||||
set -e
|
||||
# ---- Force bash even if launched with sh ----
|
||||
if [ -z "${BASH_VERSION:-}" ]; then
|
||||
echo "[x] This script must be run with bash (not sh)."
|
||||
echo " Run: bash $0"
|
||||
exec bash "$0" "$@"
|
||||
fi
|
||||
|
||||
# Colors for output
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Ensure admin paths are searchable (many tools live here)
|
||||
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
|
||||
|
||||
# ----------------------------
|
||||
# Pretty output
|
||||
# ----------------------------
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${BLUE}[*]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[x]${NC} $*"; }
|
||||
|
||||
# ----------------------------
|
||||
# Progress tracking
|
||||
# ----------------------------
|
||||
CURRENT_STEP=0
|
||||
TOTAL_STEPS=0
|
||||
|
||||
progress() {
|
||||
local msg="$1"
|
||||
((CURRENT_STEP++)) || true
|
||||
local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS))
|
||||
local filled=$((pct / 5))
|
||||
local empty=$((20 - filled))
|
||||
local bar=$(printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true)
|
||||
bar+=$(printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true)
|
||||
echo -e "${BLUE}[${CURRENT_STEP}/${TOTAL_STEPS}]${NC} ${bar} ${pct}% - ${msg}"
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local line="$1"
|
||||
local cmd="${2:-unknown}"
|
||||
fail "Setup failed at line ${line}: ${cmd}"
|
||||
exit 1
|
||||
}
|
||||
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
|
||||
|
||||
# ----------------------------
|
||||
# Banner
|
||||
# ----------------------------
|
||||
echo -e "${BLUE}"
|
||||
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
|
||||
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
|
||||
@@ -20,475 +62,436 @@ echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
|
||||
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
|
||||
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
|
||||
echo -e "${NC}"
|
||||
echo "Signal Intelligence Platform - Setup Script"
|
||||
echo "INTERCEPT - Setup Script"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo
|
||||
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
cmd_exists() {
|
||||
local c="$1"
|
||||
command -v "$c" >/dev/null 2>&1 && return 0
|
||||
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
have_any() {
|
||||
local c
|
||||
for c in "$@"; do
|
||||
cmd_exists "$c" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
need_sudo() {
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
SUDO=""
|
||||
ok "Running as root"
|
||||
else
|
||||
if cmd_exists sudo; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
fail "sudo is not installed and you're not root."
|
||||
echo "Either run as root or install sudo first."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect OS
|
||||
detect_os() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
PKG_MANAGER="brew"
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
OS="debian"
|
||||
PKG_MANAGER="apt"
|
||||
else
|
||||
OS="unknown"
|
||||
PKG_MANAGER="unknown"
|
||||
fi
|
||||
echo -e "${BLUE}Detected OS:${NC} $OS"
|
||||
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
OS="debian"
|
||||
else
|
||||
OS="unknown"
|
||||
fi
|
||||
info "Detected OS: ${OS}"
|
||||
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
|
||||
}
|
||||
|
||||
# Check if a command exists
|
||||
check_cmd() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
# ----------------------------
|
||||
# Required tool checks (with alternates)
|
||||
# ----------------------------
|
||||
missing_required=()
|
||||
|
||||
# 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)" ]
|
||||
}
|
||||
check_required() {
|
||||
local label="$1"; shift
|
||||
local desc="$1"; shift
|
||||
|
||||
# Setup sudo command
|
||||
setup_sudo() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
echo -e "${BLUE}Running as root${NC}"
|
||||
elif check_cmd sudo; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# PYTHON DEPENDENCIES
|
||||
# ============================================
|
||||
install_python_deps() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
|
||||
|
||||
if ! check_cmd python3; then
|
||||
echo -e "${RED}Error: Python 3 is not installed${NC}"
|
||||
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
|
||||
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 (you have $PYTHON_VERSION)${NC}"
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo "Upgrade with: brew install python@3.11"
|
||||
else
|
||||
echo "Upgrade with: sudo apt install python3.11"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
||||
pip install -r requirements.txt
|
||||
elif [ -f "venv/bin/activate" ]; then
|
||||
echo "Found existing venv, activating..."
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
# Try 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${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}Creating virtual environment...${NC}"
|
||||
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
if ! python3 -m venv venv; then
|
||||
echo -e "${RED}Error: Failed to create virtual environment${NC}"
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo "Install with: sudo apt install python3-venv"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}NOTE: Virtual environment created.${NC}"
|
||||
echo "Activate with: source venv/bin/activate"
|
||||
fi
|
||||
|
||||
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
|
||||
if have_any "$@"; then
|
||||
ok "${label} - ${desc}"
|
||||
else
|
||||
warn "${label} - ${desc} (missing, required)"
|
||||
missing_required+=("$label")
|
||||
fi
|
||||
}
|
||||
|
||||
check_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
|
||||
echo ""
|
||||
info "Checking required tools..."
|
||||
missing_required=()
|
||||
|
||||
MISSING_TOOLS=()
|
||||
MISSING_CORE=false
|
||||
MISSING_AUDIO=false
|
||||
MISSING_WIFI=false
|
||||
MISSING_BLUETOOTH=false
|
||||
echo
|
||||
info "Core SDR:"
|
||||
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
|
||||
check_required "rtl_test" "RTL-SDR device detection" rtl_test
|
||||
check_required "multimon-ng" "Pager decoder" multimon-ng
|
||||
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
|
||||
check_required "dump1090" "ADS-B decoder" dump1090
|
||||
|
||||
echo "Core SDR Tools:"
|
||||
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
|
||||
check_tool "rtl_test" "RTL-SDR device detection" "core"
|
||||
check_tool "multimon-ng" "Pager decoder" "core"
|
||||
check_tool "rtl_433" "433MHz sensor decoder" "core"
|
||||
check_tool "dump1090" "ADS-B decoder" "core"
|
||||
echo
|
||||
info "GPS:"
|
||||
check_required "gpsd" "GPS daemon" gpsd
|
||||
|
||||
echo ""
|
||||
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
|
||||
info "Audio:"
|
||||
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
|
||||
|
||||
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
|
||||
info "WiFi:"
|
||||
check_required "airmon-ng" "Monitor mode helper" airmon-ng
|
||||
check_required "airodump-ng" "WiFi scanner" airodump-ng
|
||||
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
|
||||
check_required "hcxdumptool" "PMKID capture" hcxdumptool
|
||||
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
|
||||
|
||||
echo ""
|
||||
echo "Bluetooth Tools:"
|
||||
check_tool "hcitool" "Bluetooth scanner" "bluetooth"
|
||||
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
|
||||
check_tool "hciconfig" "Bluetooth adapter config" "bluetooth"
|
||||
echo
|
||||
info "Bluetooth:"
|
||||
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
|
||||
check_required "hcitool" "Bluetooth scan utility" hcitool
|
||||
check_required "hciconfig" "Bluetooth adapter config" hciconfig
|
||||
|
||||
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}"
|
||||
else
|
||||
echo ""
|
||||
echo -e "${GREEN}All tools installed!${NC}"
|
||||
fi
|
||||
echo
|
||||
info "SoapySDR:"
|
||||
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
|
||||
echo
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# macOS INSTALLATION
|
||||
# ============================================
|
||||
install_macos_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installing tools (macOS)...${NC}"
|
||||
echo ""
|
||||
# ----------------------------
|
||||
# Python venv + deps
|
||||
# ----------------------------
|
||||
check_python_version() {
|
||||
if ! cmd_exists python3; then
|
||||
fail "python3 not found."
|
||||
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
|
||||
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||
return
|
||||
fi
|
||||
local ver
|
||||
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
info "Python version: ${ver}"
|
||||
|
||||
# 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
|
||||
python3 - <<'PY'
|
||||
import sys
|
||||
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||
PY
|
||||
ok "Python version OK (>= 3.9)"
|
||||
}
|
||||
|
||||
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"
|
||||
install_python_deps() {
|
||||
progress "Setting up Python environment"
|
||||
check_python_version
|
||||
|
||||
if [[ ! -f requirements.txt ]]; then
|
||||
warn "requirements.txt not found; skipping Python dependency install."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# On Debian/Ubuntu, try apt packages first as they're more reliable
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
info "Installing Python packages via apt (more reliable on Debian/Ubuntu)..."
|
||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||
|
||||
# skyfield may not be available in all distros, try apt first then pip
|
||||
if ! $SUDO apt-get install -y python3-skyfield >/dev/null 2>&1; then
|
||||
warn "python3-skyfield not in apt, will try pip later"
|
||||
fi
|
||||
ok "Installed available Python packages via apt"
|
||||
fi
|
||||
|
||||
if [[ ! -d venv ]]; then
|
||||
python3 -m venv --system-site-packages venv
|
||||
ok "Created venv/ (with system site-packages)"
|
||||
else
|
||||
ok "Using existing venv/"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
|
||||
python -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
|
||||
ok "Upgraded pip tooling"
|
||||
|
||||
progress "Installing Python dependencies"
|
||||
# Try pip install, but don't fail if apt packages already satisfied deps
|
||||
if ! python -m pip install -r requirements.txt 2>/dev/null; then
|
||||
warn "Some pip packages failed - checking if apt packages cover them..."
|
||||
# Verify critical packages are available
|
||||
python -c "import flask; import requests" 2>/dev/null || {
|
||||
fail "Critical Python packages (flask, requests) not installed"
|
||||
echo "Try: sudo apt install python3-flask python3-requests"
|
||||
exit 1
|
||||
}
|
||||
ok "Core Python dependencies available"
|
||||
else
|
||||
ok "Python dependencies installed"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# DEBIAN INSTALLATION
|
||||
# ============================================
|
||||
install_debian_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installing tools (Debian/Ubuntu)...${NC}"
|
||||
echo ""
|
||||
# ----------------------------
|
||||
# macOS install (Homebrew)
|
||||
# ----------------------------
|
||||
ensure_brew() {
|
||||
cmd_exists brew && return 0
|
||||
warn "Homebrew not found. Installing Homebrew..."
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||
return
|
||||
fi
|
||||
if [[ -x /opt/homebrew/bin/brew ]]; then
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
elif [[ -x /usr/local/bin/brew ]]; then
|
||||
eval "$(/usr/local/bin/brew shellenv)"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}The following will be installed:${NC}"
|
||||
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
|
||||
$MISSING_AUDIO && echo " - Audio tools (sox)"
|
||||
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
|
||||
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
|
||||
echo ""
|
||||
|
||||
read -p "Install missing tools? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo "Updating package lists..."
|
||||
$SUDO apt update
|
||||
|
||||
# Core SDR tools
|
||||
if $MISSING_CORE; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Core SDR tools...${NC}"
|
||||
$SUDO apt install -y rtl-sdr multimon-ng 2>/dev/null || true
|
||||
|
||||
# rtl-433 (package name varies)
|
||||
if pkg_available rtl-433; then
|
||||
$SUDO apt install -y rtl-433
|
||||
elif pkg_available rtl433; then
|
||||
$SUDO apt install -y rtl433
|
||||
else
|
||||
echo -e "${YELLOW}Note: rtl-433 not in repositories, install manually${NC}"
|
||||
fi
|
||||
|
||||
# dump1090 (package varies by distribution)
|
||||
if ! check_cmd dump1090; then
|
||||
if pkg_available dump1090-fa; then
|
||||
$SUDO apt install -y dump1090-fa
|
||||
elif pkg_available dump1090-mutability; then
|
||||
$SUDO apt install -y dump1090-mutability
|
||||
elif pkg_available dump1090; then
|
||||
$SUDO apt install -y dump1090
|
||||
else
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: dump1090 not in repositories.${NC}"
|
||||
echo "Install from: https://flightaware.com/adsb/piaware/install"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Audio tools
|
||||
if $MISSING_AUDIO; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Audio tools...${NC}"
|
||||
$SUDO apt install -y sox
|
||||
fi
|
||||
|
||||
# WiFi tools
|
||||
if $MISSING_WIFI; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing WiFi tools...${NC}"
|
||||
$SUDO apt install -y aircrack-ng
|
||||
fi
|
||||
|
||||
# Bluetooth tools
|
||||
if $MISSING_BLUETOOTH; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Bluetooth tools...${NC}"
|
||||
$SUDO apt install -y bluez bluetooth
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Tool installation complete!${NC}"
|
||||
|
||||
# Setup udev rules
|
||||
setup_udev_rules
|
||||
else
|
||||
show_debian_manual
|
||||
fi
|
||||
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
|
||||
}
|
||||
|
||||
show_debian_manual() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Manual installation (Debian/Ubuntu):${NC}"
|
||||
echo ""
|
||||
echo "# Required tools"
|
||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433 sox"
|
||||
echo ""
|
||||
echo "# ADS-B tracking"
|
||||
echo "sudo apt install dump1090-mutability # or dump1090-fa"
|
||||
echo ""
|
||||
echo "# WiFi scanning (optional)"
|
||||
echo "sudo apt install aircrack-ng"
|
||||
echo ""
|
||||
echo "# Bluetooth scanning (optional)"
|
||||
echo "sudo apt install bluez bluetooth"
|
||||
brew_install() {
|
||||
local pkg="$1"
|
||||
if brew list --formula "$pkg" >/dev/null 2>&1; then
|
||||
ok "brew: ${pkg} already installed"
|
||||
return 0
|
||||
fi
|
||||
info "brew: installing ${pkg}..."
|
||||
brew install "$pkg"
|
||||
ok "brew: installed ${pkg}"
|
||||
}
|
||||
|
||||
setup_udev_rules() {
|
||||
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
|
||||
return
|
||||
install_macos_packages() {
|
||||
TOTAL_STEPS=12
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Checking Homebrew"
|
||||
ensure_brew
|
||||
|
||||
progress "Installing RTL-SDR libraries"
|
||||
brew_install librtlsdr
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
brew_install multimon-ng
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
brew_install ffmpeg
|
||||
|
||||
progress "Installing rtl_433"
|
||||
brew_install rtl_433
|
||||
|
||||
progress "Installing dump1090"
|
||||
(brew_install dump1090-mutability) || warn "dump1090 not available via Homebrew"
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
brew_install aircrack-ng
|
||||
|
||||
progress "Installing hcxtools"
|
||||
brew_install hcxtools
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
brew_install soapysdr
|
||||
|
||||
progress "Installing gpsd"
|
||||
brew_install gpsd
|
||||
|
||||
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
|
||||
echo
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Debian/Ubuntu install (APT)
|
||||
# ----------------------------
|
||||
apt_install() {
|
||||
local pkgs="$*"
|
||||
local output
|
||||
local ret=0
|
||||
output=$($SUDO apt-get install -y --no-install-recommends "$@" 2>&1) || ret=$?
|
||||
if [[ $ret -ne 0 ]]; then
|
||||
fail "Failed to install: $pkgs"
|
||||
echo "$output" | tail -10
|
||||
fail "Try running: sudo apt-get update && sudo apt-get install -y $pkgs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
apt_try_install_any() {
|
||||
local p
|
||||
for p in "$@"; do
|
||||
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||
ok "apt: installed ${p}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
install_dump1090_from_source_debian() {
|
||||
info "dump1090 not available via APT. Building from source (required)..."
|
||||
|
||||
apt_install build-essential git pkg-config \
|
||||
librtlsdr-dev libusb-1.0-0-dev \
|
||||
libncurses-dev tcl-dev python3-dev
|
||||
|
||||
# Run in subshell to isolate EXIT trap
|
||||
(
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
info "Cloning FlightAware dump1090..."
|
||||
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
|
||||
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling FlightAware dump1090..."
|
||||
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (FlightAware)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -p "Setup RTL-SDR udev rules? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
warn "FlightAware build failed. Falling back to antirez/dump1090..."
|
||||
rm -rf "$tmp_dir/dump1090"
|
||||
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|
||||
|| { fail "Failed to clone antirez dump1090"; exit 1; }
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
cd "$tmp_dir/dump1090"
|
||||
info "Compiling antirez dump1090..."
|
||||
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
|
||||
|
||||
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
|
||||
ok "dump1090 installed successfully (antirez)."
|
||||
)
|
||||
}
|
||||
|
||||
setup_udev_rules_debian() {
|
||||
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
|
||||
|
||||
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
|
||||
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
|
||||
|
||||
info "Installing RTL-SDR udev rules..."
|
||||
$SUDO tee "$rules_file" >/dev/null <<'EOF'
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
$SUDO udevadm control --reload-rules
|
||||
$SUDO udevadm trigger
|
||||
echo -e "${GREEN}udev rules installed!${NC}"
|
||||
echo "Please unplug and replug your RTL-SDR."
|
||||
fi
|
||||
EOF
|
||||
$SUDO udevadm control --reload-rules || true
|
||||
$SUDO udevadm trigger || true
|
||||
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
|
||||
echo
|
||||
}
|
||||
|
||||
# ============================================
|
||||
install_debian_packages() {
|
||||
need_sudo
|
||||
|
||||
# Suppress needrestart prompts (Ubuntu Server 22.04+)
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export NEEDRESTART_MODE=a
|
||||
|
||||
TOTAL_STEPS=15
|
||||
CURRENT_STEP=0
|
||||
|
||||
progress "Updating APT package lists"
|
||||
$SUDO apt-get update -y >/dev/null
|
||||
|
||||
progress "Installing RTL-SDR"
|
||||
apt_install rtl-sdr
|
||||
|
||||
progress "Installing multimon-ng"
|
||||
apt_install multimon-ng
|
||||
|
||||
progress "Installing ffmpeg"
|
||||
apt_install ffmpeg
|
||||
|
||||
progress "Installing rtl_433"
|
||||
apt_try_install_any rtl-433 rtl433 || warn "rtl-433 not available"
|
||||
|
||||
progress "Installing aircrack-ng"
|
||||
apt_install aircrack-ng || true
|
||||
|
||||
progress "Installing hcxdumptool"
|
||||
apt_install hcxdumptool || true
|
||||
|
||||
progress "Installing hcxtools"
|
||||
apt_install hcxtools || true
|
||||
|
||||
progress "Installing Bluetooth tools"
|
||||
apt_install bluez bluetooth || true
|
||||
|
||||
progress "Installing SoapySDR"
|
||||
apt_install soapysdr-tools || true
|
||||
|
||||
progress "Installing gpsd"
|
||||
apt_install gpsd gpsd-clients || true
|
||||
|
||||
progress "Installing Python packages"
|
||||
apt_install python3-venv python3-pip || true
|
||||
# Install Python packages via apt (more reliable than pip on modern Debian/Ubuntu)
|
||||
$SUDO apt-get install -y python3-flask python3-requests python3-serial >/dev/null 2>&1 || true
|
||||
$SUDO apt-get install -y python3-skyfield >/dev/null 2>&1 || true
|
||||
|
||||
progress "Installing dump1090"
|
||||
if ! cmd_exists dump1090; then
|
||||
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
|
||||
fi
|
||||
cmd_exists dump1090 || install_dump1090_from_source_debian
|
||||
|
||||
progress "Configuring udev rules"
|
||||
setup_udev_rules_debian
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# Final summary / hard fail
|
||||
# ----------------------------
|
||||
final_summary_and_hard_fail() {
|
||||
check_tools
|
||||
|
||||
echo "============================================"
|
||||
if [[ "${#missing_required[@]}" -eq 0 ]]; then
|
||||
ok "All REQUIRED tools are installed."
|
||||
else
|
||||
fail "Missing REQUIRED tools:"
|
||||
for t in "${missing_required[@]}"; do echo " - $t"; done
|
||||
echo
|
||||
fail "Exiting because required tools are missing."
|
||||
echo
|
||||
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
|
||||
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "To start INTERCEPT:"
|
||||
echo " source venv/bin/activate"
|
||||
echo " sudo python intercept.py"
|
||||
echo
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# MAIN
|
||||
# ============================================
|
||||
# ----------------------------
|
||||
main() {
|
||||
detect_os
|
||||
detect_os
|
||||
|
||||
if [[ "$OS" == "unknown" ]]; then
|
||||
echo -e "${RED}Unsupported OS. This script supports macOS and Debian/Ubuntu.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
install_macos_packages
|
||||
else
|
||||
install_debian_packages
|
||||
fi
|
||||
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
setup_sudo
|
||||
fi
|
||||
|
||||
install_python_deps
|
||||
check_tools
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
install_macos_tools
|
||||
else
|
||||
install_debian_tools
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "To start INTERCEPT:"
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
echo " source venv/bin/activate"
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
fi
|
||||
else
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
echo " sudo python3 intercept.py"
|
||||
else
|
||||
echo " sudo python3 intercept.py"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo ""
|
||||
install_python_deps
|
||||
final_summary_and_hard_fail
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
|
||||
@@ -346,7 +346,7 @@ body {
|
||||
/* Selected aircraft panel */
|
||||
.selected-aircraft {
|
||||
flex-shrink: 0;
|
||||
max-height: 280px;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -354,6 +354,18 @@ body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#aircraftPhotoContainer img {
|
||||
max-height: 140px;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.selected-callsign {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 20px;
|
||||
@@ -406,6 +418,7 @@ body {
|
||||
}
|
||||
|
||||
.aircraft-item {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(74, 158, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
@@ -478,11 +491,28 @@ body {
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 20px;
|
||||
padding: 10px 20px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 8px;
|
||||
padding: 8px 15px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(74, 158, 255, 0.3);
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.controls-bar label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls-bar select,
|
||||
.controls-bar input[type="text"],
|
||||
.controls-bar input[type="number"] {
|
||||
padding: 3px 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
@@ -653,9 +683,11 @@ body {
|
||||
/* Airband Audio Controls */
|
||||
.airband-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
margin: 0 10px;
|
||||
height: 20px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0.4;
|
||||
margin: 0 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.airband-controls {
|
||||
@@ -775,3 +807,32 @@ body {
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* GPS Indicator */
|
||||
.gps-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border: 1px solid #22c55e;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gps-indicator .gps-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: gps-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes gps-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(0.8); }
|
||||
}
|
||||
|
||||
+1464
-39
File diff suppressed because it is too large
Load Diff
@@ -692,4 +692,36 @@ body {
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
|
||||
/* Embedded Mode Styles */
|
||||
body.embedded {
|
||||
background: transparent;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
body.embedded .header {
|
||||
background: rgba(10, 12, 16, 0.95);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
body.embedded .header .logo {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
body.embedded .header .logo span {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
body.embedded .dashboard {
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
body.embedded .panel {
|
||||
background: rgba(15, 18, 24, 0.95);
|
||||
}
|
||||
|
||||
body.embedded .controls-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform (Dark Background Version) -->
|
||||
|
||||
<!-- Dark background -->
|
||||
<rect width="100" height="100" fill="#0a0a0f"/>
|
||||
|
||||
<!-- Subtle grid pattern -->
|
||||
<defs>
|
||||
<pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" fill="none" stroke="#1a1a2e" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#grid)"/>
|
||||
|
||||
<!-- Outer glow effect -->
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#glow)">
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- iNTERCEPT Logo - Signal Intelligence Platform -->
|
||||
|
||||
<!-- Signal brackets - left side (signal waves emanating) -->
|
||||
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Signal brackets - right side (signal waves emanating) -->
|
||||
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
||||
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
||||
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- The 'i' letter - center element -->
|
||||
<!-- dot of i (green accent - represents active signal) -->
|
||||
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
||||
|
||||
<!-- stem of i with styled terminals -->
|
||||
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
||||
|
||||
<!-- top terminal bar -->
|
||||
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
|
||||
<!-- bottom terminal bar -->
|
||||
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Intercept - Radio Knob Component
|
||||
* Interactive rotary knob control with drag-to-rotate
|
||||
*/
|
||||
|
||||
class RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
this.element = element;
|
||||
this.value = parseFloat(element.dataset.value) || 0;
|
||||
this.min = parseFloat(element.dataset.min) || 0;
|
||||
this.max = parseFloat(element.dataset.max) || 100;
|
||||
this.step = parseFloat(element.dataset.step) || 1;
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.isDragging = false;
|
||||
this.startY = 0;
|
||||
this.startRotation = 0;
|
||||
this.sensitivity = options.sensitivity || 1.5;
|
||||
this.onChange = options.onChange || null;
|
||||
|
||||
this.bindEvents();
|
||||
this.updateVisual();
|
||||
}
|
||||
|
||||
valueToRotation(value) {
|
||||
const range = this.max - this.min;
|
||||
const normalized = (value - this.min) / range;
|
||||
return normalized * 270 - 135; // -135 to +135 degrees
|
||||
}
|
||||
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
|
||||
// Snap to step
|
||||
value = Math.round(value / this.step) * this.step;
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Mouse events
|
||||
this.element.addEventListener('mousedown', (e) => this.startDrag(e));
|
||||
document.addEventListener('mousemove', (e) => this.drag(e));
|
||||
document.addEventListener('mouseup', () => this.endDrag());
|
||||
|
||||
// Touch support
|
||||
this.element.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.startDrag(e.touches[0]);
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchmove', (e) => {
|
||||
if (this.isDragging) {
|
||||
e.preventDefault();
|
||||
this.drag(e.touches[0]);
|
||||
}
|
||||
}, { passive: false });
|
||||
document.addEventListener('touchend', () => this.endDrag());
|
||||
|
||||
// Scroll wheel support
|
||||
this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||
|
||||
// Double-click to reset
|
||||
this.element.addEventListener('dblclick', () => this.reset());
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
this.startY = e.clientY;
|
||||
this.startRotation = this.rotation;
|
||||
this.element.style.cursor = 'grabbing';
|
||||
this.element.classList.add('active');
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
drag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const deltaY = this.startY - e.clientY;
|
||||
let newRotation = this.startRotation + deltaY * this.sensitivity;
|
||||
|
||||
// Clamp rotation
|
||||
newRotation = Math.max(-135, Math.min(135, newRotation));
|
||||
|
||||
this.rotation = newRotation;
|
||||
this.value = this.rotationToValue(this.rotation);
|
||||
this.updateVisual();
|
||||
this.dispatchChange();
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
if (!this.isDragging) return;
|
||||
this.isDragging = false;
|
||||
this.element.style.cursor = 'grab';
|
||||
this.element.classList.remove('active');
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -this.step : this.step;
|
||||
const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key
|
||||
this.setValue(this.value + delta * multiplier);
|
||||
|
||||
// Play click sound if available
|
||||
if (typeof playClickSound === 'function') {
|
||||
playClickSound();
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, silent = false) {
|
||||
this.value = Math.max(this.min, Math.min(this.max, value));
|
||||
this.rotation = this.valueToRotation(this.value);
|
||||
this.updateVisual();
|
||||
if (!silent) {
|
||||
this.dispatchChange();
|
||||
}
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
reset() {
|
||||
const defaultValue = parseFloat(this.element.dataset.default) ||
|
||||
(this.min + this.max) / 2;
|
||||
this.setValue(defaultValue);
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display
|
||||
const valueDisplayId = this.element.id.replace('Knob', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = Math.round(this.value);
|
||||
}
|
||||
|
||||
// Update data attribute
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
|
||||
dispatchChange() {
|
||||
// Custom callback
|
||||
if (this.onChange) {
|
||||
this.onChange(this.value, this);
|
||||
}
|
||||
|
||||
// Custom event
|
||||
this.element.dispatchEvent(new CustomEvent('knobchange', {
|
||||
detail: { value: this.value, knob: this },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning Dial - Larger rotary control for frequency tuning
|
||||
*/
|
||||
class TuningDial extends RadioKnob {
|
||||
constructor(element, options = {}) {
|
||||
super(element, {
|
||||
sensitivity: options.sensitivity || 0.8,
|
||||
...options
|
||||
});
|
||||
|
||||
this.fineStep = options.fineStep || 0.025;
|
||||
this.coarseStep = options.coarseStep || 0.2;
|
||||
}
|
||||
|
||||
handleWheel(e) {
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? this.fineStep : this.coarseStep;
|
||||
const delta = e.deltaY > 0 ? -step : step;
|
||||
this.setValue(this.value + delta);
|
||||
}
|
||||
|
||||
// Override to not round to step for smooth tuning
|
||||
rotationToValue(rotation) {
|
||||
const normalized = (rotation + 135) / 270;
|
||||
let value = this.min + normalized * (this.max - this.min);
|
||||
return Math.max(this.min, Math.min(this.max, value));
|
||||
}
|
||||
|
||||
updateVisual() {
|
||||
this.element.style.transform = `rotate(${this.rotation}deg)`;
|
||||
|
||||
// Update associated value display with decimals
|
||||
const valueDisplayId = this.element.id.replace('Dial', 'Value');
|
||||
const valueDisplay = document.getElementById(valueDisplayId);
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = this.value.toFixed(3);
|
||||
}
|
||||
|
||||
this.element.dataset.value = this.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all radio knobs on the page
|
||||
*/
|
||||
function initRadioKnobs() {
|
||||
// Initialize standard knobs
|
||||
document.querySelectorAll('.radio-knob').forEach(element => {
|
||||
if (!element._knob) {
|
||||
element._knob = new RadioKnob(element);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tuning dials
|
||||
document.querySelectorAll('.tuning-dial').forEach(element => {
|
||||
if (!element._dial) {
|
||||
element._dial = new TuningDial(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', initRadioKnobs);
|
||||
|
||||
// Export for use in modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { RadioKnob, TuningDial, initRadioKnobs };
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Intercept - Core Application Logic
|
||||
* Global state, mode switching, and shared functionality
|
||||
*/
|
||||
|
||||
// ============== GLOBAL STATE ==============
|
||||
|
||||
// Mode state flags
|
||||
let eventSource = null;
|
||||
let isRunning = false;
|
||||
let isSensorRunning = false;
|
||||
let isAdsbRunning = false;
|
||||
let isWifiRunning = false;
|
||||
let isBtRunning = false;
|
||||
let currentMode = 'pager';
|
||||
|
||||
// Message counters
|
||||
let msgCount = 0;
|
||||
let pocsagCount = 0;
|
||||
let flexCount = 0;
|
||||
let sensorCount = 0;
|
||||
let filteredCount = 0;
|
||||
|
||||
// Device list (populated from server via Jinja2)
|
||||
let deviceList = [];
|
||||
|
||||
// Auto-scroll setting
|
||||
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
|
||||
|
||||
// Mute setting
|
||||
let muted = localStorage.getItem('audioMuted') === 'true';
|
||||
|
||||
// Observer location (load from localStorage or default to London)
|
||||
let observerLocation = (function() {
|
||||
const saved = localStorage.getItem('observerLocation');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.lat && parsed.lon) return parsed;
|
||||
} catch (e) {}
|
||||
}
|
||||
return { lat: 51.5074, lon: -0.1278 };
|
||||
})();
|
||||
|
||||
// Message storage for export
|
||||
let allMessages = [];
|
||||
|
||||
// Track unique sensor devices
|
||||
let uniqueDevices = new Set();
|
||||
|
||||
// SDR device usage tracking
|
||||
let sdrDeviceUsage = {};
|
||||
|
||||
// ============== DISCLAIMER HANDLING ==============
|
||||
|
||||
function checkDisclaimer() {
|
||||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||||
if (accepted === 'true') {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function acceptDisclaimer() {
|
||||
localStorage.setItem('disclaimerAccepted', 'true');
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
}
|
||||
|
||||
function declineDisclaimer() {
|
||||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||||
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
|
||||
}
|
||||
|
||||
// ============== HEADER CLOCK ==============
|
||||
|
||||
function updateHeaderClock() {
|
||||
const now = new Date();
|
||||
const utc = now.toISOString().substring(11, 19);
|
||||
document.getElementById('headerUtcTime').textContent = utc;
|
||||
}
|
||||
|
||||
// ============== HEADER STATS SYNC ==============
|
||||
|
||||
function syncHeaderStats() {
|
||||
// Pager stats
|
||||
document.getElementById('headerMsgCount').textContent = msgCount;
|
||||
document.getElementById('headerPocsagCount').textContent = pocsagCount;
|
||||
document.getElementById('headerFlexCount').textContent = flexCount;
|
||||
|
||||
// Sensor stats
|
||||
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
|
||||
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
|
||||
|
||||
// WiFi stats
|
||||
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
|
||||
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
|
||||
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Aircraft stats
|
||||
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
|
||||
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
|
||||
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
}
|
||||
|
||||
// ============== MODE SWITCHING ==============
|
||||
|
||||
function switchMode(mode) {
|
||||
// Stop any running scans when switching modes
|
||||
if (isRunning && typeof stopDecoding === 'function') stopDecoding();
|
||||
if (isSensorRunning && typeof stopSensorDecoding === 'function') stopSensorDecoding();
|
||||
if (isWifiRunning && typeof stopWifiScan === 'function') stopWifiScan();
|
||||
if (isBtRunning && typeof stopBtScan === 'function') stopBtScan();
|
||||
if (isAdsbRunning && typeof stopAdsbScan === 'function') stopAdsbScan();
|
||||
|
||||
currentMode = mode;
|
||||
|
||||
// Remove active from all nav buttons, then add to the correct one
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
|
||||
const modeMap = {
|
||||
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
|
||||
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
|
||||
'listening': 'listening'
|
||||
};
|
||||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||||
const label = btn.querySelector('.nav-label');
|
||||
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle mode content visibility
|
||||
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
|
||||
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
|
||||
|
||||
// Toggle stats visibility
|
||||
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
|
||||
document.getElementById('headerSatelliteStats').classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats').classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats').classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
|
||||
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
|
||||
|
||||
// Update active mode indicator
|
||||
const modeNames = {
|
||||
'pager': 'PAGER',
|
||||
'sensor': '433MHZ',
|
||||
'aircraft': 'AIRCRAFT',
|
||||
'satellite': 'SATELLITE',
|
||||
'wifi': 'WIFI',
|
||||
'bluetooth': 'BLUETOOTH',
|
||||
'listening': 'LISTENING POST'
|
||||
};
|
||||
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
|
||||
|
||||
// Toggle layout containers
|
||||
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Respect the "Show Radar Display" checkbox for aircraft mode
|
||||
const showRadar = document.getElementById('adsbEnableMap')?.checked;
|
||||
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
|
||||
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
|
||||
|
||||
// Update output panel title based on mode
|
||||
const titles = {
|
||||
'pager': 'Pager Decoder',
|
||||
'sensor': '433MHz Sensor Monitor',
|
||||
'aircraft': 'ADS-B Aircraft Tracker',
|
||||
'satellite': 'Satellite Monitor',
|
||||
'wifi': 'WiFi Scanner',
|
||||
'bluetooth': 'Bluetooth Scanner',
|
||||
'listening': 'Listening Post'
|
||||
};
|
||||
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
|
||||
document.getElementById('reconPanel').style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
} else {
|
||||
if (reconBtn) reconBtn.style.display = 'inline-block';
|
||||
if (intelBtn) intelBtn.style.display = 'inline-block';
|
||||
if (typeof reconEnabled !== 'undefined' && reconEnabled) {
|
||||
document.getElementById('reconPanel').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show RTL-SDR device section for modes that use it
|
||||
document.getElementById('rtlDeviceSection').style.display =
|
||||
(mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
|
||||
|
||||
// Toggle mode-specific tool status displays
|
||||
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
|
||||
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
|
||||
|
||||
// Hide waterfall and output console for modes with their own visualizations
|
||||
document.querySelector('.waterfall-container').style.display =
|
||||
(mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.getElementById('output').style.display =
|
||||
(mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
|
||||
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||
|
||||
// Load interfaces and initialize visualizations when switching modes
|
||||
if (mode === 'wifi') {
|
||||
if (typeof refreshWifiInterfaces === 'function') refreshWifiInterfaces();
|
||||
if (typeof initRadar === 'function') initRadar();
|
||||
if (typeof initWatchList === 'function') initWatchList();
|
||||
} else if (mode === 'bluetooth') {
|
||||
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
|
||||
if (typeof initBtRadar === 'function') initBtRadar();
|
||||
} else if (mode === 'aircraft') {
|
||||
if (typeof checkAdsbTools === 'function') checkAdsbTools();
|
||||
if (typeof initAircraftRadar === 'function') initAircraftRadar();
|
||||
} else if (mode === 'satellite') {
|
||||
if (typeof initPolarPlot === 'function') initPolarPlot();
|
||||
if (typeof initSatelliteList === 'function') initSatelliteList();
|
||||
} else if (mode === 'listening') {
|
||||
if (typeof checkScannerTools === 'function') checkScannerTools();
|
||||
if (typeof checkAudioTools === 'function') checkAudioTools();
|
||||
if (typeof populateScannerDeviceSelect === 'function') populateScannerDeviceSelect();
|
||||
if (typeof populateAudioDeviceSelect === 'function') populateAudioDeviceSelect();
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SECTION COLLAPSE ==============
|
||||
|
||||
function toggleSection(el) {
|
||||
el.closest('.section').classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// ============== THEME MANAGEMENT ==============
|
||||
|
||||
function toggleTheme() {
|
||||
const html = document.documentElement;
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
html.setAttribute('data-theme', newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Update button text
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = newTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
const btn = document.getElementById('themeToggle');
|
||||
if (btn) {
|
||||
btn.textContent = savedTheme === 'light' ? '🌙' : '☀️';
|
||||
}
|
||||
}
|
||||
|
||||
// ============== AUTO-SCROLL ==============
|
||||
|
||||
function toggleAutoScroll() {
|
||||
autoScroll = !autoScroll;
|
||||
localStorage.setItem('autoScroll', autoScroll);
|
||||
updateAutoScrollButton();
|
||||
}
|
||||
|
||||
function updateAutoScrollButton() {
|
||||
const btn = document.getElementById('autoScrollBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
|
||||
btn.classList.toggle('active', autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== SDR DEVICE MANAGEMENT ==============
|
||||
|
||||
function getSelectedDevice() {
|
||||
return document.getElementById('deviceSelect').value;
|
||||
}
|
||||
|
||||
function getSelectedSDRType() {
|
||||
return document.getElementById('sdrTypeSelect').value;
|
||||
}
|
||||
|
||||
function reserveDevice(deviceIndex, modeId) {
|
||||
sdrDeviceUsage[modeId] = deviceIndex;
|
||||
}
|
||||
|
||||
function releaseDevice(modeId) {
|
||||
delete sdrDeviceUsage[modeId];
|
||||
}
|
||||
|
||||
function checkDeviceAvailability(requestingMode) {
|
||||
const selectedDevice = parseInt(getSelectedDevice());
|
||||
for (const [mode, device] of Object.entries(sdrDeviceUsage)) {
|
||||
if (mode !== requestingMode && device === selectedDevice) {
|
||||
alert(`Device ${selectedDevice} is currently in use by ${mode} mode. Please select a different device or stop the other scan first.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============== BIAS-T SETTINGS ==============
|
||||
|
||||
function saveBiasTSetting() {
|
||||
const enabled = document.getElementById('biasT')?.checked || false;
|
||||
localStorage.setItem('biasTEnabled', enabled);
|
||||
}
|
||||
|
||||
function getBiasTEnabled() {
|
||||
return document.getElementById('biasT')?.checked || false;
|
||||
}
|
||||
|
||||
function loadBiasTSetting() {
|
||||
const saved = localStorage.getItem('biasTEnabled');
|
||||
if (saved === 'true') {
|
||||
const checkbox = document.getElementById('biasT');
|
||||
if (checkbox) checkbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============== REMOTE SDR ==============
|
||||
|
||||
function toggleRemoteSDR() {
|
||||
const useRemote = document.getElementById('useRemoteSDR').checked;
|
||||
const configDiv = document.getElementById('remoteSDRConfig');
|
||||
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
|
||||
|
||||
if (useRemote) {
|
||||
configDiv.style.display = 'block';
|
||||
localControls.forEach(el => el.disabled = true);
|
||||
} else {
|
||||
configDiv.style.display = 'none';
|
||||
localControls.forEach(el => el.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteSDRConfig() {
|
||||
const useRemote = document.getElementById('useRemoteSDR')?.checked;
|
||||
if (!useRemote) return null;
|
||||
|
||||
const host = document.getElementById('rtlTcpHost')?.value || 'localhost';
|
||||
const port = parseInt(document.getElementById('rtlTcpPort')?.value || '1234');
|
||||
|
||||
if (!host || isNaN(port)) {
|
||||
alert('Please enter valid rtl_tcp host and port');
|
||||
return false;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
// ============== OUTPUT DISPLAY ==============
|
||||
|
||||
function showInfo(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const infoEl = document.createElement('div');
|
||||
infoEl.className = 'info-msg';
|
||||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
|
||||
infoEl.textContent = text;
|
||||
output.insertBefore(infoEl, output.firstChild);
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
const output = document.getElementById('output');
|
||||
if (!output) return;
|
||||
|
||||
const placeholder = output.querySelector('.placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'error-msg';
|
||||
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
|
||||
errorEl.textContent = '⚠ ' + text;
|
||||
output.insertBefore(errorEl, output.firstChild);
|
||||
}
|
||||
|
||||
// ============== OBSERVER LOCATION ==============
|
||||
|
||||
function saveObserverLocation() {
|
||||
const lat = parseFloat(document.getElementById('adsbObsLat')?.value || document.getElementById('obsLat')?.value);
|
||||
const lon = parseFloat(document.getElementById('adsbObsLon')?.value || document.getElementById('obsLon')?.value);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Sync both input sets
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
function useGeolocation() {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
|
||||
observerLocation = { lat, lon };
|
||||
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||
|
||||
// Update all input fields
|
||||
const adsbLat = document.getElementById('adsbObsLat');
|
||||
const adsbLon = document.getElementById('adsbObsLon');
|
||||
const satLat = document.getElementById('obsLat');
|
||||
const satLon = document.getElementById('obsLon');
|
||||
|
||||
if (adsbLat) adsbLat.value = lat.toFixed(4);
|
||||
if (adsbLon) adsbLon.value = lon.toFixed(4);
|
||||
if (satLat) satLat.value = lat.toFixed(4);
|
||||
if (satLon) satLon.value = lon.toFixed(4);
|
||||
|
||||
showInfo(`Location set to ${lat.toFixed(4)}, ${lon.toFixed(4)}`);
|
||||
},
|
||||
(error) => {
|
||||
showError('Geolocation failed: ' + error.message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
showError('Geolocation not supported by browser');
|
||||
}
|
||||
}
|
||||
|
||||
// ============== EXPORT FUNCTIONS ==============
|
||||
|
||||
function exportCSV() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
|
||||
const csv = [headers.join(',')];
|
||||
allMessages.forEach(msg => {
|
||||
const row = [
|
||||
msg.timestamp || '',
|
||||
msg.protocol || '',
|
||||
msg.address || '',
|
||||
msg.function || '',
|
||||
msg.msg_type || '',
|
||||
'"' + (msg.message || '').replace(/"/g, '""') + '"'
|
||||
];
|
||||
csv.push(row.join(','));
|
||||
});
|
||||
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
|
||||
}
|
||||
|
||||
function exportJSON() {
|
||||
if (allMessages.length === 0) {
|
||||
alert('No messages to export');
|
||||
return;
|
||||
}
|
||||
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
function initApp() {
|
||||
// Check disclaimer
|
||||
checkDisclaimer();
|
||||
|
||||
// Load theme
|
||||
loadTheme();
|
||||
|
||||
// Start clock
|
||||
updateHeaderClock();
|
||||
setInterval(updateHeaderClock, 1000);
|
||||
|
||||
// Start stats sync
|
||||
setInterval(syncHeaderStats, 500);
|
||||
|
||||
// Load bias-T setting
|
||||
loadBiasTSetting();
|
||||
|
||||
// Initialize observer location inputs
|
||||
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||
const obsLatInput = document.getElementById('obsLat');
|
||||
const obsLonInput = document.getElementById('obsLon');
|
||||
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
|
||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||
|
||||
// Update UI state
|
||||
updateAutoScrollButton();
|
||||
|
||||
// Make sections collapsible
|
||||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||||
h3.addEventListener('click', function() {
|
||||
this.parentElement.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
// Collapse all sections by default (except SDR Device which is first)
|
||||
document.querySelectorAll('.section').forEach((section, index) => {
|
||||
if (index > 0) {
|
||||
section.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run initialization when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Intercept - Audio System
|
||||
* Web Audio API alerts, notifications, and sound effects
|
||||
*/
|
||||
|
||||
// ============== AUDIO STATE ==============
|
||||
|
||||
let audioContext = null;
|
||||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||||
let notificationsEnabled = false;
|
||||
|
||||
// ============== AUDIO CONTEXT ==============
|
||||
|
||||
/**
|
||||
* Initialize the Web Audio API context
|
||||
* Must be called after user interaction due to browser autoplay policies
|
||||
*/
|
||||
function initAudio() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the audio context
|
||||
* @returns {AudioContext}
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
// ============== ALERT SOUNDS ==============
|
||||
|
||||
/**
|
||||
* Play a basic alert beep
|
||||
* Used for message received notifications
|
||||
*/
|
||||
function playAlert() {
|
||||
if (audioMuted || !audioContext) return;
|
||||
|
||||
try {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
oscillator.frequency.value = 880;
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play alert sound by type
|
||||
* @param {string} type - 'emergency', 'military', 'warning', 'info'
|
||||
*/
|
||||
function playAlertSound(type) {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
switch (type) {
|
||||
case 'emergency':
|
||||
// Urgent two-tone alert for emergencies
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
break;
|
||||
|
||||
case 'military':
|
||||
// Single tone for military aircraft detection
|
||||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'warning':
|
||||
// Warning tone (descending)
|
||||
oscillator.frequency.setValueAtTime(660, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.3);
|
||||
gainNode.gain.setValueAtTime(0.25, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.3);
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
default:
|
||||
// Simple info tone
|
||||
oscillator.frequency.setValueAtTime(440, ctx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.15, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15);
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.15);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Audio alert failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play scanner signal detected sound
|
||||
* A distinctive ascending tone for radio scanner
|
||||
*/
|
||||
function playSignalDetectedSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// Ascending tone
|
||||
oscillator.frequency.setValueAtTime(400, ctx.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(800, ctx.currentTime + 0.15);
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.2);
|
||||
} catch (e) {
|
||||
console.warn('Signal detected sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a click sound for UI feedback
|
||||
*/
|
||||
function playClickSound() {
|
||||
if (audioMuted) return;
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.frequency.value = 1000;
|
||||
oscillator.type = 'square';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.05);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.05);
|
||||
} catch (e) {
|
||||
console.warn('Click sound failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== MUTE CONTROL ==============
|
||||
|
||||
/**
|
||||
* Toggle mute state
|
||||
*/
|
||||
function toggleMute() {
|
||||
audioMuted = !audioMuted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set mute state
|
||||
* @param {boolean} muted - Whether audio should be muted
|
||||
*/
|
||||
function setMuted(muted) {
|
||||
audioMuted = muted;
|
||||
localStorage.setItem('audioMuted', audioMuted);
|
||||
updateMuteButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current mute state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMuted() {
|
||||
return audioMuted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mute button UI
|
||||
*/
|
||||
function updateMuteButton() {
|
||||
const btn = document.getElementById('muteBtn');
|
||||
if (btn) {
|
||||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||||
btn.classList.toggle('muted', audioMuted);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== DESKTOP NOTIFICATIONS ==============
|
||||
|
||||
/**
|
||||
* Request notification permission from user
|
||||
*/
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().then(permission => {
|
||||
notificationsEnabled = permission === 'granted';
|
||||
if (notificationsEnabled && typeof showInfo === 'function') {
|
||||
showInfo('🔔 Desktop notifications enabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a desktop notification
|
||||
* @param {string} title - Notification title
|
||||
* @param {string} body - Notification body
|
||||
*/
|
||||
function showNotification(title, body) {
|
||||
if (notificationsEnabled && document.hidden) {
|
||||
new Notification(title, {
|
||||
body: body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'intercept-' + Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============== INITIALIZATION ==============
|
||||
|
||||
/**
|
||||
* Initialize audio system
|
||||
* Should be called on first user interaction
|
||||
*/
|
||||
function initAudioSystem() {
|
||||
// Initialize audio context
|
||||
initAudio();
|
||||
|
||||
// Update mute button state
|
||||
updateMuteButton();
|
||||
|
||||
// Check notification permission
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
notificationsEnabled = true;
|
||||
} else if (Notification.permission === 'default') {
|
||||
// Will request on first interaction
|
||||
document.addEventListener('click', function requestOnce() {
|
||||
requestNotificationPermission();
|
||||
document.removeEventListener('click', requestOnce);
|
||||
}, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first user interaction (required for Web Audio API)
|
||||
document.addEventListener('click', function initOnInteraction() {
|
||||
initAudio();
|
||||
document.removeEventListener('click', initOnInteraction);
|
||||
}, { once: true });
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Intercept - Core Utility Functions
|
||||
* Pure utility functions with no DOM dependencies
|
||||
*/
|
||||
|
||||
// ============== HTML ESCAPING ==============
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped HTML
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape text for use in HTML attributes (especially onclick handlers)
|
||||
* @param {string} text - Text to escape
|
||||
* @returns {string} Escaped attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
var s = String(text);
|
||||
s = s.replace(/&/g, '&');
|
||||
s = s.replace(/'/g, ''');
|
||||
s = s.replace(/"/g, '"');
|
||||
s = s.replace(/</g, '<');
|
||||
s = s.replace(/>/g, '>');
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============== VALIDATION ==============
|
||||
|
||||
/**
|
||||
* Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||||
* @param {string} mac - MAC address to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidMac(mac) {
|
||||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WiFi channel (1-200 covers all bands)
|
||||
* @param {string|number} ch - Channel number
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
function isValidChannel(ch) {
|
||||
const num = parseInt(ch, 10);
|
||||
return !isNaN(num) && num >= 1 && num <= 200;
|
||||
}
|
||||
|
||||
// ============== TIME FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Get relative time string from timestamp
|
||||
* @param {string} timestamp - Time string in HH:MM:SS format
|
||||
* @returns {string} Relative time like "5s ago", "2m ago"
|
||||
*/
|
||||
function getRelativeTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const now = new Date();
|
||||
const parts = timestamp.split(':');
|
||||
const msgTime = new Date();
|
||||
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
|
||||
|
||||
const diff = Math.floor((now - msgTime) / 1000);
|
||||
if (diff < 5) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UTC time string
|
||||
* @param {Date} date - Date object
|
||||
* @returns {string} UTC time in HH:MM:SS format
|
||||
*/
|
||||
function formatUtcTime(date) {
|
||||
return date.toISOString().substring(11, 19);
|
||||
}
|
||||
|
||||
// ============== DISTANCE CALCULATIONS ==============
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in nautical miles
|
||||
* Uses Haversine formula
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in nautical miles
|
||||
*/
|
||||
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
|
||||
const R = 3440.065; // Earth radius in nautical miles
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers
|
||||
* @param {number} lat1 - Latitude of first point
|
||||
* @param {number} lon1 - Longitude of first point
|
||||
* @param {number} lat2 - Latitude of second point
|
||||
* @param {number} lon2 - Longitude of second point
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
function calculateDistanceKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371; // Earth radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// ============== FILE OPERATIONS ==============
|
||||
|
||||
/**
|
||||
* Download content as a file
|
||||
* @param {string} content - File content
|
||||
* @param {string} filename - Name for the downloaded file
|
||||
* @param {string} type - MIME type
|
||||
*/
|
||||
function downloadFile(content, filename, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ============== FREQUENCY FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format frequency value with proper units
|
||||
* @param {number} freqMhz - Frequency in MHz
|
||||
* @param {number} decimals - Number of decimal places (default 3)
|
||||
* @returns {string} Formatted frequency string
|
||||
*/
|
||||
function formatFrequency(freqMhz, decimals = 3) {
|
||||
return freqMhz.toFixed(decimals) + ' MHz';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frequency string to MHz
|
||||
* @param {string} freqStr - Frequency string (e.g., "118.0", "118.0 MHz")
|
||||
* @returns {number} Frequency in MHz
|
||||
*/
|
||||
function parseFrequency(freqStr) {
|
||||
return parseFloat(freqStr.replace(/[^\d.-]/g, ''));
|
||||
}
|
||||
|
||||
// ============== LOCAL STORAGE HELPERS ==============
|
||||
|
||||
/**
|
||||
* Get item from localStorage with JSON parsing
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} defaultValue - Default value if key doesn't exist
|
||||
* @returns {*} Parsed value or default
|
||||
*/
|
||||
function getStorageItem(key, defaultValue = null) {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set item in localStorage with JSON stringification
|
||||
* @param {string} key - Storage key
|
||||
* @param {*} value - Value to store
|
||||
*/
|
||||
function setStorageItem(key, value) {
|
||||
if (typeof value === 'object') {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ============== ARRAY/OBJECT UTILITIES ==============
|
||||
|
||||
/**
|
||||
* Debounce function execution
|
||||
* @param {Function} func - Function to debounce
|
||||
* @param {number} wait - Wait time in milliseconds
|
||||
* @returns {Function} Debounced function
|
||||
*/
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function execution
|
||||
* @param {Function} func - Function to throttle
|
||||
* @param {number} limit - Time limit in milliseconds
|
||||
* @returns {Function} Throttled function
|
||||
*/
|
||||
function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ============== NUMBER FORMATTING ==============
|
||||
|
||||
/**
|
||||
* Format large numbers with K/M suffixes
|
||||
* @param {number} num - Number to format
|
||||
* @returns {string} Formatted string
|
||||
*/
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a number between min and max
|
||||
* @param {number} num - Number to clamp
|
||||
* @param {number} min - Minimum value
|
||||
* @param {number} max - Maximum value
|
||||
* @returns {number} Clamped value
|
||||
*/
|
||||
function clamp(num, min, max) {
|
||||
return Math.min(Math.max(num, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value from one range to another
|
||||
* @param {number} value - Value to map
|
||||
* @param {number} inMin - Input range minimum
|
||||
* @param {number} inMax - Input range maximum
|
||||
* @param {number} outMin - Output range minimum
|
||||
* @param {number} outMax - Output range maximum
|
||||
* @returns {number} Mapped value
|
||||
*/
|
||||
function mapRange(value, inMin, inMax, outMin, outMax) {
|
||||
return (value - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1008
-278
File diff suppressed because it is too large
Load Diff
+1822
-2644
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SATELLITE COMMAND // INTERCEPT</title>
|
||||
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
||||
<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>
|
||||
@@ -16,7 +16,7 @@
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
SATELLITE COMMAND
|
||||
<span>// INTERCEPT</span>
|
||||
<span>// iNTERCEPT - See the Invisible</span>
|
||||
</div>
|
||||
<div class="stats-badges">
|
||||
<div class="stat-badge">
|
||||
@@ -183,6 +183,10 @@
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Check if embedded mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isEmbedded = urlParams.get('embedded') === 'true';
|
||||
|
||||
// Dashboard state
|
||||
let passes = [];
|
||||
let selectedPass = null;
|
||||
@@ -223,7 +227,29 @@
|
||||
calculatePasses();
|
||||
}
|
||||
|
||||
function setupEmbeddedMode() {
|
||||
if (isEmbedded) {
|
||||
// Hide back link when embedded
|
||||
const backLink = document.querySelector('.back-link');
|
||||
if (backLink) backLink.style.display = 'none';
|
||||
|
||||
// Add embedded class to body for CSS adjustments
|
||||
document.body.classList.add('embedded');
|
||||
|
||||
// Compact the header slightly
|
||||
const header = document.querySelector('.header');
|
||||
if (header) header.style.padding = '10px 20px';
|
||||
|
||||
// Hide decorative elements
|
||||
const gridBg = document.querySelector('.grid-bg');
|
||||
const scanline = document.querySelector('.scanline');
|
||||
if (gridBg) gridBg.style.display = 'none';
|
||||
if (scanline) scanline.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setupEmbeddedMode();
|
||||
initGroundMap();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
@@ -361,7 +387,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -720,7 +746,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
@@ -818,7 +844,7 @@
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
||||
|
||||
if (el > -5) {
|
||||
const posEl = Math.max(0, el);
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import pytest
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
from flask import Flask
|
||||
from routes.bluetooth import bluetooth_bp, classify_bt_device, detect_tracker
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_app_module(mocker):
|
||||
mock_app = mocker.patch("routes.bluetooth.app_module")
|
||||
mock_app.bt_devices = {}
|
||||
mock_app.bt_beacons = {}
|
||||
mock_app.bt_services = {}
|
||||
mock_app.bt_queue = MagicMock()
|
||||
mock_app.bt_lock = MagicMock()
|
||||
mock_app.bt_process = None
|
||||
mock_app.bt_interface = "hci0"
|
||||
return mock_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def test_classify_bt_device_by_name():
|
||||
"""Test classification based on common naming patterns."""
|
||||
assert classify_bt_device("Sony WH-1000XM4", None, None) == "audio"
|
||||
assert classify_bt_device("iPhone 15", None, None) == "phone"
|
||||
assert classify_bt_device("Garmin Fenix", None, None) == "wearable"
|
||||
assert classify_bt_device("Microsoft Mouse", None, None) == "input"
|
||||
assert classify_bt_device("AirTag", None, None) == "tracker"
|
||||
assert classify_bt_device("Generic Device", None, None) == "other"
|
||||
|
||||
|
||||
def test_classify_bt_device_by_class():
|
||||
"""Test classification based on Bluetooth Class of Device (CoD)."""
|
||||
assert classify_bt_device(None, 0x0100, None) == "computer"
|
||||
assert classify_bt_device(None, 0x0200, None) == "phone"
|
||||
assert classify_bt_device(None, 0x0400, None) == "audio"
|
||||
|
||||
|
||||
def test_detect_tracker_by_mac():
|
||||
"""Test tracker detection using MAC OUI prefixes."""
|
||||
# Assuming 'FF:FF:FF' is a mock prefix in patterns for testing
|
||||
with patch("routes.bluetooth.TILE_PREFIXES", ["FF:FF"]):
|
||||
result = detect_tracker("FF:FF:00:11:22:33", "Unknown")
|
||||
assert result["type"] == "tile"
|
||||
|
||||
|
||||
def test_detect_tracker_by_name():
|
||||
"""Test tracker detection using name strings."""
|
||||
result = detect_tracker("00:11:22:33:44:55", "My AirTag")
|
||||
assert result["type"] == "airtag"
|
||||
assert result["risk"] == "high"
|
||||
|
||||
|
||||
# --- Route Tests ---
|
||||
|
||||
|
||||
def test_get_interfaces_route(client, mocker):
|
||||
"""Test the /interfaces endpoint with mocked system output."""
|
||||
mock_run = mocker.patch("subprocess.run")
|
||||
# Mocking hciconfig output for a Linux system
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="hci0:\tType: Primary Bus: USB\n\tBD Address: 00:11:22:33:44:55 ACL MTU: 1021:8 SCO MTU: 64:1\n\tUP RUNNING\n"
|
||||
)
|
||||
mocker.patch("platform.system", return_value="Linux")
|
||||
mocker.patch("routes.bluetooth.check_tool", return_value=True)
|
||||
|
||||
response = client.get("/bt/interfaces")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["interfaces"][0]["name"] == "hci0"
|
||||
assert data["interfaces"][0]["status"] == "up"
|
||||
assert data["tools"]["hcitool"] is True
|
||||
|
||||
|
||||
def test_stop_scan_route(client, mock_app_module):
|
||||
"""Test stopping a running scan process."""
|
||||
mock_process = MagicMock()
|
||||
mock_app_module.bt_process = mock_process
|
||||
|
||||
response = client.post("/bt/scan/stop")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "stopped"
|
||||
mock_process.terminate.assert_called_once()
|
||||
|
||||
|
||||
def test_enum_services_error_no_mac(client):
|
||||
"""Test service enumeration validation."""
|
||||
response = client.post("/bt/enum", json={})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["status"] == "error"
|
||||
|
||||
|
||||
def test_get_devices_route(client, mock_app_module):
|
||||
"""Test retrieving the current device list from memory."""
|
||||
mock_app_module.bt_devices = {"00:11:22:33:44:55": {"mac": "00:11:22:33:44:55", "name": "Test Device"}}
|
||||
|
||||
response = client.get("/bt/devices")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(data["devices"]) == 1
|
||||
assert data["devices"][0]["name"] == "Test Device"
|
||||
|
||||
|
||||
def test_reload_oui_route(client, mocker):
|
||||
"""Test the OUI database reload functionality."""
|
||||
mocker.patch("routes.bluetooth.load_oui_database", return_value={"001122": "Test Corp"})
|
||||
|
||||
response = client.post("/bt/reload-oui")
|
||||
data = response.get_json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert data["status"] == "success"
|
||||
assert data["entries"] > 0
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Aircraft database for ICAO hex to type/registration lookup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger('intercept.aircraft_db')
|
||||
|
||||
# Database file location (project root)
|
||||
DB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DB_FILE = os.path.join(DB_DIR, 'aircraft_db.json')
|
||||
DB_META_FILE = os.path.join(DB_DIR, 'aircraft_db_meta.json')
|
||||
|
||||
# Mictronics database URLs (raw GitHub)
|
||||
AIRCRAFT_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/aircrafts.json'
|
||||
TYPES_DB_URL = 'https://raw.githubusercontent.com/Mictronics/readsb-protobuf/dev/webapp/src/db/types.json'
|
||||
GITHUB_API_URL = 'https://api.github.com/repos/Mictronics/readsb-protobuf/commits?path=webapp/src/db/aircrafts.json&per_page=1'
|
||||
|
||||
# In-memory cache
|
||||
_aircraft_cache: dict[str, dict[str, str]] = {}
|
||||
_types_cache: dict[str, str] = {}
|
||||
_cache_lock = threading.Lock()
|
||||
_db_loaded = False
|
||||
_db_version: str | None = None
|
||||
_update_available: bool = False
|
||||
_latest_version: str | None = None
|
||||
|
||||
|
||||
def get_db_status() -> dict[str, Any]:
|
||||
"""Get current database status."""
|
||||
exists = os.path.exists(DB_FILE)
|
||||
meta = _load_meta()
|
||||
|
||||
return {
|
||||
'installed': exists,
|
||||
'version': meta.get('version') if meta else None,
|
||||
'downloaded': meta.get('downloaded') if meta else None,
|
||||
'aircraft_count': len(_aircraft_cache) if _db_loaded else 0,
|
||||
'update_available': _update_available,
|
||||
'latest_version': _latest_version,
|
||||
}
|
||||
|
||||
|
||||
def _load_meta() -> dict[str, Any] | None:
|
||||
"""Load database metadata."""
|
||||
try:
|
||||
if os.path.exists(DB_META_FILE):
|
||||
with open(DB_META_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading aircraft db meta: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_meta(version: str) -> None:
|
||||
"""Save database metadata."""
|
||||
try:
|
||||
meta = {
|
||||
'version': version,
|
||||
'downloaded': datetime.utcnow().isoformat() + 'Z',
|
||||
}
|
||||
with open(DB_META_FILE, 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving aircraft db meta: {e}")
|
||||
|
||||
|
||||
def load_database() -> bool:
|
||||
"""Load aircraft database into memory. Returns True if successful."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
if not os.path.exists(DB_FILE):
|
||||
logger.info("Aircraft database not installed")
|
||||
return False
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
with open(DB_FILE, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
_aircraft_cache = data.get('aircraft', {})
|
||||
_types_cache = data.get('types', {})
|
||||
_db_loaded = True
|
||||
|
||||
meta = _load_meta()
|
||||
_db_version = meta.get('version') if meta else 'unknown'
|
||||
|
||||
logger.info(f"Loaded aircraft database: {len(_aircraft_cache)} aircraft, {len(_types_cache)} types")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading aircraft database: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def lookup(icao: str) -> dict[str, str] | None:
|
||||
"""
|
||||
Look up aircraft by ICAO hex code.
|
||||
|
||||
Returns dict with keys: registration, type_code, type_desc
|
||||
Or None if not found.
|
||||
"""
|
||||
if not _db_loaded:
|
||||
return None
|
||||
|
||||
icao_upper = icao.upper()
|
||||
|
||||
with _cache_lock:
|
||||
aircraft = _aircraft_cache.get(icao_upper)
|
||||
if not aircraft:
|
||||
return None
|
||||
|
||||
# Database format is array: [registration, type_code, flags, ...]
|
||||
# Handle both list format (from Mictronics) and dict format (legacy)
|
||||
if isinstance(aircraft, list):
|
||||
reg = aircraft[0] if len(aircraft) > 0 else ''
|
||||
type_code = aircraft[1] if len(aircraft) > 1 else ''
|
||||
else:
|
||||
# Dict format fallback
|
||||
reg = aircraft.get('r', '')
|
||||
type_code = aircraft.get('t', '')
|
||||
|
||||
# Look up type description
|
||||
type_desc = ''
|
||||
if type_code and type_code in _types_cache:
|
||||
type_desc = _types_cache[type_code]
|
||||
|
||||
return {
|
||||
'registration': reg,
|
||||
'type_code': type_code,
|
||||
'type_desc': type_desc,
|
||||
}
|
||||
|
||||
|
||||
def check_for_updates() -> dict[str, Any]:
|
||||
"""
|
||||
Check GitHub for database updates.
|
||||
Returns status dict with update_available flag.
|
||||
"""
|
||||
global _update_available, _latest_version
|
||||
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if commits and len(commits) > 0:
|
||||
latest_sha = commits[0]['sha'][:8]
|
||||
latest_date = commits[0]['commit']['committer']['date']
|
||||
_latest_version = f"{latest_date[:10]}_{latest_sha}"
|
||||
|
||||
meta = _load_meta()
|
||||
current_version = meta.get('version') if meta else None
|
||||
|
||||
_update_available = current_version != _latest_version
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'current_version': current_version,
|
||||
'latest_version': _latest_version,
|
||||
'update_available': _update_available,
|
||||
}
|
||||
except URLError as e:
|
||||
logger.warning(f"Failed to check for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking for updates: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
return {'success': False, 'error': 'Unknown error'}
|
||||
|
||||
|
||||
def download_database(progress_callback=None) -> dict[str, Any]:
|
||||
"""
|
||||
Download latest aircraft database from Mictronics repo.
|
||||
Returns status dict.
|
||||
"""
|
||||
global _update_available
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
progress_callback('Downloading aircraft database...')
|
||||
|
||||
# Download aircraft database
|
||||
req = Request(AIRCRAFT_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=60) as response:
|
||||
aircraft_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Downloading type codes...')
|
||||
|
||||
# Download types database
|
||||
req = Request(TYPES_DB_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=30) as response:
|
||||
types_data = json.loads(response.read().decode('utf-8'))
|
||||
|
||||
if progress_callback:
|
||||
progress_callback('Processing database...')
|
||||
|
||||
# Combine into single file
|
||||
combined = {
|
||||
'aircraft': aircraft_data,
|
||||
'types': types_data,
|
||||
}
|
||||
|
||||
# Save to file
|
||||
with open(DB_FILE, 'w') as f:
|
||||
json.dump(combined, f, separators=(',', ':')) # Compact JSON
|
||||
|
||||
# Get version from GitHub
|
||||
version = datetime.utcnow().strftime('%Y-%m-%d')
|
||||
try:
|
||||
req = Request(GITHUB_API_URL, headers={'User-Agent': 'Intercept-SIGINT'})
|
||||
with urlopen(req, timeout=10) as response:
|
||||
commits = json.loads(response.read().decode('utf-8'))
|
||||
if commits:
|
||||
sha = commits[0]['sha'][:8]
|
||||
date = commits[0]['commit']['committer']['date'][:10]
|
||||
version = f"{date}_{sha}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_save_meta(version)
|
||||
_update_available = False
|
||||
|
||||
# Reload into memory
|
||||
load_database()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f'Downloaded {len(aircraft_data)} aircraft, {len(types_data)} types',
|
||||
'version': version,
|
||||
}
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
return {'success': False, 'error': f'Download failed: {e}'}
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading database: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
def delete_database() -> dict[str, Any]:
|
||||
"""Delete local database files."""
|
||||
global _aircraft_cache, _types_cache, _db_loaded, _db_version
|
||||
|
||||
try:
|
||||
with _cache_lock:
|
||||
_aircraft_cache = {}
|
||||
_types_cache = {}
|
||||
_db_loaded = False
|
||||
_db_version = None
|
||||
|
||||
if os.path.exists(DB_FILE):
|
||||
os.remove(DB_FILE)
|
||||
if os.path.exists(DB_META_FILE):
|
||||
os.remove(DB_META_FILE)
|
||||
|
||||
return {'success': True, 'message': 'Database deleted'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
@@ -99,6 +99,23 @@ class DataStore:
|
||||
with self._lock:
|
||||
return key in self.data
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Get an entry using subscript notation."""
|
||||
with self._lock:
|
||||
return self.data[key]
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
"""Set an entry using subscript notation."""
|
||||
with self._lock:
|
||||
self.data[key] = value
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
"""Delete an entry using subscript notation."""
|
||||
with self._lock:
|
||||
del self.data[key]
|
||||
del self.timestamps[key]
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Remove entries older than max_age.
|
||||
|
||||
+21
-1
@@ -1,15 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
|
||||
# Additional paths to search for tools (e.g., /usr/sbin on Debian)
|
||||
EXTRA_TOOL_PATHS = ['/usr/sbin', '/sbin']
|
||||
|
||||
|
||||
def check_tool(name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
return shutil.which(name) is not None
|
||||
return get_tool_path(name) is not None
|
||||
|
||||
|
||||
def get_tool_path(name: str) -> str | None:
|
||||
"""Get the full path to a tool, checking standard PATH and extra locations."""
|
||||
# First check standard PATH
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
|
||||
# Check additional paths (e.g., /usr/sbin for aircrack-ng on Debian)
|
||||
for extra_path in EXTRA_TOOL_PATHS:
|
||||
full_path = os.path.join(extra_path, name)
|
||||
if os.path.isfile(full_path) and os.access(full_path, os.X_OK):
|
||||
return full_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Comprehensive tool dependency definitions
|
||||
|
||||
+28
-483
@@ -1,32 +1,20 @@
|
||||
"""
|
||||
GPS dongle support for INTERCEPT.
|
||||
GPS support for INTERCEPT via gpsd daemon.
|
||||
|
||||
Provides detection and reading of USB GPS dongles via serial port.
|
||||
Parses NMEA sentences to extract location data.
|
||||
Provides GPS location data by connecting to the gpsd daemon.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable, Union
|
||||
from typing import Optional, Callable
|
||||
|
||||
logger = logging.getLogger('intercept.gps')
|
||||
|
||||
# Try to import serial, but don't fail if not available
|
||||
try:
|
||||
import serial
|
||||
SERIAL_AVAILABLE = True
|
||||
except ImportError:
|
||||
SERIAL_AVAILABLE = False
|
||||
logger.warning("pyserial not installed - GPS dongle support disabled")
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSPosition:
|
||||
@@ -34,10 +22,10 @@ class GPSPosition:
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # knots
|
||||
speed: Optional[float] = None # m/s
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
|
||||
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
timestamp: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
|
||||
@@ -56,407 +44,6 @@ class GPSPosition:
|
||||
}
|
||||
|
||||
|
||||
def detect_gps_devices() -> list[dict]:
|
||||
"""
|
||||
Detect potential GPS serial devices.
|
||||
|
||||
Returns a list of device info dictionaries.
|
||||
"""
|
||||
devices = []
|
||||
|
||||
# Common GPS device patterns by platform
|
||||
patterns = []
|
||||
|
||||
if os.name == 'posix':
|
||||
# Linux
|
||||
patterns.extend([
|
||||
'/dev/ttyUSB*', # USB serial adapters
|
||||
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
|
||||
'/dev/gps*', # gpsd symlinks
|
||||
])
|
||||
# macOS
|
||||
patterns.extend([
|
||||
'/dev/tty.usbserial*',
|
||||
'/dev/tty.usbmodem*',
|
||||
'/dev/cu.usbserial*',
|
||||
'/dev/cu.usbmodem*',
|
||||
])
|
||||
|
||||
for pattern in patterns:
|
||||
for path in glob.glob(pattern):
|
||||
# Try to get device info
|
||||
device_info = {
|
||||
'path': path,
|
||||
'name': os.path.basename(path),
|
||||
'type': 'serial',
|
||||
}
|
||||
|
||||
# Check if it's readable
|
||||
if os.access(path, os.R_OK):
|
||||
device_info['accessible'] = True
|
||||
else:
|
||||
device_info['accessible'] = False
|
||||
device_info['error'] = 'Permission denied'
|
||||
|
||||
devices.append(device_info)
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
|
||||
"""
|
||||
Parse NMEA coordinate format to decimal degrees.
|
||||
|
||||
NMEA format: DDDMM.MMMM or DDMM.MMMM
|
||||
"""
|
||||
if not coord or not direction:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Find the decimal point
|
||||
dot_pos = coord.index('.')
|
||||
|
||||
# Degrees are everything before the last 2 digits before decimal
|
||||
degrees = int(coord[:dot_pos - 2])
|
||||
minutes = float(coord[dot_pos - 2:])
|
||||
|
||||
result = degrees + (minutes / 60.0)
|
||||
|
||||
# Apply direction
|
||||
if direction in ('S', 'W'):
|
||||
result = -result
|
||||
|
||||
return result
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
|
||||
|
||||
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
|
||||
"""
|
||||
if len(parts) < 10:
|
||||
return None
|
||||
|
||||
try:
|
||||
fix_quality = int(parts[6]) if parts[6] else 0
|
||||
|
||||
# No fix
|
||||
if fix_quality == 0:
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[2], parts[3])
|
||||
lon = parse_nmea_coordinate(parts[4], parts[5])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
satellites = int(parts[7]) if parts[7] else None
|
||||
altitude = float(parts[9]) if parts[9] else None
|
||||
|
||||
# Parse time (HHMMSS.sss)
|
||||
timestamp = None
|
||||
if parts[1]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
if len(time_str) >= 6:
|
||||
now = datetime.utcnow()
|
||||
timestamp = now.replace(
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
microsecond=0
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=altitude,
|
||||
satellites=satellites,
|
||||
fix_quality=fix_quality,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"GGA parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse GPRMC/GNRMC sentence (Recommended Minimum).
|
||||
|
||||
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
|
||||
"""
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Check status (A=active/valid, V=void/invalid)
|
||||
if parts[2] != 'A':
|
||||
return None
|
||||
|
||||
lat = parse_nmea_coordinate(parts[3], parts[4])
|
||||
lon = parse_nmea_coordinate(parts[5], parts[6])
|
||||
|
||||
if lat is None or lon is None:
|
||||
return None
|
||||
|
||||
# Parse optional fields
|
||||
speed = float(parts[7]) if parts[7] else None # knots
|
||||
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
if parts[1] and len(parts) > 9 and parts[9]:
|
||||
try:
|
||||
time_str = parts[1].split('.')[0]
|
||||
date_str = parts[9]
|
||||
if len(time_str) >= 6 and len(date_str) >= 6:
|
||||
timestamp = datetime(
|
||||
year=2000 + int(date_str[4:6]),
|
||||
month=int(date_str[2:4]),
|
||||
day=int(date_str[0:2]),
|
||||
hour=int(time_str[0:2]),
|
||||
minute=int(time_str[2:4]),
|
||||
second=int(time_str[4:6]),
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
speed=speed,
|
||||
heading=heading,
|
||||
timestamp=timestamp,
|
||||
fix_quality=1, # RMC with A status means valid fix
|
||||
)
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.debug(f"RMC parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
|
||||
"""
|
||||
Parse an NMEA sentence and extract position data.
|
||||
|
||||
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
|
||||
"""
|
||||
sentence = sentence.strip()
|
||||
|
||||
# Validate checksum if present
|
||||
if '*' in sentence:
|
||||
data, checksum = sentence.rsplit('*', 1)
|
||||
if data.startswith('$'):
|
||||
data = data[1:]
|
||||
|
||||
# Calculate checksum
|
||||
calc_checksum = 0
|
||||
for char in data:
|
||||
calc_checksum ^= ord(char)
|
||||
|
||||
try:
|
||||
if int(checksum, 16) != calc_checksum:
|
||||
logger.debug(f"Checksum mismatch: {sentence}")
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Remove $ prefix if present
|
||||
if sentence.startswith('$'):
|
||||
sentence = sentence[1:]
|
||||
|
||||
# Remove checksum for parsing
|
||||
if '*' in sentence:
|
||||
sentence = sentence.split('*')[0]
|
||||
|
||||
parts = sentence.split(',')
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
msg_type = parts[0]
|
||||
|
||||
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
|
||||
if msg_type.endswith('GGA'):
|
||||
return parse_gga(parts)
|
||||
elif msg_type.endswith('RMC'):
|
||||
return parse_rmc(parts)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GPSReader:
|
||||
"""
|
||||
Reads GPS data from a serial device.
|
||||
|
||||
Runs in a background thread and maintains current position.
|
||||
"""
|
||||
|
||||
def __init__(self, device_path: str, baudrate: int = 9600):
|
||||
self.device_path = device_path
|
||||
self.baudrate = baudrate
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._serial: Optional['serial.Serial'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position."""
|
||||
with self._lock:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the reader is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_update(self) -> Optional[datetime]:
|
||||
"""Get the time of the last position update."""
|
||||
with self._lock:
|
||||
return self._last_update
|
||||
|
||||
@property
|
||||
def error(self) -> Optional[str]:
|
||||
"""Get any error message."""
|
||||
with self._lock:
|
||||
return self._error
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Remove a position update callback."""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start reading GPS data in a background thread."""
|
||||
if not SERIAL_AVAILABLE:
|
||||
self._error = "pyserial not installed"
|
||||
return False
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._serial = serial.Serial(
|
||||
self.device_path,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1.0
|
||||
)
|
||||
self._running = True
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
logger.info(f"Started GPS reader on {self.device_path}")
|
||||
return True
|
||||
|
||||
except serial.SerialException as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop reading GPS data."""
|
||||
self._running = False
|
||||
|
||||
if self._serial:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
logger.info(f"Stopped GPS reader on {self.device_path}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread loop for reading GPS data."""
|
||||
buffer = ""
|
||||
sentence_count = 0
|
||||
bytes_read = 0
|
||||
|
||||
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
|
||||
|
||||
while self._running and self._serial:
|
||||
try:
|
||||
# Read available data
|
||||
waiting = self._serial.in_waiting
|
||||
if waiting:
|
||||
data = self._serial.read(waiting)
|
||||
bytes_read += len(data)
|
||||
if bytes_read <= 500 or bytes_read % 1000 == 0:
|
||||
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
|
||||
# Process complete lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('$'):
|
||||
sentence_count += 1
|
||||
# Log first few sentences and periodically after that
|
||||
if sentence_count <= 10 or sentence_count % 50 == 0:
|
||||
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
|
||||
|
||||
position = parse_nmea_sentence(line)
|
||||
if position:
|
||||
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
|
||||
position.device = self.device_path
|
||||
self._update_position(position)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
except serial.SerialException as e:
|
||||
logger.error(f"GPS read error: {e}")
|
||||
with self._lock:
|
||||
self._error = str(e)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"GPS parse error: {e}")
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
# Merge data from different sentence types
|
||||
if self._position:
|
||||
# Keep altitude from GGA if RMC doesn't have it
|
||||
if position.altitude is None and self._position.altitude:
|
||||
position.altitude = self._position.altitude
|
||||
# Keep satellites from GGA
|
||||
if position.satellites is None and self._position.satellites:
|
||||
position.satellites = self._position.satellites
|
||||
|
||||
self._position = position
|
||||
self._last_update = datetime.utcnow()
|
||||
self._error = None
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(position)
|
||||
except Exception as e:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
class GPSDClient:
|
||||
"""
|
||||
Connects to gpsd daemon for GPS data.
|
||||
@@ -506,14 +93,9 @@ class GPSDClient:
|
||||
|
||||
@property
|
||||
def device_path(self) -> str:
|
||||
"""Return gpsd connection info (for compatibility with GPSReader)."""
|
||||
"""Return gpsd connection info."""
|
||||
return f"gpsd://{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def baudrate(self) -> int:
|
||||
"""Return 0 for gpsd (for compatibility with GPSReader)."""
|
||||
return 0
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
@@ -667,7 +249,7 @@ class GPSDClient:
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd (not knots)
|
||||
speed=msg.get('speed'), # m/s in gpsd
|
||||
heading=msg.get('track'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
@@ -692,47 +274,15 @@ class GPSDClient:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
# Type alias for GPS source (either serial reader or gpsd client)
|
||||
GPSSource = Union[GPSReader, GPSDClient]
|
||||
|
||||
# Global GPS reader instance
|
||||
_gps_reader: Optional[GPSSource] = None
|
||||
# Global GPS client instance
|
||||
_gps_client: Optional[GPSDClient] = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSSource]:
|
||||
"""Get the global GPS reader/client instance."""
|
||||
def get_gps_reader() -> Optional[GPSDClient]:
|
||||
"""Get the global GPS client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_reader
|
||||
|
||||
|
||||
def start_gps(device_path: str, baudrate: int = 9600,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS reader.
|
||||
|
||||
Args:
|
||||
device_path: Path to the GPS serial device
|
||||
baudrate: Serial baudrate (default 9600)
|
||||
callback: Optional callback for position updates (registered before start to avoid race condition)
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
|
||||
_gps_reader = GPSReader(device_path, baudrate)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
return _gps_client
|
||||
|
||||
|
||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
# Stop existing client if any
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
|
||||
_gps_reader = GPSDClient(host, port)
|
||||
_gps_client = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
_gps_client.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
return _gps_client.start()
|
||||
|
||||
|
||||
def stop_gps() -> None:
|
||||
"""Stop the global GPS reader/client."""
|
||||
global _gps_reader
|
||||
"""Stop the global GPS client."""
|
||||
global _gps_client
|
||||
|
||||
with _gps_lock:
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
_gps_reader = None
|
||||
if _gps_client:
|
||||
_gps_client.stop()
|
||||
_gps_client = None
|
||||
|
||||
|
||||
def get_current_position() -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position from the global reader."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
return reader.position
|
||||
"""Get the current GPS position from the global client."""
|
||||
client = get_gps_reader()
|
||||
if client:
|
||||
return client.position
|
||||
return None
|
||||
|
||||
|
||||
def is_serial_available() -> bool:
|
||||
"""Check if pyserial is available."""
|
||||
return SERIAL_AVAILABLE
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Process health monitoring and auto-restart functionality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Callable, Dict, Optional, Any
|
||||
|
||||
logger = logging.getLogger('intercept.process_monitor')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Information about a monitored process."""
|
||||
name: str
|
||||
process: Any # subprocess.Popen
|
||||
started_at: datetime = field(default_factory=datetime.now)
|
||||
restart_count: int = 0
|
||||
last_restart: Optional[datetime] = None
|
||||
restart_callback: Optional[Callable] = None
|
||||
max_restarts: int = 3
|
||||
backoff_seconds: float = 5.0
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ProcessMonitor:
|
||||
"""
|
||||
Monitor and auto-restart processes.
|
||||
|
||||
Usage:
|
||||
monitor = ProcessMonitor()
|
||||
monitor.register('pager', process, restart_callback=start_pager)
|
||||
monitor.start()
|
||||
"""
|
||||
|
||||
def __init__(self, check_interval: float = 5.0):
|
||||
self.processes: Dict[str, ProcessInfo] = {}
|
||||
self.check_interval = check_interval
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
process: Any,
|
||||
restart_callback: Optional[Callable] = None,
|
||||
max_restarts: int = 3,
|
||||
backoff_seconds: float = 5.0
|
||||
) -> None:
|
||||
"""
|
||||
Register a process for monitoring.
|
||||
|
||||
Args:
|
||||
name: Unique name for the process
|
||||
process: The subprocess.Popen object
|
||||
restart_callback: Function to call to restart the process
|
||||
max_restarts: Maximum number of automatic restarts
|
||||
backoff_seconds: Base backoff time between restarts
|
||||
"""
|
||||
with self._lock:
|
||||
self.processes[name] = ProcessInfo(
|
||||
name=name,
|
||||
process=process,
|
||||
restart_callback=restart_callback,
|
||||
max_restarts=max_restarts,
|
||||
backoff_seconds=backoff_seconds
|
||||
)
|
||||
logger.info(f"Registered process for monitoring: {name}")
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Remove a process from monitoring."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
del self.processes[name]
|
||||
logger.info(f"Unregistered process: {name}")
|
||||
|
||||
def update_process(self, name: str, process: Any) -> None:
|
||||
"""Update the process object for a registered name."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].process = process
|
||||
self.processes[name].started_at = datetime.now()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the monitoring thread."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Process monitor started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the monitoring thread."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=self.check_interval + 1)
|
||||
logger.info("Process monitor stopped")
|
||||
|
||||
def _monitor_loop(self) -> None:
|
||||
"""Main monitoring loop."""
|
||||
while self._running:
|
||||
self._check_all_processes()
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def _check_all_processes(self) -> None:
|
||||
"""Check health of all registered processes."""
|
||||
with self._lock:
|
||||
for name, info in list(self.processes.items()):
|
||||
if not info.enabled:
|
||||
continue
|
||||
|
||||
if info.process is None:
|
||||
continue
|
||||
|
||||
# Check if process has terminated
|
||||
return_code = info.process.poll()
|
||||
if return_code is not None:
|
||||
logger.warning(
|
||||
f"Process '{name}' terminated with code {return_code}"
|
||||
)
|
||||
self._handle_crash(name, info)
|
||||
|
||||
def _handle_crash(self, name: str, info: ProcessInfo) -> None:
|
||||
"""Handle a crashed process."""
|
||||
if info.restart_callback is None:
|
||||
logger.info(f"No restart callback for '{name}', skipping auto-restart")
|
||||
return
|
||||
|
||||
if info.restart_count >= info.max_restarts:
|
||||
logger.error(
|
||||
f"Process '{name}' exceeded max restarts ({info.max_restarts}), "
|
||||
"disabling auto-restart"
|
||||
)
|
||||
info.enabled = False
|
||||
return
|
||||
|
||||
# Calculate backoff with exponential increase
|
||||
backoff = info.backoff_seconds * (2 ** info.restart_count)
|
||||
logger.info(
|
||||
f"Attempting to restart '{name}' in {backoff:.1f}s "
|
||||
f"(attempt {info.restart_count + 1}/{info.max_restarts})"
|
||||
)
|
||||
|
||||
# Wait for backoff period
|
||||
time.sleep(backoff)
|
||||
|
||||
# Attempt restart
|
||||
try:
|
||||
info.restart_callback()
|
||||
info.restart_count += 1
|
||||
info.last_restart = datetime.now()
|
||||
logger.info(f"Successfully restarted '{name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restart '{name}': {e}")
|
||||
info.restart_count += 1
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get status of all monitored processes.
|
||||
|
||||
Returns:
|
||||
Dict with process status information
|
||||
"""
|
||||
with self._lock:
|
||||
status = {}
|
||||
for name, info in self.processes.items():
|
||||
is_running = (
|
||||
info.process is not None and
|
||||
info.process.poll() is None
|
||||
)
|
||||
status[name] = {
|
||||
'running': is_running,
|
||||
'started_at': info.started_at.isoformat() if info.started_at else None,
|
||||
'restart_count': info.restart_count,
|
||||
'last_restart': info.last_restart.isoformat() if info.last_restart else None,
|
||||
'auto_restart_enabled': info.enabled,
|
||||
'return_code': info.process.poll() if info.process else None
|
||||
}
|
||||
return status
|
||||
|
||||
def reset_restart_count(self, name: str) -> None:
|
||||
"""Reset the restart count for a process (e.g., after manual restart)."""
|
||||
with self._lock:
|
||||
if name in self.processes:
|
||||
self.processes[name].restart_count = 0
|
||||
self.processes[name].enabled = True
|
||||
|
||||
def is_healthy(self) -> bool:
|
||||
"""Check if all processes are healthy."""
|
||||
with self._lock:
|
||||
for info in self.processes.values():
|
||||
if info.process is not None and info.process.poll() is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Global monitor instance
|
||||
process_monitor = ProcessMonitor()
|
||||
@@ -31,6 +31,7 @@ from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
from .validation import (
|
||||
SDRValidationError,
|
||||
validate_frequency,
|
||||
@@ -51,6 +52,7 @@ class SDRFactory:
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -217,6 +219,7 @@ __all__ = [
|
||||
'LimeSDRCommandBuilder',
|
||||
'HackRFCommandBuilder',
|
||||
'AirspyCommandBuilder',
|
||||
'SDRPlayCommandBuilder',
|
||||
# Validation
|
||||
'SDRValidationError',
|
||||
'validate_frequency',
|
||||
|
||||
+15
-3
@@ -64,7 +64,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
@@ -87,6 +88,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
@@ -95,7 +99,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
@@ -115,6 +120,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -122,7 +130,8 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
@@ -141,6 +150,9 @@ class AirspyCommandBuilder(CommandBuilder):
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
|
||||
+10
-3
@@ -19,6 +19,7 @@ class SDRType(Enum):
|
||||
LIME_SDR = "limesdr"
|
||||
HACKRF = "hackrf"
|
||||
AIRSPY = "airspy"
|
||||
SDRPLAY = "sdrplay"
|
||||
# Future support
|
||||
# USRP = "usrp"
|
||||
# BLADE_RF = "bladerf"
|
||||
@@ -93,7 +94,8 @@ class CommandBuilder(ABC):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build FM demodulation command (for pager decoding).
|
||||
@@ -106,6 +108,7 @@ class CommandBuilder(ABC):
|
||||
ppm: PPM frequency correction
|
||||
modulation: Modulation type (fm, am, etc.)
|
||||
squelch: Squelch level
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -116,7 +119,8 @@ class CommandBuilder(ABC):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ADS-B decoder command.
|
||||
@@ -124,6 +128,7 @@ class CommandBuilder(ABC):
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
gain: Gain in dB (None for auto)
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
@@ -136,7 +141,8 @@ class CommandBuilder(ABC):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ISM band decoder command (433MHz sensors).
|
||||
@@ -146,6 +152,7 @@ class CommandBuilder(ABC):
|
||||
frequency_mhz: Center frequency in MHz (default 433.92)
|
||||
gain: Gain in dB (None for auto)
|
||||
ppm: PPM frequency correction
|
||||
bias_t: Enable bias-T power (for active antennas)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
|
||||
+16
-3
@@ -29,12 +29,14 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .sdrplay import SDRPlayCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
SDRType.SDRPLAY: SDRPlayCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
@@ -64,6 +66,7 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||
'sdrplay': SDRType.SDRPLAY,
|
||||
# Future support
|
||||
# 'uhd': SDRType.USRP,
|
||||
# 'bladerf': SDRType.BLADE_RF,
|
||||
@@ -144,6 +147,15 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
return devices
|
||||
|
||||
|
||||
def _find_soapy_util() -> str | None:
|
||||
"""Find SoapySDR utility command (name varies by distribution)."""
|
||||
# Try different command names used across distributions
|
||||
for cmd in ['SoapySDRUtil', 'soapy_sdr_util', 'soapysdr-util']:
|
||||
if _check_tool(cmd):
|
||||
return cmd
|
||||
return None
|
||||
|
||||
|
||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect SDR devices via SoapySDR.
|
||||
@@ -156,13 +168,14 @@ def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRD
|
||||
devices: list[SDRDevice] = []
|
||||
skip_types = skip_types or set()
|
||||
|
||||
if not _check_tool('SoapySDRUtil'):
|
||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||
soapy_cmd = _find_soapy_util()
|
||||
if not soapy_cmd:
|
||||
logger.debug("SoapySDR utility not found, skipping SoapySDR detection")
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
[soapy_cmd, '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
|
||||
+15
-3
@@ -60,7 +60,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
@@ -84,6 +85,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
@@ -92,7 +96,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
@@ -112,6 +117,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -119,7 +127,8 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
@@ -138,6 +147,9 @@ class HackRFCommandBuilder(CommandBuilder):
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
|
||||
@@ -41,12 +41,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with LimeSDR.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -73,13 +75,15 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses dump1090 compiled with SoapySDR support, or readsb as alternative.
|
||||
Note: Requires dump1090 with SoapySDR support or readsb.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
@@ -102,12 +106,14 @@ class LimeSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
Note: LimeSDR does not support bias-T, parameter is ignored.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
|
||||
+15
-3
@@ -45,7 +45,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_fm command for FM demodulation.
|
||||
@@ -69,6 +70,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout for piping
|
||||
cmd.append('-')
|
||||
|
||||
@@ -77,7 +81,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command for ADS-B decoding.
|
||||
@@ -104,6 +109,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
@@ -111,7 +119,8 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command for ISM band sensor decoding.
|
||||
@@ -131,6 +140,9 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
||||
if ppm is not None and ppm != 0:
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
SDRPlay command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
SDRPlay RSP devices support 1 kHz to 2 GHz frequency range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class SDRPlayCommandBuilder(CommandBuilder):
|
||||
"""SDRPlay command builder using SoapySDR tools."""
|
||||
|
||||
# SDRPlay RSP capabilities (RSPdx, RSP1A, RSPduo, etc.)
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.SDRPLAY,
|
||||
freq_min_mhz=0.001, # 1 kHz
|
||||
freq_max_mhz=2000.0, # 2 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=59.0, # IFGR range
|
||||
sample_rates=[62500, 96000, 125000, 192000, 250000, 384000, 500000, 1000000, 2000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # SDRPlay has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for SDRPlay."""
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver=sdrplay,serial={device.serial}'
|
||||
return 'driver=sdrplay'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with SDRPlay.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', f'IFGR={int(gain)}'])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['--enable-bias-t'])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
bias_t: bool = False
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if bias_t:
|
||||
cmd.extend(['-T'])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return SDRPlay capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.SDRPLAY
|
||||
@@ -195,3 +195,64 @@ def sanitize_device_name(name: str | None) -> str:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(name)[:64])
|
||||
|
||||
|
||||
def validate_network_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate network interface name to prevent command injection.
|
||||
|
||||
Interface names must:
|
||||
- Start with a letter
|
||||
- Contain only alphanumeric, underscore, or hyphen
|
||||
- Be 1-15 characters long (Linux IFNAMSIZ limit)
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
if not name:
|
||||
raise ValueError("Interface name cannot be empty")
|
||||
|
||||
if len(name) > 15:
|
||||
raise ValueError(f"Interface name too long (max 15 chars): {name}")
|
||||
|
||||
# Must start with letter, contain only alphanumeric/underscore/hyphen
|
||||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', name):
|
||||
raise ValueError(f"Invalid interface name: {name}")
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def validate_bluetooth_interface(name: Any) -> str:
|
||||
"""
|
||||
Validate Bluetooth interface name (hciX format).
|
||||
|
||||
Args:
|
||||
name: Interface name to validate
|
||||
|
||||
Returns:
|
||||
Validated interface name
|
||||
|
||||
Raises:
|
||||
ValueError: If interface name is invalid
|
||||
"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise ValueError("Bluetooth interface name is required")
|
||||
|
||||
name = name.strip()
|
||||
|
||||
# Must be hciX format where X is a number 0-255
|
||||
if not re.match(r'^hci([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$', name):
|
||||
raise ValueError(f"Invalid Bluetooth interface name (expected hciX): {name}")
|
||||
|
||||
return name
|
||||
|
||||
Reference in New Issue
Block a user