mirror of
https://github.com/smittix/intercept.git
synced 2026-07-12 11:38:12 -07:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e179bfd2 | |||
| 20d9178159 | |||
| b2c32173e1 | |||
| 82a2883f82 | |||
| 1807d736b1 | |||
| f2b1839fdc | |||
| 564ef3706f | |||
| 417fa280c3 | |||
| 5077e56d76 | |||
| 3a7c429c4b | |||
| f7ccd56ec0 | |||
| 1f2a7ee523 | |||
| ba4c6999a6 | |||
| 27cbd47a80 | |||
| 351e478dd8 | |||
| 06f82bb88f | |||
| d658b98a25 | |||
| 85e0f228ed | |||
| 3098a61487 | |||
| 4f393c85ac | |||
| 9e7ef3a1c7 | |||
| 3633c5f13c | |||
| 409656b35a | |||
| 482560dbed | |||
| ac0235312c | |||
| 2905f3a3cd | |||
| bd48a8c4e7 | |||
| 7b877727e1 | |||
| a83e0f5c9a | |||
| e01c651bb4 | |||
| 3a0a697bac | |||
| 7d8fa288fc | |||
| c9b76ee8c1 | |||
| 5ed9674e1f | |||
| 3437a2fc0a | |||
| cf48020b58 | |||
| e7ca13a49e | |||
| 0c678867e9 | |||
| 646a9220c9 | |||
| 03f639b79f | |||
| 9742c269cb | |||
| ddbed245e1 | |||
| 0547086e4c | |||
| 11c65448f5 | |||
| 5c895144bc | |||
| 1398a5dedd | |||
| b44546af53 | |||
| 2736c0c107 | |||
| bde977c73d | |||
| 4cf2fa9fe5 | |||
| d1b10d55bd | |||
| fb7a3860ea | |||
| b1ced5daf7 | |||
| 0966b6aeca | |||
| 58f3acb484 | |||
| 5990119db3 | |||
| ac7e3e15f3 | |||
| 1e58812f50 | |||
| 43ff1a8b74 | |||
| fc5a4b1b6b | |||
| 13be9c189e | |||
| ebe0bf9431 | |||
| 0f6f642fd9 | |||
| f1e55fc44c | |||
| 22791b6c3b | |||
| 0cf9360fce | |||
| 7c215cf08c | |||
| 31b70140d6 | |||
| 7309cf0565 | |||
| 170f5dac36 | |||
| 61543b8b34 | |||
| ec988dd35b | |||
| 3e09140b0a | |||
| 32a4c096b2 | |||
| f7a08f890d | |||
| ef66929848 | |||
| 3ee9f5e749 | |||
| f661efc9ae | |||
| c6f994938f | |||
| d0b2a868c8 | |||
| ee9a4fceef | |||
| 0c22564e31 | |||
| bb7a9c53a2 | |||
| 8c31e7f9d0 | |||
| ddeff002c9 | |||
| b0bc7e6e91 | |||
| 6b915ed86e | |||
| 583288fafc | |||
| d7db623fc4 | |||
| 959a1d9f6e | |||
| 1d699bf3c8 | |||
| 77479c0d5c | |||
| a01fd7415f | |||
| 1312ed5e2f | |||
| d9226d7a40 | |||
| 9575805122 | |||
| 9384729c3c | |||
| 6bc75b005d | |||
| fb7309c094 | |||
| 78de7cdb11 | |||
| 5c313304b5 | |||
| f8490c68cc | |||
| 257ea9d979 | |||
| 2ec14750f8 | |||
| e9b5a5b89e | |||
| a8ff965db6 | |||
| dea170bcae | |||
| 4f930f0705 | |||
| 27ea326568 | |||
| 48c6847e9b | |||
| a295ef80bd | |||
| 2bd659c6a6 | |||
| 71f2709df5 | |||
| 8b4b7c1fec | |||
| d62f4153bf | |||
| 249efbb41e | |||
| 0716999ac5 | |||
| 7ad7ea3bdf | |||
| 45798de3ec | |||
| 11d43a08c2 | |||
| e2f8361974 | |||
| 62fc3a8f2c | |||
| e073d8ba47 | |||
| e3ce4525b2 | |||
| 6f3243ada2 | |||
| bf142b7e93 | |||
| 9f2fa1b489 | |||
| cfec3d2889 | |||
| e526401b39 | |||
| f8eedee7fd | |||
| 4560c00248 | |||
| e622834ca5 | |||
| 933871beb9 | |||
| fd9399d838 | |||
| 2ad74a059e | |||
| 77680da014 | |||
| dc5510d0b7 | |||
| 31efc4d276 | |||
| 23fb43002b | |||
| a0783cbd63 | |||
| dd44cda998 | |||
| 890129cba4 | |||
| c3cce80fe7 | |||
| 64eab5c7a2 | |||
| a53bede5e6 | |||
| 7573664041 | |||
| 372460b15e | |||
| 40ae516905 | |||
| 1ccc970d96 | |||
| ed6c8575ee | |||
| 197cd84f21 | |||
| 4aaaa94f9d | |||
| fa8c44c0bf | |||
| 175e40a404 | |||
| 97862816bf | |||
| 561f38b809 | |||
| ac32f8d6a1 | |||
| 225cdd5cd9 | |||
| be34b87036 | |||
| bca22dcdd8 | |||
| bb6386783a | |||
| 902575b583 | |||
| e5eb95231c | |||
| 2f9bc3a26e | |||
| 3b0565e8a9 | |||
| 19d62a904f | |||
| 820b8e0dab | |||
| d61718683b | |||
| 5a73fe7fb9 | |||
| 63b12e7b43 | |||
| 9cefaed0f0 | |||
| 68b74de2e7 | |||
| 324df4e3c0 |
@@ -0,0 +1,38 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
*.egg
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test/Dev
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Captured files (don't include in image)
|
||||
*.cap
|
||||
*.pcap
|
||||
*.csv
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# INTERCEPT - Signal Intelligence Platform
|
||||
# Docker container for running the web interface
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for RTL-SDR tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# RTL-SDR tools
|
||||
rtl-sdr \
|
||||
# 433MHz decoder
|
||||
rtl-433 \
|
||||
# Pager decoder
|
||||
multimon-ng \
|
||||
# WiFi tools (aircrack-ng suite)
|
||||
aircrack-ng \
|
||||
# Bluetooth tools
|
||||
bluez \
|
||||
# Cleanup
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose web interface port
|
||||
EXPOSE 5050
|
||||
|
||||
# Environment variables with defaults
|
||||
ENV INTERCEPT_HOST=0.0.0.0 \
|
||||
INTERCEPT_PORT=5050 \
|
||||
INTERCEPT_LOG_LEVEL=INFO
|
||||
|
||||
# Run the application
|
||||
CMD ["python", "intercept.py"]
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025
|
||||
Copyright (c) 2025 smittix
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,172 +1,130 @@
|
||||
# INTERCEPT
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/python-3.7+-blue.svg" alt="Python 3.7+">
|
||||
<img src="https://img.shields.io/badge/python-3.9+-blue.svg" alt="Python 3.9+">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green.svg" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg" alt="Platform">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Signal Intelligence // POCSAG & FLEX Pager Decoder</strong>
|
||||
<strong>Signal Intelligence Platform</strong><br>
|
||||
A web-based front-end for signal intelligence tools.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A sleek, modern web-based pager decoder using RTL-SDR and multimon-ng.<br>
|
||||
Decode POCSAG and FLEX pager signals with a futuristic SpaceX-inspired interface.
|
||||
<img src="static/images/screenshots/screenshot2.png" alt="Screenshot">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## What is INTERCEPT?
|
||||
|
||||
- **Real-time decoding** of POCSAG (512/1200/2400) and FLEX protocols
|
||||
- **Web-based interface** - no desktop app needed
|
||||
- **Live message streaming** via Server-Sent Events (SSE)
|
||||
- **Message logging** to file with timestamps
|
||||
- **Customizable frequency presets** stored in browser
|
||||
- **RTL-SDR device detection** and selection
|
||||
- **Configurable gain, squelch, and PPM correction**
|
||||
- **Modern dark UI** with SpaceX-inspired aesthetics
|
||||
INTERCEPT provides a unified web interface for signal intelligence tools:
|
||||
|
||||
## Screenshots
|
||||
- **Pager Decoding** - POCSAG/FLEX via rtl_fm + multimon-ng
|
||||
- **433MHz Sensors** - Weather stations, TPMS, IoT via rtl_433
|
||||
- **Aircraft Tracking** - ADS-B via dump1090 with real-time map
|
||||
- **Satellite Tracking** - Pass prediction using TLE data
|
||||
- **WiFi Recon** - Monitor mode scanning via aircrack-ng
|
||||
- **Bluetooth Scanning** - Device discovery and tracker detection
|
||||
|
||||
The interface features a sleek dark theme with cyan accents, real-time message display, and intuitive controls.
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/z3g3NJMe">Join our Discord</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
./setup.sh
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
|
||||
Open http://localhost:5050 in your browser.
|
||||
|
||||
<details>
|
||||
<summary><strong>Alternative: Install with uv</strong></summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/smittix/intercept.git
|
||||
cd intercept
|
||||
uv venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
uv sync
|
||||
sudo python3 intercept.py
|
||||
```
|
||||
</details>
|
||||
|
||||
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Hardware
|
||||
- RTL-SDR compatible dongle (RTL2832U based)
|
||||
- **Python 3.9+**
|
||||
- **SDR Hardware** - RTL-SDR (~$25), LimeSDR, or HackRF
|
||||
- **External Tools** - rtl-sdr, multimon-ng, rtl_433, dump1090, aircrack-ng
|
||||
|
||||
### Software
|
||||
- Python 3.7+
|
||||
- Flask
|
||||
- rtl-sdr tools (`rtl_fm`)
|
||||
- multimon-ng
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install RTL-SDR tools
|
||||
|
||||
**macOS (Homebrew):**
|
||||
Quick install (Ubuntu/Debian):
|
||||
```bash
|
||||
brew install rtl-sdr
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install rtl-sdr
|
||||
```
|
||||
See [Hardware & Installation](docs/HARDWARE.md) for full details.
|
||||
|
||||
**Arch Linux:**
|
||||
```bash
|
||||
sudo pacman -S rtl-sdr
|
||||
```
|
||||
---
|
||||
|
||||
### 2. Install multimon-ng
|
||||
## Documentation
|
||||
|
||||
**macOS (Homebrew):**
|
||||
```bash
|
||||
brew install multimon-ng
|
||||
```
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Features](docs/FEATURES.md) | Complete feature list for all modules |
|
||||
| [Usage Guide](docs/USAGE.md) | Detailed instructions for each mode |
|
||||
| [Troubleshooting](docs/TROUBLESHOOTING.md) | Solutions for common issues |
|
||||
| [Hardware & Installation](docs/HARDWARE.md) | SDR hardware and tool installation |
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt-get install multimon-ng
|
||||
```
|
||||
---
|
||||
|
||||
**From source:**
|
||||
```bash
|
||||
git clone https://github.com/EliasOewortal/multimon-ng.git
|
||||
cd multimon-ng
|
||||
mkdir build && cd build
|
||||
cmake ..
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
## Development
|
||||
|
||||
### 3. Install Python dependencies
|
||||
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.
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Contributions and improvements welcome.
|
||||
|
||||
### 4. Clone and run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/intercept.git
|
||||
cd intercept
|
||||
python3 intercept.py
|
||||
```
|
||||
|
||||
Open your browser to `http://localhost:5050`
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Select Device** - Choose your RTL-SDR device from the dropdown
|
||||
2. **Set Frequency** - Enter a frequency in MHz or use a preset
|
||||
3. **Choose Protocols** - Select which protocols to decode (POCSAG/FLEX)
|
||||
4. **Adjust Settings** - Set gain, squelch, and PPM correction as needed
|
||||
5. **Start Decoding** - Click the green "Start Decoding" button
|
||||
6. **View Messages** - Decoded messages appear in real-time in the output panel
|
||||
|
||||
### Frequency Presets
|
||||
|
||||
- Click a preset button to quickly set a frequency
|
||||
- Add custom presets using the input field and "Add" button
|
||||
- Right-click a preset to remove it
|
||||
- Click "Reset to Defaults" to restore default frequencies
|
||||
|
||||
### Message Logging
|
||||
|
||||
Enable logging in the Logging section to save decoded messages to a file. Messages are saved with timestamp, protocol, address, and content.
|
||||
|
||||
## Default Frequencies (UK)
|
||||
|
||||
- **153.350 MHz** - UK pager frequency
|
||||
- **153.025 MHz** - UK pager frequency
|
||||
|
||||
You can customize these presets in the web interface.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/` | GET | Main web interface |
|
||||
| `/devices` | GET | List RTL-SDR devices |
|
||||
| `/start` | POST | Start decoding |
|
||||
| `/stop` | POST | Stop decoding |
|
||||
| `/status` | GET | Get decoder status |
|
||||
| `/stream` | GET | SSE stream for messages |
|
||||
| `/logging` | POST | Toggle message logging |
|
||||
| `/killall` | POST | Kill all decoder processes |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No devices found
|
||||
- Ensure your RTL-SDR is plugged in
|
||||
- Check `rtl_test` works from command line
|
||||
- On Linux, you may need to blacklist the DVB-T driver
|
||||
|
||||
### No messages appearing
|
||||
- Verify the frequency is correct for your area
|
||||
- Adjust the gain (try 30-40 dB)
|
||||
- Check that pager services are active in your area
|
||||
- Ensure antenna is connected
|
||||
|
||||
### Device busy error
|
||||
- Click "Kill All Processes" to stop any stale processes
|
||||
- Unplug and replug the RTL-SDR device
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [rtl-sdr](https://osmocom.org/projects/rtl-sdr/wiki) - RTL-SDR drivers
|
||||
- [multimon-ng](https://github.com/EliasOenal/multimon-ng) - Multi-protocol decoder
|
||||
- Inspired by the SpaceX mission control aesthetic
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is for educational and authorized use only. Ensure you comply with local laws regarding radio reception and privacy. Intercepting private communications without authorization may be illegal in your jurisdiction.
|
||||
**This software is for educational purposes only.**
|
||||
|
||||
- Only use with proper authorization
|
||||
- Intercepting communications without consent may be illegal
|
||||
- WiFi/Bluetooth attacks require explicit permission
|
||||
- You are responsible for compliance with applicable laws
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
## Author
|
||||
|
||||
Created by **smittix** - [GitHub](https://github.com/smittix)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
[rtl-sdr](https://osmocom.org/projects/rtl-sdr/wiki) |
|
||||
[multimon-ng](https://github.com/EliasOenal/multimon-ng) |
|
||||
[rtl_433](https://github.com/merbanan/rtl_433) |
|
||||
[dump1090](https://github.com/flightaware/dump1090) |
|
||||
[aircrack-ng](https://www.aircrack-ng.org/) |
|
||||
[Leaflet.js](https://leafletjs.com/) |
|
||||
[Celestrak](https://celestrak.org/)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
INTERCEPT - Signal Intelligence Platform
|
||||
|
||||
Flask application and shared state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import site
|
||||
|
||||
# Ensure user site-packages is available (may be disabled when running as root/sudo)
|
||||
if not site.ENABLE_USER_SITE:
|
||||
user_site = site.getusersitepackages()
|
||||
if user_site and user_site not in sys.path:
|
||||
sys.path.insert(0, user_site)
|
||||
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, render_template, jsonify, send_file, Response, request
|
||||
|
||||
from config import VERSION
|
||||
from utils.dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from utils.process import cleanup_stale_processes
|
||||
from utils.sdr import SDRFactory
|
||||
|
||||
|
||||
# Create Flask app
|
||||
app = Flask(__name__)
|
||||
|
||||
# ============================================
|
||||
# GLOBAL PROCESS MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
# Pager decoder
|
||||
current_process = None
|
||||
output_queue = queue.Queue()
|
||||
process_lock = threading.Lock()
|
||||
|
||||
# RTL_433 sensor
|
||||
sensor_process = None
|
||||
sensor_queue = queue.Queue()
|
||||
sensor_lock = threading.Lock()
|
||||
|
||||
# WiFi
|
||||
wifi_process = None
|
||||
wifi_queue = queue.Queue()
|
||||
wifi_lock = threading.Lock()
|
||||
|
||||
# Bluetooth
|
||||
bt_process = None
|
||||
bt_queue = queue.Queue()
|
||||
bt_lock = threading.Lock()
|
||||
|
||||
# ADS-B aircraft
|
||||
adsb_process = None
|
||||
adsb_queue = queue.Queue()
|
||||
adsb_lock = threading.Lock()
|
||||
|
||||
# Satellite/Iridium
|
||||
satellite_process = None
|
||||
satellite_queue = queue.Queue()
|
||||
satellite_lock = threading.Lock()
|
||||
|
||||
# ============================================
|
||||
# GLOBAL STATE DICTIONARIES
|
||||
# ============================================
|
||||
|
||||
# Logging settings
|
||||
logging_enabled = False
|
||||
log_file_path = 'pager_messages.log'
|
||||
|
||||
# WiFi state
|
||||
wifi_monitor_interface = None
|
||||
wifi_networks = {} # BSSID -> network info
|
||||
wifi_clients = {} # Client MAC -> client info
|
||||
wifi_handshakes = [] # Captured handshakes
|
||||
|
||||
# Bluetooth state
|
||||
bt_interface = None
|
||||
bt_devices = {} # MAC -> device info
|
||||
bt_beacons = {} # MAC -> beacon info (AirTags, Tiles, iBeacons)
|
||||
bt_services = {} # MAC -> list of services
|
||||
|
||||
# Aircraft (ADS-B) state
|
||||
adsb_aircraft = {} # ICAO hex -> aircraft info
|
||||
|
||||
# Satellite state
|
||||
satellite_passes = [] # Predicted satellite passes
|
||||
|
||||
|
||||
# ============================================
|
||||
# MAIN ROUTES
|
||||
# ============================================
|
||||
|
||||
@app.route('/')
|
||||
def index() -> str:
|
||||
tools = {
|
||||
'rtl_fm': check_tool('rtl_fm'),
|
||||
'multimon': check_tool('multimon-ng'),
|
||||
'rtl_433': check_tool('rtl_433')
|
||||
}
|
||||
devices = [d.to_dict() for d in SDRFactory.detect_devices()]
|
||||
return render_template('index.html', tools=tools, devices=devices, version=VERSION)
|
||||
|
||||
|
||||
@app.route('/favicon.svg')
|
||||
def favicon() -> Response:
|
||||
return send_file('favicon.svg', mimetype='image/svg+xml')
|
||||
|
||||
|
||||
@app.route('/devices')
|
||||
def get_devices() -> Response:
|
||||
"""Get all detected SDR devices with hardware type info."""
|
||||
devices = SDRFactory.detect_devices()
|
||||
return jsonify([d.to_dict() for d in devices])
|
||||
|
||||
|
||||
@app.route('/dependencies')
|
||||
def get_dependencies() -> Response:
|
||||
"""Get status of all tool dependencies."""
|
||||
results = check_all_dependencies()
|
||||
|
||||
# Determine OS for install instructions
|
||||
system = platform.system().lower()
|
||||
if system == 'darwin':
|
||||
install_method = 'brew'
|
||||
elif system == 'linux':
|
||||
install_method = 'apt'
|
||||
else:
|
||||
install_method = 'manual'
|
||||
|
||||
return jsonify({
|
||||
'os': system,
|
||||
'install_method': install_method,
|
||||
'modes': results
|
||||
})
|
||||
|
||||
|
||||
@app.route('/export/aircraft', methods=['GET'])
|
||||
def export_aircraft() -> Response:
|
||||
"""Export aircraft data as JSON or CSV."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
format_type = request.args.get('format', 'json').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['icao', 'callsign', 'altitude', 'speed', 'heading', 'lat', 'lon', 'squawk', 'last_seen'])
|
||||
|
||||
for icao, ac in adsb_aircraft.items():
|
||||
writer.writerow([
|
||||
icao,
|
||||
ac.get('callsign', ''),
|
||||
ac.get('altitude', ''),
|
||||
ac.get('speed', ''),
|
||||
ac.get('heading', ''),
|
||||
ac.get('lat', ''),
|
||||
ac.get('lon', ''),
|
||||
ac.get('squawk', ''),
|
||||
ac.get('lastSeen', '')
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=aircraft.csv'
|
||||
return response
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'aircraft': list(adsb_aircraft.values())
|
||||
})
|
||||
|
||||
|
||||
@app.route('/export/wifi', methods=['GET'])
|
||||
def export_wifi() -> Response:
|
||||
"""Export WiFi networks as JSON or CSV."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
format_type = request.args.get('format', 'json').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['bssid', 'ssid', 'channel', 'signal', 'encryption', 'clients'])
|
||||
|
||||
for bssid, net in wifi_networks.items():
|
||||
writer.writerow([
|
||||
bssid,
|
||||
net.get('ssid', ''),
|
||||
net.get('channel', ''),
|
||||
net.get('signal', ''),
|
||||
net.get('encryption', ''),
|
||||
net.get('clients', 0)
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=wifi_networks.csv'
|
||||
return response
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'networks': list(wifi_networks.values()),
|
||||
'clients': list(wifi_clients.values())
|
||||
})
|
||||
|
||||
|
||||
@app.route('/export/bluetooth', methods=['GET'])
|
||||
def export_bluetooth() -> Response:
|
||||
"""Export Bluetooth devices as JSON or CSV."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
format_type = request.args.get('format', 'json').lower()
|
||||
|
||||
if format_type == 'csv':
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['mac', 'name', 'rssi', 'type', 'manufacturer', 'last_seen'])
|
||||
|
||||
for mac, dev in bt_devices.items():
|
||||
writer.writerow([
|
||||
mac,
|
||||
dev.get('name', ''),
|
||||
dev.get('rssi', ''),
|
||||
dev.get('type', ''),
|
||||
dev.get('manufacturer', ''),
|
||||
dev.get('lastSeen', '')
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=bluetooth_devices.csv'
|
||||
return response
|
||||
else:
|
||||
return jsonify({
|
||||
'timestamp': __import__('datetime').datetime.utcnow().isoformat(),
|
||||
'devices': list(bt_devices.values()),
|
||||
'beacons': list(bt_beacons.values())
|
||||
})
|
||||
|
||||
|
||||
@app.route('/killall', methods=['POST'])
|
||||
def kill_all() -> Response:
|
||||
"""Kill all decoder and WiFi processes."""
|
||||
global current_process, sensor_process, wifi_process, adsb_process
|
||||
|
||||
# Import adsb module to reset its state
|
||||
from routes import adsb as adsb_module
|
||||
|
||||
killed = []
|
||||
processes_to_kill = [
|
||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||
'dump1090'
|
||||
]
|
||||
|
||||
for proc in processes_to_kill:
|
||||
try:
|
||||
result = subprocess.run(['pkill', '-f', proc], capture_output=True)
|
||||
if result.returncode == 0:
|
||||
killed.append(proc)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
with process_lock:
|
||||
current_process = None
|
||||
|
||||
with sensor_lock:
|
||||
sensor_process = None
|
||||
|
||||
with wifi_lock:
|
||||
wifi_process = None
|
||||
|
||||
# Reset ADS-B state
|
||||
with adsb_lock:
|
||||
adsb_process = None
|
||||
adsb_module.adsb_using_service = False
|
||||
|
||||
return jsonify({'status': 'killed', 'processes': killed})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
import argparse
|
||||
import config
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description='INTERCEPT - Signal Intelligence Platform',
|
||||
epilog='Environment variables: INTERCEPT_HOST, INTERCEPT_PORT, INTERCEPT_DEBUG, INTERCEPT_LOG_LEVEL'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-p', '--port',
|
||||
type=int,
|
||||
default=config.PORT,
|
||||
help=f'Port to run server on (default: {config.PORT})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-H', '--host',
|
||||
default=config.HOST,
|
||||
help=f'Host to bind to (default: {config.HOST})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d', '--debug',
|
||||
action='store_true',
|
||||
default=config.DEBUG,
|
||||
help='Enable debug mode'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--check-deps',
|
||||
action='store_true',
|
||||
help='Check dependencies and exit'
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check dependencies only
|
||||
if args.check_deps:
|
||||
results = check_all_dependencies()
|
||||
print("Dependency Status:")
|
||||
print("-" * 40)
|
||||
for mode, info in results.items():
|
||||
status = "✓" if info['ready'] else "✗"
|
||||
print(f"\n{status} {info['name']}:")
|
||||
for tool, tool_info in info['tools'].items():
|
||||
tool_status = "✓" if tool_info['installed'] else "✗"
|
||||
req = " (required)" if tool_info['required'] else ""
|
||||
print(f" {tool_status} {tool}{req}")
|
||||
sys.exit(0)
|
||||
|
||||
print("=" * 50)
|
||||
print(" INTERCEPT // Signal Intelligence")
|
||||
print(" Pager / 433MHz / Aircraft / Satellite / WiFi / BT")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Clean up any stale processes from previous runs
|
||||
cleanup_stale_processes()
|
||||
|
||||
# Register blueprints
|
||||
from routes import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
print(f"Open http://localhost:{args.port} in your browser")
|
||||
print()
|
||||
print("Press Ctrl+C to stop")
|
||||
print()
|
||||
|
||||
# Avoid loading a global ~/.env when running the script directly.
|
||||
app.run(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
debug=args.debug,
|
||||
threaded=True,
|
||||
load_dotenv=False,
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Configuration settings for intercept application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Application version
|
||||
VERSION = "1.2.0"
|
||||
|
||||
|
||||
def _get_env(key: str, default: str) -> str:
|
||||
"""Get environment variable with default."""
|
||||
return os.environ.get(f'INTERCEPT_{key}', default)
|
||||
|
||||
|
||||
def _get_env_int(key: str, default: int) -> int:
|
||||
"""Get environment variable as integer with default."""
|
||||
try:
|
||||
return int(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _get_env_float(key: str, default: float) -> float:
|
||||
"""Get environment variable as float with default."""
|
||||
try:
|
||||
return float(os.environ.get(f'INTERCEPT_{key}', str(default)))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _get_env_bool(key: str, default: bool) -> bool:
|
||||
"""Get environment variable as boolean with default."""
|
||||
val = os.environ.get(f'INTERCEPT_{key}', '').lower()
|
||||
if val in ('true', '1', 'yes', 'on'):
|
||||
return True
|
||||
if val in ('false', '0', 'no', 'off'):
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
# Logging configuration
|
||||
_log_level_str = _get_env('LOG_LEVEL', 'WARNING').upper()
|
||||
LOG_LEVEL = getattr(logging, _log_level_str, logging.WARNING)
|
||||
LOG_FORMAT = _get_env('LOG_FORMAT', '%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
# Server settings
|
||||
HOST = _get_env('HOST', '0.0.0.0')
|
||||
PORT = _get_env_int('PORT', 5050)
|
||||
DEBUG = _get_env_bool('DEBUG', False)
|
||||
THREADED = _get_env_bool('THREADED', True)
|
||||
|
||||
# Default RTL-SDR settings
|
||||
DEFAULT_GAIN = _get_env('DEFAULT_GAIN', '40')
|
||||
DEFAULT_DEVICE = _get_env('DEFAULT_DEVICE', '0')
|
||||
|
||||
# Pager defaults
|
||||
DEFAULT_PAGER_FREQ = _get_env('PAGER_FREQ', '929.6125M')
|
||||
|
||||
# Timeouts
|
||||
PROCESS_TIMEOUT = _get_env_int('PROCESS_TIMEOUT', 5)
|
||||
SOCKET_TIMEOUT = _get_env_int('SOCKET_TIMEOUT', 5)
|
||||
SSE_TIMEOUT = _get_env_int('SSE_TIMEOUT', 1)
|
||||
|
||||
# WiFi settings
|
||||
WIFI_UPDATE_INTERVAL = _get_env_float('WIFI_UPDATE_INTERVAL', 2.0)
|
||||
AIRODUMP_HEADER_LINES = _get_env_int('AIRODUMP_HEADER_LINES', 2)
|
||||
|
||||
# Bluetooth settings
|
||||
BT_SCAN_TIMEOUT = _get_env_int('BT_SCAN_TIMEOUT', 10)
|
||||
BT_UPDATE_INTERVAL = _get_env_float('BT_UPDATE_INTERVAL', 2.0)
|
||||
|
||||
# ADS-B settings
|
||||
ADSB_SBS_PORT = _get_env_int('ADSB_SBS_PORT', 30003)
|
||||
ADSB_UPDATE_INTERVAL = _get_env_float('ADSB_UPDATE_INTERVAL', 1.0)
|
||||
|
||||
# Satellite settings
|
||||
SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
"""Configure application logging."""
|
||||
logging.basicConfig(
|
||||
level=LOG_LEVEL,
|
||||
format=LOG_FORMAT,
|
||||
stream=sys.stderr
|
||||
)
|
||||
# Suppress Flask development server warning
|
||||
logging.getLogger('werkzeug').setLevel(LOG_LEVEL)
|
||||
@@ -0,0 +1,10 @@
|
||||
# Data modules for INTERCEPT
|
||||
from .oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from .satellites import TLE_SATELLITES
|
||||
from .patterns import (
|
||||
AIRTAG_PREFIXES,
|
||||
TILE_PREFIXES,
|
||||
SAMSUNG_TRACKER,
|
||||
DRONE_SSID_PATTERNS,
|
||||
DRONE_OUI_PREFIXES,
|
||||
)
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
||||
logger = logging.getLogger('intercept.oui')
|
||||
|
||||
|
||||
def load_oui_database() -> dict[str, str] | None:
|
||||
"""Load OUI database from external JSON file, with fallback to built-in."""
|
||||
oui_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'oui_database.json')
|
||||
try:
|
||||
if os.path.exists(oui_file):
|
||||
with open(oui_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
# Remove comment fields
|
||||
return {k: v for k, v in data.items() if not k.startswith('_')}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading oui_database.json: {e}, using built-in database")
|
||||
return None # Will fall back to built-in
|
||||
|
||||
|
||||
def get_manufacturer(mac: str) -> str:
|
||||
"""Look up manufacturer from MAC address OUI."""
|
||||
prefix = mac[:8].upper()
|
||||
return OUI_DATABASE.get(prefix, 'Unknown')
|
||||
|
||||
|
||||
# OUI Database for manufacturer lookup (expanded)
|
||||
OUI_DATABASE = {
|
||||
# Apple (extensive list)
|
||||
'00:25:DB': 'Apple', '04:52:F3': 'Apple', '0C:3E:9F': 'Apple', '10:94:BB': 'Apple',
|
||||
'14:99:E2': 'Apple', '20:78:F0': 'Apple', '28:6A:BA': 'Apple', '3C:22:FB': 'Apple',
|
||||
'40:98:AD': 'Apple', '48:D7:05': 'Apple', '4C:57:CA': 'Apple', '54:4E:90': 'Apple',
|
||||
'5C:97:F3': 'Apple', '60:F8:1D': 'Apple', '68:DB:CA': 'Apple', '70:56:81': 'Apple',
|
||||
'78:7B:8A': 'Apple', '7C:D1:C3': 'Apple', '84:FC:FE': 'Apple', '8C:2D:AA': 'Apple',
|
||||
'90:B0:ED': 'Apple', '98:01:A7': 'Apple', '98:D6:BB': 'Apple', 'A4:D1:D2': 'Apple',
|
||||
'AC:BC:32': 'Apple', 'B0:34:95': 'Apple', 'B8:C1:11': 'Apple', 'C8:69:CD': 'Apple',
|
||||
'D0:03:4B': 'Apple', 'DC:A9:04': 'Apple', 'E0:C7:67': 'Apple', 'F0:18:98': 'Apple',
|
||||
'F4:5C:89': 'Apple', '78:4F:43': 'Apple', '00:CD:FE': 'Apple', '04:4B:ED': 'Apple',
|
||||
'04:D3:CF': 'Apple', '08:66:98': 'Apple', '0C:74:C2': 'Apple', '10:DD:B1': 'Apple',
|
||||
'14:10:9F': 'Apple', '18:EE:69': 'Apple', '1C:36:BB': 'Apple', '24:A0:74': 'Apple',
|
||||
'28:37:37': 'Apple', '2C:BE:08': 'Apple', '34:08:BC': 'Apple', '38:C9:86': 'Apple',
|
||||
'3C:06:30': 'Apple', '44:D8:84': 'Apple', '48:A9:1C': 'Apple', '4C:32:75': 'Apple',
|
||||
'50:32:37': 'Apple', '54:26:96': 'Apple', '58:B0:35': 'Apple', '5C:F7:E6': 'Apple',
|
||||
'64:A3:CB': 'Apple', '68:FE:F7': 'Apple', '6C:4D:73': 'Apple', '70:DE:E2': 'Apple',
|
||||
'74:E2:F5': 'Apple', '78:67:D7': 'Apple', '7C:04:D0': 'Apple', '80:E6:50': 'Apple',
|
||||
'84:78:8B': 'Apple', '88:66:A5': 'Apple', '8C:85:90': 'Apple', '94:E9:6A': 'Apple',
|
||||
'9C:F4:8E': 'Apple', 'A0:99:9B': 'Apple', 'A4:83:E7': 'Apple', 'A8:5C:2C': 'Apple',
|
||||
'AC:1F:74': 'Apple', 'B0:19:C6': 'Apple', 'B4:F1:DA': 'Apple', 'BC:52:B7': 'Apple',
|
||||
'C0:A5:3E': 'Apple', 'C4:B3:01': 'Apple', 'CC:20:E8': 'Apple', 'D0:C5:F3': 'Apple',
|
||||
'D4:61:9D': 'Apple', 'D8:1C:79': 'Apple', 'E0:5F:45': 'Apple', 'E4:C6:3D': 'Apple',
|
||||
'F0:B4:79': 'Apple', 'F4:0F:24': 'Apple', 'F8:4D:89': 'Apple', 'FC:D8:48': 'Apple',
|
||||
# Samsung
|
||||
'00:1B:66': 'Samsung', '00:21:19': 'Samsung', '00:26:37': 'Samsung', '5C:0A:5B': 'Samsung',
|
||||
'8C:71:F8': 'Samsung', 'C4:73:1E': 'Samsung', '38:2C:4A': 'Samsung', '00:1E:4C': 'Samsung',
|
||||
'00:12:47': 'Samsung', '00:15:99': 'Samsung', '00:17:D5': 'Samsung', '00:1D:F6': 'Samsung',
|
||||
'00:21:D1': 'Samsung', '00:24:54': 'Samsung', '00:26:5D': 'Samsung', '08:D4:2B': 'Samsung',
|
||||
'10:D5:42': 'Samsung', '14:49:E0': 'Samsung', '18:3A:2D': 'Samsung', '1C:66:AA': 'Samsung',
|
||||
'24:4B:81': 'Samsung', '28:98:7B': 'Samsung', '2C:AE:2B': 'Samsung', '30:96:FB': 'Samsung',
|
||||
'34:C3:AC': 'Samsung', '38:01:95': 'Samsung', '3C:5A:37': 'Samsung', '40:0E:85': 'Samsung',
|
||||
'44:4E:1A': 'Samsung', '4C:BC:A5': 'Samsung', '50:01:BB': 'Samsung', '50:A4:D0': 'Samsung',
|
||||
'54:88:0E': 'Samsung', '58:C3:8B': 'Samsung', '5C:2E:59': 'Samsung', '60:D0:A9': 'Samsung',
|
||||
'64:B3:10': 'Samsung', '68:48:98': 'Samsung', '6C:2F:2C': 'Samsung', '70:F9:27': 'Samsung',
|
||||
'74:45:8A': 'Samsung', '78:47:1D': 'Samsung', '7C:0B:C6': 'Samsung', '84:11:9E': 'Samsung',
|
||||
'88:32:9B': 'Samsung', '8C:77:12': 'Samsung', '90:18:7C': 'Samsung', '94:35:0A': 'Samsung',
|
||||
'98:52:B1': 'Samsung', '9C:02:98': 'Samsung', 'A0:0B:BA': 'Samsung', 'A4:7B:85': 'Samsung',
|
||||
'A8:06:00': 'Samsung', 'AC:5F:3E': 'Samsung', 'B0:72:BF': 'Samsung', 'B4:79:A7': 'Samsung',
|
||||
'BC:44:86': 'Samsung', 'C0:97:27': 'Samsung', 'C4:42:02': 'Samsung', 'CC:07:AB': 'Samsung',
|
||||
'D0:22:BE': 'Samsung', 'D4:87:D8': 'Samsung', 'D8:90:E8': 'Samsung', 'E4:7C:F9': 'Samsung',
|
||||
'E8:50:8B': 'Samsung', 'F0:25:B7': 'Samsung', 'F4:7B:5E': 'Samsung', 'FC:A1:3E': 'Samsung',
|
||||
# Google
|
||||
'54:60:09': 'Google', '00:1A:11': 'Google', 'F4:F5:D8': 'Google', '94:EB:2C': 'Google',
|
||||
'64:B5:C6': 'Google', '3C:5A:B4': 'Google', 'F8:8F:CA': 'Google', '20:DF:B9': 'Google',
|
||||
'54:27:1E': 'Google', '58:CB:52': 'Google', 'A4:77:33': 'Google', 'F4:0E:22': 'Google',
|
||||
# Sony
|
||||
'00:13:A9': 'Sony', '00:1D:28': 'Sony', '00:24:BE': 'Sony', '04:5D:4B': 'Sony',
|
||||
'08:A9:5A': 'Sony', '10:4F:A8': 'Sony', '24:21:AB': 'Sony', '30:52:CB': 'Sony',
|
||||
'40:B8:37': 'Sony', '58:48:22': 'Sony', '70:9E:29': 'Sony', '84:00:D2': 'Sony',
|
||||
'AC:9B:0A': 'Sony', 'B4:52:7D': 'Sony', 'BC:60:A7': 'Sony', 'FC:0F:E6': 'Sony',
|
||||
# Bose
|
||||
'00:0C:8A': 'Bose', '04:52:C7': 'Bose', '08:DF:1F': 'Bose', '2C:41:A1': 'Bose',
|
||||
'4C:87:5D': 'Bose', '60:AB:D2': 'Bose', '88:C9:E8': 'Bose', 'D8:9C:67': 'Bose',
|
||||
# JBL/Harman
|
||||
'00:1D:DF': 'JBL', '08:AE:D6': 'JBL', '20:3C:AE': 'JBL', '44:5E:F3': 'JBL',
|
||||
'50:C9:71': 'JBL', '74:5E:1C': 'JBL', '88:C6:26': 'JBL', 'AC:12:2F': 'JBL',
|
||||
# Beats (Apple subsidiary)
|
||||
'00:61:71': 'Beats', '48:D6:D5': 'Beats', '9C:64:8B': 'Beats', 'A4:E9:75': 'Beats',
|
||||
# Jabra/GN Audio
|
||||
'00:13:17': 'Jabra', '1C:48:F9': 'Jabra', '50:C2:ED': 'Jabra', '70:BF:92': 'Jabra',
|
||||
'74:5C:4B': 'Jabra', '94:16:25': 'Jabra', 'D0:81:7A': 'Jabra', 'E8:EE:CC': 'Jabra',
|
||||
# Sennheiser
|
||||
'00:1B:66': 'Sennheiser', '00:22:27': 'Sennheiser', 'B8:AD:3E': 'Sennheiser',
|
||||
# Xiaomi
|
||||
'04:CF:8C': 'Xiaomi', '0C:1D:AF': 'Xiaomi', '10:2A:B3': 'Xiaomi', '18:59:36': 'Xiaomi',
|
||||
'20:47:DA': 'Xiaomi', '28:6C:07': 'Xiaomi', '34:CE:00': 'Xiaomi', '38:A4:ED': 'Xiaomi',
|
||||
'44:23:7C': 'Xiaomi', '50:64:2B': 'Xiaomi', '58:44:98': 'Xiaomi', '64:09:80': 'Xiaomi',
|
||||
'74:23:44': 'Xiaomi', '78:02:F8': 'Xiaomi', '7C:1C:4E': 'Xiaomi', '84:F3:EB': 'Xiaomi',
|
||||
'8C:BE:BE': 'Xiaomi', '98:FA:E3': 'Xiaomi', 'A4:77:58': 'Xiaomi', 'AC:C1:EE': 'Xiaomi',
|
||||
'B0:E2:35': 'Xiaomi', 'C4:0B:CB': 'Xiaomi', 'C8:47:8C': 'Xiaomi', 'D4:97:0B': 'Xiaomi',
|
||||
'E4:46:DA': 'Xiaomi', 'F0:B4:29': 'Xiaomi', 'FC:64:BA': 'Xiaomi',
|
||||
# Huawei
|
||||
'00:18:82': 'Huawei', '00:1E:10': 'Huawei', '00:25:68': 'Huawei', '04:B0:E7': 'Huawei',
|
||||
'08:63:61': 'Huawei', '10:1B:54': 'Huawei', '18:DE:D7': 'Huawei', '20:A6:80': 'Huawei',
|
||||
'28:31:52': 'Huawei', '34:12:98': 'Huawei', '3C:47:11': 'Huawei', '48:00:31': 'Huawei',
|
||||
'4C:50:77': 'Huawei', '5C:7D:5E': 'Huawei', '60:DE:44': 'Huawei', '70:72:3C': 'Huawei',
|
||||
'78:F5:57': 'Huawei', '80:B6:86': 'Huawei', '88:53:D4': 'Huawei', '94:04:9C': 'Huawei',
|
||||
'A4:99:47': 'Huawei', 'B4:15:13': 'Huawei', 'BC:76:70': 'Huawei', 'C8:D1:5E': 'Huawei',
|
||||
'DC:D2:FC': 'Huawei', 'E4:68:A3': 'Huawei', 'F4:63:1F': 'Huawei',
|
||||
# OnePlus/BBK
|
||||
'64:A2:F9': 'OnePlus', 'C0:EE:FB': 'OnePlus', '94:65:2D': 'OnePlus',
|
||||
# Fitbit
|
||||
'2C:09:4D': 'Fitbit', 'C4:D9:87': 'Fitbit', 'E4:88:6D': 'Fitbit',
|
||||
# Garmin
|
||||
'00:1C:D1': 'Garmin', 'C4:AC:59': 'Garmin', 'E8:0F:C8': 'Garmin',
|
||||
# Microsoft
|
||||
'00:50:F2': 'Microsoft', '28:18:78': 'Microsoft', '60:45:BD': 'Microsoft',
|
||||
'7C:1E:52': 'Microsoft', '98:5F:D3': 'Microsoft', 'B4:0E:DE': 'Microsoft',
|
||||
# Intel
|
||||
'00:1B:21': 'Intel', '00:1C:C0': 'Intel', '00:1E:64': 'Intel', '00:21:5C': 'Intel',
|
||||
'08:D4:0C': 'Intel', '18:1D:EA': 'Intel', '34:02:86': 'Intel', '40:74:E0': 'Intel',
|
||||
'48:51:B7': 'Intel', '58:A0:23': 'Intel', '64:D4:DA': 'Intel', '80:19:34': 'Intel',
|
||||
'8C:8D:28': 'Intel', 'A4:4E:31': 'Intel', 'B4:6B:FC': 'Intel', 'C8:D0:83': 'Intel',
|
||||
# Qualcomm/Atheros
|
||||
'00:03:7F': 'Qualcomm', '00:24:E4': 'Qualcomm', '04:F0:21': 'Qualcomm',
|
||||
'1C:4B:D6': 'Qualcomm', '88:71:B1': 'Qualcomm', 'A0:65:18': 'Qualcomm',
|
||||
# Broadcom
|
||||
'00:10:18': 'Broadcom', '00:1A:2B': 'Broadcom', '20:10:7A': 'Broadcom',
|
||||
# Realtek
|
||||
'00:0A:EB': 'Realtek', '00:E0:4C': 'Realtek', '48:02:2A': 'Realtek',
|
||||
'52:54:00': 'Realtek', '80:EA:96': 'Realtek',
|
||||
# Logitech
|
||||
'00:1F:20': 'Logitech', '34:88:5D': 'Logitech', '6C:B7:49': 'Logitech',
|
||||
# Lenovo
|
||||
'00:09:2D': 'Lenovo', '28:D2:44': 'Lenovo', '54:EE:75': 'Lenovo', '98:FA:9B': 'Lenovo',
|
||||
# Dell
|
||||
'00:14:22': 'Dell', '00:1A:A0': 'Dell', '18:DB:F2': 'Dell', '34:17:EB': 'Dell',
|
||||
'78:2B:CB': 'Dell', 'A4:BA:DB': 'Dell', 'E4:B9:7A': 'Dell',
|
||||
# HP
|
||||
'00:0F:61': 'HP', '00:14:C2': 'HP', '10:1F:74': 'HP', '28:80:23': 'HP',
|
||||
'38:63:BB': 'HP', '5C:B9:01': 'HP', '80:CE:62': 'HP', 'A0:D3:C1': 'HP',
|
||||
# Tile
|
||||
'F8:E4:E3': 'Tile', 'C4:E7:BE': 'Tile', 'DC:54:D7': 'Tile', 'E4:B0:21': 'Tile',
|
||||
# Raspberry Pi
|
||||
'B8:27:EB': 'Raspberry Pi', 'DC:A6:32': 'Raspberry Pi', 'E4:5F:01': 'Raspberry Pi',
|
||||
# Amazon
|
||||
'00:FC:8B': 'Amazon', '10:CE:A9': 'Amazon', '34:D2:70': 'Amazon', '40:B4:CD': 'Amazon',
|
||||
'44:65:0D': 'Amazon', '68:54:FD': 'Amazon', '74:C2:46': 'Amazon', '84:D6:D0': 'Amazon',
|
||||
'A0:02:DC': 'Amazon', 'AC:63:BE': 'Amazon', 'B4:7C:9C': 'Amazon', 'FC:65:DE': 'Amazon',
|
||||
# Skullcandy
|
||||
'00:01:00': 'Skullcandy', '88:E6:03': 'Skullcandy',
|
||||
# Bang & Olufsen
|
||||
'00:21:3E': 'Bang & Olufsen', '78:C5:E5': 'Bang & Olufsen',
|
||||
# Audio-Technica
|
||||
'A0:E9:DB': 'Audio-Technica', 'EC:81:93': 'Audio-Technica',
|
||||
# Plantronics/Poly
|
||||
'00:1D:DF': 'Plantronics', 'B0:B4:48': 'Plantronics', 'E8:FC:AF': 'Plantronics',
|
||||
# Anker
|
||||
'AC:89:95': 'Anker', 'E8:AB:FA': 'Anker',
|
||||
# Misc/Generic
|
||||
'00:00:0A': 'Omron', '00:1A:7D': 'Cyber-Blue', '00:1E:3D': 'Alps Electric',
|
||||
'00:0B:57': 'Silicon Wave', '00:02:72': 'CC&C',
|
||||
}
|
||||
|
||||
# Try to load from external file (easier to update)
|
||||
_external_oui = load_oui_database()
|
||||
if _external_oui:
|
||||
OUI_DATABASE = _external_oui
|
||||
logger.info(f"Loaded {len(OUI_DATABASE)} entries from oui_database.json")
|
||||
else:
|
||||
logger.info(f"Using built-in database with {len(OUI_DATABASE)} entries")
|
||||
@@ -0,0 +1,39 @@
|
||||
# Detection patterns for various device types
|
||||
|
||||
# Known beacon prefixes for tracker detection
|
||||
AIRTAG_PREFIXES = ['4C:00'] # Apple continuity
|
||||
TILE_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A']
|
||||
SAMSUNG_TRACKER = ['58:4D', 'A0:75']
|
||||
|
||||
# Drone detection patterns (SSID patterns)
|
||||
DRONE_SSID_PATTERNS = [
|
||||
# DJI
|
||||
'DJI-', 'DJI_', 'Mavic', 'Phantom', 'Spark-', 'Mini-', 'Air-', 'Inspire',
|
||||
'Matrice', 'Avata', 'FPV-', 'Osmo', 'RoboMaster', 'Tello',
|
||||
# Parrot
|
||||
'Parrot', 'Bebop', 'Anafi', 'Disco-', 'Mambo', 'Swing',
|
||||
# Autel
|
||||
'Autel', 'EVO-', 'Dragonfish', 'Lite+', 'Nano',
|
||||
# Skydio
|
||||
'Skydio',
|
||||
# Other brands
|
||||
'Holy Stone', 'Potensic', 'SYMA', 'Hubsan', 'Eachine', 'FIMI',
|
||||
'Xiaomi_FIMI', 'Yuneec', 'Typhoon', 'PowerVision', 'PowerEgg',
|
||||
# Generic drone patterns
|
||||
'Drone', 'UAV-', 'Quadcopter', 'FPV_', 'RC-Drone'
|
||||
]
|
||||
|
||||
# Drone OUI prefixes (MAC address prefixes for drone manufacturers)
|
||||
DRONE_OUI_PREFIXES = {
|
||||
# DJI
|
||||
'60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
|
||||
'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
|
||||
# Parrot
|
||||
'90:03:B7': 'Parrot', 'A0:14:3D': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
|
||||
# Autel
|
||||
'8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel',
|
||||
# Yuneec
|
||||
'60:60:1F': 'Yuneec',
|
||||
# Skydio
|
||||
'F8:0F:6F': 'Skydio',
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# TLE data for satellite tracking (updated periodically)
|
||||
TLE_SATELLITES = {
|
||||
'ISS': ('ISS (ZARYA)',
|
||||
'1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 0000',
|
||||
'2 25544 51.6400 0.0000 0000000 0.0000 0.0000 15.50000000000000'),
|
||||
'NOAA-20': ('NOAA 20 (JPSS-1)',
|
||||
'1 43013U 17073A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 43013 98.7400 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
||||
'NOAA-21': ('NOAA 21 (JPSS-2)',
|
||||
'1 54234U 22150A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 54234 98.7100 0.0000 0001000 0.0000 0.0000 14.19000000000000'),
|
||||
'METEOR-M2': ('METEOR-M 2',
|
||||
'1 40069U 14037A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 40069 98.5400 0.0000 0005000 0.0000 0.0000 14.21000000000000'),
|
||||
'METEOR-M2-3': ('METEOR-M2 3',
|
||||
'1 57166U 23091A 24001.00000000 .00000-0 00000-0 00000-0 0 0000',
|
||||
'2 57166 98.7700 0.0000 0002000 0.0000 0.0000 14.23000000000000'),
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
# INTERCEPT Features
|
||||
|
||||
Complete feature list for all modules.
|
||||
|
||||
## Pager Decoding
|
||||
|
||||
- **Real-time decoding** of POCSAG (512/1200/2400) and FLEX protocols
|
||||
- **Customizable frequency presets** stored in browser
|
||||
- **Auto-restart** on frequency change while decoding
|
||||
|
||||
## 433MHz Sensor Decoding
|
||||
|
||||
- **200+ device protocols** supported via rtl_433
|
||||
- **Weather stations** - temperature, humidity, wind, rain
|
||||
- **TPMS** - Tire pressure monitoring sensors
|
||||
- **Doorbells, remotes, and IoT devices**
|
||||
- **Smart meters** and utility monitors
|
||||
|
||||
## ADS-B Aircraft Tracking
|
||||
|
||||
- **Real-time aircraft tracking** via dump1090 or rtl_adsb
|
||||
- **Full-screen dashboard** - dedicated popout with virtual radar scope
|
||||
- **Interactive Leaflet map** with OpenStreetMap tiles (dark-themed)
|
||||
- **Aircraft trails** - optional flight path history visualization
|
||||
- **Range rings** - distance reference circles from observer position
|
||||
- **Aircraft filtering** - show all, military only, civil only, or emergency only
|
||||
- **Marker clustering** - group nearby aircraft at lower zoom levels
|
||||
- **Reception statistics** - max range, message rate, busiest hour, total seen
|
||||
- **Observer location** - manual input or GPS geolocation
|
||||
- **Audio alerts** - notifications for military and emergency aircraft
|
||||
- **Emergency squawk highlighting** - visual alerts for 7500/7600/7700
|
||||
- **Aircraft details popup** - callsign, altitude, speed, heading, squawk, ICAO
|
||||
|
||||
## Satellite Tracking
|
||||
|
||||
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
|
||||
- **Polar sky plot** - real-time satellite positions on azimuth/elevation display
|
||||
- **Ground track map** - satellite orbit path with past/future trajectory
|
||||
- **Pass prediction** for satellites using TLE data
|
||||
- **Add satellites** via manual TLE entry or Celestrak import
|
||||
- **Celestrak integration** - fetch by category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
- **Next pass countdown** - time remaining, visibility duration, max elevation
|
||||
- **Telemetry panel** - real-time azimuth, elevation, range, velocity
|
||||
- **Multiple satellite tracking** simultaneously
|
||||
|
||||
## WiFi Reconnaissance
|
||||
|
||||
- **Monitor mode** management via airmon-ng
|
||||
- **Network scanning** with airodump-ng and channel hopping
|
||||
- **Handshake capture** with real-time status and auto-detection
|
||||
- **Deauthentication attacks** for authorized testing
|
||||
- **Channel utilization** visualization (2.4GHz and 5GHz)
|
||||
- **Security overview** chart and real-time radar display
|
||||
- **Client vendor lookup** via OUI database
|
||||
- **Drone detection** - automatic detection via SSID patterns and OUI (DJI, Parrot, Autel, etc.)
|
||||
- **Rogue AP detection** - alerts for same SSID on multiple BSSIDs
|
||||
- **Signal history graph** - track signal strength over time for any device
|
||||
- **Network topology** - visual map of APs and connected clients
|
||||
- **Channel recommendation** - optimal channel suggestions based on congestion
|
||||
- **Hidden SSID revealer** - captures hidden networks from probe requests
|
||||
- **Client probe analysis** - privacy leak detection from probe requests
|
||||
- **Device correlation** - matches WiFi and Bluetooth devices by manufacturer
|
||||
|
||||
## Bluetooth Scanning
|
||||
|
||||
- **BLE and Classic** Bluetooth device scanning
|
||||
- **Multiple scan modes** - hcitool, bluetoothctl
|
||||
- **Tracker detection** - AirTag, Tile, Samsung SmartTag, Chipolo
|
||||
- **Device classification** - phones, audio, wearables, computers
|
||||
- **Manufacturer lookup** via OUI database
|
||||
- **Proximity radar** visualization
|
||||
- **Device type breakdown** chart
|
||||
|
||||
## User Interface
|
||||
|
||||
- **Mode-specific header stats** - real-time badges showing key metrics per mode
|
||||
- **UTC clock** - always visible in header for time-critical operations
|
||||
- **Active mode indicator** - shows current mode with pulse animation
|
||||
- **Collapsible sections** - click any header to collapse/expand
|
||||
- **Panel styling** - gradient backgrounds with indicator dots
|
||||
- **Tabbed mode selector** with icons (grouped by SDR/RF and Wireless)
|
||||
- **Consistent design** - unified styling across main dashboard and popouts
|
||||
- **Dark/Light theme toggle** - click moon/sun icon in header, preference saved
|
||||
- **Browser notifications** - desktop alerts for critical events (drones, rogue APs, handshakes)
|
||||
- **Built-in help page** - accessible via ? button or F1 key
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| F1 | Open help |
|
||||
| ? | Open help (when not typing) |
|
||||
| Escape | Close help/modals |
|
||||
|
||||
## General
|
||||
|
||||
- **Web-based interface** - no desktop app needed
|
||||
- **Live message streaming** via Server-Sent Events (SSE)
|
||||
- **Audio alerts** with mute toggle
|
||||
- **Message export** to CSV/JSON
|
||||
- **Signal activity meter** and waterfall display
|
||||
- **Message logging** to file with timestamps
|
||||
- **Multi-SDR hardware support** - RTL-SDR, LimeSDR, HackRF
|
||||
- **Automatic device detection** across all supported hardware
|
||||
- **Hardware-specific validation** - frequency/gain ranges per device type
|
||||
- **Configurable gain and PPM correction**
|
||||
- **Device intelligence** dashboard with tracking
|
||||
- **GPS dongle support** - USB GPS receivers for precise observer location
|
||||
- **Disclaimer acceptance** on first use
|
||||
- **Auto-stop** when switching between modes
|
||||
@@ -0,0 +1,166 @@
|
||||
# Hardware & Installation
|
||||
|
||||
## Supported SDR Hardware
|
||||
|
||||
| Hardware | Frequency Range | Gain Range | TX | Price | Notes |
|
||||
|----------|-----------------|------------|-----|-------|-------|
|
||||
| **RTL-SDR** | 24 - 1766 MHz | 0 - 50 dB | No | ~$25 | Most common, budget-friendly |
|
||||
| **LimeSDR** | 0.1 - 3800 MHz | 0 - 73 dB | Yes | ~$300 | Wide range, requires SoapySDR |
|
||||
| **HackRF** | 1 - 6000 MHz | 0 - 62 dB | Yes | ~$300 | Ultra-wide range, requires SoapySDR |
|
||||
|
||||
INTERCEPT automatically detects connected devices and shows hardware-specific capabilities in the UI.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Hardware
|
||||
- **SDR Device** - RTL-SDR, LimeSDR, or HackRF
|
||||
- **WiFi adapter** capable of monitor mode (for WiFi features)
|
||||
- **Bluetooth adapter** (for Bluetooth features)
|
||||
- **GPS dongle** (optional, for precise location)
|
||||
|
||||
### Software
|
||||
- **Python 3.9+** required
|
||||
- External tools (see installation below)
|
||||
|
||||
## Tool Installation
|
||||
|
||||
### Core SDR Tools
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| rtl-sdr | `brew install librtlsdr` | `sudo apt install rtl-sdr` | RTL-SDR support |
|
||||
| multimon-ng | `brew install multimon-ng` | `sudo apt install multimon-ng` | Pager decoding |
|
||||
| rtl_433 | `brew install rtl_433` | `sudo apt install rtl-433` | 433MHz sensors |
|
||||
| dump1090 | `brew install dump1090-mutability` | `sudo apt install dump1090-mutability` | ADS-B aircraft |
|
||||
| aircrack-ng | `brew install aircrack-ng` | `sudo apt install aircrack-ng` | WiFi reconnaissance |
|
||||
| bluez | Built-in (limited) | `sudo apt install bluez bluetooth` | Bluetooth scanning |
|
||||
|
||||
### LimeSDR / HackRF Support (Optional)
|
||||
|
||||
| Tool | macOS | Ubuntu/Debian | Purpose |
|
||||
|------|-------|---------------|---------|
|
||||
| SoapySDR | `brew install soapysdr` | `sudo apt install soapysdr-tools` | Universal SDR abstraction |
|
||||
| LimeSDR | `brew install limesuite soapylms7` | `sudo apt install limesuite soapysdr-module-lms7` | LimeSDR support |
|
||||
| HackRF | `brew install hackrf soapyhackrf` | `sudo apt install hackrf soapysdr-module-hackrf` | HackRF support |
|
||||
| readsb | Build from source | Build from source | ADS-B with SoapySDR |
|
||||
|
||||
> **Note:** RTL-SDR works out of the box. LimeSDR and HackRF require SoapySDR plus the hardware-specific driver.
|
||||
|
||||
## Quick Install Commands
|
||||
|
||||
### Ubuntu/Debian
|
||||
> [!NOTE]
|
||||
> Known Issue: On the latest version of Debian (Trixie) and those distros that use it dump1090 is not available in the repsitories and will need to be built from source until the developers release it.
|
||||
```bash
|
||||
# Core tools
|
||||
sudo apt update
|
||||
sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability aircrack-ng bluez bluetooth
|
||||
|
||||
# LimeSDR (optional)
|
||||
sudo apt install soapysdr-tools limesuite soapysdr-module-lms7
|
||||
|
||||
# HackRF (optional)
|
||||
sudo apt install hackrf soapysdr-module-hackrf
|
||||
```
|
||||
|
||||
### macOS (Homebrew)
|
||||
```bash
|
||||
# Core tools
|
||||
brew install librtlsdr multimon-ng rtl_433 dump1090-mutability aircrack-ng
|
||||
|
||||
# LimeSDR (optional)
|
||||
brew install soapysdr limesuite soapylms7
|
||||
|
||||
# HackRF (optional)
|
||||
brew install hackrf soapyhackrf
|
||||
```
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
# Core tools
|
||||
sudo pacman -S rtl-sdr multimon-ng
|
||||
yay -S rtl_433 dump1090
|
||||
|
||||
# LimeSDR/HackRF (optional)
|
||||
sudo pacman -S soapysdr limesuite hackrf
|
||||
```
|
||||
|
||||
## Linux udev Rules
|
||||
|
||||
If your SDR isn't detected, 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 device.
|
||||
|
||||
## Blacklist DVB-T Driver (Linux)
|
||||
|
||||
The default DVB-T driver conflicts with rtl-sdr:
|
||||
|
||||
```bash
|
||||
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo modprobe -r dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
## Verify Installation
|
||||
|
||||
Check what's installed:
|
||||
```bash
|
||||
python3 intercept.py --check-deps
|
||||
```
|
||||
|
||||
Test SDR detection:
|
||||
```bash
|
||||
# RTL-SDR
|
||||
rtl_test
|
||||
|
||||
# LimeSDR/HackRF
|
||||
SoapySDRUtil --find
|
||||
```
|
||||
|
||||
## Python Dependencies
|
||||
|
||||
### Option 1: setup.sh (Recommended)
|
||||
```bash
|
||||
./setup.sh
|
||||
```
|
||||
This creates a virtual environment and installs dependencies automatically.
|
||||
|
||||
### Option 2: pip
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Option 3: uv (Fast alternative)
|
||||
[uv](https://github.com/astral-sh/uv) is a fast Python package installer.
|
||||
|
||||
```bash
|
||||
# Install uv (if not already installed)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Create venv and install deps
|
||||
uv venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
uv sync
|
||||
|
||||
# Or just install deps in existing environment
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Option 4: pip with pyproject.toml
|
||||
```bash
|
||||
pip install . # Install as package
|
||||
pip install -e . # Install in editable mode (for development)
|
||||
pip install -e ".[dev]" # Include dev dependencies
|
||||
```
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Troubleshooting
|
||||
|
||||
Solutions for common issues.
|
||||
|
||||
## Python / Installation Issues
|
||||
|
||||
### "ModuleNotFoundError: No module named 'flask'"
|
||||
|
||||
Install Python dependencies first:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or with python3 explicitly
|
||||
python3 -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### "TypeError: 'type' object is not subscriptable"
|
||||
|
||||
This error occurs on Python 3.7 or 3.8. **INTERCEPT requires Python 3.9 or later.**
|
||||
|
||||
```bash
|
||||
# Check your Python version
|
||||
python3 --version
|
||||
|
||||
# Ubuntu/Debian - install newer Python
|
||||
sudo apt update
|
||||
sudo apt install python3.11 python3.11-venv python3-pip
|
||||
|
||||
# Run with newer Python
|
||||
python3.11 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### "externally-managed-environment" error (Ubuntu 23.04+, Debian 12+)
|
||||
|
||||
Modern systems use PEP 668 to protect system Python. Use a virtual environment:
|
||||
|
||||
```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)
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
### "pip: command not found"
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install python3-pip
|
||||
|
||||
# macOS
|
||||
python3 -m ensurepip --upgrade
|
||||
```
|
||||
|
||||
### Permission denied during pip install
|
||||
|
||||
```bash
|
||||
# Install to user directory
|
||||
pip install --user -r requirements.txt
|
||||
```
|
||||
|
||||
## SDR Hardware Issues
|
||||
|
||||
### No SDR devices found
|
||||
|
||||
1. Ensure your SDR device is plugged in
|
||||
2. Check detection:
|
||||
- RTL-SDR: `rtl_test`
|
||||
- LimeSDR/HackRF: `SoapySDRUtil --find`
|
||||
3. On Linux, add udev rules (see below)
|
||||
4. Blacklist conflicting drivers:
|
||||
```bash
|
||||
echo "blacklist dvb_usb_rtl28xxu" | sudo tee /etc/modprobe.d/blacklist-rtl.conf
|
||||
sudo modprobe -r dvb_usb_rtl28xxu
|
||||
```
|
||||
|
||||
### Linux udev rules for RTL-SDR
|
||||
|
||||
```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.
|
||||
|
||||
### Device busy error
|
||||
|
||||
1. Click "Kill All Processes" in the UI
|
||||
2. Unplug and replug the SDR device
|
||||
3. Check for other applications: `lsof | grep rtl`
|
||||
|
||||
### LimeSDR/HackRF not detected
|
||||
|
||||
1. Verify SoapySDR is installed: `SoapySDRUtil --info`
|
||||
2. Check driver is loaded: `SoapySDRUtil --find`
|
||||
3. May need udev rules or run as root
|
||||
|
||||
## WiFi Issues
|
||||
|
||||
### Monitor mode fails
|
||||
|
||||
1. Ensure running as root/sudo
|
||||
2. Check adapter supports monitor mode: `iw list | grep monitor`
|
||||
3. Kill interfering processes: `airmon-ng check kill`
|
||||
|
||||
### Permission denied when scanning
|
||||
|
||||
Run INTERCEPT with sudo:
|
||||
```bash
|
||||
sudo python3 intercept.py
|
||||
# Or with venv:
|
||||
sudo venv/bin/python intercept.py
|
||||
```
|
||||
|
||||
### Interface not found after enabling monitor mode
|
||||
|
||||
Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon). The interface should auto-select, but if not, manually select the monitor interface from the dropdown.
|
||||
|
||||
## Bluetooth Issues
|
||||
|
||||
### No Bluetooth adapter found
|
||||
|
||||
```bash
|
||||
# Check if adapter is detected
|
||||
hciconfig
|
||||
|
||||
# Ubuntu/Debian - install BlueZ
|
||||
sudo apt install bluez bluetooth
|
||||
```
|
||||
|
||||
### Permission denied
|
||||
|
||||
Run with sudo or add your user to the bluetooth group:
|
||||
```bash
|
||||
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)
|
||||
|
||||
1. Verify frequency is correct for your area
|
||||
2. Adjust gain (try 30-40 dB)
|
||||
3. Check pager services are active in your area
|
||||
4. Ensure antenna is connected
|
||||
|
||||
### No aircraft appearing (ADS-B mode)
|
||||
|
||||
1. Verify dump1090 or readsb 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
|
||||
|
||||
### Satellite passes not calculating
|
||||
|
||||
1. Ensure skyfield is installed: `pip install skyfield`
|
||||
2. Check TLE data is valid and recent
|
||||
3. Verify observer location is set correctly
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# INTERCEPT Usage Guide
|
||||
|
||||
Detailed instructions for each mode.
|
||||
|
||||
## Pager Mode
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR, LimeSDR, or HackRF)
|
||||
2. **Select Device** - Choose your SDR device from the dropdown
|
||||
3. **Set Frequency** - Enter a frequency in MHz or use a preset
|
||||
4. **Choose Protocols** - Select which protocols to decode (POCSAG/FLEX)
|
||||
5. **Adjust Settings** - Set gain, squelch, and PPM correction as needed
|
||||
6. **Start Decoding** - Click the green "Start Decoding" button
|
||||
|
||||
### Frequency Presets
|
||||
|
||||
- Click a preset button to quickly set a frequency
|
||||
- Add custom presets using the input field and "Add" button
|
||||
- Right-click a preset to remove it
|
||||
- Click "Reset to Defaults" to restore default frequencies
|
||||
|
||||
## 433MHz Sensor Mode
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type
|
||||
2. **Select Device** - Choose your SDR device
|
||||
3. **Start Decoding** - Click "Start Decoding"
|
||||
4. **View Sensors** - Decoded sensor data appears in real-time
|
||||
|
||||
Supports 200+ protocols including weather stations, TPMS, doorbells, and IoT devices.
|
||||
|
||||
## WiFi Mode
|
||||
|
||||
1. **Select Interface** - Choose a WiFi adapter capable of monitor mode
|
||||
2. **Enable Monitor Mode** - Click "Enable Monitor" (uncheck "Kill processes" to preserve other connections)
|
||||
3. **Start Scanning** - Click "Start Scanning" to begin
|
||||
4. **View Networks** - Networks appear in the output panel with signal strength
|
||||
5. **Track Devices** - Click the chart icon on any network to track its signal over time
|
||||
6. **Capture Handshakes** - Click "Capture" on a network to start handshake capture
|
||||
|
||||
### Tips
|
||||
|
||||
- Run with `sudo` for monitor mode to work
|
||||
- Check your adapter supports monitor mode: `iw list | grep monitor`
|
||||
- Use "Kill processes" option if NetworkManager interferes
|
||||
|
||||
## Bluetooth Mode
|
||||
|
||||
1. **Select Interface** - Choose your Bluetooth adapter
|
||||
2. **Choose Mode** - Select scan mode (hcitool, bluetoothctl)
|
||||
3. **Start Scanning** - Click "Start Scanning"
|
||||
4. **View Devices** - Devices appear with name, address, and classification
|
||||
|
||||
### Tracker Detection
|
||||
|
||||
INTERCEPT automatically detects known trackers:
|
||||
- Apple AirTag
|
||||
- Tile
|
||||
- Samsung SmartTag
|
||||
- Chipolo
|
||||
|
||||
## Aircraft Mode (ADS-B)
|
||||
|
||||
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
|
||||
2. **Check Tools** - Ensure dump1090 or readsb is installed
|
||||
3. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
|
||||
5. **View Map** - Aircraft appear on the interactive Leaflet map
|
||||
6. **Click Aircraft** - Click markers for detailed information
|
||||
7. **Display Options** - Toggle callsigns, altitude, trails, range rings, clustering
|
||||
8. **Filter Aircraft** - Use dropdown to show all, military, civil, or emergency only
|
||||
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
|
||||
|
||||
### Emergency Squawks
|
||||
|
||||
The system highlights aircraft transmitting emergency squawks:
|
||||
- **7500** - Hijack
|
||||
- **7600** - Radio failure
|
||||
- **7700** - General emergency
|
||||
|
||||
## Satellite Mode
|
||||
|
||||
1. **Set Location** - Choose location source:
|
||||
- **Manual Entry** - Type coordinates directly
|
||||
- **Browser GPS** - Use browser's built-in geolocation
|
||||
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
|
||||
2. **Add Satellites** - Click "Add Satellite" to enter TLE data or fetch from Celestrak
|
||||
3. **Calculate Passes** - Click "Calculate Passes" to predict upcoming passes
|
||||
4. **View Sky Plot** - Polar plot shows satellite positions in real-time
|
||||
5. **Ground Track** - Map displays satellite orbit path and current position
|
||||
6. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated satellite view
|
||||
|
||||
### Adding Satellites from Celestrak
|
||||
|
||||
1. Click "Add Satellite"
|
||||
2. Select "Fetch from Celestrak"
|
||||
3. Choose a category (Amateur, Weather, ISS, Starlink, etc.)
|
||||
4. Select satellites to add
|
||||
|
||||
## Configuration
|
||||
|
||||
INTERCEPT can be configured via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `INTERCEPT_HOST` | `0.0.0.0` | Server bind address |
|
||||
| `INTERCEPT_PORT` | `5050` | Server port |
|
||||
| `INTERCEPT_DEBUG` | `false` | Enable debug mode |
|
||||
| `INTERCEPT_LOG_LEVEL` | `WARNING` | Log level (DEBUG, INFO, WARNING, ERROR) |
|
||||
| `INTERCEPT_DEFAULT_GAIN` | `40` | Default RTL-SDR gain |
|
||||
|
||||
Example: `INTERCEPT_PORT=8080 sudo python3 intercept.py`
|
||||
|
||||
## Command-line Options
|
||||
|
||||
```
|
||||
python3 intercept.py --help
|
||||
|
||||
-p, --port PORT Port to run server on (default: 5050)
|
||||
-H, --host HOST Host to bind to (default: 0.0.0.0)
|
||||
-d, --debug Enable debug mode
|
||||
--check-deps Check dependencies and exit
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
<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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
+38
-1474
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"_comment": "OUI Database - Add new vendor prefixes here. Format: 'AA:BB:CC': 'Manufacturer Name'",
|
||||
"_updated": "2024-12-21",
|
||||
|
||||
"00:25:DB": "Apple", "04:52:F3": "Apple", "0C:3E:9F": "Apple", "10:94:BB": "Apple",
|
||||
"14:99:E2": "Apple", "20:78:F0": "Apple", "28:6A:BA": "Apple", "3C:22:FB": "Apple",
|
||||
"40:98:AD": "Apple", "48:D7:05": "Apple", "4C:57:CA": "Apple", "54:4E:90": "Apple",
|
||||
"5C:97:F3": "Apple", "60:F8:1D": "Apple", "68:DB:CA": "Apple", "70:56:81": "Apple",
|
||||
"78:7B:8A": "Apple", "7C:D1:C3": "Apple", "84:FC:FE": "Apple", "8C:2D:AA": "Apple",
|
||||
"90:B0:ED": "Apple", "98:01:A7": "Apple", "98:D6:BB": "Apple", "A4:D1:D2": "Apple",
|
||||
"AC:BC:32": "Apple", "B0:34:95": "Apple", "B8:C1:11": "Apple", "C8:69:CD": "Apple",
|
||||
"D0:03:4B": "Apple", "DC:A9:04": "Apple", "E0:C7:67": "Apple", "F0:18:98": "Apple",
|
||||
"F4:5C:89": "Apple", "78:4F:43": "Apple", "00:CD:FE": "Apple", "04:4B:ED": "Apple",
|
||||
"04:D3:CF": "Apple", "08:66:98": "Apple", "0C:74:C2": "Apple", "10:DD:B1": "Apple",
|
||||
"14:10:9F": "Apple", "18:EE:69": "Apple", "1C:36:BB": "Apple", "24:A0:74": "Apple",
|
||||
"28:37:37": "Apple", "2C:BE:08": "Apple", "34:08:BC": "Apple", "38:C9:86": "Apple",
|
||||
"3C:06:30": "Apple", "44:D8:84": "Apple", "48:A9:1C": "Apple", "4C:32:75": "Apple",
|
||||
"50:32:37": "Apple", "54:26:96": "Apple", "58:B0:35": "Apple", "5C:F7:E6": "Apple",
|
||||
"64:A3:CB": "Apple", "68:FE:F7": "Apple", "6C:4D:73": "Apple", "70:DE:E2": "Apple",
|
||||
"74:E2:F5": "Apple", "78:67:D7": "Apple", "7C:04:D0": "Apple", "80:E6:50": "Apple",
|
||||
"84:78:8B": "Apple", "88:66:A5": "Apple", "8C:85:90": "Apple", "94:E9:6A": "Apple",
|
||||
"9C:F4:8E": "Apple", "A0:99:9B": "Apple", "A4:83:E7": "Apple", "A8:5C:2C": "Apple",
|
||||
"AC:1F:74": "Apple", "B0:19:C6": "Apple", "B4:F1:DA": "Apple", "BC:52:B7": "Apple",
|
||||
"C0:A5:3E": "Apple", "C4:B3:01": "Apple", "CC:20:E8": "Apple", "D0:C5:F3": "Apple",
|
||||
"D4:61:9D": "Apple", "D8:1C:79": "Apple", "E0:5F:45": "Apple", "E4:C6:3D": "Apple",
|
||||
"F0:B4:79": "Apple", "F4:0F:24": "Apple", "F8:4D:89": "Apple", "FC:D8:48": "Apple",
|
||||
|
||||
"00:1B:66": "Samsung", "00:21:19": "Samsung", "00:26:37": "Samsung", "5C:0A:5B": "Samsung",
|
||||
"8C:71:F8": "Samsung", "C4:73:1E": "Samsung", "38:2C:4A": "Samsung", "00:1E:4C": "Samsung",
|
||||
"00:12:47": "Samsung", "00:15:99": "Samsung", "00:17:D5": "Samsung", "00:1D:F6": "Samsung",
|
||||
"00:21:D1": "Samsung", "00:24:54": "Samsung", "00:26:5D": "Samsung", "08:D4:2B": "Samsung",
|
||||
"10:D5:42": "Samsung", "14:49:E0": "Samsung", "18:3A:2D": "Samsung", "1C:66:AA": "Samsung",
|
||||
"24:4B:81": "Samsung", "28:98:7B": "Samsung", "2C:AE:2B": "Samsung", "30:96:FB": "Samsung",
|
||||
"34:C3:AC": "Samsung", "38:01:95": "Samsung", "3C:5A:37": "Samsung", "40:0E:85": "Samsung",
|
||||
"44:4E:1A": "Samsung", "4C:BC:A5": "Samsung", "50:01:BB": "Samsung", "50:A4:D0": "Samsung",
|
||||
"54:88:0E": "Samsung", "58:C3:8B": "Samsung", "5C:2E:59": "Samsung", "60:D0:A9": "Samsung",
|
||||
"64:B3:10": "Samsung", "68:48:98": "Samsung", "6C:2F:2C": "Samsung", "70:F9:27": "Samsung",
|
||||
"74:45:8A": "Samsung", "78:47:1D": "Samsung", "7C:0B:C6": "Samsung", "84:11:9E": "Samsung",
|
||||
"88:32:9B": "Samsung", "8C:77:12": "Samsung", "90:18:7C": "Samsung", "94:35:0A": "Samsung",
|
||||
"98:52:B1": "Samsung", "9C:02:98": "Samsung", "A0:0B:BA": "Samsung", "A4:7B:85": "Samsung",
|
||||
"A8:06:00": "Samsung", "AC:5F:3E": "Samsung", "B0:72:BF": "Samsung", "B4:79:A7": "Samsung",
|
||||
"BC:44:86": "Samsung", "C0:97:27": "Samsung", "C4:42:02": "Samsung", "CC:07:AB": "Samsung",
|
||||
"D0:22:BE": "Samsung", "D4:87:D8": "Samsung", "D8:90:E8": "Samsung", "E4:7C:F9": "Samsung",
|
||||
"E8:50:8B": "Samsung", "F0:25:B7": "Samsung", "F4:7B:5E": "Samsung", "FC:A1:3E": "Samsung",
|
||||
|
||||
"54:60:09": "Google", "00:1A:11": "Google", "F4:F5:D8": "Google", "94:EB:2C": "Google",
|
||||
"64:B5:C6": "Google", "3C:5A:B4": "Google", "F8:8F:CA": "Google", "20:DF:B9": "Google",
|
||||
"54:27:1E": "Google", "58:CB:52": "Google", "A4:77:33": "Google", "F4:0E:22": "Google",
|
||||
|
||||
"00:13:A9": "Sony", "00:1D:28": "Sony", "00:24:BE": "Sony", "04:5D:4B": "Sony",
|
||||
"08:A9:5A": "Sony", "10:4F:A8": "Sony", "24:21:AB": "Sony", "30:52:CB": "Sony",
|
||||
"40:B8:37": "Sony", "58:48:22": "Sony", "70:9E:29": "Sony", "84:00:D2": "Sony",
|
||||
"AC:9B:0A": "Sony", "B4:52:7D": "Sony", "BC:60:A7": "Sony", "FC:0F:E6": "Sony",
|
||||
|
||||
"00:0C:8A": "Bose", "04:52:C7": "Bose", "08:DF:1F": "Bose", "2C:41:A1": "Bose",
|
||||
"4C:87:5D": "Bose", "60:AB:D2": "Bose", "88:C9:E8": "Bose", "D8:9C:67": "Bose",
|
||||
|
||||
"00:1D:DF": "JBL", "08:AE:D6": "JBL", "20:3C:AE": "JBL", "44:5E:F3": "JBL",
|
||||
"50:C9:71": "JBL", "74:5E:1C": "JBL", "88:C6:26": "JBL", "AC:12:2F": "JBL",
|
||||
|
||||
"00:61:71": "Beats", "48:D6:D5": "Beats", "9C:64:8B": "Beats", "A4:E9:75": "Beats",
|
||||
|
||||
"00:13:17": "Jabra", "1C:48:F9": "Jabra", "50:C2:ED": "Jabra", "70:BF:92": "Jabra",
|
||||
"74:5C:4B": "Jabra", "94:16:25": "Jabra", "D0:81:7A": "Jabra", "E8:EE:CC": "Jabra",
|
||||
|
||||
"00:22:27": "Sennheiser", "B8:AD:3E": "Sennheiser",
|
||||
|
||||
"04:CF:8C": "Xiaomi", "0C:1D:AF": "Xiaomi", "10:2A:B3": "Xiaomi", "18:59:36": "Xiaomi",
|
||||
"20:47:DA": "Xiaomi", "28:6C:07": "Xiaomi", "34:CE:00": "Xiaomi", "38:A4:ED": "Xiaomi",
|
||||
"44:23:7C": "Xiaomi", "50:64:2B": "Xiaomi", "58:44:98": "Xiaomi", "64:09:80": "Xiaomi",
|
||||
"74:23:44": "Xiaomi", "78:02:F8": "Xiaomi", "7C:1C:4E": "Xiaomi", "84:F3:EB": "Xiaomi",
|
||||
"8C:BE:BE": "Xiaomi", "98:FA:E3": "Xiaomi", "A4:77:58": "Xiaomi", "AC:C1:EE": "Xiaomi",
|
||||
"B0:E2:35": "Xiaomi", "C4:0B:CB": "Xiaomi", "C8:47:8C": "Xiaomi", "D4:97:0B": "Xiaomi",
|
||||
"E4:46:DA": "Xiaomi", "F0:B4:29": "Xiaomi", "FC:64:BA": "Xiaomi",
|
||||
|
||||
"00:18:82": "Huawei", "00:1E:10": "Huawei", "00:25:68": "Huawei", "04:B0:E7": "Huawei",
|
||||
"08:63:61": "Huawei", "10:1B:54": "Huawei", "18:DE:D7": "Huawei", "20:A6:80": "Huawei",
|
||||
"28:31:52": "Huawei", "34:12:98": "Huawei", "3C:47:11": "Huawei", "48:00:31": "Huawei",
|
||||
"4C:50:77": "Huawei", "5C:7D:5E": "Huawei", "60:DE:44": "Huawei", "70:72:3C": "Huawei",
|
||||
"78:F5:57": "Huawei", "80:B6:86": "Huawei", "88:53:D4": "Huawei", "94:04:9C": "Huawei",
|
||||
"A4:99:47": "Huawei", "B4:15:13": "Huawei", "BC:76:70": "Huawei", "C8:D1:5E": "Huawei",
|
||||
"DC:D2:FC": "Huawei", "E4:68:A3": "Huawei", "F4:63:1F": "Huawei",
|
||||
|
||||
"64:A2:F9": "OnePlus", "C0:EE:FB": "OnePlus", "94:65:2D": "OnePlus",
|
||||
|
||||
"2C:09:4D": "Fitbit", "C4:D9:87": "Fitbit", "E4:88:6D": "Fitbit",
|
||||
|
||||
"00:1C:D1": "Garmin", "C4:AC:59": "Garmin", "E8:0F:C8": "Garmin",
|
||||
|
||||
"00:50:F2": "Microsoft", "28:18:78": "Microsoft", "60:45:BD": "Microsoft",
|
||||
"7C:1E:52": "Microsoft", "98:5F:D3": "Microsoft", "B4:0E:DE": "Microsoft",
|
||||
|
||||
"00:1B:21": "Intel", "00:1C:C0": "Intel", "00:1E:64": "Intel", "00:21:5C": "Intel",
|
||||
"08:D4:0C": "Intel", "18:1D:EA": "Intel", "34:02:86": "Intel", "40:74:E0": "Intel",
|
||||
"48:51:B7": "Intel", "58:A0:23": "Intel", "64:D4:DA": "Intel", "80:19:34": "Intel",
|
||||
"8C:8D:28": "Intel", "A4:4E:31": "Intel", "B4:6B:FC": "Intel", "C8:D0:83": "Intel",
|
||||
|
||||
"00:03:7F": "Qualcomm", "00:24:E4": "Qualcomm", "04:F0:21": "Qualcomm",
|
||||
"1C:4B:D6": "Qualcomm", "88:71:B1": "Qualcomm", "A0:65:18": "Qualcomm",
|
||||
|
||||
"00:10:18": "Broadcom", "00:1A:2B": "Broadcom", "20:10:7A": "Broadcom",
|
||||
|
||||
"00:0A:EB": "Realtek", "00:E0:4C": "Realtek", "48:02:2A": "Realtek",
|
||||
"52:54:00": "Realtek", "80:EA:96": "Realtek",
|
||||
|
||||
"00:1F:20": "Logitech", "34:88:5D": "Logitech", "6C:B7:49": "Logitech",
|
||||
|
||||
"00:09:2D": "Lenovo", "28:D2:44": "Lenovo", "54:EE:75": "Lenovo", "98:FA:9B": "Lenovo",
|
||||
|
||||
"00:14:22": "Dell", "00:1A:A0": "Dell", "18:DB:F2": "Dell", "34:17:EB": "Dell",
|
||||
"78:2B:CB": "Dell", "A4:BA:DB": "Dell", "E4:B9:7A": "Dell",
|
||||
|
||||
"00:0F:61": "HP", "00:14:C2": "HP", "10:1F:74": "HP", "28:80:23": "HP",
|
||||
"38:63:BB": "HP", "5C:B9:01": "HP", "80:CE:62": "HP", "A0:D3:C1": "HP",
|
||||
|
||||
"F8:E4:E3": "Tile", "C4:E7:BE": "Tile", "DC:54:D7": "Tile", "E4:B0:21": "Tile",
|
||||
|
||||
"B8:27:EB": "Raspberry Pi", "DC:A6:32": "Raspberry Pi", "E4:5F:01": "Raspberry Pi",
|
||||
|
||||
"00:FC:8B": "Amazon", "10:CE:A9": "Amazon", "34:D2:70": "Amazon", "40:B4:CD": "Amazon",
|
||||
"44:65:0D": "Amazon", "68:54:FD": "Amazon", "74:C2:46": "Amazon", "84:D6:D0": "Amazon",
|
||||
"A0:02:DC": "Amazon", "AC:63:BE": "Amazon", "B4:7C:9C": "Amazon", "FC:65:DE": "Amazon",
|
||||
|
||||
"00:01:00": "Skullcandy", "88:E6:03": "Skullcandy",
|
||||
|
||||
"00:21:3E": "Bang & Olufsen", "78:C5:E5": "Bang & Olufsen",
|
||||
|
||||
"A0:E9:DB": "Audio-Technica", "EC:81:93": "Audio-Technica",
|
||||
|
||||
"B0:B4:48": "Plantronics", "E8:FC:AF": "Plantronics",
|
||||
|
||||
"AC:89:95": "Anker", "E8:AB:FA": "Anker",
|
||||
|
||||
"00:00:0A": "Omron", "00:1A:7D": "Cyber-Blue", "00:1E:3D": "Alps Electric",
|
||||
"00:0B:57": "Silicon Wave", "00:02:72": "CC&C"
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
[project]
|
||||
name = "intercept"
|
||||
version = "1.2.0"
|
||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "Intercept Contributors"}
|
||||
]
|
||||
keywords = ["sdr", "rtl-sdr", "signals", "pager", "adsb", "wifi", "bluetooth"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Flask",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Operating System :: MacOS",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Communications",
|
||||
"Topic :: System :: Networking :: Monitoring",
|
||||
]
|
||||
dependencies = [
|
||||
"flask>=2.0.0",
|
||||
"skyfield>=1.45",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/smittix/intercept"
|
||||
Repository = "https://github.com/smittix/intercept"
|
||||
Issues = "https://github.com/smittix/intercept/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.1.0",
|
||||
"black>=23.0.0",
|
||||
"mypy>=1.0.0",
|
||||
"types-flask>=1.1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
intercept = "intercept:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["routes*", "utils*", "data*"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py39"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
"SIM", # flake8-simplify
|
||||
]
|
||||
ignore = [
|
||||
"E501", # line too long (handled by formatter)
|
||||
"B008", # do not perform function calls in argument defaults
|
||||
"B905", # zip without explicit strict
|
||||
"SIM108", # use ternary operator instead of if-else
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app", "config", "routes", "utils", "data"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
target-version = ["py39", "py310", "py311", "py312"]
|
||||
include = '\.pyi?$'
|
||||
exclude = '''
|
||||
/(
|
||||
\.git
|
||||
| \.mypy_cache
|
||||
| \.pytest_cache
|
||||
| \.venv
|
||||
| venv
|
||||
| __pycache__
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.9"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
ignore_missing_imports = true
|
||||
exclude = [
|
||||
"tests/",
|
||||
"venv/",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --tb=short"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app", "routes", "utils", "data"]
|
||||
omit = ["tests/*", "*/__pycache__/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if __name__ == .__main__.:",
|
||||
"raise NotImplementedError",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Development dependencies
|
||||
-r requirements.txt
|
||||
|
||||
# Testing
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
|
||||
# Code quality
|
||||
ruff>=0.1.0
|
||||
black>=23.0.0
|
||||
mypy>=1.0.0
|
||||
|
||||
# Type stubs
|
||||
types-flask>=1.1.0
|
||||
@@ -1 +1,15 @@
|
||||
# Core dependencies
|
||||
flask>=2.0.0
|
||||
|
||||
# Satellite tracking (optional - only needed for satellite features)
|
||||
skyfield>=1.45
|
||||
|
||||
# GPS dongle support (optional - only needed for USB GPS receivers)
|
||||
pyserial>=3.5
|
||||
|
||||
# Development dependencies (install with: pip install -r requirements-dev.txt)
|
||||
# pytest>=7.0.0
|
||||
# pytest-cov>=4.0.0
|
||||
# ruff>=0.1.0
|
||||
# black>=23.0.0
|
||||
# mypy>=1.0.0
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Routes package - registers all blueprints with the Flask app
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all route blueprints with the Flask app."""
|
||||
from .pager import pager_bp
|
||||
from .sensor import sensor_bp
|
||||
from .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .adsb import adsb_bp
|
||||
from .satellite import satellite_bp
|
||||
from .gps import gps_bp
|
||||
|
||||
app.register_blueprint(pager_bp)
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(satellite_bp)
|
||||
app.register_blueprint(gps_bp)
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
"""ADS-B aircraft tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response, render_template
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import adsb_logger as logger
|
||||
from utils.validation import (
|
||||
validate_device_index, validate_gain,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
adsb_bp = Blueprint('adsb', __name__, url_prefix='/adsb')
|
||||
|
||||
# Track if using service
|
||||
adsb_using_service = False
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
adsb_last_message_time = None
|
||||
|
||||
# Common installation paths for dump1090 (when not in PATH)
|
||||
DUMP1090_PATHS = [
|
||||
# Homebrew on Apple Silicon (M1/M2/M3)
|
||||
'/opt/homebrew/bin/dump1090',
|
||||
'/opt/homebrew/bin/dump1090-fa',
|
||||
'/opt/homebrew/bin/dump1090-mutability',
|
||||
# Homebrew on Intel Mac
|
||||
'/usr/local/bin/dump1090',
|
||||
'/usr/local/bin/dump1090-fa',
|
||||
'/usr/local/bin/dump1090-mutability',
|
||||
# Linux system paths
|
||||
'/usr/bin/dump1090',
|
||||
'/usr/bin/dump1090-fa',
|
||||
'/usr/bin/dump1090-mutability',
|
||||
]
|
||||
|
||||
|
||||
def find_dump1090():
|
||||
"""Find dump1090 binary, checking PATH and common locations."""
|
||||
# First try PATH
|
||||
for name in ['dump1090', 'dump1090-mutability', 'dump1090-fa']:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return path
|
||||
# Check common installation paths directly
|
||||
for path in DUMP1090_PATHS:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def check_dump1090_service():
|
||||
"""Check if dump1090 SBS port (30003) is available."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2)
|
||||
result = sock.connect_ex(('localhost', 30003))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return 'localhost:30003'
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def parse_sbs_stream(service_addr):
|
||||
"""Parse SBS format data from dump1090 port 30003."""
|
||||
global adsb_using_service, adsb_connected, adsb_messages_received, adsb_last_message_time
|
||||
|
||||
host, port = service_addr.split(':')
|
||||
port = int(port)
|
||||
|
||||
logger.info(f"SBS stream parser started, connecting to {host}:{port}")
|
||||
adsb_connected = False
|
||||
adsb_messages_received = 0
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5)
|
||||
sock.connect((host, port))
|
||||
adsb_connected = True
|
||||
logger.info("Connected to SBS stream")
|
||||
|
||||
buffer = ""
|
||||
last_update = time.time()
|
||||
pending_updates = set()
|
||||
|
||||
while adsb_using_service:
|
||||
try:
|
||||
data = sock.recv(4096).decode('utf-8', errors='ignore')
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(',')
|
||||
if len(parts) < 11 or parts[0] != 'MSG':
|
||||
continue
|
||||
|
||||
msg_type = parts[1]
|
||||
icao = parts[4].upper()
|
||||
if not icao:
|
||||
continue
|
||||
|
||||
aircraft = app_module.adsb_aircraft.get(icao, {'icao': icao})
|
||||
|
||||
if msg_type == '1' and len(parts) > 10:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
aircraft['callsign'] = callsign
|
||||
|
||||
elif msg_type == '3' and len(parts) > 15:
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[14] and parts[15]:
|
||||
try:
|
||||
aircraft['lat'] = float(parts[14])
|
||||
aircraft['lon'] = float(parts[15])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '4' and len(parts) > 13:
|
||||
if parts[12]:
|
||||
try:
|
||||
aircraft['speed'] = int(float(parts[12]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if parts[13]:
|
||||
try:
|
||||
aircraft['heading'] = int(float(parts[13]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '5' and len(parts) > 11:
|
||||
if parts[10]:
|
||||
callsign = parts[10].strip()
|
||||
if callsign:
|
||||
aircraft['callsign'] = callsign
|
||||
if parts[11]:
|
||||
try:
|
||||
aircraft['altitude'] = int(float(parts[11]))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
elif msg_type == '6' and len(parts) > 17:
|
||||
if parts[17]:
|
||||
aircraft['squawk'] = parts[17]
|
||||
|
||||
app_module.adsb_aircraft[icao] = aircraft
|
||||
pending_updates.add(icao)
|
||||
adsb_messages_received += 1
|
||||
adsb_last_message_time = time.time()
|
||||
|
||||
now = time.time()
|
||||
if now - last_update >= 1.0:
|
||||
for update_icao in pending_updates:
|
||||
if update_icao in app_module.adsb_aircraft:
|
||||
app_module.adsb_queue.put({
|
||||
'type': 'aircraft',
|
||||
**app_module.adsb_aircraft[update_icao]
|
||||
})
|
||||
pending_updates.clear()
|
||||
last_update = now
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
sock.close()
|
||||
adsb_connected = False
|
||||
except Exception as e:
|
||||
adsb_connected = False
|
||||
logger.warning(f"SBS connection error: {e}, reconnecting...")
|
||||
time.sleep(2)
|
||||
|
||||
adsb_connected = False
|
||||
logger.info("SBS stream parser stopped")
|
||||
|
||||
|
||||
@adsb_bp.route('/tools')
|
||||
def check_adsb_tools():
|
||||
"""Check for ADS-B decoding tools."""
|
||||
return jsonify({
|
||||
'dump1090': find_dump1090() is not None,
|
||||
'rtl_adsb': shutil.which('rtl_adsb') is not None
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/status')
|
||||
def adsb_status():
|
||||
"""Get ADS-B tracking status for debugging."""
|
||||
return jsonify({
|
||||
'tracking_active': adsb_using_service,
|
||||
'connected_to_sbs': adsb_connected,
|
||||
'messages_received': adsb_messages_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(),
|
||||
'port_30003_open': check_dump1090_service() is not None
|
||||
})
|
||||
|
||||
|
||||
@adsb_bp.route('/start', methods=['POST'])
|
||||
def start_adsb():
|
||||
"""Start ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if adsb_using_service:
|
||||
return jsonify({'status': 'already_running', 'message': 'ADS-B tracking already active'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
gain = int(validate_gain(data.get('gain', '40')))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Check for remote SBS connection (e.g., remote dump1090)
|
||||
remote_sbs_host = data.get('remote_sbs_host')
|
||||
remote_sbs_port = data.get('remote_sbs_port', 30003)
|
||||
|
||||
if remote_sbs_host:
|
||||
# Validate and connect to remote dump1090 SBS output
|
||||
try:
|
||||
remote_sbs_host = validate_rtl_tcp_host(remote_sbs_host)
|
||||
remote_sbs_port = validate_rtl_tcp_port(remote_sbs_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
remote_addr = f"{remote_sbs_host}:{remote_sbs_port}"
|
||||
logger.info(f"Connecting to remote dump1090 SBS at {remote_addr}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(remote_addr,), daemon=True)
|
||||
thread.start()
|
||||
return jsonify({'status': 'started', 'message': f'Connected to remote dump1090 at {remote_addr}'})
|
||||
|
||||
# Check if dump1090 is already running externally (e.g., user started it manually)
|
||||
existing_service = check_dump1090_service()
|
||||
if existing_service:
|
||||
logger.info(f"Found existing dump1090 service at {existing_service}")
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=(existing_service,), daemon=True)
|
||||
thread.start()
|
||||
return jsonify({'status': 'started', 'message': 'Connected to existing dump1090 service'})
|
||||
|
||||
# Get SDR type from request
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# For RTL-SDR, use dump1090. For other hardware, need readsb with SoapySDR
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
dump1090_path = find_dump1090()
|
||||
if not dump1090_path:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 not found. Install dump1090/dump1090-fa or ensure it is in /usr/local/bin/'})
|
||||
else:
|
||||
# For LimeSDR/HackRF, check for readsb (dump1090 with SoapySDR support)
|
||||
dump1090_path = shutil.which('readsb') or find_dump1090()
|
||||
if not dump1090_path:
|
||||
return jsonify({'status': 'error', 'message': f'readsb or dump1090 not found for {sdr_type.value}. Install readsb with SoapySDR support.'})
|
||||
|
||||
# Kill any stale app-started process
|
||||
if app_module.adsb_process:
|
||||
try:
|
||||
app_module.adsb_process.terminate()
|
||||
app_module.adsb_process.wait(timeout=2)
|
||||
except Exception:
|
||||
pass
|
||||
app_module.adsb_process = None
|
||||
|
||||
# Create device object and build command via abstraction layer
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
builder = SDRFactory.get_builder(sdr_type)
|
||||
|
||||
# Build ADS-B decoder command
|
||||
cmd = builder.build_adsb_command(
|
||||
device=sdr_device,
|
||||
gain=float(gain)
|
||||
)
|
||||
|
||||
# For RTL-SDR, ensure we use the found dump1090 path
|
||||
if sdr_type == SDRType.RTL_SDR:
|
||||
cmd[0] = dump1090_path
|
||||
|
||||
try:
|
||||
app_module.adsb_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
time.sleep(3)
|
||||
|
||||
if app_module.adsb_process.poll() is not None:
|
||||
return jsonify({'status': 'error', 'message': 'dump1090 failed to start. Check RTL-SDR device permissions or if another process is using it.'})
|
||||
|
||||
adsb_using_service = True
|
||||
thread = threading.Thread(target=parse_sbs_stream, args=('localhost:30003',), daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({'status': 'started', 'message': 'ADS-B tracking started'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@adsb_bp.route('/stop', methods=['POST'])
|
||||
def stop_adsb():
|
||||
"""Stop ADS-B tracking."""
|
||||
global adsb_using_service
|
||||
|
||||
with app_module.adsb_lock:
|
||||
if app_module.adsb_process:
|
||||
app_module.adsb_process.terminate()
|
||||
try:
|
||||
app_module.adsb_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.adsb_process.kill()
|
||||
app_module.adsb_process = None
|
||||
adsb_using_service = False
|
||||
|
||||
app_module.adsb_aircraft = {}
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@adsb_bp.route('/stream')
|
||||
def stream_adsb():
|
||||
"""SSE stream for ADS-B aircraft."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.adsb_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
@adsb_bp.route('/dashboard')
|
||||
def adsb_dashboard():
|
||||
"""Popout ADS-B dashboard."""
|
||||
return render_template('adsb_dashboard.html')
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Bluetooth reconnaissance routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import pty
|
||||
import queue
|
||||
import re
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
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.logging import bluetooth_logger as logger
|
||||
from utils.sse import format_sse
|
||||
from data.oui import OUI_DATABASE, load_oui_database, get_manufacturer
|
||||
from data.patterns import AIRTAG_PREFIXES, TILE_PREFIXES, SAMSUNG_TRACKER
|
||||
|
||||
bluetooth_bp = Blueprint('bluetooth', __name__, url_prefix='/bt')
|
||||
|
||||
|
||||
def classify_bt_device(name, device_class, services, manufacturer=None):
|
||||
"""Classify Bluetooth device type based on available info."""
|
||||
name_lower = (name or '').lower()
|
||||
mfr_lower = (manufacturer or '').lower()
|
||||
|
||||
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'
|
||||
]
|
||||
if any(x in name_lower for x in audio_patterns):
|
||||
return 'audio'
|
||||
|
||||
wearable_patterns = [
|
||||
'watch', 'band', 'fitbit', 'garmin', 'mi band', 'miband', 'amazfit',
|
||||
'galaxy watch', 'gear', 'versa', 'sense', 'charge', 'inspire'
|
||||
]
|
||||
if any(x in name_lower for x in wearable_patterns):
|
||||
return 'wearable'
|
||||
|
||||
phone_patterns = [
|
||||
'iphone', 'galaxy', 'pixel', 'phone', 'android', 'oneplus', 'huawei', 'xiaomi'
|
||||
]
|
||||
if any(x in name_lower for x in phone_patterns):
|
||||
return 'phone'
|
||||
|
||||
tracker_patterns = ['airtag', 'tile', 'smarttag', 'chipolo', 'find my']
|
||||
if any(x in name_lower for x in tracker_patterns):
|
||||
return 'tracker'
|
||||
|
||||
input_patterns = ['keyboard', 'mouse', 'controller', 'gamepad', 'remote']
|
||||
if any(x in name_lower for x in input_patterns):
|
||||
return 'input'
|
||||
|
||||
if mfr_lower in ['bose', 'jbl', 'sony', 'sennheiser', 'jabra', 'beats']:
|
||||
return 'audio'
|
||||
if mfr_lower in ['fitbit', 'garmin']:
|
||||
return 'wearable'
|
||||
if mfr_lower == 'tile':
|
||||
return 'tracker'
|
||||
|
||||
if device_class:
|
||||
major_class = (device_class >> 8) & 0x1F
|
||||
if major_class == 1:
|
||||
return 'computer'
|
||||
elif major_class == 2:
|
||||
return 'phone'
|
||||
elif major_class == 4:
|
||||
return 'audio'
|
||||
elif major_class == 5:
|
||||
return 'input'
|
||||
elif major_class == 7:
|
||||
return 'wearable'
|
||||
|
||||
return 'other'
|
||||
|
||||
|
||||
def detect_tracker(mac, name, manufacturer_data=None):
|
||||
"""Detect if device is a known tracker."""
|
||||
mac_prefix = mac[:5].upper()
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in AIRTAG_PREFIXES):
|
||||
if manufacturer_data and b'\\x4c\\x00' in manufacturer_data:
|
||||
return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in TILE_PREFIXES):
|
||||
return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
|
||||
|
||||
if any(mac_prefix.startswith(p) for p in SAMSUNG_TRACKER):
|
||||
return {'type': 'smarttag', 'name': 'Samsung SmartTag', 'risk': 'medium'}
|
||||
|
||||
name_lower = (name or '').lower()
|
||||
if 'airtag' in name_lower:
|
||||
return {'type': 'airtag', 'name': 'Apple AirTag', 'risk': 'high'}
|
||||
if 'tile' in name_lower:
|
||||
return {'type': 'tile', 'name': 'Tile Tracker', 'risk': 'medium'}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def detect_bt_interfaces():
|
||||
"""Detect available Bluetooth interfaces."""
|
||||
interfaces = []
|
||||
|
||||
if platform.system() == 'Linux':
|
||||
try:
|
||||
result = subprocess.run(['hciconfig'], capture_output=True, text=True, timeout=5)
|
||||
blocks = re.split(r'(?=^hci\d+:)', result.stdout, flags=re.MULTILINE)
|
||||
for block in blocks:
|
||||
if block.strip():
|
||||
first_line = block.split('\n')[0]
|
||||
match = re.match(r'(hci\d+):', first_line)
|
||||
if match:
|
||||
iface_name = match.group(1)
|
||||
is_up = 'UP RUNNING' in block or '\tUP ' in block
|
||||
interfaces.append({
|
||||
'name': iface_name,
|
||||
'type': 'hci',
|
||||
'status': 'up' if is_up else 'down'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elif platform.system() == 'Darwin':
|
||||
interfaces.append({
|
||||
'name': 'default',
|
||||
'type': 'macos',
|
||||
'status': 'available'
|
||||
})
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def stream_bt_scan(process, scan_mode):
|
||||
"""Stream Bluetooth scan output to queue."""
|
||||
try:
|
||||
app_module.bt_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
if scan_mode == 'hcitool':
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line or 'LE Scan' in line:
|
||||
continue
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) >= 1 and ':' in parts[0]:
|
||||
mac = parts[0]
|
||||
name = ' '.join(parts[1:]) if len(parts) > 1 else ''
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
tracker = detect_tracker(mac, name)
|
||||
if tracker:
|
||||
device['tracker'] = tracker
|
||||
|
||||
is_new = mac not in app_module.bt_devices
|
||||
app_module.bt_devices[mac] = device
|
||||
|
||||
app_module.bt_queue.put({
|
||||
**device,
|
||||
'type': 'device',
|
||||
'device_type': device.get('type', 'other'),
|
||||
'action': 'new' if is_new else 'update',
|
||||
})
|
||||
|
||||
elif scan_mode == 'bluetoothctl':
|
||||
master_fd = getattr(process, '_master_fd', None)
|
||||
if not master_fd:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': 'bluetoothctl pty not available'})
|
||||
return
|
||||
|
||||
buffer = ''
|
||||
while process.poll() is None:
|
||||
readable, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
if readable:
|
||||
try:
|
||||
data = os.read(master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
line = re.sub(r'\x1b\[[0-9;]*m', '', line)
|
||||
line = re.sub(r'\r', '', line)
|
||||
|
||||
if 'Device' in line:
|
||||
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()
|
||||
|
||||
manufacturer = get_manufacturer(mac)
|
||||
device = {
|
||||
'mac': mac,
|
||||
'name': name or '[Unknown]',
|
||||
'manufacturer': manufacturer,
|
||||
'type': classify_bt_device(name, None, None, manufacturer),
|
||||
'rssi': None,
|
||||
'last_seen': time.time()
|
||||
}
|
||||
|
||||
tracker = detect_tracker(mac, name)
|
||||
if tracker:
|
||||
device['tracker'] = tracker
|
||||
|
||||
is_new = mac not in app_module.bt_devices
|
||||
app_module.bt_devices[mac] = device
|
||||
|
||||
app_module.bt_queue.put({
|
||||
**device,
|
||||
'type': 'device',
|
||||
'device_type': device.get('type', 'other'),
|
||||
'action': 'new' if is_new else 'update',
|
||||
})
|
||||
except OSError:
|
||||
break
|
||||
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
app_module.bt_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.bt_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.bt_lock:
|
||||
app_module.bt_process = None
|
||||
|
||||
|
||||
@bluetooth_bp.route('/reload-oui', methods=['POST'])
|
||||
def reload_oui_database_route():
|
||||
"""Reload OUI database from external file."""
|
||||
new_db = load_oui_database()
|
||||
if new_db:
|
||||
OUI_DATABASE.clear()
|
||||
OUI_DATABASE.update(new_db)
|
||||
return jsonify({'status': 'success', 'entries': len(OUI_DATABASE)})
|
||||
return jsonify({'status': 'error', 'message': 'Could not load oui_database.json'})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/interfaces')
|
||||
def get_bt_interfaces():
|
||||
"""Get available Bluetooth interfaces and tools."""
|
||||
interfaces = detect_bt_interfaces()
|
||||
tools = {
|
||||
'hcitool': check_tool('hcitool'),
|
||||
'bluetoothctl': check_tool('bluetoothctl'),
|
||||
'hciconfig': check_tool('hciconfig'),
|
||||
'l2ping': check_tool('l2ping'),
|
||||
'sdptool': check_tool('sdptool')
|
||||
}
|
||||
return jsonify({
|
||||
'interfaces': interfaces,
|
||||
'tools': tools,
|
||||
'current_interface': app_module.bt_interface
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/start', methods=['POST'])
|
||||
def start_bt_scan():
|
||||
"""Start Bluetooth scanning."""
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
if app_module.bt_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running'})
|
||||
else:
|
||||
app_module.bt_process = None
|
||||
|
||||
data = request.json
|
||||
scan_mode = data.get('mode', 'hcitool')
|
||||
interface = data.get('interface', 'hci0')
|
||||
scan_ble = data.get('scan_ble', True)
|
||||
|
||||
app_module.bt_interface = interface
|
||||
app_module.bt_devices = {}
|
||||
|
||||
while not app_module.bt_queue.empty():
|
||||
try:
|
||||
app_module.bt_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
try:
|
||||
if scan_mode == 'hcitool':
|
||||
if scan_ble:
|
||||
cmd = ['hcitool', '-i', interface, 'lescan', '--duplicates']
|
||||
else:
|
||||
cmd = ['hcitool', '-i', interface, 'scan']
|
||||
|
||||
app_module.bt_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
elif scan_mode == 'bluetoothctl':
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
app_module.bt_process = subprocess.Popen(
|
||||
['bluetoothctl'],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
os.close(slave_fd)
|
||||
app_module.bt_process._master_fd = master_fd
|
||||
|
||||
time.sleep(0.5)
|
||||
os.write(master_fd, b'power on\n')
|
||||
time.sleep(0.3)
|
||||
os.write(master_fd, b'scan on\n')
|
||||
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': f'Unknown scan mode: {scan_mode}'})
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if app_module.bt_process.poll() is not None:
|
||||
stderr_output = app_module.bt_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
app_module.bt_process = None
|
||||
return jsonify({'status': 'error', 'message': stderr_output or 'Process failed to start'})
|
||||
|
||||
thread = threading.Thread(target=stream_bt_scan, args=(app_module.bt_process, scan_mode))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.bt_queue.put({'type': 'info', 'text': f'Started {scan_mode} scan on {interface}'})
|
||||
return jsonify({'status': 'started', 'mode': scan_mode, 'interface': interface})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_bt_scan():
|
||||
"""Stop Bluetooth scanning."""
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
app_module.bt_process.terminate()
|
||||
try:
|
||||
app_module.bt_process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.bt_process.kill()
|
||||
app_module.bt_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/reset', methods=['POST'])
|
||||
def reset_bt_adapter():
|
||||
"""Reset Bluetooth adapter."""
|
||||
data = request.json
|
||||
interface = data.get('interface', 'hci0')
|
||||
|
||||
with app_module.bt_lock:
|
||||
if app_module.bt_process:
|
||||
try:
|
||||
app_module.bt_process.terminate()
|
||||
app_module.bt_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.bt_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
app_module.bt_process = None
|
||||
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'hcitool'], capture_output=True, timeout=2)
|
||||
subprocess.run(['pkill', '-f', 'bluetoothctl'], capture_output=True, timeout=2)
|
||||
time.sleep(0.5)
|
||||
|
||||
subprocess.run(['rfkill', 'unblock', 'bluetooth'], capture_output=True, timeout=5)
|
||||
subprocess.run(['hciconfig', interface, 'down'], capture_output=True, timeout=5)
|
||||
time.sleep(1)
|
||||
subprocess.run(['hciconfig', interface, 'up'], capture_output=True, timeout=5)
|
||||
time.sleep(0.5)
|
||||
|
||||
result = subprocess.run(['hciconfig', interface], capture_output=True, text=True, timeout=5)
|
||||
is_up = 'UP RUNNING' in result.stdout
|
||||
|
||||
return jsonify({
|
||||
'status': 'success' if is_up else 'warning',
|
||||
'message': f'Adapter {interface} reset' if is_up else f'Reset attempted but adapter may still be down',
|
||||
'is_up': is_up
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/enum', methods=['POST'])
|
||||
def enum_bt_services():
|
||||
"""Enumerate services on a Bluetooth device."""
|
||||
data = request.json
|
||||
target_mac = data.get('mac')
|
||||
|
||||
if not target_mac:
|
||||
return jsonify({'status': 'error', 'message': 'Target MAC required'})
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['sdptool', 'browse', target_mac],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
|
||||
services = []
|
||||
current_service = {}
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('Service Name:'):
|
||||
if current_service:
|
||||
services.append(current_service)
|
||||
current_service = {'name': line.split(':', 1)[1].strip()}
|
||||
elif line.startswith('Service Description:'):
|
||||
current_service['description'] = line.split(':', 1)[1].strip()
|
||||
|
||||
if current_service:
|
||||
services.append(current_service)
|
||||
|
||||
app_module.bt_services[target_mac] = services
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'mac': target_mac,
|
||||
'services': services
|
||||
})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'error', 'message': 'Connection timed out'})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'sdptool not found'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/devices')
|
||||
def get_bt_devices():
|
||||
"""Get current list of discovered Bluetooth devices."""
|
||||
return jsonify({
|
||||
'devices': list(app_module.bt_devices.values()),
|
||||
'beacons': list(app_module.bt_beacons.values()),
|
||||
'interface': app_module.bt_interface
|
||||
})
|
||||
|
||||
|
||||
@bluetooth_bp.route('/stream')
|
||||
def stream_bt():
|
||||
"""SSE stream for Bluetooth events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.bt_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
"""GPS dongle routes for USB GPS device support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
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')
|
||||
|
||||
gps_bp = Blueprint('gps', __name__, url_prefix='/gps')
|
||||
|
||||
# Queue for SSE position updates
|
||||
_gps_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||
|
||||
|
||||
def _position_callback(position: GPSPosition) -> None:
|
||||
"""Callback to queue position updates for SSE stream."""
|
||||
try:
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
except queue.Full:
|
||||
# Discard oldest if queue is full
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
_gps_queue.put_nowait(position.to_dict())
|
||||
except queue.Empty:
|
||||
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('/gpsd/check')
|
||||
def check_gpsd_available():
|
||||
"""Check if gpsd is reachable."""
|
||||
import socket
|
||||
|
||||
host = request.args.get('host', 'localhost')
|
||||
port = int(request.args.get('port', 2947))
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.connect((host, port))
|
||||
sock.close()
|
||||
return jsonify({
|
||||
'available': True,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'gpsd reachable at {host}:{port}'
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'available': False,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/devices')
|
||||
def list_gps_devices():
|
||||
"""List available GPS serial devices."""
|
||||
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():
|
||||
try:
|
||||
_gps_queue.get_nowait()
|
||||
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
|
||||
success = start_gpsd(host, port, callback=_position_callback)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'host': host,
|
||||
'port': port,
|
||||
'source': 'gpsd'
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
error = reader.error if reader else 'Unknown error'
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': f'Failed to connect to gpsd: {error}'
|
||||
}), 500
|
||||
|
||||
|
||||
@gps_bp.route('/stop', methods=['POST'])
|
||||
def stop_gps_reader():
|
||||
"""Stop GPS reader."""
|
||||
reader = get_gps_reader()
|
||||
if reader:
|
||||
reader.remove_callback(_position_callback)
|
||||
|
||||
stop_gps()
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@gps_bp.route('/status')
|
||||
def get_gps_status():
|
||||
"""Get current GPS reader status."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'running': False,
|
||||
'device': None,
|
||||
'position': None,
|
||||
'error': None,
|
||||
'message': 'GPS reader not started'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'device': reader.device_path,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/position')
|
||||
def get_position():
|
||||
"""Get current GPS position."""
|
||||
position = get_current_position()
|
||||
|
||||
if position:
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'position': position.to_dict()
|
||||
})
|
||||
else:
|
||||
reader = get_gps_reader()
|
||||
if not reader or not reader.is_running:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'GPS reader not running'
|
||||
}), 400
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'waiting',
|
||||
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky'
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/debug')
|
||||
def debug_gps():
|
||||
"""Debug endpoint showing GPS reader state."""
|
||||
reader = get_gps_reader()
|
||||
|
||||
if not reader:
|
||||
return jsonify({
|
||||
'reader': None,
|
||||
'message': 'No GPS reader initialized'
|
||||
})
|
||||
|
||||
position = reader.position
|
||||
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||
return jsonify({
|
||||
'running': reader.is_running,
|
||||
'source': source,
|
||||
'device': reader.device_path,
|
||||
'baudrate': reader.baudrate,
|
||||
'has_position': position is not None,
|
||||
'position': position.to_dict() if position else None,
|
||||
'last_update': reader.last_update.isoformat() if reader.last_update else None,
|
||||
'error': reader.error,
|
||||
'callbacks_registered': len(reader._callbacks),
|
||||
})
|
||||
|
||||
|
||||
@gps_bp.route('/stream')
|
||||
def stream_gps():
|
||||
"""SSE stream of GPS position updates."""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
position = _gps_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse({'type': 'position', **position})
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
"""Pager decoding routes (POCSAG/FLEX)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import pty
|
||||
import queue
|
||||
import select
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import pager_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType, SDRValidationError
|
||||
|
||||
pager_bp = Blueprint('pager', __name__)
|
||||
|
||||
|
||||
def parse_multimon_output(line: str) -> dict[str, str] | None:
|
||||
"""Parse multimon-ng output line."""
|
||||
line = line.strip()
|
||||
|
||||
# POCSAG parsing - with message content
|
||||
pocsag_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s+(Alpha|Numeric):\s*(.*)',
|
||||
line
|
||||
)
|
||||
if pocsag_match:
|
||||
return {
|
||||
'protocol': pocsag_match.group(1),
|
||||
'address': pocsag_match.group(2),
|
||||
'function': pocsag_match.group(3),
|
||||
'msg_type': pocsag_match.group(4),
|
||||
'message': pocsag_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# POCSAG parsing - address only (no message content)
|
||||
pocsag_addr_match = re.match(
|
||||
r'(POCSAG\d+):\s*Address:\s*(\d+)\s+Function:\s*(\d+)\s*$',
|
||||
line
|
||||
)
|
||||
if pocsag_addr_match:
|
||||
return {
|
||||
'protocol': pocsag_addr_match.group(1),
|
||||
'address': pocsag_addr_match.group(2),
|
||||
'function': pocsag_addr_match.group(3),
|
||||
'msg_type': 'Tone',
|
||||
'message': '[Tone Only]'
|
||||
}
|
||||
|
||||
# FLEX parsing (standard format)
|
||||
flex_match = re.match(
|
||||
r'FLEX[:\|]\s*[\d\-]+[\s\|]+[\d:]+[\s\|]+([\d/A-Z]+)[\s\|]+([\d.]+)[\s\|]+\[?(\d+)\]?[\s\|]+(\w+)[\s\|]+(.*)',
|
||||
line
|
||||
)
|
||||
if flex_match:
|
||||
return {
|
||||
'protocol': 'FLEX',
|
||||
'address': flex_match.group(3),
|
||||
'function': flex_match.group(1),
|
||||
'msg_type': flex_match.group(4),
|
||||
'message': flex_match.group(5).strip() or '[No Message]'
|
||||
}
|
||||
|
||||
# Simple FLEX format
|
||||
flex_simple = re.match(r'FLEX:\s*(.+)', line)
|
||||
if flex_simple:
|
||||
return {
|
||||
'protocol': 'FLEX',
|
||||
'address': 'Unknown',
|
||||
'function': '',
|
||||
'msg_type': 'Unknown',
|
||||
'message': flex_simple.group(1).strip()
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def log_message(msg: dict[str, Any]) -> None:
|
||||
"""Log a message to file if logging is enabled."""
|
||||
if not app_module.logging_enabled:
|
||||
return
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {msg.get('protocol', 'UNKNOWN')} | {msg.get('address', '')} | {msg.get('message', '')}\n")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log message: {e}")
|
||||
|
||||
|
||||
def stream_decoder(master_fd: int, process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream decoder output to queue using PTY for unbuffered output."""
|
||||
try:
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
ready, _, _ = select.select([master_fd], [], [], 1.0)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
if ready:
|
||||
try:
|
||||
data = os.read(master_fd, 1024)
|
||||
if not data:
|
||||
break
|
||||
buffer += data.decode('utf-8', errors='replace')
|
||||
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parsed = parse_multimon_output(line)
|
||||
if parsed:
|
||||
parsed['timestamp'] = datetime.now().strftime('%H:%M:%S')
|
||||
app_module.output_queue.put({'type': 'message', **parsed})
|
||||
log_message(parsed)
|
||||
else:
|
||||
app_module.output_queue.put({'type': 'raw', 'text': line})
|
||||
except OSError:
|
||||
break
|
||||
|
||||
if process.poll() is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
app_module.output_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
process.wait()
|
||||
app_module.output_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.process_lock:
|
||||
app_module.current_process = None
|
||||
|
||||
|
||||
@pager_bp.route('/start', methods=['POST'])
|
||||
def start_decoding() -> Response:
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
return jsonify({'status': 'error', 'message': 'Already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '929.6125'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
squelch = data.get('squelch', '0')
|
||||
try:
|
||||
squelch = int(squelch)
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise ValueError("Squelch must be between 0 and 1000")
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid squelch value'}), 400
|
||||
|
||||
# Validate protocols
|
||||
valid_protocols = ['POCSAG512', 'POCSAG1200', 'POCSAG2400', 'FLEX']
|
||||
protocols = data.get('protocols', valid_protocols)
|
||||
if not isinstance(protocols, list):
|
||||
return jsonify({'status': 'error', 'message': 'Protocols must be a list'}), 400
|
||||
protocols = [p for p in protocols if p in valid_protocols]
|
||||
if not protocols:
|
||||
protocols = valid_protocols
|
||||
|
||||
# Clear queue
|
||||
while not app_module.output_queue.empty():
|
||||
try:
|
||||
app_module.output_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Build multimon-ng decoder arguments
|
||||
decoders = []
|
||||
for proto in protocols:
|
||||
if proto == 'POCSAG512':
|
||||
decoders.extend(['-a', 'POCSAG512'])
|
||||
elif proto == 'POCSAG1200':
|
||||
decoders.extend(['-a', 'POCSAG1200'])
|
||||
elif proto == 'POCSAG2400':
|
||||
decoders.extend(['-a', 'POCSAG2400'])
|
||||
elif proto == 'FLEX':
|
||||
decoders.extend(['-a', 'FLEX'])
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build FM demodulation command
|
||||
rtl_cmd = builder.build_fm_demod_command(
|
||||
device=sdr_device,
|
||||
frequency_mhz=freq,
|
||||
sample_rate=22050,
|
||||
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
|
||||
)
|
||||
|
||||
multimon_cmd = ['multimon-ng', '-t', 'raw'] + decoders + ['-f', 'alpha', '-']
|
||||
|
||||
full_cmd = ' '.join(rtl_cmd) + ' | ' + ' '.join(multimon_cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
# Create pipe: rtl_fm | multimon-ng
|
||||
rtl_process = subprocess.Popen(
|
||||
rtl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
# Start a thread to monitor rtl_fm stderr for errors
|
||||
def monitor_rtl_stderr():
|
||||
for line in rtl_process.stderr:
|
||||
err_text = line.decode('utf-8', errors='replace').strip()
|
||||
if err_text:
|
||||
logger.debug(f"[RTL_FM] {err_text}")
|
||||
app_module.output_queue.put({'type': 'raw', 'text': f'[rtl_fm] {err_text}'})
|
||||
|
||||
rtl_stderr_thread = threading.Thread(target=monitor_rtl_stderr)
|
||||
rtl_stderr_thread.daemon = True
|
||||
rtl_stderr_thread.start()
|
||||
|
||||
# Create a pseudo-terminal for multimon-ng output
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
multimon_process = subprocess.Popen(
|
||||
multimon_cmd,
|
||||
stdin=rtl_process.stdout,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
close_fds=True
|
||||
)
|
||||
|
||||
os.close(slave_fd)
|
||||
rtl_process.stdout.close()
|
||||
|
||||
app_module.current_process = multimon_process
|
||||
app_module.current_process._rtl_process = rtl_process
|
||||
app_module.current_process._master_fd = master_fd
|
||||
|
||||
# Start output thread with PTY master fd
|
||||
thread = threading.Thread(target=stream_decoder, args=(master_fd, multimon_process))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.output_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError as e:
|
||||
return jsonify({'status': 'error', 'message': f'Tool not found: {e.filename}'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@pager_bp.route('/stop', methods=['POST'])
|
||||
def stop_decoding() -> Response:
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process:
|
||||
# Kill rtl_fm process first
|
||||
if hasattr(app_module.current_process, '_rtl_process'):
|
||||
try:
|
||||
app_module.current_process._rtl_process.terminate()
|
||||
app_module.current_process._rtl_process.wait(timeout=2)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
try:
|
||||
app_module.current_process._rtl_process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Close PTY master fd
|
||||
if hasattr(app_module.current_process, '_master_fd'):
|
||||
try:
|
||||
os.close(app_module.current_process._master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Kill multimon-ng
|
||||
app_module.current_process.terminate()
|
||||
try:
|
||||
app_module.current_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.current_process.kill()
|
||||
|
||||
app_module.current_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@pager_bp.route('/status')
|
||||
def get_status() -> Response:
|
||||
"""Check if decoder is currently running."""
|
||||
with app_module.process_lock:
|
||||
if app_module.current_process and app_module.current_process.poll() is None:
|
||||
return jsonify({'running': True, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
return jsonify({'running': False, 'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/logging', methods=['POST'])
|
||||
def toggle_logging() -> Response:
|
||||
"""Toggle message logging."""
|
||||
data = request.json or {}
|
||||
if 'enabled' in data:
|
||||
app_module.logging_enabled = bool(data['enabled'])
|
||||
|
||||
if 'log_file' in data and data['log_file']:
|
||||
# Validate path to prevent directory traversal
|
||||
try:
|
||||
requested_path = pathlib.Path(data['log_file']).resolve()
|
||||
# Only allow files in the current directory or logs subdirectory
|
||||
cwd = pathlib.Path('.').resolve()
|
||||
logs_dir = (cwd / 'logs').resolve()
|
||||
|
||||
# Check if path is within allowed directories
|
||||
is_in_cwd = str(requested_path).startswith(str(cwd))
|
||||
is_in_logs = str(requested_path).startswith(str(logs_dir))
|
||||
|
||||
if not (is_in_cwd or is_in_logs):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
|
||||
# Ensure it's not a directory
|
||||
if requested_path.is_dir():
|
||||
return jsonify({'status': 'error', 'message': 'Log file path must be a file, not a directory'}), 400
|
||||
|
||||
app_module.log_file_path = str(requested_path)
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"Invalid log file path: {e}")
|
||||
return jsonify({'status': 'error', 'message': 'Invalid log file path'}), 400
|
||||
|
||||
return jsonify({'logging': app_module.logging_enabled, 'log_file': app_module.log_file_path})
|
||||
|
||||
|
||||
@pager_bp.route('/stream')
|
||||
def stream() -> Response:
|
||||
import json
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0 # Send keepalive every 30 seconds instead of 1 second
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.output_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Satellite tracking routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template, Response
|
||||
|
||||
from data.satellites import TLE_SATELLITES
|
||||
from utils.logging import satellite_logger as logger
|
||||
from utils.validation import validate_latitude, validate_longitude, validate_hours, validate_elevation
|
||||
|
||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||
|
||||
# Maximum response size for external requests (1MB)
|
||||
MAX_RESPONSE_SIZE = 1024 * 1024
|
||||
|
||||
# Allowed hosts for TLE fetching
|
||||
ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www.celestrak.com']
|
||||
|
||||
# Local TLE cache (can be updated via API)
|
||||
_tle_cache = dict(TLE_SATELLITES)
|
||||
|
||||
|
||||
@satellite_bp.route('/dashboard')
|
||||
def satellite_dashboard():
|
||||
"""Popout satellite tracking dashboard."""
|
||||
return render_template('satellite_dashboard.html')
|
||||
|
||||
|
||||
@satellite_bp.route('/predict', methods=['POST'])
|
||||
def predict_passes():
|
||||
"""Calculate satellite passes using skyfield."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
from skyfield.almanac import find_discrete
|
||||
except ImportError:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': 'skyfield library not installed. Run: pip install skyfield'
|
||||
}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
hours = validate_hours(data.get('hours', 24))
|
||||
min_el = validate_elevation(data.get('minEl', 10))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
sat_input = data.get('satellites', ['ISS', 'NOAA-15', 'NOAA-18', 'NOAA-19'])
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
passes = []
|
||||
colors = {
|
||||
'ISS': '#00ffff',
|
||||
'NOAA-15': '#00ff00',
|
||||
'NOAA-18': '#ff6600',
|
||||
'NOAA-19': '#ff3366',
|
||||
'NOAA-20': '#00ffaa',
|
||||
'METEOR-M2': '#9370DB',
|
||||
'METEOR-M2-3': '#ff00ff'
|
||||
}
|
||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
||||
|
||||
ts = load.timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
|
||||
t0 = ts.now()
|
||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||
|
||||
for sat_name in satellites:
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def above_horizon(t):
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t)
|
||||
alt, _, _ = topocentric.altaz()
|
||||
return alt.degrees > 0
|
||||
|
||||
above_horizon.step_days = 1/720
|
||||
|
||||
try:
|
||||
times, events = find_discrete(t0, t1, above_horizon)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
i = 0
|
||||
while i < len(times):
|
||||
if i < len(events) and events[i]:
|
||||
rise_time = times[i]
|
||||
set_time = None
|
||||
for j in range(i + 1, len(times)):
|
||||
if not events[j]:
|
||||
set_time = times[j]
|
||||
i = j
|
||||
break
|
||||
|
||||
if set_time is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
trajectory = []
|
||||
max_elevation = 0
|
||||
num_points = 30
|
||||
|
||||
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
||||
|
||||
for k in range(num_points):
|
||||
frac = k / (num_points - 1)
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(t_point)
|
||||
alt, az, _ = topocentric.altaz()
|
||||
|
||||
el = alt.degrees
|
||||
azimuth = az.degrees
|
||||
|
||||
if el > max_elevation:
|
||||
max_elevation = el
|
||||
|
||||
trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
|
||||
|
||||
if max_elevation >= min_el:
|
||||
duration_minutes = int(duration_seconds / 60)
|
||||
|
||||
ground_track = []
|
||||
for k in range(60):
|
||||
frac = k / 59
|
||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
||||
geocentric = satellite.at(t_point)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
ground_track.append({
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees)
|
||||
})
|
||||
|
||||
current_geo = satellite.at(ts.now())
|
||||
current_subpoint = wgs84.subpoint(current_geo)
|
||||
|
||||
passes.append({
|
||||
'satellite': sat_name,
|
||||
'norad': name_to_norad.get(sat_name, 0),
|
||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||
'maxEl': float(round(max_elevation, 1)),
|
||||
'duration': int(duration_minutes),
|
||||
'trajectory': trajectory,
|
||||
'groundTrack': ground_track,
|
||||
'currentPos': {
|
||||
'lat': float(current_subpoint.latitude.degrees),
|
||||
'lon': float(current_subpoint.longitude.degrees)
|
||||
},
|
||||
'color': colors.get(sat_name, '#00ff00')
|
||||
})
|
||||
|
||||
i += 1
|
||||
|
||||
passes.sort(key=lambda p: p['startTime'])
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'passes': passes
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/position', methods=['POST'])
|
||||
def get_satellite_position():
|
||||
"""Get real-time positions of satellites."""
|
||||
try:
|
||||
from skyfield.api import load, wgs84, EarthSatellite
|
||||
except ImportError:
|
||||
return jsonify({'status': 'error', 'message': 'skyfield not installed'}), 503
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sat_input = data.get('satellites', [])
|
||||
include_track = bool(data.get('includeTrack', True))
|
||||
|
||||
norad_to_name = {
|
||||
25544: 'ISS',
|
||||
25338: 'NOAA-15',
|
||||
28654: 'NOAA-18',
|
||||
33591: 'NOAA-19',
|
||||
43013: 'NOAA-20',
|
||||
40069: 'METEOR-M2',
|
||||
57166: 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
satellites = []
|
||||
for sat in sat_input:
|
||||
if isinstance(sat, int) and sat in norad_to_name:
|
||||
satellites.append(norad_to_name[sat])
|
||||
else:
|
||||
satellites.append(sat)
|
||||
|
||||
ts = load.timescale()
|
||||
observer = wgs84.latlon(lat, lon)
|
||||
now = ts.now()
|
||||
now_dt = now.utc_datetime()
|
||||
|
||||
positions = []
|
||||
|
||||
for sat_name in satellites:
|
||||
if sat_name not in _tle_cache:
|
||||
continue
|
||||
|
||||
tle_data = _tle_cache[sat_name]
|
||||
try:
|
||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||
|
||||
geocentric = satellite.at(now)
|
||||
subpoint = wgs84.subpoint(geocentric)
|
||||
|
||||
diff = satellite - observer
|
||||
topocentric = diff.at(now)
|
||||
alt, az, distance = topocentric.altaz()
|
||||
|
||||
pos_data = {
|
||||
'satellite': sat_name,
|
||||
'lat': float(subpoint.latitude.degrees),
|
||||
'lon': float(subpoint.longitude.degrees),
|
||||
'altitude': float(geocentric.distance().km - 6371),
|
||||
'elevation': float(alt.degrees),
|
||||
'azimuth': float(az.degrees),
|
||||
'distance': float(distance.km),
|
||||
'visible': bool(alt.degrees > 0)
|
||||
}
|
||||
|
||||
if include_track:
|
||||
orbit_track = []
|
||||
for minutes_offset in range(-45, 46, 1):
|
||||
t_point = ts.utc(now_dt + timedelta(minutes=minutes_offset))
|
||||
try:
|
||||
geo = satellite.at(t_point)
|
||||
sp = wgs84.subpoint(geo)
|
||||
orbit_track.append({
|
||||
'lat': float(sp.latitude.degrees),
|
||||
'lon': float(sp.longitude.degrees),
|
||||
'past': minutes_offset < 0
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
pos_data['track'] = orbit_track
|
||||
|
||||
positions.append(pos_data)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'positions': positions,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
|
||||
@satellite_bp.route('/update-tle', methods=['POST'])
|
||||
def update_tle():
|
||||
"""Update TLE data from CelesTrak."""
|
||||
global _tle_cache
|
||||
|
||||
try:
|
||||
name_mappings = {
|
||||
'ISS (ZARYA)': 'ISS',
|
||||
'NOAA 15': 'NOAA-15',
|
||||
'NOAA 18': 'NOAA-18',
|
||||
'NOAA 19': 'NOAA-19',
|
||||
'METEOR-M 2': 'METEOR-M2',
|
||||
'METEOR-M2 3': 'METEOR-M2-3'
|
||||
}
|
||||
|
||||
updated = []
|
||||
|
||||
for group in ['stations', 'weather']:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=tle'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
internal_name = name_mappings.get(name, name)
|
||||
|
||||
if internal_name in _tle_cache:
|
||||
_tle_cache[internal_name] = (name, line1, line2)
|
||||
updated.append(internal_name)
|
||||
|
||||
i += 3
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching {group}: {e}")
|
||||
continue
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'updated': updated
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@satellite_bp.route('/celestrak/<category>')
|
||||
def fetch_celestrak(category):
|
||||
"""Fetch TLE data from CelesTrak for a category."""
|
||||
valid_categories = [
|
||||
'stations', 'weather', 'noaa', 'goes', 'resource', 'sarsat',
|
||||
'dmc', 'tdrss', 'argos', 'planet', 'spire', 'geo', 'intelsat',
|
||||
'ses', 'iridium', 'iridium-NEXT', 'starlink', 'oneweb',
|
||||
'amateur', 'cubesat', 'visual'
|
||||
]
|
||||
|
||||
if category not in valid_categories:
|
||||
return jsonify({'status': 'error', 'message': f'Invalid category. Valid: {valid_categories}'})
|
||||
|
||||
try:
|
||||
url = f'https://celestrak.org/NORAD/elements/gp.php?GROUP={category}&FORMAT=tle'
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
content = response.read().decode('utf-8')
|
||||
|
||||
satellites = []
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
i = 0
|
||||
while i + 2 < len(lines):
|
||||
name = lines[i].strip()
|
||||
line1 = lines[i + 1].strip()
|
||||
line2 = lines[i + 2].strip()
|
||||
|
||||
if not (line1.startswith('1 ') and line2.startswith('2 ')):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
norad_id = int(line1[2:7])
|
||||
satellites.append({
|
||||
'name': name,
|
||||
'norad': norad_id,
|
||||
'tle1': line1,
|
||||
'tle2': line2
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
i += 3
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'category': category,
|
||||
'satellites': satellites
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
@@ -0,0 +1,198 @@
|
||||
"""RTL_433 sensor monitoring routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
import app as app_module
|
||||
from utils.logging import sensor_logger as logger
|
||||
from utils.validation import (
|
||||
validate_frequency, validate_device_index, validate_gain, validate_ppm,
|
||||
validate_rtl_tcp_host, validate_rtl_tcp_port
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
from utils.process import safe_terminate, register_process
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
sensor_bp = Blueprint('sensor', __name__)
|
||||
|
||||
|
||||
def stream_sensor_output(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stream rtl_433 JSON output to queue."""
|
||||
try:
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'started'})
|
||||
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
line = line.decode('utf-8', errors='replace').strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
# rtl_433 outputs JSON objects, one per line
|
||||
data = json.loads(line)
|
||||
data['type'] = 'sensor'
|
||||
app_module.sensor_queue.put(data)
|
||||
|
||||
# Log if enabled
|
||||
if app_module.logging_enabled:
|
||||
try:
|
||||
with open(app_module.log_file_path, 'a') as f:
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
f.write(f"{timestamp} | {data.get('model', 'Unknown')} | {json.dumps(data)}\n")
|
||||
except Exception:
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
# Not JSON, send as raw
|
||||
app_module.sensor_queue.put({'type': 'raw', 'text': line})
|
||||
|
||||
except Exception as e:
|
||||
app_module.sensor_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.sensor_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.sensor_lock:
|
||||
app_module.sensor_process = None
|
||||
|
||||
|
||||
@sensor_bp.route('/start_sensor', methods=['POST'])
|
||||
def start_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
return jsonify({'status': 'error', 'message': 'Sensor already running'}), 409
|
||||
|
||||
data = request.json or {}
|
||||
|
||||
# Validate inputs
|
||||
try:
|
||||
freq = validate_frequency(data.get('frequency', '433.92'))
|
||||
gain = validate_gain(data.get('gain', '0'))
|
||||
ppm = validate_ppm(data.get('ppm', '0'))
|
||||
device = validate_device_index(data.get('device', '0'))
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
# Clear queue
|
||||
while not app_module.sensor_queue.empty():
|
||||
try:
|
||||
app_module.sensor_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
# Get SDR type and build command via abstraction layer
|
||||
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
||||
try:
|
||||
sdr_type = SDRType(sdr_type_str)
|
||||
except ValueError:
|
||||
sdr_type = SDRType.RTL_SDR
|
||||
|
||||
# Check for rtl_tcp (remote SDR) connection
|
||||
rtl_tcp_host = data.get('rtl_tcp_host')
|
||||
rtl_tcp_port = data.get('rtl_tcp_port', 1234)
|
||||
|
||||
if rtl_tcp_host:
|
||||
# Validate and create network device
|
||||
try:
|
||||
rtl_tcp_host = validate_rtl_tcp_host(rtl_tcp_host)
|
||||
rtl_tcp_port = validate_rtl_tcp_port(rtl_tcp_port)
|
||||
except ValueError as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||
|
||||
sdr_device = SDRFactory.create_network_device(rtl_tcp_host, rtl_tcp_port)
|
||||
logger.info(f"Using remote SDR: rtl_tcp://{rtl_tcp_host}:{rtl_tcp_port}")
|
||||
else:
|
||||
# Create local device object
|
||||
sdr_device = SDRFactory.create_default_device(sdr_type, index=device)
|
||||
|
||||
builder = SDRFactory.get_builder(sdr_device.sdr_type)
|
||||
|
||||
# Build ISM band decoder command
|
||||
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
|
||||
)
|
||||
|
||||
full_cmd = ' '.join(cmd)
|
||||
logger.info(f"Running: {full_cmd}")
|
||||
|
||||
try:
|
||||
app_module.sensor_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# Start output thread
|
||||
thread = threading.Thread(target=stream_sensor_output, args=(app_module.sensor_process,))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Monitor stderr
|
||||
def monitor_stderr():
|
||||
for line in app_module.sensor_process.stderr:
|
||||
err = line.decode('utf-8', errors='replace').strip()
|
||||
if err:
|
||||
logger.debug(f"[rtl_433] {err}")
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'[rtl_433] {err}'})
|
||||
|
||||
stderr_thread = threading.Thread(target=monitor_stderr)
|
||||
stderr_thread.daemon = True
|
||||
stderr_thread.start()
|
||||
|
||||
app_module.sensor_queue.put({'type': 'info', 'text': f'Command: {full_cmd}'})
|
||||
|
||||
return jsonify({'status': 'started', 'command': full_cmd})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'rtl_433 not found. Install with: brew install rtl_433'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@sensor_bp.route('/stop_sensor', methods=['POST'])
|
||||
def stop_sensor() -> Response:
|
||||
with app_module.sensor_lock:
|
||||
if app_module.sensor_process:
|
||||
app_module.sensor_process.terminate()
|
||||
try:
|
||||
app_module.sensor_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.sensor_process.kill()
|
||||
app_module.sensor_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@sensor_bp.route('/stream_sensor')
|
||||
def stream_sensor() -> Response:
|
||||
def generate() -> Generator[str, None, None]:
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.sensor_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
+788
@@ -0,0 +1,788 @@
|
||||
"""WiFi reconnaissance routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
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.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.sse import format_sse
|
||||
from data.oui import get_manufacturer
|
||||
|
||||
wifi_bp = Blueprint('wifi', __name__, url_prefix='/wifi')
|
||||
|
||||
# PMKID process state
|
||||
pmkid_process = None
|
||||
pmkid_lock = threading.Lock()
|
||||
|
||||
|
||||
def detect_wifi_interfaces():
|
||||
"""Detect available WiFi interfaces."""
|
||||
interfaces = []
|
||||
|
||||
if platform.system() == 'Darwin': # macOS
|
||||
try:
|
||||
result = subprocess.run(['networksetup', '-listallhardwareports'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
lines = result.stdout.split('\n')
|
||||
for i, line in enumerate(lines):
|
||||
if 'Wi-Fi' in line or 'AirPort' in line:
|
||||
for j in range(i+1, min(i+3, len(lines))):
|
||||
if 'Device:' in lines[j]:
|
||||
device = lines[j].split('Device:')[1].strip()
|
||||
interfaces.append({
|
||||
'name': device,
|
||||
'type': 'internal',
|
||||
'monitor_capable': False,
|
||||
'status': 'up'
|
||||
})
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting macOS interfaces: {e}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(['system_profiler', 'SPUSBDataType'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if 'Wireless' in result.stdout or 'WLAN' in result.stdout or '802.11' in result.stdout:
|
||||
interfaces.append({
|
||||
'name': 'USB WiFi Adapter',
|
||||
'type': 'usb',
|
||||
'monitor_capable': True,
|
||||
'status': 'detected'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
else: # Linux
|
||||
try:
|
||||
result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5)
|
||||
current_iface = None
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if line.startswith('Interface'):
|
||||
current_iface = line.split()[1]
|
||||
elif current_iface and 'type' in line:
|
||||
iface_type = line.split()[-1]
|
||||
interfaces.append({
|
||||
'name': current_iface,
|
||||
'type': iface_type,
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
current_iface = None
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IEEE 802.11' in line:
|
||||
iface = line.split()[0]
|
||||
interfaces.append({
|
||||
'name': iface,
|
||||
'type': 'managed',
|
||||
'monitor_capable': True,
|
||||
'status': 'up'
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error detecting Linux interfaces: {e}")
|
||||
|
||||
return interfaces
|
||||
|
||||
|
||||
def parse_airodump_csv(csv_path):
|
||||
"""Parse airodump-ng CSV output file."""
|
||||
networks = {}
|
||||
clients = {}
|
||||
|
||||
try:
|
||||
with open(csv_path, 'r', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
sections = content.split('\n\n')
|
||||
|
||||
for section in sections:
|
||||
lines = section.strip().split('\n')
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
header = lines[0] if lines else ''
|
||||
|
||||
if 'BSSID' in header and 'ESSID' in header:
|
||||
for line in lines[1:]:
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 14:
|
||||
bssid = parts[0]
|
||||
if bssid and ':' in bssid:
|
||||
networks[bssid] = {
|
||||
'bssid': bssid,
|
||||
'first_seen': parts[1],
|
||||
'last_seen': parts[2],
|
||||
'channel': parts[3],
|
||||
'speed': parts[4],
|
||||
'privacy': parts[5],
|
||||
'cipher': parts[6],
|
||||
'auth': parts[7],
|
||||
'power': parts[8],
|
||||
'beacons': parts[9],
|
||||
'ivs': parts[10],
|
||||
'lan_ip': parts[11],
|
||||
'essid': parts[13] or 'Hidden'
|
||||
}
|
||||
|
||||
elif 'Station MAC' in header:
|
||||
for line in lines[1:]:
|
||||
parts = [p.strip() for p in line.split(',')]
|
||||
if len(parts) >= 6:
|
||||
station = parts[0]
|
||||
if station and ':' in station:
|
||||
vendor = get_manufacturer(station)
|
||||
clients[station] = {
|
||||
'mac': station,
|
||||
'first_seen': parts[1],
|
||||
'last_seen': parts[2],
|
||||
'power': parts[3],
|
||||
'packets': parts[4],
|
||||
'bssid': parts[5],
|
||||
'probes': parts[6] if len(parts) > 6 else '',
|
||||
'vendor': vendor
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing CSV: {e}")
|
||||
|
||||
return networks, clients
|
||||
|
||||
|
||||
def stream_airodump_output(process, csv_path):
|
||||
"""Stream airodump-ng output to queue."""
|
||||
try:
|
||||
app_module.wifi_queue.put({'type': 'status', 'text': 'started'})
|
||||
last_parse = 0
|
||||
start_time = time.time()
|
||||
csv_found = False
|
||||
|
||||
while process.poll() is None:
|
||||
try:
|
||||
fd = process.stderr.fileno()
|
||||
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
|
||||
|
||||
stderr_data = process.stderr.read()
|
||||
if stderr_data:
|
||||
stderr_text = stderr_data.decode('utf-8', errors='replace').strip()
|
||||
if stderr_text:
|
||||
for line in stderr_text.split('\n'):
|
||||
line = line.strip()
|
||||
if line and not line.startswith('CH') and not line.startswith('Elapsed'):
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng: {line}'})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current_time = time.time()
|
||||
if current_time - last_parse >= 2:
|
||||
csv_file = csv_path + '-01.csv'
|
||||
if os.path.exists(csv_file):
|
||||
csv_found = True
|
||||
networks, clients = parse_airodump_csv(csv_file)
|
||||
|
||||
for bssid, net in networks.items():
|
||||
if bssid not in app_module.wifi_networks:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'network',
|
||||
'action': 'new',
|
||||
**net
|
||||
})
|
||||
else:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'network',
|
||||
'action': 'update',
|
||||
**net
|
||||
})
|
||||
|
||||
for mac, client in clients.items():
|
||||
if mac not in app_module.wifi_clients:
|
||||
app_module.wifi_queue.put({
|
||||
'type': 'client',
|
||||
'action': 'new',
|
||||
**client
|
||||
})
|
||||
|
||||
app_module.wifi_networks = networks
|
||||
app_module.wifi_clients = clients
|
||||
last_parse = current_time
|
||||
|
||||
if current_time - start_time > 5 and not csv_found:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': 'No scan data after 5 seconds. Check if monitor mode is properly enabled.'})
|
||||
start_time = current_time + 30
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
try:
|
||||
remaining_stderr = process.stderr.read()
|
||||
if remaining_stderr:
|
||||
stderr_text = remaining_stderr.decode('utf-8', errors='replace').strip()
|
||||
if stderr_text:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited: {stderr_text}'})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
exit_code = process.returncode
|
||||
if exit_code != 0 and exit_code is not None:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': f'airodump-ng exited with code {exit_code}'})
|
||||
|
||||
except Exception as e:
|
||||
app_module.wifi_queue.put({'type': 'error', 'text': str(e)})
|
||||
finally:
|
||||
process.wait()
|
||||
app_module.wifi_queue.put({'type': 'status', 'text': 'stopped'})
|
||||
with app_module.wifi_lock:
|
||||
app_module.wifi_process = None
|
||||
|
||||
|
||||
@wifi_bp.route('/interfaces')
|
||||
def get_wifi_interfaces():
|
||||
"""Get available WiFi interfaces."""
|
||||
interfaces = detect_wifi_interfaces()
|
||||
tools = {
|
||||
'airmon': check_tool('airmon-ng'),
|
||||
'airodump': check_tool('airodump-ng'),
|
||||
'aireplay': check_tool('aireplay-ng'),
|
||||
'iw': check_tool('iw')
|
||||
}
|
||||
return jsonify({'interfaces': interfaces, 'tools': tools, 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
|
||||
@wifi_bp.route('/monitor', methods=['POST'])
|
||||
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'})
|
||||
|
||||
if action == 'start':
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
def get_wireless_interfaces():
|
||||
interfaces = set()
|
||||
try:
|
||||
result = subprocess.run(['iwconfig'], capture_output=True, text=True, timeout=5)
|
||||
for line in result.stdout.split('\n'):
|
||||
if line and not line.startswith(' ') and 'no wireless' not in line.lower():
|
||||
iface = line.split()[0] if line.split() else None
|
||||
if iface:
|
||||
interfaces.add(iface)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
for iface in os.listdir('/sys/class/net'):
|
||||
if os.path.exists(f'/sys/class/net/{iface}/wireless'):
|
||||
interfaces.add(iface)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5)
|
||||
for match in re.finditer(r'^\d+:\s+(\S+):', result.stdout, re.MULTILINE):
|
||||
iface = match.group(1).rstrip(':')
|
||||
if iface.startswith('wl') or 'mon' in iface:
|
||||
interfaces.add(iface)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
return interfaces
|
||||
|
||||
interfaces_before = get_wireless_interfaces()
|
||||
|
||||
kill_processes = data.get('kill_processes', False)
|
||||
if kill_processes:
|
||||
subprocess.run(['airmon-ng', 'check', 'kill'], capture_output=True, timeout=10)
|
||||
|
||||
result = subprocess.run(['airmon-ng', 'start', interface],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
time.sleep(1)
|
||||
interfaces_after = get_wireless_interfaces()
|
||||
|
||||
new_interfaces = interfaces_after - interfaces_before
|
||||
monitor_iface = None
|
||||
|
||||
if new_interfaces:
|
||||
for iface in new_interfaces:
|
||||
if 'mon' in iface:
|
||||
monitor_iface = iface
|
||||
break
|
||||
if not monitor_iface:
|
||||
monitor_iface = list(new_interfaces)[0]
|
||||
|
||||
if not monitor_iface:
|
||||
# Patterns to extract monitor interface name from airmon-ng output
|
||||
# Interface names: start with letter, contain alphanumeric/underscore/dash
|
||||
patterns = [
|
||||
# Look for interface names ending in 'mon' (most reliable)
|
||||
r'\b([a-zA-Z][a-zA-Z0-9_-]*mon)\b',
|
||||
# Airmon-ng format: [phyX]interfacename
|
||||
r'\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*mon)',
|
||||
# "enabled for/on [phyX]interface" format
|
||||
r'enabled.*?\[phy\d+\]([a-zA-Z][a-zA-Z0-9_-]*)',
|
||||
# Original interface with 'mon' appended
|
||||
r'\b(' + re.escape(interface) + r'mon)\b',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, output, re.IGNORECASE)
|
||||
if match:
|
||||
candidate = match.group(1)
|
||||
# Validate it looks like an interface name (not channel info like "10)")
|
||||
if candidate and not candidate[0].isdigit() and ')' not in candidate:
|
||||
monitor_iface = candidate
|
||||
break
|
||||
|
||||
if not monitor_iface:
|
||||
try:
|
||||
result = subprocess.run(['iwconfig', interface], capture_output=True, text=True, timeout=5)
|
||||
if 'Mode:Monitor' in result.stdout:
|
||||
monitor_iface = interface
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
if not monitor_iface:
|
||||
potential = interface + 'mon'
|
||||
if potential in interfaces_after:
|
||||
monitor_iface = potential
|
||||
|
||||
if not monitor_iface:
|
||||
monitor_iface = interface + 'mon'
|
||||
|
||||
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}'})
|
||||
return jsonify({'status': 'success', 'monitor_interface': app_module.wifi_monitor_interface})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"Error enabling monitor mode: {e}", exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'monitor', 'control'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = interface
|
||||
return jsonify({'status': 'success', 'monitor_interface': interface})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor mode tools available.'})
|
||||
|
||||
else: # stop
|
||||
if check_tool('airmon-ng'):
|
||||
try:
|
||||
subprocess.run(['airmon-ng', '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'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
elif check_tool('iw'):
|
||||
try:
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'down'], capture_output=True)
|
||||
subprocess.run(['iw', interface, 'set', 'type', 'managed'], capture_output=True)
|
||||
subprocess.run(['ip', 'link', 'set', interface, 'up'], capture_output=True)
|
||||
app_module.wifi_monitor_interface = None
|
||||
return jsonify({'status': 'success', 'message': 'Monitor mode disabled'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
return jsonify({'status': 'error', 'message': 'Unknown action'})
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/start', methods=['POST'])
|
||||
def start_wifi_scan():
|
||||
"""Start WiFi scanning with airodump-ng."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
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')
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface available.'})
|
||||
|
||||
app_module.wifi_networks = {}
|
||||
app_module.wifi_clients = {}
|
||||
|
||||
while not app_module.wifi_queue.empty():
|
||||
try:
|
||||
app_module.wifi_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
csv_path = '/tmp/intercept_wifi'
|
||||
|
||||
for f in [f'/tmp/intercept_wifi-01.csv', f'/tmp/intercept_wifi-01.cap']:
|
||||
try:
|
||||
os.remove(f)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
'-w', csv_path,
|
||||
'--output-format', 'csv,pcap',
|
||||
'--band', band,
|
||||
interface
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
logger.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
app_module.wifi_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
if app_module.wifi_process.poll() is not None:
|
||||
stderr_output = app_module.wifi_process.stderr.read().decode('utf-8', errors='replace').strip()
|
||||
stdout_output = app_module.wifi_process.stdout.read().decode('utf-8', errors='replace').strip()
|
||||
exit_code = app_module.wifi_process.returncode
|
||||
app_module.wifi_process = None
|
||||
|
||||
error_msg = stderr_output or stdout_output or f'Process exited with code {exit_code}'
|
||||
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.'
|
||||
elif 'Operation not permitted' in error_msg:
|
||||
error_msg = 'Permission denied. Try running with sudo.'
|
||||
|
||||
return jsonify({'status': 'error', 'message': error_msg})
|
||||
|
||||
thread = threading.Thread(target=stream_airodump_output, args=(app_module.wifi_process, csv_path))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Started scanning on {interface}'})
|
||||
|
||||
return jsonify({'status': 'started', 'interface': interface})
|
||||
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'airodump-ng not found.'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_wifi_scan():
|
||||
"""Stop WiFi scanning."""
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
app_module.wifi_process.terminate()
|
||||
try:
|
||||
app_module.wifi_process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
app_module.wifi_process.kill()
|
||||
app_module.wifi_process = None
|
||||
return jsonify({'status': 'stopped'})
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
|
||||
@wifi_bp.route('/deauth', methods=['POST'])
|
||||
def send_deauth():
|
||||
"""Send deauthentication packets."""
|
||||
data = request.json
|
||||
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
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'Target BSSID required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
if not is_valid_mac(target_client):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid client MAC format'})
|
||||
|
||||
try:
|
||||
count = int(count)
|
||||
if count < 1 or count > 100:
|
||||
count = 5
|
||||
except (ValueError, TypeError):
|
||||
count = 5
|
||||
|
||||
if not interface:
|
||||
return jsonify({'status': 'error', 'message': 'No monitor interface'})
|
||||
|
||||
if not check_tool('aireplay-ng'):
|
||||
return jsonify({'status': 'error', 'message': 'aireplay-ng not found'})
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
'aireplay-ng',
|
||||
'--deauth', str(count),
|
||||
'-a', target_bssid,
|
||||
'-c', target_client,
|
||||
interface
|
||||
]
|
||||
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Sending {count} deauth packets to {target_bssid}'})
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
return jsonify({'status': 'success', 'message': f'Sent {count} deauth packets'})
|
||||
else:
|
||||
return jsonify({'status': 'error', 'message': result.stderr})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({'status': 'success', 'message': 'Deauth sent (timed out)'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/capture', methods=['POST'])
|
||||
def capture_handshake():
|
||||
"""Start targeted handshake capture."""
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid or not channel:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID and channel required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
if not is_valid_channel(channel):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid channel'})
|
||||
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process:
|
||||
return jsonify({'status': 'error', 'message': 'Scan already running.'})
|
||||
|
||||
capture_path = f'/tmp/intercept_handshake_{target_bssid.replace(":", "")}'
|
||||
|
||||
cmd = [
|
||||
'airodump-ng',
|
||||
'-c', str(channel),
|
||||
'--bssid', target_bssid,
|
||||
'-w', capture_path,
|
||||
'--output-format', 'pcap',
|
||||
interface
|
||||
]
|
||||
|
||||
try:
|
||||
app_module.wifi_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
app_module.wifi_queue.put({'type': 'info', 'text': f'Capturing handshakes for {target_bssid}'})
|
||||
return jsonify({'status': 'started', 'capture_file': capture_path + '-01.cap'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/handshake/status', methods=['POST'])
|
||||
def check_handshake_status():
|
||||
"""Check if a handshake has been captured."""
|
||||
data = request.json
|
||||
capture_file = data.get('file', '')
|
||||
target_bssid = data.get('bssid', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_handshake_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
with app_module.wifi_lock:
|
||||
if app_module.wifi_process and app_module.wifi_process.poll() is None:
|
||||
return jsonify({'status': 'running', 'file_exists': False, 'handshake_found': False})
|
||||
else:
|
||||
return jsonify({'status': 'stopped', 'file_exists': False, 'handshake_found': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
handshake_found = False
|
||||
|
||||
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
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking handshake: {e}")
|
||||
|
||||
return jsonify({
|
||||
'status': 'running' if app_module.wifi_process and app_module.wifi_process.poll() is None else 'stopped',
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file,
|
||||
'handshake_found': handshake_found
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/capture', methods=['POST'])
|
||||
def capture_pmkid():
|
||||
"""Start PMKID capture using hcxdumptool."""
|
||||
global pmkid_process
|
||||
|
||||
data = request.json
|
||||
target_bssid = data.get('bssid')
|
||||
channel = data.get('channel')
|
||||
interface = data.get('interface') or app_module.wifi_monitor_interface
|
||||
|
||||
if not target_bssid:
|
||||
return jsonify({'status': 'error', 'message': 'BSSID required'})
|
||||
|
||||
if not is_valid_mac(target_bssid):
|
||||
return jsonify({'status': 'error', 'message': 'Invalid BSSID format'})
|
||||
|
||||
with pmkid_lock:
|
||||
if pmkid_process and pmkid_process.poll() is None:
|
||||
return jsonify({'status': 'error', 'message': 'PMKID capture already running'})
|
||||
|
||||
capture_path = f'/tmp/intercept_pmkid_{target_bssid.replace(":", "")}.pcapng'
|
||||
filter_file = f'/tmp/pmkid_filter_{target_bssid.replace(":", "")}'
|
||||
with open(filter_file, 'w') as f:
|
||||
f.write(target_bssid.replace(':', '').lower())
|
||||
|
||||
cmd = [
|
||||
'hcxdumptool',
|
||||
'-i', interface,
|
||||
'-o', capture_path,
|
||||
'--filterlist_ap', filter_file,
|
||||
'--filtermode', '2',
|
||||
'--enable_status', '1'
|
||||
]
|
||||
|
||||
if channel:
|
||||
cmd.extend(['-c', str(channel)])
|
||||
|
||||
try:
|
||||
pmkid_process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
return jsonify({'status': 'started', 'file': capture_path})
|
||||
except FileNotFoundError:
|
||||
return jsonify({'status': 'error', 'message': 'hcxdumptool not found.'})
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'error', 'message': str(e)})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/status', methods=['POST'])
|
||||
def check_pmkid_status():
|
||||
"""Check if PMKID has been captured."""
|
||||
data = request.json
|
||||
capture_file = data.get('file', '')
|
||||
|
||||
if not capture_file.startswith('/tmp/intercept_pmkid_') or '..' in capture_file:
|
||||
return jsonify({'status': 'error', 'message': 'Invalid capture file path'})
|
||||
|
||||
if not os.path.exists(capture_file):
|
||||
return jsonify({'pmkid_found': False, 'file_exists': False})
|
||||
|
||||
file_size = os.path.getsize(capture_file)
|
||||
pmkid_found = False
|
||||
|
||||
try:
|
||||
hash_file = capture_file.replace('.pcapng', '.22000')
|
||||
result = subprocess.run(
|
||||
['hcxpcapngtool', '-o', hash_file, capture_file],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if os.path.exists(hash_file) and os.path.getsize(hash_file) > 0:
|
||||
pmkid_found = True
|
||||
except FileNotFoundError:
|
||||
pmkid_found = file_size > 1000
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'pmkid_found': pmkid_found,
|
||||
'file_exists': True,
|
||||
'file_size': file_size,
|
||||
'file': capture_file
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/pmkid/stop', methods=['POST'])
|
||||
def stop_pmkid():
|
||||
"""Stop PMKID capture."""
|
||||
global pmkid_process
|
||||
|
||||
with pmkid_lock:
|
||||
if pmkid_process:
|
||||
pmkid_process.terminate()
|
||||
try:
|
||||
pmkid_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
pmkid_process.kill()
|
||||
pmkid_process = None
|
||||
|
||||
return jsonify({'status': 'stopped'})
|
||||
|
||||
|
||||
@wifi_bp.route('/networks')
|
||||
def get_wifi_networks():
|
||||
"""Get current list of discovered networks."""
|
||||
return jsonify({
|
||||
'networks': list(app_module.wifi_networks.values()),
|
||||
'clients': list(app_module.wifi_clients.values()),
|
||||
'handshakes': app_module.wifi_handshakes,
|
||||
'monitor_interface': app_module.wifi_monitor_interface
|
||||
})
|
||||
|
||||
|
||||
@wifi_bp.route('/stream')
|
||||
def stream_wifi():
|
||||
"""SSE stream for WiFi events."""
|
||||
def generate():
|
||||
last_keepalive = time.time()
|
||||
keepalive_interval = 30.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = app_module.wifi_queue.get(timeout=1)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
@@ -0,0 +1,479 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# INTERCEPT Setup Script
|
||||
# Installs Python dependencies and checks for external tools
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}"
|
||||
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
|
||||
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
|
||||
echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
|
||||
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
|
||||
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
|
||||
echo -e "${NC}"
|
||||
echo "Signal Intelligence Platform - Setup Script"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Detect OS
|
||||
detect_os() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
PKG_MANAGER="brew"
|
||||
elif [[ -f /etc/debian_version ]]; then
|
||||
OS="debian"
|
||||
PKG_MANAGER="apt"
|
||||
elif [[ -f /etc/redhat-release ]]; then
|
||||
OS="redhat"
|
||||
PKG_MANAGER="dnf"
|
||||
elif [[ -f /etc/arch-release ]]; then
|
||||
OS="arch"
|
||||
PKG_MANAGER="pacman"
|
||||
else
|
||||
OS="unknown"
|
||||
PKG_MANAGER="unknown"
|
||||
fi
|
||||
echo -e "${BLUE}Detected OS:${NC} $OS (package manager: $PKG_MANAGER)"
|
||||
}
|
||||
|
||||
# Check if a command exists
|
||||
check_cmd() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
|
||||
# Check if a package is installable (has a candidate version)
|
||||
pkg_available() {
|
||||
local candidate
|
||||
candidate=$(apt-cache policy "$1" 2>/dev/null | grep "Candidate:" | awk '{print $2}')
|
||||
[ -n "$candidate" ] && [ "$candidate" != "(none)" ]
|
||||
}
|
||||
|
||||
# Setup sudo command (empty if running as root)
|
||||
setup_sudo() {
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO=""
|
||||
echo -e "${BLUE}Running as root${NC}"
|
||||
elif check_cmd sudo; then
|
||||
SUDO="sudo"
|
||||
else
|
||||
echo -e "${RED}Error: Not running as root and sudo is not installed${NC}"
|
||||
echo ""
|
||||
echo "Please either:"
|
||||
echo " 1. Run this script as root: su -c './setup.sh'"
|
||||
echo " 2. Install sudo: apt install sudo"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Install Python dependencies
|
||||
install_python_deps() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[1/3] Installing Python dependencies...${NC}"
|
||||
|
||||
if ! check_cmd python3; then
|
||||
echo -e "${RED}Error: Python 3 is not installed${NC}"
|
||||
echo "Please install Python 3.9 or later"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Python version (need 3.9+)
|
||||
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
|
||||
PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)')
|
||||
PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)')
|
||||
echo "Python version: $PYTHON_VERSION"
|
||||
|
||||
if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 9 ]); then
|
||||
echo -e "${RED}Error: Python 3.9 or later is required${NC}"
|
||||
echo "You have Python $PYTHON_VERSION"
|
||||
echo ""
|
||||
echo "Please upgrade Python:"
|
||||
if [ -n "$SUDO" ]; then
|
||||
echo " Ubuntu/Debian: sudo apt install python3.11"
|
||||
else
|
||||
echo " Ubuntu/Debian: apt install python3.11"
|
||||
fi
|
||||
echo " macOS: brew install python@3.11"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if we're in a virtual environment
|
||||
if [ -n "$VIRTUAL_ENV" ]; then
|
||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
||||
pip install -r requirements.txt
|
||||
elif [ -f "venv/bin/activate" ]; then
|
||||
echo "Found existing venv, activating..."
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
# Try direct pip install first, fall back to venv if it fails (PEP 668)
|
||||
echo "Attempting to install dependencies..."
|
||||
if python3 -m pip install -r requirements.txt 2>/dev/null; then
|
||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
# If pip install failed (likely PEP 668), create a virtual environment
|
||||
echo ""
|
||||
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
|
||||
echo "Creating virtual environment..."
|
||||
|
||||
# Remove any incomplete venv directory from previous failed attempts
|
||||
if [ -d "venv" ] && [ ! -f "venv/bin/activate" ]; then
|
||||
echo "Removing incomplete venv directory..."
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
if ! python3 -m venv venv; then
|
||||
echo -e "${RED}Error: Failed to create virtual environment${NC}"
|
||||
echo ""
|
||||
echo "On Debian/Ubuntu, install the venv module with:"
|
||||
if [ -n "$SUDO" ]; then
|
||||
echo " sudo apt install python3-venv"
|
||||
else
|
||||
echo " apt install python3-venv"
|
||||
fi
|
||||
echo ""
|
||||
echo "Then run this setup script again."
|
||||
exit 1
|
||||
fi
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
echo ""
|
||||
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
|
||||
echo "You must activate it before running INTERCEPT:"
|
||||
echo " source venv/bin/activate"
|
||||
if [ -n "$SUDO" ]; then
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
else
|
||||
echo " venv/bin/python intercept.py"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||
}
|
||||
|
||||
# Check external tools
|
||||
check_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[2/3] Checking external tools...${NC}"
|
||||
echo ""
|
||||
|
||||
MISSING_TOOLS=()
|
||||
MISSING_CORE=false
|
||||
MISSING_WIFI=false
|
||||
MISSING_BLUETOOTH=false
|
||||
|
||||
# Core SDR tools
|
||||
echo "Core SDR Tools:"
|
||||
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
|
||||
check_tool "rtl_test" "RTL-SDR device detection" "core"
|
||||
check_tool "multimon-ng" "Pager decoder" "core"
|
||||
check_tool "rtl_433" "433MHz sensor decoder" "core"
|
||||
check_tool "dump1090" "ADS-B decoder" "core"
|
||||
|
||||
echo ""
|
||||
echo "Additional SDR Hardware (optional):"
|
||||
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional"
|
||||
check_tool "LimeUtil" "LimeSDR tools" "optional"
|
||||
check_tool "hackrf_info" "HackRF tools" "optional"
|
||||
|
||||
echo ""
|
||||
echo "WiFi Tools:"
|
||||
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
|
||||
check_tool "airodump-ng" "WiFi scanner" "wifi"
|
||||
|
||||
echo ""
|
||||
echo "Bluetooth Tools:"
|
||||
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
|
||||
check_tool "hcitool" "Bluetooth HCI tool" "bluetooth"
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Some tools are missing.${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
check_tool() {
|
||||
local cmd=$1
|
||||
local desc=$2
|
||||
local category=$3
|
||||
if check_cmd "$cmd"; then
|
||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
||||
MISSING_TOOLS+=("$cmd")
|
||||
case "$category" in
|
||||
core) MISSING_CORE=true ;;
|
||||
wifi) MISSING_WIFI=true ;;
|
||||
bluetooth) MISSING_BLUETOOTH=true ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Install tools on Debian/Ubuntu
|
||||
install_debian_tools() {
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installing tools...${NC}"
|
||||
echo ""
|
||||
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}The following tool categories need to be installed:${NC}"
|
||||
$MISSING_CORE && echo " - Core SDR tools (rtl-sdr, multimon-ng, rtl-433, dump1090)"
|
||||
$MISSING_WIFI && echo " - WiFi tools (aircrack-ng)"
|
||||
$MISSING_BLUETOOTH && echo " - Bluetooth tools (bluez)"
|
||||
echo ""
|
||||
|
||||
read -p "Would you like to install missing tools automatically? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
echo ""
|
||||
echo "Updating package lists..."
|
||||
$SUDO apt update
|
||||
|
||||
# Core SDR tools
|
||||
if $MISSING_CORE; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Core SDR tools...${NC}"
|
||||
|
||||
# Install packages that are reliably available
|
||||
$SUDO apt install -y rtl-sdr multimon-ng
|
||||
|
||||
# rtl-433 may be named differently or unavailable
|
||||
if pkg_available rtl-433; then
|
||||
$SUDO apt install -y rtl-433
|
||||
elif pkg_available rtl433; then
|
||||
$SUDO apt install -y rtl433
|
||||
else
|
||||
echo -e "${YELLOW}Note: rtl-433 not found in repositories. Install manually or from source.${NC}"
|
||||
fi
|
||||
|
||||
# dump1090 - try available variants, not available on all Debian versions
|
||||
if pkg_available dump1090-fa; then
|
||||
$SUDO apt install -y dump1090-fa
|
||||
elif pkg_available dump1090-mutability; then
|
||||
$SUDO apt install -y dump1090-mutability
|
||||
elif pkg_available dump1090; then
|
||||
$SUDO apt install -y dump1090
|
||||
elif ! check_cmd dump1090; then
|
||||
echo ""
|
||||
echo -e "${YELLOW}Note: dump1090 not available in your repos (e.g. Debian Trixie).${NC}"
|
||||
echo " FlightAware version: https://flightaware.com/adsb/piaware/install"
|
||||
echo " Or from source: https://github.com/flightaware/dump1090"
|
||||
fi
|
||||
fi
|
||||
|
||||
# WiFi tools
|
||||
if $MISSING_WIFI; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing WiFi tools...${NC}"
|
||||
$SUDO apt install -y aircrack-ng
|
||||
fi
|
||||
|
||||
# Bluetooth tools
|
||||
if $MISSING_BLUETOOTH; then
|
||||
echo ""
|
||||
echo -e "${BLUE}Installing Bluetooth tools...${NC}"
|
||||
$SUDO apt install -y bluez bluetooth
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Tool installation complete!${NC}"
|
||||
|
||||
# Setup udev rules automatically
|
||||
setup_udev_rules_auto
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping automatic installation."
|
||||
show_manual_instructions
|
||||
fi
|
||||
}
|
||||
|
||||
# Setup udev rules automatically (Debian)
|
||||
setup_udev_rules_auto() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Setting up RTL-SDR udev rules...${NC}"
|
||||
|
||||
if [ -f /etc/udev/rules.d/20-rtlsdr.rules ]; then
|
||||
echo "udev rules already exist, skipping."
|
||||
return
|
||||
fi
|
||||
|
||||
read -p "Would you like to setup RTL-SDR udev rules? [Y/n] " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
|
||||
$SUDO bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
|
||||
EOF'
|
||||
$SUDO udevadm control --reload-rules
|
||||
$SUDO udevadm trigger
|
||||
echo -e "${GREEN}udev rules installed!${NC}"
|
||||
echo "Please unplug and replug your RTL-SDR device."
|
||||
fi
|
||||
}
|
||||
|
||||
# Show manual installation instructions
|
||||
show_manual_instructions() {
|
||||
echo ""
|
||||
echo -e "${BLUE}Manual installation instructions:${NC}"
|
||||
echo ""
|
||||
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
||||
echo ""
|
||||
|
||||
if ! check_cmd brew; then
|
||||
echo "First, install Homebrew:"
|
||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "# Core SDR tools"
|
||||
echo "brew install librtlsdr multimon-ng rtl_433 dump1090-mutability"
|
||||
echo ""
|
||||
echo "# LimeSDR support (optional)"
|
||||
echo "brew install soapysdr limesuite soapylms7"
|
||||
echo ""
|
||||
echo "# HackRF support (optional)"
|
||||
echo "brew install hackrf soapyhackrf"
|
||||
echo ""
|
||||
echo "# WiFi tools"
|
||||
echo "brew install aircrack-ng"
|
||||
|
||||
elif [[ "$OS" == "debian" ]]; then
|
||||
echo -e "${YELLOW}Ubuntu/Debian:${NC}"
|
||||
echo ""
|
||||
echo "# Core SDR tools"
|
||||
echo "sudo apt update"
|
||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433"
|
||||
echo ""
|
||||
echo "# dump1090 (try one of these - package name varies):"
|
||||
echo "sudo apt install dump1090-fa # FlightAware version"
|
||||
echo "# Or install from: https://flightaware.com/adsb/piaware/install"
|
||||
echo ""
|
||||
echo "# LimeSDR support (optional)"
|
||||
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
|
||||
echo ""
|
||||
echo "# HackRF support (optional)"
|
||||
echo "sudo apt install hackrf soapysdr-module-hackrf"
|
||||
echo ""
|
||||
echo "# WiFi tools"
|
||||
echo "sudo apt install aircrack-ng"
|
||||
echo ""
|
||||
echo "# Bluetooth tools"
|
||||
echo "sudo apt install bluez bluetooth"
|
||||
|
||||
elif [[ "$OS" == "arch" ]]; then
|
||||
echo -e "${YELLOW}Arch Linux:${NC}"
|
||||
echo ""
|
||||
echo "# Core SDR tools"
|
||||
echo "sudo pacman -S rtl-sdr multimon-ng"
|
||||
echo "yay -S rtl_433 dump1090"
|
||||
echo ""
|
||||
echo "# LimeSDR/HackRF support (optional)"
|
||||
echo "sudo pacman -S soapysdr limesuite hackrf"
|
||||
|
||||
elif [[ "$OS" == "redhat" ]]; then
|
||||
echo -e "${YELLOW}Fedora/RHEL:${NC}"
|
||||
echo ""
|
||||
echo "# Core SDR tools"
|
||||
echo "sudo dnf install rtl-sdr"
|
||||
echo "# multimon-ng, rtl_433, dump1090 may need to be built from source"
|
||||
|
||||
else
|
||||
echo "Please install the following tools manually:"
|
||||
for tool in "${MISSING_TOOLS[@]}"; do
|
||||
echo " - $tool"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Show installation instructions (decides auto vs manual)
|
||||
install_or_show_instructions() {
|
||||
if [[ "$OS" == "debian" ]]; then
|
||||
install_debian_tools
|
||||
else
|
||||
echo ""
|
||||
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
|
||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}All tools are installed!${NC}"
|
||||
else
|
||||
show_manual_instructions
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# RTL-SDR udev rules (Linux only)
|
||||
setup_udev_rules() {
|
||||
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
|
||||
echo ""
|
||||
echo -e "${BLUE}RTL-SDR udev rules (Linux only):${NC}"
|
||||
echo ""
|
||||
echo "If your RTL-SDR is not detected, you may need to add udev rules:"
|
||||
echo ""
|
||||
echo "sudo bash -c 'cat > /etc/udev/rules.d/20-rtlsdr.rules << EOF"
|
||||
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"'
|
||||
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"'
|
||||
echo "EOF'"
|
||||
echo ""
|
||||
echo "sudo udevadm control --reload-rules"
|
||||
echo "sudo udevadm trigger"
|
||||
echo ""
|
||||
echo "Then unplug and replug your RTL-SDR device."
|
||||
fi
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
detect_os
|
||||
setup_sudo
|
||||
install_python_deps
|
||||
check_tools
|
||||
install_or_show_instructions
|
||||
|
||||
# Show udev rules instructions for non-Debian Linux (Debian handles it automatically)
|
||||
if [[ "$OS" != "debian" ]]; then
|
||||
setup_udev_rules
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "To start INTERCEPT:"
|
||||
if [ -d "venv" ]; then
|
||||
echo " source venv/bin/activate"
|
||||
if [ -n "$SUDO" ]; then
|
||||
echo " sudo venv/bin/python intercept.py"
|
||||
else
|
||||
echo " venv/bin/python intercept.py"
|
||||
fi
|
||||
else
|
||||
if [ -n "$SUDO" ]; then
|
||||
echo " sudo python3 intercept.py"
|
||||
else
|
||||
echo " python3 intercept.py"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
echo "Then open http://localhost:5050 in your browser"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,643 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--border-glow: #00ff88;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent-green: #00ff88;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-orange: #ff9500;
|
||||
--accent-red: #ff4444;
|
||||
--accent-yellow: #ffcc00;
|
||||
--grid-line: rgba(0, 255, 136, 0.1);
|
||||
--radar-cyan: #00ffff;
|
||||
--radar-bg: #1a1a2e;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated radar sweep background */
|
||||
.radar-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Scan line effect */
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
||||
animation: scan 4s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 255, 136, 0.1) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.3);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 4px;
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
margin-left: 15px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.inactive {
|
||||
background: var(--accent-red);
|
||||
box-shadow: 0 0 10px var(--accent-red);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stats badges in header */
|
||||
.stats-badges {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-badge .value {
|
||||
color: var(--accent-green);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-badge .label {
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.datetime {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-green);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--accent-green);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Main dashboard grid */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 15px;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.1);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-green);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Main display container (map + radar scope) */
|
||||
.main-display {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.display-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#radarMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#radarScope {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
background: var(--radar-bg);
|
||||
}
|
||||
|
||||
#radarScope.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#radarCanvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Right sidebar */
|
||||
.sidebar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid rgba(0, 255, 136, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* View toggle */
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.view-btn.active {
|
||||
background: var(--accent-green);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Selected aircraft panel */
|
||||
.selected-aircraft {
|
||||
flex-shrink: 0;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.selected-callsign {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 15px var(--accent-green);
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.telemetry-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.telemetry-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border-left: 2px solid var(--accent-green);
|
||||
}
|
||||
|
||||
.telemetry-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Aircraft list */
|
||||
.aircraft-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aircraft-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.aircraft-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 255, 136, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.aircraft-item:hover {
|
||||
border-color: var(--accent-green);
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.aircraft-item.selected {
|
||||
border-color: var(--accent-green);
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.2);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
.aircraft-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aircraft-callsign {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.aircraft-icao {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.aircraft-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.aircraft-detail {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aircraft-detail-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--accent-cyan);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.aircraft-detail-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Bottom controls bar */
|
||||
.controls-bar {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.control-group input[type="checkbox"] {
|
||||
accent-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-green);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.control-group input[type="text"] {
|
||||
width: 80px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-green);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Start/stop button */
|
||||
.start-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid var(--accent-green);
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
color: var(--accent-green);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: var(--accent-green);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.start-btn.active {
|
||||
background: var(--accent-red);
|
||||
border-color: var(--accent-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.start-btn.active:hover {
|
||||
box-shadow: 0 0 20px rgba(255, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* GPS button */
|
||||
.gps-btn {
|
||||
padding: 6px 10px;
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-green);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-container {
|
||||
background: var(--bg-dark) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-panel) !important;
|
||||
color: var(--accent-green) !important;
|
||||
border-color: rgba(0, 255, 136, 0.3) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-green);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* No aircraft message */
|
||||
.no-aircraft {
|
||||
text-align: center;
|
||||
padding: 30px 15px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-aircraft-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1000px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
}
|
||||
|
||||
.main-display {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(0, 255, 136, 0.2);
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
grid-row: 3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-dark: #0a0a0f;
|
||||
--bg-panel: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--border-glow: #00d4ff;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-green: #00ff88;
|
||||
--accent-orange: #ff9500;
|
||||
--accent-red: #ff4444;
|
||||
--accent-purple: #a855f7;
|
||||
--grid-line: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Animated grid background */
|
||||
.grid-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
animation: gridMove 20s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(50px, 50px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scan line effect */
|
||||
.scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
animation: scan 3s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 212, 255, 0.1) 0%, transparent 100%);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 4px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
margin-left: 15px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* Stats badges in header */
|
||||
.stats-badges {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stat-badge .value {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-badge .value.highlight {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.stat-badge .label {
|
||||
color: var(--text-secondary);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 10px var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.datetime {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Main dashboard grid */
|
||||
.dashboard {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 340px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 0;
|
||||
height: calc(100vh - 60px);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 10px 15px;
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.1);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-cyan);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 12px;
|
||||
height: calc(100% - 40px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Polar plot */
|
||||
.polar-container {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#polarPlot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Ground track map */
|
||||
.map-container {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#groundMap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Right sidebar */
|
||||
.sidebar {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid rgba(0, 212, 255, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Satellite selector at top of sidebar */
|
||||
.satellite-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.satellite-selector label {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.satellite-selector select {
|
||||
flex: 1;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.satellite-selector select:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 15px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Countdown panel */
|
||||
.countdown-panel {
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 255, 0.1) 0%, rgba(0, 255, 136, 0.05) 100%);
|
||||
}
|
||||
|
||||
.countdown-display {
|
||||
text-align: center;
|
||||
padding: 15px 10px;
|
||||
}
|
||||
|
||||
.next-pass-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.satellite-name {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 15px var(--accent-cyan);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.countdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.countdown-block {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.countdown-value {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 10px var(--accent-cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.countdown-value.active {
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 15px var(--accent-green);
|
||||
animation: countPulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes countPulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Telemetry panel */
|
||||
.telemetry-panel {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.telemetry-rows {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.telemetry-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.telemetry-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.telemetry-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Pass list */
|
||||
.pass-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pass-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pass-item {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 212, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pass-item:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
.pass-item.active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
.pass-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pass-sat-name {
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.pass-quality {
|
||||
font-size: 8px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pass-quality.excellent {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.pass-quality.good {
|
||||
background: rgba(0, 212, 255, 0.2);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.pass-quality.fair {
|
||||
background: rgba(255, 149, 0, 0.2);
|
||||
color: var(--accent-orange);
|
||||
}
|
||||
|
||||
.pass-item-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pass-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Bottom controls bar */
|
||||
.controls-bar {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.control-group input[type="text"],
|
||||
.control-group input[type="number"] {
|
||||
width: 90px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.control-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 10px rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent-cyan);
|
||||
color: var(--bg-dark);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
box-shadow: 0 0 25px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Leaflet dark theme overrides */
|
||||
.leaflet-container {
|
||||
background: var(--bg-dark) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
background: var(--bg-panel) !important;
|
||||
color: var(--accent-cyan) !important;
|
||||
border-color: rgba(0, 212, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-attribution {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr auto auto;
|
||||
}
|
||||
|
||||
.polar-container {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
flex-direction: row;
|
||||
border-left: none;
|
||||
border-top: 1px solid rgba(0, 212, 255, 0.2);
|
||||
max-height: 250px;
|
||||
}
|
||||
|
||||
.sidebar>* {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
grid-row: 3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
}
|
||||
|
||||
.polar-container,
|
||||
.map-container {
|
||||
grid-column: 1;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
flex-direction: column;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.controls-bar {
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 969 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,860 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SATELLITE COMMAND // INTERCEPT</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid-bg"></div>
|
||||
<div class="scanline"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="logo">
|
||||
SATELLITE COMMAND
|
||||
<span>// INTERCEPT</span>
|
||||
</div>
|
||||
<div class="stats-badges">
|
||||
<div class="stat-badge">
|
||||
<span class="value" id="statTracked">7</span>
|
||||
<span class="label">satellites</span>
|
||||
</div>
|
||||
<div class="stat-badge">
|
||||
<span class="value highlight" id="statVisible">0</span>
|
||||
<span class="label">visible</span>
|
||||
</div>
|
||||
<div class="stat-badge">
|
||||
<span class="value" id="statPasses">0</span>
|
||||
<span class="label">passes</span>
|
||||
</div>
|
||||
<div class="stat-badge">
|
||||
<span class="value" id="statMaxEl">0</span>
|
||||
<span class="label">best el</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<div class="status-item">
|
||||
<div class="status-dot" id="trackingDot"></div>
|
||||
<span id="trackingStatus">TRACKING</span>
|
||||
</div>
|
||||
<div class="datetime" id="utcTime">--:--:-- UTC</div>
|
||||
<a href="/?mode=satellite" class="back-link">Main Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dashboard">
|
||||
<!-- Polar Plot -->
|
||||
<div class="panel polar-container">
|
||||
<div class="panel-header">
|
||||
<span>SKY VIEW // POLAR PLOT</span>
|
||||
<div class="panel-indicator"></div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<canvas id="polarPlot"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ground Track Map -->
|
||||
<div class="panel map-container">
|
||||
<div class="panel-header">
|
||||
<span>GROUND TRACK // WORLD VIEW</span>
|
||||
<div class="panel-indicator"></div>
|
||||
</div>
|
||||
<div class="panel-content" style="padding: 0;">
|
||||
<div id="groundMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar">
|
||||
<!-- Satellite Selector -->
|
||||
<div class="satellite-selector">
|
||||
<label>TARGET:</label>
|
||||
<select id="satSelect" onchange="onSatelliteChange()">
|
||||
<option value="25544">ISS (ZARYA)</option>
|
||||
<option value="25338">NOAA 15</option>
|
||||
<option value="28654">NOAA 18</option>
|
||||
<option value="33591">NOAA 19</option>
|
||||
<option value="40069">METEOR-M2</option>
|
||||
<option value="43013">NOAA 20</option>
|
||||
<option value="54234">METEOR-M2-3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Countdown -->
|
||||
<div class="panel countdown-panel">
|
||||
<div class="panel-header">
|
||||
<span>NEXT PASS</span>
|
||||
<div class="panel-indicator"></div>
|
||||
</div>
|
||||
<div class="countdown-display">
|
||||
<div class="next-pass-label">Incoming Signal</div>
|
||||
<div class="satellite-name" id="countdownSat">AWAITING DATA</div>
|
||||
<div class="countdown-grid">
|
||||
<div class="countdown-block">
|
||||
<div class="countdown-value" id="countDays">--</div>
|
||||
<div class="countdown-label">Days</div>
|
||||
</div>
|
||||
<div class="countdown-block">
|
||||
<div class="countdown-value" id="countHours">--</div>
|
||||
<div class="countdown-label">Hours</div>
|
||||
</div>
|
||||
<div class="countdown-block">
|
||||
<div class="countdown-value" id="countMins">--</div>
|
||||
<div class="countdown-label">Mins</div>
|
||||
</div>
|
||||
<div class="countdown-block">
|
||||
<div class="countdown-value" id="countSecs">--</div>
|
||||
<div class="countdown-label">Secs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry -->
|
||||
<div class="panel telemetry-panel">
|
||||
<div class="panel-header">
|
||||
<span>LIVE TELEMETRY</span>
|
||||
<div class="panel-indicator"></div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="telemetry-rows">
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Latitude</div>
|
||||
<div class="telemetry-value" id="telLat">---.----</div>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Longitude</div>
|
||||
<div class="telemetry-value" id="telLon">---.----</div>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Altitude</div>
|
||||
<div class="telemetry-value" id="telAlt">--- km</div>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Elevation</div>
|
||||
<div class="telemetry-value" id="telEl">--.-</div>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Azimuth</div>
|
||||
<div class="telemetry-value" id="telAz">---.-</div>
|
||||
</div>
|
||||
<div class="telemetry-item">
|
||||
<div class="telemetry-label">Distance</div>
|
||||
<div class="telemetry-value" id="telDist">---- km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pass List -->
|
||||
<div class="panel pass-list">
|
||||
<div class="panel-header">
|
||||
<span>UPCOMING PASSES <span id="passCount" style="color: var(--accent-cyan);"></span></span>
|
||||
<div class="panel-indicator"></div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="pass-list-content" id="passList">
|
||||
<div style="text-align:center;color:var(--text-secondary);padding:20px;">
|
||||
Calculating passes...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls Bar -->
|
||||
<div class="controls-bar">
|
||||
<div class="control-group">
|
||||
<span class="control-label">Lat:</span>
|
||||
<input type="number" id="obsLat" value="51.5074" step="0.0001">
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<span class="control-label">Lon:</span>
|
||||
<input type="number" id="obsLon" value="-0.1278" step="0.0001">
|
||||
</div>
|
||||
<button class="btn" onclick="getLocation()">GPS</button>
|
||||
<button class="btn primary" onclick="calculatePasses()">CALCULATE</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Dashboard state
|
||||
let passes = [];
|
||||
let selectedPass = null;
|
||||
let groundMap = null;
|
||||
let satMarker = null;
|
||||
let trackLine = null;
|
||||
let observerMarker = null;
|
||||
let orbitTrack = null;
|
||||
let selectedSatellite = 25544;
|
||||
|
||||
const satellites = {
|
||||
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
|
||||
25338: { name: 'NOAA 15', color: '#00ff00' },
|
||||
28654: { name: 'NOAA 18', color: '#ff6600' },
|
||||
33591: { name: 'NOAA 19', color: '#ff3366' },
|
||||
40069: { name: 'METEOR-M2', color: '#9370DB' },
|
||||
43013: { name: 'NOAA 20', color: '#00ffaa' },
|
||||
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
|
||||
};
|
||||
|
||||
function onSatelliteChange() {
|
||||
const select = document.getElementById('satSelect');
|
||||
selectedSatellite = parseInt(select.value);
|
||||
const satName = satellites[selectedSatellite]?.name || 'Unknown';
|
||||
|
||||
document.getElementById('trackingStatus').textContent = 'ACQUIRING';
|
||||
document.getElementById('trackingDot').style.background = 'var(--accent-orange)';
|
||||
|
||||
selectedPass = null;
|
||||
passes = [];
|
||||
|
||||
if (groundMap) {
|
||||
if (trackLine) { groundMap.removeLayer(trackLine); trackLine = null; }
|
||||
if (satMarker) { groundMap.removeLayer(satMarker); satMarker = null; }
|
||||
if (orbitTrack) { groundMap.removeLayer(orbitTrack); orbitTrack = null; }
|
||||
}
|
||||
|
||||
calculatePasses();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initGroundMap();
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
setInterval(updateCountdown, 1000);
|
||||
setInterval(updateRealTimePositions, 5000);
|
||||
getLocation();
|
||||
});
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
document.getElementById('utcTime').textContent =
|
||||
now.toISOString().substring(11, 19) + ' UTC';
|
||||
}
|
||||
|
||||
function initGroundMap() {
|
||||
groundMap = L.map('groundMap', {
|
||||
center: [20, 0],
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: 10,
|
||||
worldCopyJump: true
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution: '©OpenStreetMap, ©CartoDB'
|
||||
}).addTo(groundMap);
|
||||
}
|
||||
|
||||
function getLocation() {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(pos => {
|
||||
document.getElementById('obsLat').value = pos.coords.latitude.toFixed(4);
|
||||
document.getElementById('obsLon').value = pos.coords.longitude.toFixed(4);
|
||||
calculatePasses();
|
||||
}, () => {
|
||||
calculatePasses();
|
||||
});
|
||||
} else {
|
||||
calculatePasses();
|
||||
}
|
||||
}
|
||||
|
||||
async function calculatePasses() {
|
||||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||||
const satName = satellites[selectedSatellite]?.name || 'Unknown';
|
||||
|
||||
try {
|
||||
const response = await fetch('/satellite/predict', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: 48,
|
||||
minEl: 5,
|
||||
satellites: [selectedSatellite]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
passes = data.passes;
|
||||
renderPassList();
|
||||
updateStats();
|
||||
if (passes.length > 0) {
|
||||
selectPass(0);
|
||||
}
|
||||
updateObserverMarker(lat, lon);
|
||||
|
||||
document.getElementById('trackingStatus').textContent = 'TRACKING';
|
||||
document.getElementById('trackingDot').style.background = 'var(--accent-green)';
|
||||
} else {
|
||||
document.getElementById('trackingStatus').textContent = 'ERROR';
|
||||
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Pass calculation error:', err);
|
||||
document.getElementById('trackingStatus').textContent = 'OFFLINE';
|
||||
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPassList() {
|
||||
const container = document.getElementById('passList');
|
||||
const countEl = document.getElementById('passCount');
|
||||
|
||||
if (passes.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">No passes found</div>';
|
||||
if (countEl) countEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (countEl) countEl.textContent = `(${passes.length})`;
|
||||
|
||||
container.innerHTML = passes.slice(0, 10).map((pass, idx) => {
|
||||
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
|
||||
const qualityText = pass.maxEl >= 60 ? 'EXCELLENT' : pass.maxEl >= 30 ? 'GOOD' : 'FAIR';
|
||||
const time = pass.startTime.split(' ')[1] || pass.startTime;
|
||||
|
||||
return `
|
||||
<div class="pass-item ${selectedPass === idx ? 'active' : ''}" onclick="selectPass(${idx})">
|
||||
<div class="pass-item-header">
|
||||
<span class="pass-sat-name">${pass.satellite}</span>
|
||||
<span class="pass-quality ${quality}">${qualityText}</span>
|
||||
</div>
|
||||
<div class="pass-item-details">
|
||||
<span class="pass-time">${time}</span>
|
||||
<span>${pass.maxEl.toFixed(0)}° · ${pass.duration} min</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectPass(idx) {
|
||||
selectedPass = idx;
|
||||
renderPassList();
|
||||
|
||||
const pass = passes[idx];
|
||||
if (!pass) return;
|
||||
|
||||
drawPolarPlot(pass);
|
||||
updateGroundTrack(pass);
|
||||
updateTelemetry(pass);
|
||||
updateRealTimePositions(true);
|
||||
}
|
||||
|
||||
function drawPolarPlot(pass) {
|
||||
const canvas = document.getElementById('polarPlot');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height - 20;
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Elevation rings
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let el = 30; el <= 90; el += 30) {
|
||||
const r = radius * (1 - el / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
||||
ctx.font = '10px JetBrains Mono';
|
||||
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
||||
}
|
||||
|
||||
// Horizon
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Cardinal lines
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let az = 0; az < 360; az += 45) {
|
||||
const angle = (az - 90) * Math.PI / 180;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Cardinal labels
|
||||
ctx.font = 'bold 14px Orbitron';
|
||||
const labels = [
|
||||
{ text: 'N', az: 0, color: '#ff4444' },
|
||||
{ text: 'E', az: 90, color: '#00d4ff' },
|
||||
{ text: 'S', az: 180, color: '#00d4ff' },
|
||||
{ text: 'W', az: 270, color: '#00d4ff' }
|
||||
];
|
||||
labels.forEach(l => {
|
||||
const angle = (l.az - 90) * Math.PI / 180;
|
||||
const x = cx + (radius + 20) * Math.cos(angle);
|
||||
const y = cy + (radius + 20) * Math.sin(angle);
|
||||
ctx.fillStyle = l.color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(l.text, x, y);
|
||||
});
|
||||
|
||||
// Pass trajectory
|
||||
if (pass && pass.trajectory) {
|
||||
ctx.strokeStyle = pass.color || '#00d4ff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([8, 4]);
|
||||
ctx.beginPath();
|
||||
|
||||
let maxElPoint = null;
|
||||
let maxEl = 0;
|
||||
|
||||
pass.trajectory.forEach((pt, i) => {
|
||||
const r = radius * (1 - pt.el / 90);
|
||||
const angle = (pt.az - 90) * Math.PI / 180;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
|
||||
if (pt.el > maxEl) {
|
||||
maxEl = pt.el;
|
||||
maxElPoint = { x, y };
|
||||
}
|
||||
});
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (maxElPoint) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(maxElPoint.x, maxElPoint.y, 8, 0, Math.PI * 2);
|
||||
ctx.fillStyle = pass.color || '#00d4ff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGroundTrack(pass) {
|
||||
if (!groundMap) return;
|
||||
|
||||
if (trackLine) { groundMap.removeLayer(trackLine); trackLine = null; }
|
||||
if (satMarker) { groundMap.removeLayer(satMarker); satMarker = null; }
|
||||
if (orbitTrack) { groundMap.removeLayer(orbitTrack); orbitTrack = null; }
|
||||
|
||||
if (pass && pass.groundTrack) {
|
||||
const segments = [];
|
||||
let currentSegment = [];
|
||||
|
||||
for (let i = 0; i < pass.groundTrack.length; i++) {
|
||||
const p = pass.groundTrack[i];
|
||||
if (currentSegment.length > 0) {
|
||||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||||
if (crossesAntimeridian) {
|
||||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
}
|
||||
currentSegment.push([p.lat, p.lon]);
|
||||
}
|
||||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||||
|
||||
trackLine = L.layerGroup();
|
||||
const allCoords = [];
|
||||
segments.forEach(seg => {
|
||||
L.polyline(seg, {
|
||||
color: pass.color || '#00d4ff',
|
||||
weight: 4,
|
||||
opacity: 1.0
|
||||
}).addTo(trackLine);
|
||||
allCoords.push(...seg);
|
||||
});
|
||||
trackLine.addTo(groundMap);
|
||||
|
||||
if (pass.currentPos) {
|
||||
const satIcon = L.divIcon({
|
||||
className: 'sat-marker',
|
||||
html: `<div style="width: 16px; height: 16px; background: ${pass.color || '#00d4ff'}; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 20px ${pass.color || '#00d4ff'};"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
|
||||
satMarker = L.marker([pass.currentPos.lat, pass.currentPos.lon], { icon: satIcon })
|
||||
.addTo(groundMap)
|
||||
.bindPopup(`<b>${pass.name}</b><br>Alt: ${pass.currentPos.alt?.toFixed(0)} km`);
|
||||
}
|
||||
|
||||
if (allCoords.length > 0) {
|
||||
groundMap.fitBounds(L.latLngBounds(allCoords), { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateObserverMarker(lat, lon) {
|
||||
if (!groundMap) return;
|
||||
|
||||
if (observerMarker) groundMap.removeLayer(observerMarker);
|
||||
|
||||
const obsIcon = L.divIcon({
|
||||
className: 'obs-marker',
|
||||
html: `<div style="width: 12px; height: 12px; background: #ff9500; border-radius: 50%; border: 2px solid #fff; box-shadow: 0 0 15px #ff9500;"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6]
|
||||
});
|
||||
|
||||
observerMarker = L.marker([lat, lon], { icon: obsIcon })
|
||||
.addTo(groundMap)
|
||||
.bindPopup('Observer Location');
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('statTracked').textContent = Object.keys(satellites).length;
|
||||
document.getElementById('statPasses').textContent = passes.length;
|
||||
|
||||
const maxEl = passes.reduce((max, p) => Math.max(max, p.maxEl || 0), 0);
|
||||
document.getElementById('statMaxEl').textContent = maxEl.toFixed(0) + '°';
|
||||
}
|
||||
|
||||
function updateTelemetry(pass) {
|
||||
if (!pass || !pass.currentPos) {
|
||||
document.getElementById('telLat').textContent = '---.----';
|
||||
document.getElementById('telLon').textContent = '---.----';
|
||||
document.getElementById('telAlt').textContent = '--- km';
|
||||
document.getElementById('telEl').textContent = '--.-';
|
||||
document.getElementById('telAz').textContent = '---.-';
|
||||
document.getElementById('telDist').textContent = '---- km';
|
||||
return;
|
||||
}
|
||||
|
||||
const pos = pass.currentPos;
|
||||
document.getElementById('telLat').textContent = (pos.lat || 0).toFixed(4) + '°';
|
||||
document.getElementById('telLon').textContent = (pos.lon || 0).toFixed(4) + '°';
|
||||
document.getElementById('telAlt').textContent = (pos.alt || 0).toFixed(0) + ' km';
|
||||
document.getElementById('telEl').textContent = (pos.el || 0).toFixed(1) + '°';
|
||||
document.getElementById('telAz').textContent = (pos.az || 0).toFixed(1) + '°';
|
||||
document.getElementById('telDist').textContent = (pos.dist || 0).toFixed(0) + ' km';
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!passes || passes.length === 0) {
|
||||
document.getElementById('countdownSat').textContent = 'NO PASSES FOUND';
|
||||
document.getElementById('countDays').textContent = '--';
|
||||
document.getElementById('countHours').textContent = '--';
|
||||
document.getElementById('countMins').textContent = '--';
|
||||
document.getElementById('countSecs').textContent = '--';
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let nextPass = null;
|
||||
|
||||
for (const pass of passes) {
|
||||
const start = new Date(pass.startTimeISO);
|
||||
if (start > now) {
|
||||
nextPass = pass;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!nextPass) nextPass = passes[0];
|
||||
|
||||
document.getElementById('countdownSat').textContent = nextPass.satellite;
|
||||
|
||||
const passTime = new Date(nextPass.startTimeISO);
|
||||
const diff = passTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
document.getElementById('countDays').textContent = '00';
|
||||
document.getElementById('countHours').textContent = '00';
|
||||
document.getElementById('countMins').textContent = '00';
|
||||
document.getElementById('countSecs').textContent = '00';
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const secs = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
document.getElementById('countDays').textContent = days.toString().padStart(2, '0');
|
||||
document.getElementById('countHours').textContent = hours.toString().padStart(2, '0');
|
||||
document.getElementById('countMins').textContent = mins.toString().padStart(2, '0');
|
||||
document.getElementById('countSecs').textContent = secs.toString().padStart(2, '0');
|
||||
|
||||
const elements = ['countDays', 'countHours', 'countMins', 'countSecs'].map(id => document.getElementById(id));
|
||||
if (diff < 60000) {
|
||||
elements.forEach(el => el.classList.add('active'));
|
||||
} else {
|
||||
elements.forEach(el => el.classList.remove('active'));
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRealTimePositions(fitBoundsToOrbit = false) {
|
||||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||||
|
||||
let targetSatellite = selectedSatellite;
|
||||
let satColor = satellites[selectedSatellite]?.color || '#00d4ff';
|
||||
|
||||
if (selectedPass !== null && passes[selectedPass]) {
|
||||
const pass = passes[selectedPass];
|
||||
targetSatellite = pass.satellite;
|
||||
satColor = pass.color || satColor;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/satellite/position', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
satellites: [targetSatellite],
|
||||
includeTrack: true
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success' && data.positions.length > 0) {
|
||||
const pos = data.positions[0];
|
||||
|
||||
document.getElementById('telLat').textContent = pos.lat.toFixed(4) + '°';
|
||||
document.getElementById('telLon').textContent = pos.lon.toFixed(4) + '°';
|
||||
document.getElementById('telAlt').textContent = pos.altitude.toFixed(0) + ' km';
|
||||
document.getElementById('telEl').textContent = pos.elevation.toFixed(1) + '°';
|
||||
document.getElementById('telAz').textContent = pos.azimuth.toFixed(1) + '°';
|
||||
document.getElementById('telDist').textContent = pos.distance.toFixed(0) + ' km';
|
||||
|
||||
document.getElementById('statVisible').textContent = pos.elevation > 0 ? '1' : '0';
|
||||
|
||||
if (groundMap) {
|
||||
if (satMarker) groundMap.removeLayer(satMarker);
|
||||
|
||||
const satIcon = L.divIcon({
|
||||
className: 'sat-marker-live',
|
||||
html: `<div style="width: 20px; height: 20px; background: ${satColor}; border-radius: 50%; border: 3px solid #fff; box-shadow: 0 0 20px ${satColor}, 0 0 40px ${satColor};"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
});
|
||||
satMarker = L.marker([pos.lat, pos.lon], { icon: satIcon }).addTo(groundMap);
|
||||
}
|
||||
|
||||
if (pos.track && groundMap) {
|
||||
if (orbitTrack) groundMap.removeLayer(orbitTrack);
|
||||
|
||||
const segments = [];
|
||||
let currentSegment = [];
|
||||
|
||||
for (let i = 0; i < pos.track.length; i++) {
|
||||
const p = pos.track[i];
|
||||
if (currentSegment.length > 0) {
|
||||
const prevLon = currentSegment[currentSegment.length - 1][1];
|
||||
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
|
||||
if (crossesAntimeridian) {
|
||||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||||
currentSegment = [];
|
||||
}
|
||||
}
|
||||
currentSegment.push([p.lat, p.lon]);
|
||||
}
|
||||
if (currentSegment.length >= 1) segments.push(currentSegment);
|
||||
|
||||
orbitTrack = L.layerGroup();
|
||||
const allOrbitCoords = [];
|
||||
segments.forEach(seg => {
|
||||
L.polyline(seg, {
|
||||
color: satColor,
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '5, 5'
|
||||
}).addTo(orbitTrack);
|
||||
allOrbitCoords.push(...seg);
|
||||
});
|
||||
orbitTrack.addTo(groundMap);
|
||||
|
||||
if (fitBoundsToOrbit && allOrbitCoords.length > 0) {
|
||||
allOrbitCoords.push([lat, lon]);
|
||||
groundMap.fitBounds(L.latLngBounds(allOrbitCoords), { padding: [30, 30] });
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedPass !== null && passes[selectedPass]) {
|
||||
drawPolarPlot(passes[selectedPass]);
|
||||
drawCurrentPositionOnPolar(pos.azimuth, pos.elevation, satColor);
|
||||
} else {
|
||||
drawPolarPlotWithPosition(pos.azimuth, pos.elevation, satColor);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Position update error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPolarPlotWithPosition(az, el, color) {
|
||||
const canvas = document.getElementById('polarPlot');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const rect = canvas.parentElement.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height - 20;
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
|
||||
ctx.fillStyle = '#0a0a0f';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let elRing = 30; elRing <= 90; elRing += 30) {
|
||||
const r = radius * (1 - elRing / 90);
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
||||
ctx.font = '10px JetBrains Mono';
|
||||
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let azLine = 0; azLine < 360; azLine += 45) {
|
||||
const angle = (azLine - 90) * Math.PI / 180;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.font = 'bold 14px Orbitron';
|
||||
const labels = [
|
||||
{ text: 'N', az: 0, color: '#ff4444' },
|
||||
{ text: 'E', az: 90, color: '#00d4ff' },
|
||||
{ text: 'S', az: 180, color: '#00d4ff' },
|
||||
{ text: 'W', az: 270, color: '#00d4ff' }
|
||||
];
|
||||
labels.forEach(l => {
|
||||
const angle = (l.az - 90) * Math.PI / 180;
|
||||
const x = cx + (radius + 20) * Math.cos(angle);
|
||||
const y = cy + (radius + 20) * Math.sin(angle);
|
||||
ctx.fillStyle = l.color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(l.text, x, y);
|
||||
});
|
||||
|
||||
if (el > -5) {
|
||||
const posEl = Math.max(0, el);
|
||||
const r = radius * (1 - posEl / 90);
|
||||
const angle = (az - 90) * Math.PI / 180;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 25, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = 'bold 11px Orbitron';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
||||
|
||||
ctx.font = '10px JetBrains Mono';
|
||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||
} else {
|
||||
ctx.font = '12px Rajdhani';
|
||||
ctx.fillStyle = '#ff4444';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCurrentPositionOnPolar(az, el, color) {
|
||||
const canvas = document.getElementById('polarPlot');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = Math.min(cx, cy) - 40;
|
||||
|
||||
if (el > -5) {
|
||||
const posEl = Math.max(0, el);
|
||||
const r = radius * (1 - posEl / 90);
|
||||
const angle = (az - 90) * Math.PI / 180;
|
||||
const x = cx + r * Math.cos(angle);
|
||||
const y = cy + r * Math.sin(angle);
|
||||
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, 'transparent');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 25, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 10, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.font = 'bold 11px Orbitron';
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
||||
|
||||
ctx.font = '10px JetBrains Mono';
|
||||
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
||||
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Pytest configuration and fixtures."""
|
||||
|
||||
import pytest
|
||||
from app import app as flask_app
|
||||
from routes import register_blueprints
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing."""
|
||||
register_blueprints(flask_app)
|
||||
flask_app.config['TESTING'] = True
|
||||
return flask_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for main application routes."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_index_page(client):
|
||||
"""Test that index page loads."""
|
||||
response = client.get('/')
|
||||
assert response.status_code == 200
|
||||
assert b'INTERCEPT' in response.data
|
||||
|
||||
|
||||
def test_dependencies_endpoint(client):
|
||||
"""Test dependencies endpoint returns valid JSON."""
|
||||
response = client.get('/dependencies')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'modes' in data
|
||||
assert 'os' in data
|
||||
|
||||
|
||||
def test_devices_endpoint(client):
|
||||
"""Test devices endpoint returns list."""
|
||||
response = client.get('/devices')
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
def test_satellite_dashboard(client):
|
||||
"""Test satellite dashboard loads."""
|
||||
response = client.get('/satellite/dashboard')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_adsb_dashboard(client):
|
||||
"""Test ADS-B dashboard loads."""
|
||||
response = client.get('/adsb/dashboard')
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for configuration module."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestConfigEnvVars:
|
||||
"""Tests for environment variable configuration."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test that default values are set."""
|
||||
from config import PORT, HOST, DEBUG
|
||||
|
||||
assert PORT == 5050
|
||||
assert HOST == '0.0.0.0'
|
||||
assert DEBUG is False
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
"""Test that environment variables override defaults."""
|
||||
monkeypatch.setenv('INTERCEPT_PORT', '8080')
|
||||
monkeypatch.setenv('INTERCEPT_DEBUG', 'true')
|
||||
|
||||
# Re-import to get new values
|
||||
import importlib
|
||||
import config
|
||||
importlib.reload(config)
|
||||
|
||||
assert config.PORT == 8080
|
||||
assert config.DEBUG is True
|
||||
|
||||
# Reset
|
||||
monkeypatch.delenv('INTERCEPT_PORT', raising=False)
|
||||
monkeypatch.delenv('INTERCEPT_DEBUG', raising=False)
|
||||
importlib.reload(config)
|
||||
|
||||
def test_invalid_env_values(self, monkeypatch):
|
||||
"""Test that invalid env values fall back to defaults."""
|
||||
monkeypatch.setenv('INTERCEPT_PORT', 'invalid')
|
||||
|
||||
import importlib
|
||||
import config
|
||||
importlib.reload(config)
|
||||
|
||||
# Should fall back to default
|
||||
assert config.PORT == 5050
|
||||
|
||||
monkeypatch.delenv('INTERCEPT_PORT', raising=False)
|
||||
importlib.reload(config)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for utility modules."""
|
||||
|
||||
import pytest
|
||||
from utils.process import is_valid_mac, is_valid_channel
|
||||
from utils.dependencies import check_tool
|
||||
from data.oui import get_manufacturer
|
||||
|
||||
|
||||
class TestMacValidation:
|
||||
"""Tests for MAC address validation."""
|
||||
|
||||
def test_valid_mac(self):
|
||||
"""Test valid MAC addresses."""
|
||||
assert is_valid_mac('AA:BB:CC:DD:EE:FF') is True
|
||||
assert is_valid_mac('aa:bb:cc:dd:ee:ff') is True
|
||||
assert is_valid_mac('00:11:22:33:44:55') is True
|
||||
|
||||
def test_invalid_mac(self):
|
||||
"""Test invalid MAC addresses."""
|
||||
assert is_valid_mac('') is False
|
||||
assert is_valid_mac(None) is False
|
||||
assert is_valid_mac('invalid') is False
|
||||
assert is_valid_mac('AA:BB:CC:DD:EE') is False
|
||||
assert is_valid_mac('AA-BB-CC-DD-EE-FF') is False
|
||||
|
||||
|
||||
class TestChannelValidation:
|
||||
"""Tests for WiFi channel validation."""
|
||||
|
||||
def test_valid_channels(self):
|
||||
"""Test valid channel numbers."""
|
||||
assert is_valid_channel(1) is True
|
||||
assert is_valid_channel(6) is True
|
||||
assert is_valid_channel(11) is True
|
||||
assert is_valid_channel('36') is True
|
||||
assert is_valid_channel(149) is True
|
||||
|
||||
def test_invalid_channels(self):
|
||||
"""Test invalid channel numbers."""
|
||||
assert is_valid_channel(0) is False
|
||||
assert is_valid_channel(-1) is False
|
||||
assert is_valid_channel(201) is False
|
||||
assert is_valid_channel(None) is False
|
||||
assert is_valid_channel('invalid') is False
|
||||
|
||||
|
||||
class TestToolCheck:
|
||||
"""Tests for tool availability checking."""
|
||||
|
||||
def test_common_tools(self):
|
||||
"""Test checking for common tools."""
|
||||
# These should return bool, regardless of whether installed
|
||||
assert isinstance(check_tool('ls'), bool)
|
||||
assert isinstance(check_tool('nonexistent_tool_12345'), bool)
|
||||
|
||||
def test_nonexistent_tool(self):
|
||||
"""Test that nonexistent tools return False."""
|
||||
assert check_tool('nonexistent_tool_xyz_12345') is False
|
||||
|
||||
|
||||
class TestOuiLookup:
|
||||
"""Tests for OUI manufacturer lookup."""
|
||||
|
||||
def test_known_manufacturer(self):
|
||||
"""Test looking up known manufacturers."""
|
||||
# Apple prefix
|
||||
result = get_manufacturer('00:25:DB:AA:BB:CC')
|
||||
assert result == 'Apple' or result == 'Unknown'
|
||||
|
||||
def test_unknown_manufacturer(self):
|
||||
"""Test looking up unknown manufacturer."""
|
||||
result = get_manufacturer('FF:FF:FF:FF:FF:FF')
|
||||
assert result == 'Unknown'
|
||||
@@ -0,0 +1,43 @@
|
||||
# Utility modules for INTERCEPT
|
||||
from .dependencies import check_tool, check_all_dependencies, TOOL_DEPENDENCIES
|
||||
from .process import (
|
||||
cleanup_stale_processes,
|
||||
is_valid_mac,
|
||||
is_valid_channel,
|
||||
detect_devices,
|
||||
safe_terminate,
|
||||
register_process,
|
||||
unregister_process,
|
||||
cleanup_all_processes,
|
||||
)
|
||||
from .logging import (
|
||||
get_logger,
|
||||
app_logger,
|
||||
pager_logger,
|
||||
sensor_logger,
|
||||
wifi_logger,
|
||||
bluetooth_logger,
|
||||
adsb_logger,
|
||||
satellite_logger,
|
||||
)
|
||||
from .validation import (
|
||||
escape_html,
|
||||
validate_latitude,
|
||||
validate_longitude,
|
||||
validate_frequency,
|
||||
validate_device_index,
|
||||
validate_rtl_tcp_host,
|
||||
validate_rtl_tcp_port,
|
||||
validate_gain,
|
||||
validate_ppm,
|
||||
validate_hours,
|
||||
validate_elevation,
|
||||
validate_wifi_channel,
|
||||
validate_mac_address,
|
||||
validate_positive_int,
|
||||
sanitize_callsign,
|
||||
sanitize_ssid,
|
||||
sanitize_device_name,
|
||||
)
|
||||
from .sse import sse_stream, format_sse, clear_queue
|
||||
from .cleanup import DataStore, CleanupManager, cleanup_manager, cleanup_dict
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Data cleanup utilities for stale entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.cleanup')
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""Thread-safe data store with automatic cleanup of stale entries."""
|
||||
|
||||
def __init__(self, max_age_seconds: float = 300.0, name: str = 'data'):
|
||||
"""
|
||||
Initialize data store.
|
||||
|
||||
Args:
|
||||
max_age_seconds: Maximum age of entries before cleanup (default 5 minutes)
|
||||
name: Name for logging purposes
|
||||
"""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.timestamps: dict[str, float] = {}
|
||||
self.max_age = max_age_seconds
|
||||
self.name = name
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Add or update an entry."""
|
||||
with self._lock:
|
||||
self.data[key] = value
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get an entry."""
|
||||
with self._lock:
|
||||
return self.data.get(key, default)
|
||||
|
||||
def update(self, key: str, updates: dict) -> None:
|
||||
"""Update an existing entry with new values."""
|
||||
with self._lock:
|
||||
if key in self.data:
|
||||
if isinstance(self.data[key], dict):
|
||||
self.data[key].update(updates)
|
||||
else:
|
||||
self.data[key] = updates
|
||||
else:
|
||||
self.data[key] = updates
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def touch(self, key: str) -> None:
|
||||
"""Update timestamp for an entry without changing data."""
|
||||
with self._lock:
|
||||
if key in self.data:
|
||||
self.timestamps[key] = time.time()
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete an entry."""
|
||||
with self._lock:
|
||||
if key in self.data:
|
||||
del self.data[key]
|
||||
del self.timestamps[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all entries."""
|
||||
with self._lock:
|
||||
self.data.clear()
|
||||
self.timestamps.clear()
|
||||
|
||||
def all(self) -> dict[str, Any]:
|
||||
"""Get a copy of all data."""
|
||||
with self._lock:
|
||||
return dict(self.data)
|
||||
|
||||
def keys(self) -> list[str]:
|
||||
"""Get all keys."""
|
||||
with self._lock:
|
||||
return list(self.data.keys())
|
||||
|
||||
def values(self) -> list[Any]:
|
||||
"""Get all values."""
|
||||
with self._lock:
|
||||
return list(self.data.values())
|
||||
|
||||
def items(self) -> list[tuple[str, Any]]:
|
||||
"""Get all items."""
|
||||
with self._lock:
|
||||
return list(self.data.items())
|
||||
|
||||
def __len__(self) -> int:
|
||||
with self._lock:
|
||||
return len(self.data)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
with self._lock:
|
||||
return key in self.data
|
||||
|
||||
def cleanup(self) -> int:
|
||||
"""
|
||||
Remove entries older than max_age.
|
||||
|
||||
Returns:
|
||||
Number of entries removed
|
||||
"""
|
||||
now = time.time()
|
||||
expired = []
|
||||
|
||||
with self._lock:
|
||||
for key, timestamp in self.timestamps.items():
|
||||
if now - timestamp > self.max_age:
|
||||
expired.append(key)
|
||||
|
||||
for key in expired:
|
||||
del self.data[key]
|
||||
del self.timestamps[key]
|
||||
|
||||
if expired:
|
||||
logger.debug(f"{self.name}: Cleaned up {len(expired)} stale entries")
|
||||
|
||||
return len(expired)
|
||||
|
||||
|
||||
class CleanupManager:
|
||||
"""Manages periodic cleanup of multiple data stores."""
|
||||
|
||||
def __init__(self, interval: float = 60.0):
|
||||
"""
|
||||
Initialize cleanup manager.
|
||||
|
||||
Args:
|
||||
interval: Cleanup interval in seconds
|
||||
"""
|
||||
self.stores: list[DataStore] = []
|
||||
self.interval = interval
|
||||
self._timer: threading.Timer | None = None
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register(self, store: DataStore) -> None:
|
||||
"""Register a data store for cleanup."""
|
||||
with self._lock:
|
||||
if store not in self.stores:
|
||||
self.stores.append(store)
|
||||
|
||||
def unregister(self, store: DataStore) -> None:
|
||||
"""Unregister a data store."""
|
||||
with self._lock:
|
||||
if store in self.stores:
|
||||
self.stores.remove(store)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the cleanup timer."""
|
||||
with self._lock:
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._schedule_cleanup()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the cleanup timer."""
|
||||
with self._lock:
|
||||
self._running = False
|
||||
if self._timer:
|
||||
self._timer.cancel()
|
||||
self._timer = None
|
||||
|
||||
def _schedule_cleanup(self) -> None:
|
||||
"""Schedule the next cleanup."""
|
||||
if not self._running:
|
||||
return
|
||||
self._timer = threading.Timer(self.interval, self._run_cleanup)
|
||||
self._timer.daemon = True
|
||||
self._timer.start()
|
||||
|
||||
def _run_cleanup(self) -> None:
|
||||
"""Run cleanup on all registered stores."""
|
||||
total_cleaned = 0
|
||||
|
||||
with self._lock:
|
||||
stores = list(self.stores)
|
||||
|
||||
for store in stores:
|
||||
try:
|
||||
total_cleaned += store.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up {store.name}: {e}")
|
||||
|
||||
if total_cleaned > 0:
|
||||
logger.info(f"Cleanup complete: removed {total_cleaned} stale entries")
|
||||
|
||||
self._schedule_cleanup()
|
||||
|
||||
def cleanup_now(self) -> int:
|
||||
"""Run cleanup immediately."""
|
||||
total = 0
|
||||
with self._lock:
|
||||
stores = list(self.stores)
|
||||
for store in stores:
|
||||
try:
|
||||
total += store.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up {store.name}: {e}")
|
||||
return total
|
||||
|
||||
|
||||
# Global cleanup manager
|
||||
cleanup_manager = CleanupManager(interval=60.0)
|
||||
|
||||
|
||||
def cleanup_dict(
|
||||
data: dict[str, Any],
|
||||
timestamps: dict[str, float],
|
||||
max_age_seconds: float = 300.0
|
||||
) -> list[str]:
|
||||
"""
|
||||
Clean up stale entries from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary to clean
|
||||
timestamps: Dictionary of key -> last_seen timestamp
|
||||
max_age_seconds: Maximum age in seconds
|
||||
|
||||
Returns:
|
||||
List of removed keys
|
||||
"""
|
||||
now = time.time()
|
||||
expired = []
|
||||
|
||||
for key, timestamp in list(timestamps.items()):
|
||||
if now - timestamp > max_age_seconds:
|
||||
expired.append(key)
|
||||
|
||||
for key in expired:
|
||||
data.pop(key, None)
|
||||
timestamps.pop(key, None)
|
||||
|
||||
return expired
|
||||
@@ -0,0 +1,300 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger('intercept.dependencies')
|
||||
|
||||
|
||||
def check_tool(name: str) -> bool:
|
||||
"""Check if a tool is installed."""
|
||||
return shutil.which(name) is not None
|
||||
|
||||
|
||||
# Comprehensive tool dependency definitions
|
||||
TOOL_DEPENDENCIES = {
|
||||
'pager': {
|
||||
'name': 'Pager Decoding',
|
||||
'tools': {
|
||||
'rtl_fm': {
|
||||
'required': True,
|
||||
'description': 'RTL-SDR FM demodulator',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
},
|
||||
'multimon-ng': {
|
||||
'required': True,
|
||||
'description': 'Digital transmission decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install multimon-ng',
|
||||
'brew': 'brew install multimon-ng',
|
||||
'manual': 'https://github.com/EliasOewornal/multimon-ng'
|
||||
}
|
||||
},
|
||||
'rtl_test': {
|
||||
'required': False,
|
||||
'description': 'RTL-SDR device detection',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'sensor': {
|
||||
'name': '433MHz Sensors',
|
||||
'tools': {
|
||||
'rtl_433': {
|
||||
'required': True,
|
||||
'description': 'ISM band decoder for sensors, weather stations, TPMS',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-433',
|
||||
'brew': 'brew install rtl_433',
|
||||
'manual': 'https://github.com/merbanan/rtl_433'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'wifi': {
|
||||
'name': 'WiFi Reconnaissance',
|
||||
'tools': {
|
||||
'airmon-ng': {
|
||||
'required': True,
|
||||
'description': 'Monitor mode controller',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'airodump-ng': {
|
||||
'required': True,
|
||||
'description': 'WiFi network scanner',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'aireplay-ng': {
|
||||
'required': False,
|
||||
'description': 'Deauthentication / packet injection',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'aircrack-ng': {
|
||||
'required': False,
|
||||
'description': 'Handshake verification',
|
||||
'install': {
|
||||
'apt': 'sudo apt install aircrack-ng',
|
||||
'brew': 'brew install aircrack-ng',
|
||||
'manual': 'https://aircrack-ng.org'
|
||||
}
|
||||
},
|
||||
'hcxdumptool': {
|
||||
'required': False,
|
||||
'description': 'PMKID capture tool',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hcxdumptool',
|
||||
'brew': 'brew install hcxtools',
|
||||
'manual': 'https://github.com/ZerBea/hcxdumptool'
|
||||
}
|
||||
},
|
||||
'hcxpcapngtool': {
|
||||
'required': False,
|
||||
'description': 'PMKID hash extractor',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hcxtools',
|
||||
'brew': 'brew install hcxtools',
|
||||
'manual': 'https://github.com/ZerBea/hcxtools'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'bluetooth': {
|
||||
'name': 'Bluetooth Scanning',
|
||||
'tools': {
|
||||
'hcitool': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth HCI tool (legacy)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
},
|
||||
'bluetoothctl': {
|
||||
'required': True,
|
||||
'description': 'Modern Bluetooth controller',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS (use native)',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
},
|
||||
'hciconfig': {
|
||||
'required': False,
|
||||
'description': 'Bluetooth adapter configuration',
|
||||
'install': {
|
||||
'apt': 'sudo apt install bluez',
|
||||
'brew': 'Not available on macOS',
|
||||
'manual': 'http://www.bluez.org'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'aircraft': {
|
||||
'name': 'Aircraft Tracking (ADS-B)',
|
||||
'tools': {
|
||||
'dump1090': {
|
||||
'required': False,
|
||||
'description': 'Mode S / ADS-B decoder (preferred)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install dump1090-mutability (or build dump1090-fa from source)',
|
||||
'brew': 'brew install dump1090-mutability',
|
||||
'manual': 'https://github.com/flightaware/dump1090'
|
||||
},
|
||||
'alternatives': ['dump1090-mutability', 'dump1090-fa']
|
||||
},
|
||||
'rtl_adsb': {
|
||||
'required': False,
|
||||
'description': 'Simple ADS-B decoder',
|
||||
'install': {
|
||||
'apt': 'sudo apt install rtl-sdr',
|
||||
'brew': 'brew install librtlsdr',
|
||||
'manual': 'https://osmocom.org/projects/rtl-sdr/wiki'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'satellite': {
|
||||
'name': 'Satellite Tracking',
|
||||
'tools': {
|
||||
'skyfield': {
|
||||
'required': True,
|
||||
'description': 'Python orbital mechanics library',
|
||||
'install': {
|
||||
'pip': 'pip install skyfield',
|
||||
'manual': 'https://rhodesmill.org/skyfield/'
|
||||
},
|
||||
'python_module': True
|
||||
}
|
||||
}
|
||||
},
|
||||
'sdr_hardware': {
|
||||
'name': 'SDR Hardware Support',
|
||||
'tools': {
|
||||
'SoapySDRUtil': {
|
||||
'required': False,
|
||||
'description': 'Universal SDR abstraction (required for LimeSDR, HackRF)',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-tools',
|
||||
'brew': 'brew install soapysdr',
|
||||
'manual': 'https://github.com/pothosware/SoapySDR'
|
||||
}
|
||||
},
|
||||
'rx_fm': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR FM receiver (for non-RTL hardware)',
|
||||
'install': {
|
||||
'manual': 'Part of SoapySDR utilities or build from source'
|
||||
}
|
||||
},
|
||||
'LimeUtil': {
|
||||
'required': False,
|
||||
'description': 'LimeSDR native utilities',
|
||||
'install': {
|
||||
'apt': 'sudo apt install limesuite',
|
||||
'brew': 'brew install limesuite',
|
||||
'manual': 'https://github.com/myriadrf/LimeSuite'
|
||||
}
|
||||
},
|
||||
'SoapyLMS7': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR plugin for LimeSDR',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-module-lms7',
|
||||
'brew': 'brew install soapylms7',
|
||||
'manual': 'https://github.com/myriadrf/LimeSuite'
|
||||
}
|
||||
},
|
||||
'hackrf_info': {
|
||||
'required': False,
|
||||
'description': 'HackRF native utilities',
|
||||
'install': {
|
||||
'apt': 'sudo apt install hackrf',
|
||||
'brew': 'brew install hackrf',
|
||||
'manual': 'https://github.com/greatscottgadgets/hackrf'
|
||||
}
|
||||
},
|
||||
'SoapyHackRF': {
|
||||
'required': False,
|
||||
'description': 'SoapySDR plugin for HackRF',
|
||||
'install': {
|
||||
'apt': 'sudo apt install soapysdr-module-hackrf',
|
||||
'brew': 'brew install soapyhackrf',
|
||||
'manual': 'https://github.com/pothosware/SoapyHackRF'
|
||||
}
|
||||
},
|
||||
'readsb': {
|
||||
'required': False,
|
||||
'description': 'ADS-B decoder with SoapySDR support',
|
||||
'install': {
|
||||
'apt': 'Build from source with SoapySDR support',
|
||||
'brew': 'Build from source with SoapySDR support',
|
||||
'manual': 'https://github.com/wiedehopf/readsb'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def check_all_dependencies() -> dict[str, dict[str, Any]]:
|
||||
"""Check all tool dependencies and return status."""
|
||||
results: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for mode, config in TOOL_DEPENDENCIES.items():
|
||||
mode_result = {
|
||||
'name': config['name'],
|
||||
'tools': {},
|
||||
'ready': True,
|
||||
'missing_required': []
|
||||
}
|
||||
|
||||
for tool, tool_config in config['tools'].items():
|
||||
# Check if it's a Python module
|
||||
if tool_config.get('python_module'):
|
||||
try:
|
||||
__import__(tool)
|
||||
installed = True
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to import {tool}: {type(e).__name__}: {e}")
|
||||
installed = False
|
||||
else:
|
||||
# Check for alternatives
|
||||
alternatives = tool_config.get('alternatives', [])
|
||||
installed = check_tool(tool) or any(check_tool(alt) for alt in alternatives)
|
||||
|
||||
mode_result['tools'][tool] = {
|
||||
'installed': installed,
|
||||
'required': tool_config['required'],
|
||||
'description': tool_config['description'],
|
||||
'install': tool_config['install']
|
||||
}
|
||||
|
||||
if tool_config['required'] and not installed:
|
||||
mode_result['ready'] = False
|
||||
mode_result['missing_required'].append(tool)
|
||||
|
||||
results[mode] = mode_result
|
||||
|
||||
return results
|
||||
+787
@@ -0,0 +1,787 @@
|
||||
"""
|
||||
GPS dongle support for INTERCEPT.
|
||||
|
||||
Provides detection and reading of USB GPS dongles via serial port.
|
||||
Parses NMEA sentences to extract location data.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
"""GPS position data."""
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = None # knots
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
|
||||
timestamp: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'latitude': self.latitude,
|
||||
'longitude': self.longitude,
|
||||
'altitude': self.altitude,
|
||||
'speed': self.speed,
|
||||
'heading': self.heading,
|
||||
'satellites': self.satellites,
|
||||
'fix_quality': self.fix_quality,
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'device': self.device,
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
gpsd provides a unified interface for GPS devices and handles
|
||||
device management, making it ideal when gpsd is already running.
|
||||
"""
|
||||
|
||||
DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_PORT = 2947
|
||||
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._lock = threading.Lock()
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._socket: Optional['socket.socket'] = None
|
||||
self._last_update: Optional[datetime] = None
|
||||
self._error: Optional[str] = None
|
||||
self._callbacks: list[Callable[[GPSPosition], None]] = []
|
||||
self._device: Optional[str] = None
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
"""Get the current GPS position."""
|
||||
with self._lock:
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the client is running."""
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_update(self) -> Optional[datetime]:
|
||||
"""Get the time of the last position update."""
|
||||
with self._lock:
|
||||
return self._last_update
|
||||
|
||||
@property
|
||||
def error(self) -> Optional[str]:
|
||||
"""Get any error message."""
|
||||
with self._lock:
|
||||
return self._error
|
||||
|
||||
@property
|
||||
def device_path(self) -> str:
|
||||
"""Return gpsd connection info (for compatibility with GPSReader)."""
|
||||
return f"gpsd://{self.host}:{self.port}"
|
||||
|
||||
@property
|
||||
def baudrate(self) -> int:
|
||||
"""Return 0 for gpsd (for compatibility with GPSReader)."""
|
||||
return 0
|
||||
|
||||
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Add a callback to be called on position updates."""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
|
||||
"""Remove a position update callback."""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start receiving GPS data from gpsd."""
|
||||
import socket
|
||||
|
||||
if self._running:
|
||||
return True
|
||||
|
||||
try:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.settimeout(5.0)
|
||||
self._socket.connect((self.host, self.port))
|
||||
|
||||
# Enable JSON watch mode
|
||||
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
|
||||
self._socket.send(watch_cmd.encode('ascii'))
|
||||
|
||||
self._running = True
|
||||
self._error = None
|
||||
|
||||
self._thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
if self._socket:
|
||||
try:
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop receiving GPS data."""
|
||||
self._running = False
|
||||
|
||||
if self._socket:
|
||||
try:
|
||||
# Disable watch mode
|
||||
self._socket.send(b'?WATCH={"enable":false}\n')
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
"""Background thread loop for reading gpsd data."""
|
||||
import json
|
||||
import socket
|
||||
|
||||
buffer = ""
|
||||
message_count = 0
|
||||
|
||||
print(f"[GPS] gpsd read loop started", flush=True)
|
||||
|
||||
while self._running and self._socket:
|
||||
try:
|
||||
self._socket.settimeout(1.0)
|
||||
data = self._socket.recv(4096)
|
||||
|
||||
if not data:
|
||||
logger.warning("gpsd connection closed")
|
||||
with self._lock:
|
||||
self._error = "Connection closed by gpsd"
|
||||
break
|
||||
|
||||
buffer += data.decode('ascii', errors='ignore')
|
||||
|
||||
# Process complete JSON lines
|
||||
while '\n' in buffer:
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
msg_class = msg.get('class', '')
|
||||
|
||||
message_count += 1
|
||||
if message_count <= 5 or message_count % 20 == 0:
|
||||
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
|
||||
|
||||
if msg_class == 'TPV':
|
||||
self._handle_tpv(msg)
|
||||
elif msg_class == 'DEVICES':
|
||||
# Track connected device
|
||||
devices = msg.get('devices', [])
|
||||
if devices:
|
||||
self._device = devices[0].get('path', 'unknown')
|
||||
print(f"[GPS] gpsd device: {self._device}", flush=True)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"gpsd read error: {e}")
|
||||
with self._lock:
|
||||
self._error = str(e)
|
||||
break
|
||||
|
||||
def _handle_tpv(self, msg: dict) -> None:
|
||||
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
|
||||
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
|
||||
mode = msg.get('mode', 0)
|
||||
|
||||
if mode < 2:
|
||||
# No fix yet
|
||||
return
|
||||
|
||||
lat = msg.get('lat')
|
||||
lon = msg.get('lon')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return
|
||||
|
||||
# Parse timestamp
|
||||
timestamp = None
|
||||
time_str = msg.get('time')
|
||||
if time_str:
|
||||
try:
|
||||
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
|
||||
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
position = GPSPosition(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
altitude=msg.get('alt'),
|
||||
speed=msg.get('speed'), # m/s in gpsd (not knots)
|
||||
heading=msg.get('track'),
|
||||
fix_quality=mode,
|
||||
timestamp=timestamp,
|
||||
device=self._device or f"gpsd://{self.host}:{self.port}",
|
||||
)
|
||||
|
||||
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
|
||||
self._update_position(position)
|
||||
|
||||
def _update_position(self, position: GPSPosition) -> None:
|
||||
"""Update the current position and notify callbacks."""
|
||||
with self._lock:
|
||||
self._position = position
|
||||
self._last_update = datetime.utcnow()
|
||||
self._error = None
|
||||
|
||||
# Notify callbacks
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(position)
|
||||
except Exception as e:
|
||||
logger.error(f"GPS callback error: {e}")
|
||||
|
||||
|
||||
# Type alias for GPS source (either serial reader or gpsd client)
|
||||
GPSSource = Union[GPSReader, GPSDClient]
|
||||
|
||||
# Global GPS reader instance
|
||||
_gps_reader: Optional[GPSSource] = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSSource]:
|
||||
"""Get the global GPS reader/client instance."""
|
||||
with _gps_lock:
|
||||
return _gps_reader
|
||||
|
||||
|
||||
def start_gps(device_path: str, baudrate: int = 9600,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS reader.
|
||||
|
||||
Args:
|
||||
device_path: Path to the GPS serial device
|
||||
baudrate: Serial baudrate (default 9600)
|
||||
callback: Optional callback for position updates (registered before start to avoid race condition)
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
|
||||
_gps_reader = GPSReader(device_path, baudrate)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
|
||||
|
||||
def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
"""
|
||||
Start the global GPS client connected to gpsd.
|
||||
|
||||
Args:
|
||||
host: gpsd host (default localhost)
|
||||
port: gpsd port (default 2947)
|
||||
callback: Optional callback for position updates
|
||||
|
||||
Returns:
|
||||
True if started successfully
|
||||
"""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
# Stop existing reader if any
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
|
||||
_gps_reader = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
if callback:
|
||||
_gps_reader.add_callback(callback)
|
||||
|
||||
return _gps_reader.start()
|
||||
|
||||
|
||||
def stop_gps() -> None:
|
||||
"""Stop the global GPS reader/client."""
|
||||
global _gps_reader
|
||||
|
||||
with _gps_lock:
|
||||
if _gps_reader:
|
||||
_gps_reader.stop()
|
||||
_gps_reader = 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
|
||||
return None
|
||||
|
||||
|
||||
def is_serial_available() -> bool:
|
||||
"""Check if pyserial is available."""
|
||||
return SERIAL_AVAILABLE
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Logging utilities for intercept application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from config import LOG_LEVEL, LOG_FORMAT
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get a configured logger for a module."""
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter(LOG_FORMAT))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(LOG_LEVEL)
|
||||
return logger
|
||||
|
||||
|
||||
# Pre-configured loggers for each module
|
||||
app_logger = get_logger('intercept')
|
||||
pager_logger = get_logger('intercept.pager')
|
||||
sensor_logger = get_logger('intercept.sensor')
|
||||
wifi_logger = get_logger('intercept.wifi')
|
||||
bluetooth_logger = get_logger('intercept.bluetooth')
|
||||
adsb_logger = get_logger('intercept.adsb')
|
||||
satellite_logger = get_logger('intercept.satellite')
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import signal
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
from .dependencies import check_tool
|
||||
|
||||
logger = logging.getLogger('intercept.process')
|
||||
|
||||
# Track all spawned processes for cleanup
|
||||
_spawned_processes: list[subprocess.Popen] = []
|
||||
_process_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_process(process: subprocess.Popen) -> None:
|
||||
"""Register a spawned process for cleanup on exit."""
|
||||
with _process_lock:
|
||||
_spawned_processes.append(process)
|
||||
|
||||
|
||||
def unregister_process(process: subprocess.Popen) -> None:
|
||||
"""Unregister a process from cleanup list."""
|
||||
with _process_lock:
|
||||
if process in _spawned_processes:
|
||||
_spawned_processes.remove(process)
|
||||
|
||||
|
||||
def cleanup_all_processes() -> None:
|
||||
"""Clean up all registered processes on exit."""
|
||||
logger.info("Cleaning up all spawned processes...")
|
||||
with _process_lock:
|
||||
for process in _spawned_processes:
|
||||
if process and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up process: {e}")
|
||||
_spawned_processes.clear()
|
||||
|
||||
|
||||
def safe_terminate(process: subprocess.Popen | None, timeout: float = 2.0) -> bool:
|
||||
"""
|
||||
Safely terminate a process.
|
||||
|
||||
Args:
|
||||
process: Process to terminate
|
||||
timeout: Seconds to wait before killing
|
||||
|
||||
Returns:
|
||||
True if process was terminated, False if already dead or None
|
||||
"""
|
||||
if not process:
|
||||
return False
|
||||
|
||||
if process.poll() is not None:
|
||||
# Already dead
|
||||
unregister_process(process)
|
||||
return False
|
||||
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=timeout)
|
||||
unregister_process(process)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
unregister_process(process)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Error terminating process: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Register cleanup handlers
|
||||
atexit.register(cleanup_all_processes)
|
||||
|
||||
# Handle signals for graceful shutdown
|
||||
def _signal_handler(signum, frame):
|
||||
"""Handle termination signals."""
|
||||
import sys
|
||||
logger.info(f"Received signal {signum}, cleaning up...")
|
||||
cleanup_all_processes()
|
||||
# Re-raise KeyboardInterrupt for SIGINT so Flask can handle shutdown
|
||||
if signum == signal.SIGINT:
|
||||
raise KeyboardInterrupt()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Only register signal handlers if we're not in a thread
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
except ValueError:
|
||||
# Can't set signal handlers from a thread
|
||||
pass
|
||||
|
||||
|
||||
def cleanup_stale_processes() -> None:
|
||||
"""Kill any stale processes from previous runs (but not system services)."""
|
||||
# Note: dump1090 is NOT included here as users may run it as a system service
|
||||
processes_to_kill = ['rtl_adsb', 'rtl_433', 'multimon-ng', 'rtl_fm']
|
||||
for proc_name in processes_to_kill:
|
||||
try:
|
||||
subprocess.run(['pkill', '-9', proc_name], capture_output=True)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def is_valid_mac(mac: str | None) -> bool:
|
||||
"""Validate MAC address format."""
|
||||
if not mac:
|
||||
return False
|
||||
return bool(re.match(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$', mac))
|
||||
|
||||
|
||||
def is_valid_channel(channel: str | int | None) -> bool:
|
||||
"""Validate WiFi channel number."""
|
||||
try:
|
||||
ch = int(channel) # type: ignore[arg-type]
|
||||
return 1 <= ch <= 200
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def detect_devices() -> list[dict[str, Any]]:
|
||||
"""Detect RTL-SDR devices."""
|
||||
devices: list[dict[str, Any]] = []
|
||||
|
||||
if not check_tool('rtl_test'):
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info
|
||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
match = re.match(device_pattern, line)
|
||||
if match:
|
||||
devices.append({
|
||||
'index': int(match.group(1)),
|
||||
'name': match.group(2).strip().rstrip(','),
|
||||
'serial': match.group(3) or 'N/A'
|
||||
})
|
||||
|
||||
if not devices:
|
||||
found_match = re.search(r'Found (\d+) device', output)
|
||||
if found_match:
|
||||
count = int(found_match.group(1))
|
||||
for i in range(count):
|
||||
devices.append({
|
||||
'index': i,
|
||||
'name': f'RTL-SDR Device {i}',
|
||||
'serial': 'Unknown'
|
||||
})
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return devices
|
||||
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
SDR Hardware Abstraction Layer.
|
||||
|
||||
This module provides a unified interface for multiple SDR hardware types
|
||||
including RTL-SDR, LimeSDR, and HackRF. Use SDRFactory to detect devices
|
||||
and get appropriate command builders.
|
||||
|
||||
Example usage:
|
||||
from utils.sdr import SDRFactory, SDRType
|
||||
|
||||
# Detect all connected devices
|
||||
devices = SDRFactory.detect_devices()
|
||||
|
||||
# Get a command builder for a specific device
|
||||
builder = SDRFactory.get_builder_for_device(devices[0])
|
||||
|
||||
# Or get a builder by type
|
||||
builder = SDRFactory.get_builder(SDRType.RTL_SDR)
|
||||
|
||||
# Build commands
|
||||
cmd = builder.build_fm_demod_command(device, frequency_mhz=153.35)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
from .detection import detect_all_devices
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
from .validation import (
|
||||
SDRValidationError,
|
||||
validate_frequency,
|
||||
validate_gain,
|
||||
validate_sample_rate,
|
||||
validate_ppm,
|
||||
validate_device_index,
|
||||
validate_squelch,
|
||||
get_capabilities_for_type,
|
||||
)
|
||||
|
||||
|
||||
class SDRFactory:
|
||||
"""Factory for creating SDR command builders and detecting devices."""
|
||||
|
||||
_builders: dict[SDRType, type[CommandBuilder]] = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_builder(cls, sdr_type: SDRType) -> CommandBuilder:
|
||||
"""
|
||||
Get a command builder for the specified SDR type.
|
||||
|
||||
Args:
|
||||
sdr_type: The SDR hardware type
|
||||
|
||||
Returns:
|
||||
CommandBuilder instance for the specified type
|
||||
|
||||
Raises:
|
||||
ValueError: If the SDR type is not supported
|
||||
"""
|
||||
builder_class = cls._builders.get(sdr_type)
|
||||
if not builder_class:
|
||||
raise ValueError(f"Unsupported SDR type: {sdr_type}")
|
||||
return builder_class()
|
||||
|
||||
@classmethod
|
||||
def get_builder_for_device(cls, device: SDRDevice) -> CommandBuilder:
|
||||
"""
|
||||
Get a command builder for a specific device.
|
||||
|
||||
Args:
|
||||
device: The SDR device
|
||||
|
||||
Returns:
|
||||
CommandBuilder instance for the device's type
|
||||
"""
|
||||
return cls.get_builder(device.sdr_type)
|
||||
|
||||
@classmethod
|
||||
def detect_devices(cls) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect all available SDR devices.
|
||||
|
||||
Returns:
|
||||
List of detected SDR devices
|
||||
"""
|
||||
return detect_all_devices()
|
||||
|
||||
@classmethod
|
||||
def get_supported_types(cls) -> list[SDRType]:
|
||||
"""
|
||||
Get list of supported SDR types.
|
||||
|
||||
Returns:
|
||||
List of supported SDRType values
|
||||
"""
|
||||
return list(cls._builders.keys())
|
||||
|
||||
@classmethod
|
||||
def get_capabilities(cls, sdr_type: SDRType) -> SDRCapabilities:
|
||||
"""
|
||||
Get capabilities for an SDR type.
|
||||
|
||||
Args:
|
||||
sdr_type: The SDR hardware type
|
||||
|
||||
Returns:
|
||||
SDRCapabilities for the specified type
|
||||
"""
|
||||
builder = cls.get_builder(sdr_type)
|
||||
return builder.get_capabilities()
|
||||
|
||||
@classmethod
|
||||
def get_all_capabilities(cls) -> dict[str, dict]:
|
||||
"""
|
||||
Get capabilities for all supported SDR types.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping SDR type names to capability dicts
|
||||
"""
|
||||
capabilities = {}
|
||||
for sdr_type in cls._builders:
|
||||
caps = cls.get_capabilities(sdr_type)
|
||||
capabilities[sdr_type.value] = {
|
||||
'name': sdr_type.name.replace('_', ' '),
|
||||
'freq_min_mhz': caps.freq_min_mhz,
|
||||
'freq_max_mhz': caps.freq_max_mhz,
|
||||
'gain_min': caps.gain_min,
|
||||
'gain_max': caps.gain_max,
|
||||
'sample_rates': caps.sample_rates,
|
||||
'supports_bias_t': caps.supports_bias_t,
|
||||
'supports_ppm': caps.supports_ppm,
|
||||
'tx_capable': caps.tx_capable,
|
||||
}
|
||||
return capabilities
|
||||
|
||||
@classmethod
|
||||
def create_default_device(
|
||||
cls,
|
||||
sdr_type: SDRType,
|
||||
index: int = 0,
|
||||
serial: str = 'N/A'
|
||||
) -> SDRDevice:
|
||||
"""
|
||||
Create a default device object for a given SDR type.
|
||||
|
||||
Useful when device detection didn't provide full details but
|
||||
you know the hardware type.
|
||||
|
||||
Args:
|
||||
sdr_type: The SDR hardware type
|
||||
index: Device index (default 0)
|
||||
serial: Device serial (default 'N/A')
|
||||
|
||||
Returns:
|
||||
SDRDevice with default capabilities for the type
|
||||
"""
|
||||
caps = cls.get_capabilities(sdr_type)
|
||||
return SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=index,
|
||||
name=f'{sdr_type.name.replace("_", " ")} Device {index}',
|
||||
serial=serial,
|
||||
driver=sdr_type.value,
|
||||
capabilities=caps
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_network_device(
|
||||
cls,
|
||||
host: str,
|
||||
port: int = 1234
|
||||
) -> SDRDevice:
|
||||
"""
|
||||
Create a network device for rtl_tcp connection.
|
||||
|
||||
Args:
|
||||
host: rtl_tcp server hostname or IP address
|
||||
port: rtl_tcp server port (default 1234)
|
||||
|
||||
Returns:
|
||||
SDRDevice configured for rtl_tcp connection
|
||||
"""
|
||||
caps = cls.get_capabilities(SDRType.RTL_SDR)
|
||||
return SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=0,
|
||||
name=f'{host}:{port}',
|
||||
serial='rtl_tcp',
|
||||
driver='rtl_tcp',
|
||||
capabilities=caps,
|
||||
rtl_tcp_host=host,
|
||||
rtl_tcp_port=port
|
||||
)
|
||||
|
||||
|
||||
# Export commonly used items at package level
|
||||
__all__ = [
|
||||
# Factory
|
||||
'SDRFactory',
|
||||
# Types and classes
|
||||
'SDRType',
|
||||
'SDRDevice',
|
||||
'SDRCapabilities',
|
||||
'CommandBuilder',
|
||||
# Builders
|
||||
'RTLSDRCommandBuilder',
|
||||
'LimeSDRCommandBuilder',
|
||||
'HackRFCommandBuilder',
|
||||
'AirspyCommandBuilder',
|
||||
# Validation
|
||||
'SDRValidationError',
|
||||
'validate_frequency',
|
||||
'validate_gain',
|
||||
'validate_sample_rate',
|
||||
'validate_ppm',
|
||||
'validate_device_index',
|
||||
'validate_squelch',
|
||||
'get_capabilities_for_type',
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Airspy command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
|
||||
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class AirspyCommandBuilder(CommandBuilder):
|
||||
"""Airspy command builder using SoapySDR tools."""
|
||||
|
||||
# Airspy R2/Mini capabilities (most common)
|
||||
# HF+ has different range but same interface
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.AIRSPY,
|
||||
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
|
||||
freq_max_mhz=1800.0, # 1.8 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
|
||||
sample_rates=[2500000, 3000000, 6000000, 10000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False, # Airspy has TCXO, no PPM needed
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for Airspy."""
|
||||
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver={driver},serial={device.serial}'
|
||||
return f'driver={driver}'
|
||||
|
||||
def _format_gain(self, gain: float) -> str:
|
||||
"""
|
||||
Format gain string for Airspy.
|
||||
|
||||
Airspy has three gain stages:
|
||||
- LNA: 0-15 dB
|
||||
- Mixer: 0-15 dB
|
||||
- VGA: 0-15 dB
|
||||
|
||||
This distributes the requested gain across stages.
|
||||
"""
|
||||
if gain <= 15:
|
||||
return f'LNA={int(gain)},MIX=0,VGA=0'
|
||||
elif gain <= 30:
|
||||
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
|
||||
else:
|
||||
vga = min(15, int(gain - 30))
|
||||
return f'LNA=15,MIX=15,VGA={vga}'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with Airspy.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rx_fm',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', self._format_gain(gain)])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return Airspy capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.AIRSPY
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Base classes and types for SDR hardware abstraction.
|
||||
|
||||
This module provides the core abstractions for supporting multiple SDR hardware
|
||||
types (RTL-SDR, LimeSDR, HackRF, etc.) through a unified interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class SDRType(Enum):
|
||||
"""Supported SDR hardware types."""
|
||||
RTL_SDR = "rtlsdr"
|
||||
LIME_SDR = "limesdr"
|
||||
HACKRF = "hackrf"
|
||||
AIRSPY = "airspy"
|
||||
# Future support
|
||||
# USRP = "usrp"
|
||||
# BLADE_RF = "bladerf"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDRCapabilities:
|
||||
"""Hardware capabilities for an SDR device."""
|
||||
sdr_type: SDRType
|
||||
freq_min_mhz: float # Minimum frequency in MHz
|
||||
freq_max_mhz: float # Maximum frequency in MHz
|
||||
gain_min: float # Minimum gain in dB
|
||||
gain_max: float # Maximum gain in dB
|
||||
sample_rates: list[int] = field(default_factory=list) # Supported sample rates
|
||||
supports_bias_t: bool = False # Bias-T support
|
||||
supports_ppm: bool = True # PPM correction support
|
||||
tx_capable: bool = False # Can transmit
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDRDevice:
|
||||
"""Detected SDR device."""
|
||||
sdr_type: SDRType
|
||||
index: int
|
||||
name: str
|
||||
serial: str
|
||||
driver: str # e.g., "rtlsdr", "lime", "hackrf"
|
||||
capabilities: SDRCapabilities
|
||||
rtl_tcp_host: Optional[str] = None # Remote rtl_tcp server host
|
||||
rtl_tcp_port: Optional[int] = None # Remote rtl_tcp server port
|
||||
|
||||
@property
|
||||
def is_network(self) -> bool:
|
||||
"""Check if this is a network/remote device."""
|
||||
return self.rtl_tcp_host is not None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = {
|
||||
'index': self.index,
|
||||
'name': self.name,
|
||||
'serial': self.serial,
|
||||
'sdr_type': self.sdr_type.value,
|
||||
'driver': self.driver,
|
||||
'is_network': self.is_network,
|
||||
'capabilities': {
|
||||
'freq_min_mhz': self.capabilities.freq_min_mhz,
|
||||
'freq_max_mhz': self.capabilities.freq_max_mhz,
|
||||
'gain_min': self.capabilities.gain_min,
|
||||
'gain_max': self.capabilities.gain_max,
|
||||
'sample_rates': self.capabilities.sample_rates,
|
||||
'supports_bias_t': self.capabilities.supports_bias_t,
|
||||
'supports_ppm': self.capabilities.supports_ppm,
|
||||
'tx_capable': self.capabilities.tx_capable,
|
||||
}
|
||||
}
|
||||
if self.is_network:
|
||||
result['rtl_tcp_host'] = self.rtl_tcp_host
|
||||
result['rtl_tcp_port'] = self.rtl_tcp_port
|
||||
return result
|
||||
|
||||
|
||||
class CommandBuilder(ABC):
|
||||
"""Abstract base class for building SDR commands."""
|
||||
|
||||
@abstractmethod
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build FM demodulation command (for pager decoding).
|
||||
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
frequency_mhz: Center frequency in MHz
|
||||
sample_rate: Audio sample rate (default 22050 for pager)
|
||||
gain: Gain in dB (None for auto)
|
||||
ppm: PPM frequency correction
|
||||
modulation: Modulation type (fm, am, etc.)
|
||||
squelch: Squelch level
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ADS-B decoder command.
|
||||
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
gain: Gain in dB (None for auto)
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build ISM band decoder command (433MHz sensors).
|
||||
|
||||
Args:
|
||||
device: The SDR device to use
|
||||
frequency_mhz: Center frequency in MHz (default 433.92)
|
||||
gain: Gain in dB (None for auto)
|
||||
ppm: PPM frequency correction
|
||||
|
||||
Returns:
|
||||
Command as list of strings for subprocess
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return hardware capabilities for this SDR type."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return the SDR type this builder handles."""
|
||||
pass
|
||||
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Multi-hardware SDR device detection.
|
||||
|
||||
Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from .base import SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_tool(name: str) -> bool:
|
||||
"""Check if a tool is available in PATH."""
|
||||
return shutil.which(name) is not None
|
||||
|
||||
|
||||
def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
"""Get default capabilities for an SDR type."""
|
||||
# Import here to avoid circular imports
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
from .airspy import AirspyCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
if builder_class:
|
||||
return builder_class.CAPABILITIES
|
||||
|
||||
# Fallback generic capabilities
|
||||
return SDRCapabilities(
|
||||
sdr_type=sdr_type,
|
||||
freq_min_mhz=1.0,
|
||||
freq_max_mhz=6000.0,
|
||||
gain_min=0.0,
|
||||
gain_max=50.0,
|
||||
sample_rates=[2048000],
|
||||
supports_bias_t=False,
|
||||
supports_ppm=False,
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
|
||||
def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
||||
"""Map SoapySDR driver name to SDRType."""
|
||||
mapping = {
|
||||
'rtlsdr': SDRType.RTL_SDR,
|
||||
'lime': SDRType.LIME_SDR,
|
||||
'limesdr': SDRType.LIME_SDR,
|
||||
'hackrf': SDRType.HACKRF,
|
||||
'airspy': SDRType.AIRSPY,
|
||||
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||
# Future support
|
||||
# 'uhd': SDRType.USRP,
|
||||
# 'bladerf': SDRType.BLADE_RF,
|
||||
}
|
||||
return mapping.get(driver.lower())
|
||||
|
||||
|
||||
def detect_rtlsdr_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect RTL-SDR devices using rtl_test.
|
||||
|
||||
This uses the native rtl_test tool for best compatibility with
|
||||
existing RTL-SDR installations.
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
|
||||
if not _check_tool('rtl_test'):
|
||||
logger.debug("rtl_test not found, skipping RTL-SDR detection")
|
||||
return devices
|
||||
|
||||
try:
|
||||
import os
|
||||
import platform
|
||||
env = os.environ.copy()
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
lib_paths = ['/usr/local/lib', '/opt/homebrew/lib']
|
||||
current_ld = env.get('DYLD_LIBRARY_PATH', '')
|
||||
env['DYLD_LIBRARY_PATH'] = ':'.join(lib_paths + [current_ld] if current_ld else lib_paths)
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env
|
||||
)
|
||||
output = result.stderr + result.stdout
|
||||
|
||||
# Parse device info from rtl_test output
|
||||
# Format: "0: Realtek, RTL2838UHIDIR, SN: 00000001"
|
||||
device_pattern = r'(\d+):\s+(.+?)(?:,\s*SN:\s*(\S+))?$'
|
||||
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
match = re.match(device_pattern, line)
|
||||
if match:
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=int(match.group(1)),
|
||||
name=match.group(2).strip().rstrip(','),
|
||||
serial=match.group(3) or 'N/A',
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
# Fallback: if we found devices but couldn't parse details
|
||||
if not devices:
|
||||
found_match = re.search(r'Found (\d+) device', output)
|
||||
if found_match:
|
||||
count = int(found_match.group(1))
|
||||
for i in range(count):
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
index=i,
|
||||
name=f'RTL-SDR Device {i}',
|
||||
serial='Unknown',
|
||||
driver='rtlsdr',
|
||||
capabilities=RTLSDRCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("rtl_test timed out")
|
||||
except Exception as e:
|
||||
logger.debug(f"RTL-SDR detection error: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||
"""
|
||||
Detect SDR devices via SoapySDR.
|
||||
|
||||
This detects LimeSDR, HackRF, Airspy, and other SoapySDR-compatible devices.
|
||||
|
||||
Args:
|
||||
skip_types: Set of SDRType values to skip (e.g., if already found via native detection)
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_types = skip_types or set()
|
||||
|
||||
if not _check_tool('SoapySDRUtil'):
|
||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['SoapySDRUtil', '--find'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Parse SoapySDR output
|
||||
# Format varies but typically includes lines like:
|
||||
# " driver = lime"
|
||||
# " serial = 0009060B00123456"
|
||||
# " label = LimeSDR Mini [USB 3.0] 0009060B00123456"
|
||||
|
||||
current_device: dict = {}
|
||||
device_counts: dict[SDRType, int] = {}
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
# Start of new device block
|
||||
if line.startswith('Found device'):
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
current_device = {}
|
||||
continue
|
||||
|
||||
# Parse key = value pairs
|
||||
if ' = ' in line:
|
||||
key, value = line.split(' = ', 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current_device[key] = value
|
||||
|
||||
# Don't forget the last device
|
||||
if current_device.get('driver'):
|
||||
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("SoapySDRUtil timed out")
|
||||
except Exception as e:
|
||||
logger.debug(f"SoapySDR detection error: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def _add_soapy_device(
|
||||
devices: list[SDRDevice],
|
||||
device_info: dict,
|
||||
device_counts: dict[SDRType, int],
|
||||
skip_types: set[SDRType]
|
||||
) -> None:
|
||||
"""Add a device from SoapySDR detection to the list."""
|
||||
driver = device_info.get('driver', '').lower()
|
||||
sdr_type = _driver_to_sdr_type(driver)
|
||||
|
||||
if not sdr_type:
|
||||
logger.debug(f"Unknown SoapySDR driver: {driver}")
|
||||
return
|
||||
|
||||
# Skip device types that were already found via native detection
|
||||
if sdr_type in skip_types:
|
||||
logger.debug(f"Skipping {driver} from SoapySDR (already found via native detection)")
|
||||
return
|
||||
|
||||
# Track device index per type
|
||||
if sdr_type not in device_counts:
|
||||
device_counts[sdr_type] = 0
|
||||
|
||||
index = device_counts[sdr_type]
|
||||
device_counts[sdr_type] += 1
|
||||
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=sdr_type,
|
||||
index=index,
|
||||
name=device_info.get('label', device_info.get('driver', 'Unknown')),
|
||||
serial=device_info.get('serial', 'N/A'),
|
||||
driver=driver,
|
||||
capabilities=_get_capabilities_for_type(sdr_type)
|
||||
))
|
||||
|
||||
|
||||
def detect_hackrf_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect HackRF devices using native hackrf_info tool.
|
||||
|
||||
Fallback for when SoapySDR is not available.
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
|
||||
if not _check_tool('hackrf_info'):
|
||||
return devices
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['hackrf_info'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Parse hackrf_info output
|
||||
# Look for "Serial number:" lines
|
||||
serial_pattern = r'Serial number:\s*(\S+)'
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
|
||||
serials_found = re.findall(serial_pattern, result.stdout)
|
||||
|
||||
for i, serial in enumerate(serials_found):
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.HACKRF,
|
||||
index=i,
|
||||
name=f'HackRF One',
|
||||
serial=serial,
|
||||
driver='hackrf',
|
||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
# Fallback: check if any HackRF found without serial
|
||||
if not devices and 'Found HackRF' in result.stdout:
|
||||
devices.append(SDRDevice(
|
||||
sdr_type=SDRType.HACKRF,
|
||||
index=0,
|
||||
name='HackRF One',
|
||||
serial='Unknown',
|
||||
driver='hackrf',
|
||||
capabilities=HackRFCommandBuilder.CAPABILITIES
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"HackRF detection error: {e}")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def detect_all_devices() -> list[SDRDevice]:
|
||||
"""
|
||||
Detect all connected SDR devices across all supported hardware types.
|
||||
|
||||
Returns a unified list of SDRDevice objects sorted by type and index.
|
||||
"""
|
||||
devices: list[SDRDevice] = []
|
||||
skip_in_soapy: set[SDRType] = set()
|
||||
|
||||
# RTL-SDR via native tool (primary method)
|
||||
rtlsdr_devices = detect_rtlsdr_devices()
|
||||
devices.extend(rtlsdr_devices)
|
||||
if rtlsdr_devices:
|
||||
skip_in_soapy.add(SDRType.RTL_SDR)
|
||||
|
||||
# Native HackRF detection (primary method)
|
||||
hackrf_devices = detect_hackrf_devices()
|
||||
devices.extend(hackrf_devices)
|
||||
if hackrf_devices:
|
||||
skip_in_soapy.add(SDRType.HACKRF)
|
||||
|
||||
# SoapySDR devices (LimeSDR, Airspy, and fallback for HackRF/RTL-SDR if native failed)
|
||||
soapy_devices = detect_soapy_devices(skip_types=skip_in_soapy)
|
||||
devices.extend(soapy_devices)
|
||||
|
||||
# Sort by type name, then index
|
||||
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
|
||||
|
||||
logger.info(f"Detected {len(devices)} SDR device(s)")
|
||||
for d in devices:
|
||||
logger.debug(f" {d.sdr_type.value}:{d.index} - {d.name} (serial: {d.serial})")
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
HackRF command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
HackRF supports 1 MHz to 6 GHz frequency range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class HackRFCommandBuilder(CommandBuilder):
|
||||
"""HackRF command builder using SoapySDR tools."""
|
||||
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.HACKRF,
|
||||
freq_min_mhz=1.0, # 1 MHz
|
||||
freq_max_mhz=6000.0, # 6 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=62.0, # LNA (0-40) + VGA (0-62)
|
||||
sample_rates=[2000000, 4000000, 8000000, 10000000, 20000000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=False,
|
||||
tx_capable=True
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for HackRF."""
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver=hackrf,serial={device.serial}'
|
||||
return f'driver=hackrf'
|
||||
|
||||
def _split_gain(self, gain: float) -> tuple[int, int]:
|
||||
"""
|
||||
Split total gain into LNA and VGA components.
|
||||
|
||||
HackRF has two gain stages:
|
||||
- LNA: 0-40 dB (RF amplifier)
|
||||
- VGA: 0-62 dB (IF amplifier)
|
||||
|
||||
This function distributes the requested gain across both stages.
|
||||
"""
|
||||
if gain <= 40:
|
||||
# All to LNA first
|
||||
return int(gain), 0
|
||||
else:
|
||||
# Max out LNA, rest to VGA
|
||||
lna = 40
|
||||
vga = min(62, int(gain - 40))
|
||||
return lna, vga
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with HackRF.
|
||||
"""
|
||||
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:
|
||||
lna, vga = self._split_gain(gain)
|
||||
cmd.extend(['-g', f'LNA={lna},VGA={vga}'])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||
|
||||
Uses readsb which has better SoapySDR support.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return HackRF capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.HACKRF
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
LimeSDR command builder implementation.
|
||||
|
||||
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||
LimeSDR supports 100 kHz to 3.8 GHz frequency range.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class LimeSDRCommandBuilder(CommandBuilder):
|
||||
"""LimeSDR command builder using SoapySDR tools."""
|
||||
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.LIME_SDR,
|
||||
freq_min_mhz=0.1, # 100 kHz
|
||||
freq_max_mhz=3800.0, # 3.8 GHz
|
||||
gain_min=0.0,
|
||||
gain_max=73.0, # Combined LNA + TIA + PGA
|
||||
sample_rates=[1000000, 2000000, 4000000, 8000000, 10000000, 20000000],
|
||||
supports_bias_t=False,
|
||||
supports_ppm=False, # Uses TCXO, no PPM correction needed
|
||||
tx_capable=True
|
||||
)
|
||||
|
||||
def _build_device_string(self, device: SDRDevice) -> str:
|
||||
"""Build SoapySDR device string for LimeSDR."""
|
||||
if device.serial and device.serial != 'N/A':
|
||||
return f'driver=lime,serial={device.serial}'
|
||||
return f'driver=lime'
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build SoapySDR rx_fm command for FM demodulation.
|
||||
|
||||
For pager decoding with LimeSDR.
|
||||
"""
|
||||
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:
|
||||
# LimeSDR gain is applied to LNAH element
|
||||
cmd.extend(['-g', f'LNAH={int(gain)}'])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
# Output to stdout
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 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.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
# Try readsb first (better SoapySDR support), fallback to dump1090
|
||||
cmd = [
|
||||
'readsb',
|
||||
'--net',
|
||||
'--device-type', 'soapysdr',
|
||||
'--device', device_str,
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||
|
||||
rtl_433 has native SoapySDR support via -d flag.
|
||||
"""
|
||||
device_str = self._build_device_string(device)
|
||||
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', device_str,
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
# PPM not typically needed for LimeSDR (TCXO)
|
||||
# but include if specified
|
||||
if ppm is not None and ppm != 0:
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return LimeSDR capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.LIME_SDR
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
RTL-SDR command builder implementation.
|
||||
|
||||
Uses native rtl_* tools (rtl_fm, rtl_433) and dump1090 for maximum compatibility
|
||||
with existing RTL-SDR installations. No SoapySDR dependency required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class RTLSDRCommandBuilder(CommandBuilder):
|
||||
"""RTL-SDR command builder using native rtl_* tools."""
|
||||
|
||||
CAPABILITIES = SDRCapabilities(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
freq_min_mhz=24.0,
|
||||
freq_max_mhz=1766.0,
|
||||
gain_min=0.0,
|
||||
gain_max=49.6,
|
||||
sample_rates=[250000, 1024000, 1800000, 2048000, 2400000],
|
||||
supports_bias_t=True,
|
||||
supports_ppm=True,
|
||||
tx_capable=False
|
||||
)
|
||||
|
||||
def _get_device_arg(self, device: SDRDevice) -> str:
|
||||
"""Get device argument for rtl_* tools.
|
||||
|
||||
Returns rtl_tcp connection string for network devices,
|
||||
or device index for local devices.
|
||||
"""
|
||||
if device.is_network:
|
||||
return f"rtl_tcp:{device.rtl_tcp_host}:{device.rtl_tcp_port}"
|
||||
return str(device.index)
|
||||
|
||||
def build_fm_demod_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float,
|
||||
sample_rate: int = 22050,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None,
|
||||
modulation: str = "fm",
|
||||
squelch: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_fm command for FM demodulation.
|
||||
|
||||
Used for pager decoding. Supports local devices and rtl_tcp connections.
|
||||
"""
|
||||
cmd = [
|
||||
'rtl_fm',
|
||||
'-d', self._get_device_arg(device),
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-M', modulation,
|
||||
'-s', str(sample_rate),
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(gain)])
|
||||
|
||||
if ppm is not None and ppm != 0:
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
if squelch is not None and squelch > 0:
|
||||
cmd.extend(['-l', str(squelch)])
|
||||
|
||||
# Output to stdout for piping
|
||||
cmd.append('-')
|
||||
|
||||
return cmd
|
||||
|
||||
def build_adsb_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
gain: Optional[float] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build dump1090 command for ADS-B decoding.
|
||||
|
||||
Uses dump1090 with network output for SBS data streaming.
|
||||
|
||||
Note: dump1090 does not support rtl_tcp. For remote SDR, connect to
|
||||
a remote dump1090's SBS output (port 30003) instead.
|
||||
"""
|
||||
if device.is_network:
|
||||
raise ValueError(
|
||||
"dump1090 does not support rtl_tcp. "
|
||||
"For remote ADS-B, run dump1090 on the remote machine and "
|
||||
"connect to its SBS output (port 30003)."
|
||||
)
|
||||
|
||||
cmd = [
|
||||
'dump1090',
|
||||
'--net',
|
||||
'--device-index', str(device.index),
|
||||
'--quiet'
|
||||
]
|
||||
|
||||
if gain is not None:
|
||||
cmd.extend(['--gain', str(int(gain))])
|
||||
|
||||
return cmd
|
||||
|
||||
def build_ism_command(
|
||||
self,
|
||||
device: SDRDevice,
|
||||
frequency_mhz: float = 433.92,
|
||||
gain: Optional[float] = None,
|
||||
ppm: Optional[int] = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build rtl_433 command for ISM band sensor decoding.
|
||||
|
||||
Outputs JSON for easy parsing. Supports local devices and rtl_tcp connections.
|
||||
"""
|
||||
cmd = [
|
||||
'rtl_433',
|
||||
'-d', self._get_device_arg(device),
|
||||
'-f', f'{frequency_mhz}M',
|
||||
'-F', 'json'
|
||||
]
|
||||
|
||||
if gain is not None and gain > 0:
|
||||
cmd.extend(['-g', str(int(gain))])
|
||||
|
||||
if ppm is not None and ppm != 0:
|
||||
cmd.extend(['-p', str(ppm)])
|
||||
|
||||
return cmd
|
||||
|
||||
def get_capabilities(self) -> SDRCapabilities:
|
||||
"""Return RTL-SDR capabilities."""
|
||||
return self.CAPABILITIES
|
||||
|
||||
@classmethod
|
||||
def get_sdr_type(cls) -> SDRType:
|
||||
"""Return SDR type."""
|
||||
return SDRType.RTL_SDR
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
Hardware-specific parameter validation for SDR devices.
|
||||
|
||||
Validates frequency, gain, sample rate, and other parameters against
|
||||
the capabilities of specific SDR hardware.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .base import SDRCapabilities, SDRDevice, SDRType
|
||||
|
||||
|
||||
class SDRValidationError(ValueError):
|
||||
"""Raised when SDR parameter validation fails."""
|
||||
pass
|
||||
|
||||
|
||||
def validate_frequency(
|
||||
freq_mhz: float,
|
||||
device: Optional[SDRDevice] = None,
|
||||
capabilities: Optional[SDRCapabilities] = None
|
||||
) -> float:
|
||||
"""
|
||||
Validate frequency against device capabilities.
|
||||
|
||||
Args:
|
||||
freq_mhz: Frequency in MHz
|
||||
device: SDR device (optional, takes precedence)
|
||||
capabilities: SDR capabilities (used if device not provided)
|
||||
|
||||
Returns:
|
||||
Validated frequency in MHz
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If frequency is out of range
|
||||
"""
|
||||
if device:
|
||||
caps = device.capabilities
|
||||
elif capabilities:
|
||||
caps = capabilities
|
||||
else:
|
||||
# Default RTL-SDR range for backwards compatibility
|
||||
caps = SDRCapabilities(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
freq_min_mhz=24.0,
|
||||
freq_max_mhz=1766.0,
|
||||
gain_min=0.0,
|
||||
gain_max=50.0
|
||||
)
|
||||
|
||||
if not caps.freq_min_mhz <= freq_mhz <= caps.freq_max_mhz:
|
||||
raise SDRValidationError(
|
||||
f"Frequency {freq_mhz} MHz out of range for {caps.sdr_type.value}. "
|
||||
f"Valid range: {caps.freq_min_mhz}-{caps.freq_max_mhz} MHz"
|
||||
)
|
||||
|
||||
return freq_mhz
|
||||
|
||||
|
||||
def validate_gain(
|
||||
gain: float,
|
||||
device: Optional[SDRDevice] = None,
|
||||
capabilities: Optional[SDRCapabilities] = None
|
||||
) -> float:
|
||||
"""
|
||||
Validate gain against device capabilities.
|
||||
|
||||
Args:
|
||||
gain: Gain in dB
|
||||
device: SDR device (optional, takes precedence)
|
||||
capabilities: SDR capabilities (used if device not provided)
|
||||
|
||||
Returns:
|
||||
Validated gain in dB
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If gain is out of range
|
||||
"""
|
||||
if device:
|
||||
caps = device.capabilities
|
||||
elif capabilities:
|
||||
caps = capabilities
|
||||
else:
|
||||
# Default range for backwards compatibility
|
||||
caps = SDRCapabilities(
|
||||
sdr_type=SDRType.RTL_SDR,
|
||||
freq_min_mhz=24.0,
|
||||
freq_max_mhz=1766.0,
|
||||
gain_min=0.0,
|
||||
gain_max=50.0
|
||||
)
|
||||
|
||||
# Allow 0 for auto gain
|
||||
if gain == 0:
|
||||
return gain
|
||||
|
||||
if not caps.gain_min <= gain <= caps.gain_max:
|
||||
raise SDRValidationError(
|
||||
f"Gain {gain} dB out of range for {caps.sdr_type.value}. "
|
||||
f"Valid range: {caps.gain_min}-{caps.gain_max} dB"
|
||||
)
|
||||
|
||||
return gain
|
||||
|
||||
|
||||
def validate_sample_rate(
|
||||
rate: int,
|
||||
device: Optional[SDRDevice] = None,
|
||||
capabilities: Optional[SDRCapabilities] = None,
|
||||
snap_to_nearest: bool = True
|
||||
) -> int:
|
||||
"""
|
||||
Validate sample rate against device capabilities.
|
||||
|
||||
Args:
|
||||
rate: Sample rate in Hz
|
||||
device: SDR device (optional, takes precedence)
|
||||
capabilities: SDR capabilities (used if device not provided)
|
||||
snap_to_nearest: If True, return nearest valid rate instead of raising
|
||||
|
||||
Returns:
|
||||
Validated sample rate in Hz
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If rate is invalid and snap_to_nearest is False
|
||||
"""
|
||||
if device:
|
||||
caps = device.capabilities
|
||||
elif capabilities:
|
||||
caps = capabilities
|
||||
else:
|
||||
return rate # No validation without capabilities
|
||||
|
||||
if not caps.sample_rates:
|
||||
return rate # No restrictions
|
||||
|
||||
if rate in caps.sample_rates:
|
||||
return rate
|
||||
|
||||
if snap_to_nearest:
|
||||
# Find closest valid rate
|
||||
closest = min(caps.sample_rates, key=lambda x: abs(x - rate))
|
||||
return closest
|
||||
|
||||
raise SDRValidationError(
|
||||
f"Sample rate {rate} Hz not supported by {caps.sdr_type.value}. "
|
||||
f"Valid rates: {caps.sample_rates}"
|
||||
)
|
||||
|
||||
|
||||
def validate_ppm(
|
||||
ppm: int,
|
||||
device: Optional[SDRDevice] = None,
|
||||
capabilities: Optional[SDRCapabilities] = None
|
||||
) -> int:
|
||||
"""
|
||||
Validate PPM frequency correction.
|
||||
|
||||
Args:
|
||||
ppm: PPM correction value
|
||||
device: SDR device (optional, takes precedence)
|
||||
capabilities: SDR capabilities (used if device not provided)
|
||||
|
||||
Returns:
|
||||
Validated PPM value
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If PPM is out of range or not supported
|
||||
"""
|
||||
if device:
|
||||
caps = device.capabilities
|
||||
elif capabilities:
|
||||
caps = capabilities
|
||||
else:
|
||||
caps = None
|
||||
|
||||
# Check if device supports PPM
|
||||
if caps and not caps.supports_ppm:
|
||||
if ppm != 0:
|
||||
# Warn but don't fail - some hardware just ignores PPM
|
||||
pass
|
||||
return 0 # Return 0 to indicate no correction
|
||||
|
||||
# Standard PPM range
|
||||
if not -1000 <= ppm <= 1000:
|
||||
raise SDRValidationError(
|
||||
f"PPM correction {ppm} out of range. Valid range: -1000 to 1000"
|
||||
)
|
||||
|
||||
return ppm
|
||||
|
||||
|
||||
def validate_device_index(index: int) -> int:
|
||||
"""
|
||||
Validate device index.
|
||||
|
||||
Args:
|
||||
index: Device index (0-255)
|
||||
|
||||
Returns:
|
||||
Validated device index
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If index is out of range
|
||||
"""
|
||||
if not 0 <= index <= 255:
|
||||
raise SDRValidationError(
|
||||
f"Device index {index} out of range. Valid range: 0-255"
|
||||
)
|
||||
return index
|
||||
|
||||
|
||||
def validate_squelch(squelch: int) -> int:
|
||||
"""
|
||||
Validate squelch level.
|
||||
|
||||
Args:
|
||||
squelch: Squelch level (0-1000, 0 = off)
|
||||
|
||||
Returns:
|
||||
Validated squelch level
|
||||
|
||||
Raises:
|
||||
SDRValidationError: If squelch is out of range
|
||||
"""
|
||||
if not 0 <= squelch <= 1000:
|
||||
raise SDRValidationError(
|
||||
f"Squelch {squelch} out of range. Valid range: 0-1000"
|
||||
)
|
||||
return squelch
|
||||
|
||||
|
||||
def get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
||||
"""
|
||||
Get default capabilities for an SDR type.
|
||||
|
||||
Args:
|
||||
sdr_type: The SDR type
|
||||
|
||||
Returns:
|
||||
SDRCapabilities for the specified type
|
||||
"""
|
||||
from .rtlsdr import RTLSDRCommandBuilder
|
||||
from .limesdr import LimeSDRCommandBuilder
|
||||
from .hackrf import HackRFCommandBuilder
|
||||
|
||||
builders = {
|
||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||
SDRType.HACKRF: HackRFCommandBuilder,
|
||||
}
|
||||
|
||||
builder_class = builders.get(sdr_type)
|
||||
if builder_class:
|
||||
return builder_class.CAPABILITIES
|
||||
|
||||
raise SDRValidationError(f"Unknown SDR type: {sdr_type}")
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Server-Sent Events (SSE) utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import time
|
||||
from typing import Any, Generator
|
||||
|
||||
|
||||
def sse_stream(
|
||||
data_queue: queue.Queue,
|
||||
timeout: float = 1.0,
|
||||
keepalive_interval: float = 30.0,
|
||||
stop_check: callable = None
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
Generate SSE stream from a queue.
|
||||
|
||||
Args:
|
||||
data_queue: Queue to read messages from
|
||||
timeout: Queue get timeout in seconds
|
||||
keepalive_interval: Seconds between keepalive messages
|
||||
stop_check: Optional callable that returns True to stop the stream
|
||||
|
||||
Yields:
|
||||
SSE formatted strings
|
||||
"""
|
||||
last_keepalive = time.time()
|
||||
|
||||
while True:
|
||||
# Check if we should stop
|
||||
if stop_check and stop_check():
|
||||
break
|
||||
|
||||
try:
|
||||
msg = data_queue.get(timeout=timeout)
|
||||
last_keepalive = time.time()
|
||||
yield format_sse(msg)
|
||||
except queue.Empty:
|
||||
# Send keepalive if enough time has passed
|
||||
now = time.time()
|
||||
if now - last_keepalive >= keepalive_interval:
|
||||
yield format_sse({'type': 'keepalive'})
|
||||
last_keepalive = now
|
||||
|
||||
|
||||
def format_sse(data: dict[str, Any] | str, event: str | None = None) -> str:
|
||||
"""
|
||||
Format data as SSE message.
|
||||
|
||||
Args:
|
||||
data: Data to send (will be JSON encoded if dict)
|
||||
event: Optional event name
|
||||
|
||||
Returns:
|
||||
SSE formatted string
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
data = json.dumps(data)
|
||||
|
||||
lines = []
|
||||
if event:
|
||||
lines.append(f"event: {event}")
|
||||
lines.append(f"data: {data}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def clear_queue(q: queue.Queue) -> int:
|
||||
"""
|
||||
Clear all items from a queue.
|
||||
|
||||
Args:
|
||||
q: Queue to clear
|
||||
|
||||
Returns:
|
||||
Number of items cleared
|
||||
"""
|
||||
count = 0
|
||||
while True:
|
||||
try:
|
||||
q.get_nowait()
|
||||
count += 1
|
||||
except queue.Empty:
|
||||
break
|
||||
return count
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Input validation utilities for API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def escape_html(text: str | None) -> str:
|
||||
"""Escape HTML special characters to prevent XSS attacks."""
|
||||
if text is None:
|
||||
return ''
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
html_escape_table = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
return ''.join(html_escape_table.get(c, c) for c in text)
|
||||
|
||||
|
||||
def validate_latitude(lat: Any) -> float:
|
||||
"""Validate and return latitude value."""
|
||||
try:
|
||||
lat_float = float(lat)
|
||||
if not -90 <= lat_float <= 90:
|
||||
raise ValueError(f"Latitude must be between -90 and 90, got {lat_float}")
|
||||
return lat_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid latitude: {lat}") from e
|
||||
|
||||
|
||||
def validate_longitude(lon: Any) -> float:
|
||||
"""Validate and return longitude value."""
|
||||
try:
|
||||
lon_float = float(lon)
|
||||
if not -180 <= lon_float <= 180:
|
||||
raise ValueError(f"Longitude must be between -180 and 180, got {lon_float}")
|
||||
return lon_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid longitude: {lon}") from e
|
||||
|
||||
|
||||
def validate_frequency(freq: Any, min_mhz: float = 24.0, max_mhz: float = 1766.0) -> float:
|
||||
"""Validate and return frequency in MHz."""
|
||||
try:
|
||||
freq_float = float(freq)
|
||||
if not min_mhz <= freq_float <= max_mhz:
|
||||
raise ValueError(f"Frequency must be between {min_mhz} and {max_mhz} MHz, got {freq_float}")
|
||||
return freq_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid frequency: {freq}") from e
|
||||
|
||||
|
||||
def validate_device_index(device: Any) -> int:
|
||||
"""Validate and return RTL-SDR device index."""
|
||||
try:
|
||||
device_int = int(device)
|
||||
if not 0 <= device_int <= 255:
|
||||
raise ValueError(f"Device index must be between 0 and 255, got {device_int}")
|
||||
return device_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid device index: {device}") from e
|
||||
|
||||
|
||||
def validate_rtl_tcp_host(host: Any) -> str:
|
||||
"""Validate and return rtl_tcp server hostname or IP address."""
|
||||
if not host or not isinstance(host, str):
|
||||
raise ValueError("rtl_tcp host is required")
|
||||
host = host.strip()
|
||||
if not host:
|
||||
raise ValueError("rtl_tcp host cannot be empty")
|
||||
# Allow alphanumeric, dots, hyphens (valid for hostnames and IPs)
|
||||
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9.\-]*$', host):
|
||||
raise ValueError(f"Invalid rtl_tcp host: {host}")
|
||||
if len(host) > 253:
|
||||
raise ValueError("rtl_tcp host too long")
|
||||
return host
|
||||
|
||||
|
||||
def validate_rtl_tcp_port(port: Any) -> int:
|
||||
"""Validate and return rtl_tcp server port."""
|
||||
try:
|
||||
port_int = int(port)
|
||||
if not 1 <= port_int <= 65535:
|
||||
raise ValueError(f"Port must be between 1 and 65535, got {port_int}")
|
||||
return port_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid rtl_tcp port: {port}") from e
|
||||
|
||||
|
||||
def validate_gain(gain: Any) -> float:
|
||||
"""Validate and return gain value."""
|
||||
try:
|
||||
gain_float = float(gain)
|
||||
if not 0 <= gain_float <= 50:
|
||||
raise ValueError(f"Gain must be between 0 and 50, got {gain_float}")
|
||||
return gain_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid gain: {gain}") from e
|
||||
|
||||
|
||||
def validate_ppm(ppm: Any) -> int:
|
||||
"""Validate and return PPM correction value."""
|
||||
try:
|
||||
ppm_int = int(ppm)
|
||||
if not -1000 <= ppm_int <= 1000:
|
||||
raise ValueError(f"PPM must be between -1000 and 1000, got {ppm_int}")
|
||||
return ppm_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid PPM: {ppm}") from e
|
||||
|
||||
|
||||
def validate_hours(hours: Any, min_hours: int = 1, max_hours: int = 168) -> int:
|
||||
"""Validate and return hours value (for satellite predictions)."""
|
||||
try:
|
||||
hours_int = int(hours)
|
||||
if not min_hours <= hours_int <= max_hours:
|
||||
raise ValueError(f"Hours must be between {min_hours} and {max_hours}, got {hours_int}")
|
||||
return hours_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid hours: {hours}") from e
|
||||
|
||||
|
||||
def validate_elevation(elevation: Any) -> float:
|
||||
"""Validate and return elevation angle."""
|
||||
try:
|
||||
el_float = float(elevation)
|
||||
if not 0 <= el_float <= 90:
|
||||
raise ValueError(f"Elevation must be between 0 and 90, got {el_float}")
|
||||
return el_float
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid elevation: {elevation}") from e
|
||||
|
||||
|
||||
def validate_wifi_channel(channel: Any) -> int:
|
||||
"""Validate and return WiFi channel."""
|
||||
try:
|
||||
ch_int = int(channel)
|
||||
# Valid WiFi channels: 1-14 (2.4GHz), 32-177 (5GHz)
|
||||
valid_2ghz = 1 <= ch_int <= 14
|
||||
valid_5ghz = 32 <= ch_int <= 177
|
||||
if not (valid_2ghz or valid_5ghz):
|
||||
raise ValueError(f"Invalid WiFi channel: {ch_int}")
|
||||
return ch_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid WiFi channel: {channel}") from e
|
||||
|
||||
|
||||
def validate_mac_address(mac: Any) -> str:
|
||||
"""Validate and return MAC address."""
|
||||
if not mac or not isinstance(mac, str):
|
||||
raise ValueError("MAC address is required")
|
||||
mac = mac.upper().strip()
|
||||
if not re.match(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$', mac):
|
||||
raise ValueError(f"Invalid MAC address format: {mac}")
|
||||
return mac
|
||||
|
||||
|
||||
def validate_positive_int(value: Any, name: str = 'value', max_val: int | None = None) -> int:
|
||||
"""Validate and return a positive integer."""
|
||||
try:
|
||||
val_int = int(value)
|
||||
if val_int < 0:
|
||||
raise ValueError(f"{name} must be positive, got {val_int}")
|
||||
if max_val is not None and val_int > max_val:
|
||||
raise ValueError(f"{name} must be <= {max_val}, got {val_int}")
|
||||
return val_int
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Invalid {name}: {value}") from e
|
||||
|
||||
|
||||
def sanitize_callsign(callsign: str | None) -> str:
|
||||
"""Sanitize aircraft callsign for display."""
|
||||
if not callsign:
|
||||
return ''
|
||||
# Only allow alphanumeric, dash, and space
|
||||
return re.sub(r'[^A-Za-z0-9\- ]', '', str(callsign))[:10]
|
||||
|
||||
|
||||
def sanitize_ssid(ssid: str | None) -> str:
|
||||
"""Sanitize WiFi SSID for display."""
|
||||
if not ssid:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(ssid)[:64])
|
||||
|
||||
|
||||
def sanitize_device_name(name: str | None) -> str:
|
||||
"""Sanitize Bluetooth device name for display."""
|
||||
if not name:
|
||||
return ''
|
||||
# Escape HTML and limit length
|
||||
return escape_html(str(name)[:64])
|
||||
Reference in New Issue
Block a user