mirror of
https://github.com/smittix/intercept.git
synced 2026-06-13 16:23:34 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e179bfd2 | |||
| 20d9178159 | |||
| b2c32173e1 | |||
| 82a2883f82 | |||
| 1807d736b1 | |||
| f2b1839fdc | |||
| 564ef3706f | |||
| 417fa280c3 | |||
| 5077e56d76 | |||
| 3a7c429c4b | |||
| f7ccd56ec0 | |||
| 1f2a7ee523 |
@@ -49,6 +49,19 @@ sudo python3 intercept.py
|
|||||||
|
|
||||||
Open http://localhost:5050 in your browser.
|
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).
|
> **Note:** Requires Python 3.9+ and external tools. See [Hardware & Installation](docs/HARDWARE.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "1.1.0"
|
VERSION = "1.2.0"
|
||||||
|
|
||||||
|
|
||||||
def _get_env(key: str, default: str) -> str:
|
def _get_env(key: str, default: str) -> str:
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ INTERCEPT automatically detects connected devices and shows hardware-specific ca
|
|||||||
## Quick Install Commands
|
## Quick Install Commands
|
||||||
|
|
||||||
### Ubuntu/Debian
|
### 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
|
```bash
|
||||||
# Core tools
|
# Core tools
|
||||||
sudo apt update
|
sudo apt update
|
||||||
@@ -123,3 +125,42 @@ rtl_test
|
|||||||
# LimeSDR/HackRF
|
# LimeSDR/HackRF
|
||||||
SoapySDRUtil --find
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "1.0.0"
|
version = "1.2.0"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
@@ -28,8 +28,14 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=2.0.0",
|
"flask>=2.0.0",
|
||||||
"skyfield>=1.45",
|
"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]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
|
|||||||
+88
-8
@@ -16,9 +16,11 @@ from utils.gps import (
|
|||||||
is_serial_available,
|
is_serial_available,
|
||||||
get_gps_reader,
|
get_gps_reader,
|
||||||
start_gps,
|
start_gps,
|
||||||
|
start_gpsd,
|
||||||
stop_gps,
|
stop_gps,
|
||||||
get_current_position,
|
get_current_position,
|
||||||
GPSPosition,
|
GPSPosition,
|
||||||
|
GPSDClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger('intercept.gps')
|
logger = get_logger('intercept.gps')
|
||||||
@@ -51,6 +53,34 @@ def check_gps_available():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@gps_bp.route('/gpsd/check')
|
||||||
|
def check_gpsd_available():
|
||||||
|
"""Check if gpsd is reachable."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
host = request.args.get('host', 'localhost')
|
||||||
|
port = int(request.args.get('port', 2947))
|
||||||
|
|
||||||
|
try:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.settimeout(2.0)
|
||||||
|
sock.connect((host, port))
|
||||||
|
sock.close()
|
||||||
|
return jsonify({
|
||||||
|
'available': True,
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'message': f'gpsd reachable at {host}:{port}'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'available': False,
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@gps_bp.route('/devices')
|
@gps_bp.route('/devices')
|
||||||
def list_gps_devices():
|
def list_gps_devices():
|
||||||
"""List available GPS serial devices."""
|
"""List available GPS serial devices."""
|
||||||
@@ -109,19 +139,15 @@ def start_gps_reader():
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Start the GPS reader
|
# Start the GPS reader with callback pre-registered (avoids race condition)
|
||||||
success = start_gps(device_path, baudrate)
|
success = start_gps(device_path, baudrate, callback=_position_callback)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
# Register callback for SSE streaming
|
|
||||||
reader = get_gps_reader()
|
|
||||||
if reader:
|
|
||||||
reader.add_callback(_position_callback)
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'started',
|
'status': 'started',
|
||||||
'device': device_path,
|
'device': device_path,
|
||||||
'baudrate': baudrate
|
'baudrate': baudrate,
|
||||||
|
'source': 'serial'
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
reader = get_gps_reader()
|
reader = get_gps_reader()
|
||||||
@@ -132,6 +158,58 @@ def start_gps_reader():
|
|||||||
}), 500
|
}), 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'])
|
@gps_bp.route('/stop', methods=['POST'])
|
||||||
def stop_gps_reader():
|
def stop_gps_reader():
|
||||||
"""Stop GPS reader."""
|
"""Stop GPS reader."""
|
||||||
@@ -205,8 +283,10 @@ def debug_gps():
|
|||||||
})
|
})
|
||||||
|
|
||||||
position = reader.position
|
position = reader.position
|
||||||
|
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'running': reader.is_running,
|
'running': reader.is_running,
|
||||||
|
'source': source,
|
||||||
'device': reader.device_path,
|
'device': reader.device_path,
|
||||||
'baudrate': reader.baudrate,
|
'baudrate': reader.baudrate,
|
||||||
'has_position': position is not None,
|
'has_position': position is not None,
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ check_cmd() {
|
|||||||
command -v "$1" &> /dev/null
|
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 dependencies
|
||||||
install_python_deps() {
|
install_python_deps() {
|
||||||
echo ""
|
echo ""
|
||||||
@@ -72,7 +96,11 @@ install_python_deps() {
|
|||||||
echo "You have Python $PYTHON_VERSION"
|
echo "You have Python $PYTHON_VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Please upgrade Python:"
|
echo "Please upgrade Python:"
|
||||||
echo " Ubuntu/Debian: sudo apt install python3.11"
|
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"
|
echo " macOS: brew install python@3.11"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -81,7 +109,7 @@ install_python_deps() {
|
|||||||
if [ -n "$VIRTUAL_ENV" ]; then
|
if [ -n "$VIRTUAL_ENV" ]; then
|
||||||
echo "Using virtual environment: $VIRTUAL_ENV"
|
echo "Using virtual environment: $VIRTUAL_ENV"
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
elif [ -d "venv" ]; then
|
elif [ -f "venv/bin/activate" ]; then
|
||||||
echo "Found existing venv, activating..."
|
echo "Found existing venv, activating..."
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
@@ -97,14 +125,37 @@ install_python_deps() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
|
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
|
||||||
echo "Creating virtual environment..."
|
echo "Creating virtual environment..."
|
||||||
python3 -m venv venv
|
|
||||||
|
# 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
|
source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
|
echo -e "${YELLOW}NOTE: A virtual environment was created.${NC}"
|
||||||
echo "You must activate it before running INTERCEPT:"
|
echo "You must activate it before running INTERCEPT:"
|
||||||
echo " source venv/bin/activate"
|
echo " source venv/bin/activate"
|
||||||
echo " sudo venv/bin/python intercept.py"
|
if [ -n "$SUDO" ]; then
|
||||||
|
echo " sudo venv/bin/python intercept.py"
|
||||||
|
else
|
||||||
|
echo " venv/bin/python intercept.py"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
echo -e "${GREEN}Python dependencies installed successfully${NC}"
|
||||||
@@ -117,67 +168,175 @@ check_tools() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
MISSING_TOOLS=()
|
MISSING_TOOLS=()
|
||||||
|
MISSING_CORE=false
|
||||||
|
MISSING_WIFI=false
|
||||||
|
MISSING_BLUETOOTH=false
|
||||||
|
|
||||||
# Core SDR tools
|
# Core SDR tools
|
||||||
echo "Core SDR Tools:"
|
echo "Core SDR Tools:"
|
||||||
check_tool "rtl_fm" "RTL-SDR FM demodulator"
|
check_tool "rtl_fm" "RTL-SDR FM demodulator" "core"
|
||||||
check_tool "rtl_test" "RTL-SDR device detection"
|
check_tool "rtl_test" "RTL-SDR device detection" "core"
|
||||||
check_tool "multimon-ng" "Pager decoder"
|
check_tool "multimon-ng" "Pager decoder" "core"
|
||||||
check_tool "rtl_433" "433MHz sensor decoder"
|
check_tool "rtl_433" "433MHz sensor decoder" "core"
|
||||||
check_tool "dump1090" "ADS-B decoder"
|
check_tool "dump1090" "ADS-B decoder" "core"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Additional SDR Hardware (optional):"
|
echo "Additional SDR Hardware (optional):"
|
||||||
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)"
|
check_tool "SoapySDRUtil" "SoapySDR (for LimeSDR/HackRF)" "optional"
|
||||||
check_tool "LimeUtil" "LimeSDR tools"
|
check_tool "LimeUtil" "LimeSDR tools" "optional"
|
||||||
check_tool "hackrf_info" "HackRF tools"
|
check_tool "hackrf_info" "HackRF tools" "optional"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "WiFi Tools:"
|
echo "WiFi Tools:"
|
||||||
check_tool "airmon-ng" "WiFi monitor mode"
|
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
|
||||||
check_tool "airodump-ng" "WiFi scanner"
|
check_tool "airodump-ng" "WiFi scanner" "wifi"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Bluetooth Tools:"
|
echo "Bluetooth Tools:"
|
||||||
check_tool "bluetoothctl" "Bluetooth controller"
|
check_tool "bluetoothctl" "Bluetooth controller" "bluetooth"
|
||||||
check_tool "hcitool" "Bluetooth HCI tool"
|
check_tool "hcitool" "Bluetooth HCI tool" "bluetooth"
|
||||||
|
|
||||||
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
if [ ${#MISSING_TOOLS[@]} -gt 0 ]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}Some tools are missing. See installation instructions below.${NC}"
|
echo -e "${YELLOW}Some tools are missing.${NC}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check_tool() {
|
check_tool() {
|
||||||
local cmd=$1
|
local cmd=$1
|
||||||
local desc=$2
|
local desc=$2
|
||||||
|
local category=$3
|
||||||
if check_cmd "$cmd"; then
|
if check_cmd "$cmd"; then
|
||||||
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
echo -e " ${GREEN}✓${NC} $cmd - $desc"
|
||||||
else
|
else
|
||||||
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
echo -e " ${RED}✗${NC} $cmd - $desc ${YELLOW}(not found)${NC}"
|
||||||
MISSING_TOOLS+=("$cmd")
|
MISSING_TOOLS+=("$cmd")
|
||||||
|
case "$category" in
|
||||||
|
core) MISSING_CORE=true ;;
|
||||||
|
wifi) MISSING_WIFI=true ;;
|
||||||
|
bluetooth) MISSING_BLUETOOTH=true ;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show installation instructions
|
# Install tools on Debian/Ubuntu
|
||||||
show_install_instructions() {
|
install_debian_tools() {
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
|
echo -e "${BLUE}[3/3] Installing tools...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
|
||||||
echo -e "${GREEN}All tools are installed!${NC}"
|
echo -e "${GREEN}All tools are already installed!${NC}"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Run the following commands to install missing tools:"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$OS" == "macos" ]]; then
|
if [[ "$OS" == "macos" ]]; then
|
||||||
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
echo -e "${YELLOW}macOS (Homebrew):${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if Homebrew is installed
|
|
||||||
if ! check_cmd brew; then
|
if ! check_cmd brew; then
|
||||||
echo "First, install Homebrew:"
|
echo "First, install Homebrew:"
|
||||||
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
||||||
@@ -201,7 +360,11 @@ show_install_instructions() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "# Core SDR tools"
|
echo "# Core SDR tools"
|
||||||
echo "sudo apt update"
|
echo "sudo apt update"
|
||||||
echo "sudo apt install rtl-sdr multimon-ng rtl-433 dump1090-mutability"
|
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 ""
|
||||||
echo "# LimeSDR support (optional)"
|
echo "# LimeSDR support (optional)"
|
||||||
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
|
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
|
||||||
@@ -240,6 +403,22 @@ show_install_instructions() {
|
|||||||
fi
|
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)
|
# RTL-SDR udev rules (Linux only)
|
||||||
setup_udev_rules() {
|
setup_udev_rules() {
|
||||||
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
|
if [[ "$OS" != "macos" ]] && [[ "$OS" != "unknown" ]]; then
|
||||||
@@ -263,10 +442,15 @@ setup_udev_rules() {
|
|||||||
# Main
|
# Main
|
||||||
main() {
|
main() {
|
||||||
detect_os
|
detect_os
|
||||||
|
setup_sudo
|
||||||
install_python_deps
|
install_python_deps
|
||||||
check_tools
|
check_tools
|
||||||
show_install_instructions
|
install_or_show_instructions
|
||||||
setup_udev_rules
|
|
||||||
|
# Show udev rules instructions for non-Debian Linux (Debian handles it automatically)
|
||||||
|
if [[ "$OS" != "debian" ]]; then
|
||||||
|
setup_udev_rules
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "============================================"
|
echo "============================================"
|
||||||
@@ -275,9 +459,17 @@ main() {
|
|||||||
echo "To start INTERCEPT:"
|
echo "To start INTERCEPT:"
|
||||||
if [ -d "venv" ]; then
|
if [ -d "venv" ]; then
|
||||||
echo " source venv/bin/activate"
|
echo " source venv/bin/activate"
|
||||||
echo " sudo venv/bin/python intercept.py"
|
if [ -n "$SUDO" ]; then
|
||||||
|
echo " sudo venv/bin/python intercept.py"
|
||||||
|
else
|
||||||
|
echo " venv/bin/python intercept.py"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " sudo python3 intercept.py"
|
if [ -n "$SUDO" ]; then
|
||||||
|
echo " sudo python3 intercept.py"
|
||||||
|
else
|
||||||
|
echo " python3 intercept.py"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "Then open http://localhost:5050 in your browser"
|
echo "Then open http://localhost:5050 in your browser"
|
||||||
|
|||||||
@@ -141,6 +141,7 @@
|
|||||||
<option value="manual">Manual</option>
|
<option value="manual">Manual</option>
|
||||||
<option value="browser">Browser</option>
|
<option value="browser">Browser</option>
|
||||||
<option value="dongle">USB GPS</option>
|
<option value="dongle">USB GPS</option>
|
||||||
|
<option value="gpsd">gpsd</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group" id="browserGpsGroup">
|
<div class="control-group" id="browserGpsGroup">
|
||||||
@@ -159,6 +160,13 @@
|
|||||||
<button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button>
|
<button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button>
|
||||||
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
|
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group gps-gpsd-controls" style="display: none;">
|
||||||
|
<input type="text" id="gpsdHost" value="localhost" placeholder="Host" style="width: 80px; font-size: 10px;">
|
||||||
|
<span style="color: #666;">:</span>
|
||||||
|
<input type="number" id="gpsdPort" value="2947" min="1" max="65535" style="width: 50px; font-size: 10px;">
|
||||||
|
<button class="gps-btn gps-connect-btn" onclick="startGpsdClient()">Connect</button>
|
||||||
|
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
|
||||||
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
|
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
|
||||||
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
|
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
|
||||||
@@ -208,8 +216,17 @@
|
|||||||
messageTimestamps: []
|
messageTimestamps: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observer location and range rings
|
// Observer location and range rings (load from localStorage or default to London)
|
||||||
let observerLocation = { lat: 51.5074, lon: -0.1278 };
|
let observerLocation = (function() {
|
||||||
|
const saved = localStorage.getItem('observerLocation');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.lat && parsed.lon) return parsed;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
|
})();
|
||||||
let rangeRingsLayer = null;
|
let rangeRingsLayer = null;
|
||||||
let observerMarker = null;
|
let observerMarker = null;
|
||||||
|
|
||||||
@@ -834,6 +851,10 @@
|
|||||||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||||||
observerLocation.lat = lat;
|
observerLocation.lat = lat;
|
||||||
observerLocation.lon = lon;
|
observerLocation.lon = lon;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||||
|
|
||||||
if (radarMap) {
|
if (radarMap) {
|
||||||
radarMap.setView([lat, lon], radarMap.getZoom());
|
radarMap.setView([lat, lon], radarMap.getZoom());
|
||||||
}
|
}
|
||||||
@@ -858,6 +879,10 @@
|
|||||||
(position) => {
|
(position) => {
|
||||||
observerLocation.lat = position.coords.latitude;
|
observerLocation.lat = position.coords.latitude;
|
||||||
observerLocation.lon = position.coords.longitude;
|
observerLocation.lon = position.coords.longitude;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||||
|
|
||||||
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
|
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
|
||||||
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
|
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
|
||||||
if (radarMap) {
|
if (radarMap) {
|
||||||
@@ -881,18 +906,22 @@
|
|||||||
const source = document.getElementById('gpsSource').value;
|
const source = document.getElementById('gpsSource').value;
|
||||||
const browserGroup = document.getElementById('browserGpsGroup');
|
const browserGroup = document.getElementById('browserGpsGroup');
|
||||||
const dongleControls = document.querySelector('.gps-dongle-controls');
|
const dongleControls = document.querySelector('.gps-dongle-controls');
|
||||||
|
const gpsdControls = document.querySelector('.gps-gpsd-controls');
|
||||||
|
|
||||||
|
// Hide all first
|
||||||
|
browserGroup.style.display = 'none';
|
||||||
|
dongleControls.style.display = 'none';
|
||||||
|
gpsdControls.style.display = 'none';
|
||||||
|
|
||||||
if (source === 'dongle') {
|
if (source === 'dongle') {
|
||||||
browserGroup.style.display = 'none';
|
|
||||||
dongleControls.style.display = 'flex';
|
dongleControls.style.display = 'flex';
|
||||||
refreshGpsDevices();
|
refreshGpsDevices();
|
||||||
} else if (source === 'browser') {
|
} else if (source === 'browser') {
|
||||||
browserGroup.style.display = 'flex';
|
browserGroup.style.display = 'flex';
|
||||||
dongleControls.style.display = 'none';
|
} else if (source === 'gpsd') {
|
||||||
} else {
|
gpsdControls.style.display = 'flex';
|
||||||
browserGroup.style.display = 'none';
|
|
||||||
dongleControls.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
// 'manual' keeps everything hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshGpsDevices() {
|
async function refreshGpsDevices() {
|
||||||
@@ -935,8 +964,7 @@
|
|||||||
if (data.status === 'started') {
|
if (data.status === 'started') {
|
||||||
gpsConnected = true;
|
gpsConnected = true;
|
||||||
startGpsStream();
|
startGpsStream();
|
||||||
document.querySelector('.gps-connect-btn').style.display = 'none';
|
updateGpsButtons(true, '.gps-dongle-controls');
|
||||||
document.querySelector('.gps-disconnect-btn').style.display = 'block';
|
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to start GPS: ' + data.message);
|
alert('Failed to start GPS: ' + data.message);
|
||||||
}
|
}
|
||||||
@@ -945,6 +973,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startGpsdClient() {
|
||||||
|
const host = document.getElementById('gpsdHost').value || 'localhost';
|
||||||
|
const port = parseInt(document.getElementById('gpsdPort').value) || 2947;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/gps/gpsd/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host: host, port: port })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'started') {
|
||||||
|
gpsConnected = true;
|
||||||
|
startGpsStream();
|
||||||
|
updateGpsButtons(true, '.gps-gpsd-controls');
|
||||||
|
} else {
|
||||||
|
alert('Failed to connect to gpsd: ' + data.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('gpsd connection error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGpsButtons(connected, containerSelector) {
|
||||||
|
// Update buttons in the specified container
|
||||||
|
const container = document.querySelector(containerSelector);
|
||||||
|
if (container) {
|
||||||
|
const connectBtn = container.querySelector('.gps-connect-btn');
|
||||||
|
const disconnectBtn = container.querySelector('.gps-disconnect-btn');
|
||||||
|
if (connectBtn) connectBtn.style.display = connected ? 'none' : 'block';
|
||||||
|
if (disconnectBtn) disconnectBtn.style.display = connected ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function stopGpsDongle() {
|
async function stopGpsDongle() {
|
||||||
try {
|
try {
|
||||||
if (gpsEventSource) {
|
if (gpsEventSource) {
|
||||||
@@ -953,8 +1016,9 @@
|
|||||||
}
|
}
|
||||||
await fetch('/gps/stop', { method: 'POST' });
|
await fetch('/gps/stop', { method: 'POST' });
|
||||||
gpsConnected = false;
|
gpsConnected = false;
|
||||||
document.querySelector('.gps-connect-btn').style.display = 'block';
|
// Reset buttons in both containers
|
||||||
document.querySelector('.gps-disconnect-btn').style.display = 'none';
|
updateGpsButtons(false, '.gps-dongle-controls');
|
||||||
|
updateGpsButtons(false, '.gps-gpsd-controls');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('GPS stop error:', e);
|
console.warn('GPS stop error:', e);
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1091,12 @@
|
|||||||
// INITIALIZATION
|
// INITIALIZATION
|
||||||
// ============================================
|
// ============================================
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize observer location input fields from saved location
|
||||||
|
const obsLatInput = document.getElementById('obsLat');
|
||||||
|
const obsLonInput = document.getElementById('obsLon');
|
||||||
|
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||||
|
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||||
|
|
||||||
initMap();
|
initMap();
|
||||||
updateClock();
|
updateClock();
|
||||||
setInterval(updateClock, 1000);
|
setInterval(updateClock, 1000);
|
||||||
|
|||||||
+146
-30
@@ -275,6 +275,7 @@
|
|||||||
<option value="rtlsdr">RTL-SDR</option>
|
<option value="rtlsdr">RTL-SDR</option>
|
||||||
<option value="limesdr">LimeSDR</option>
|
<option value="limesdr">LimeSDR</option>
|
||||||
<option value="hackrf">HackRF</option>
|
<option value="hackrf">HackRF</option>
|
||||||
|
<option value="airspy">Airspy</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -754,22 +755,36 @@
|
|||||||
📍 Use Browser Location
|
📍 Use Browser Location
|
||||||
</button>
|
</button>
|
||||||
<div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;">
|
<div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;">
|
||||||
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
<div style="margin-bottom: 5px;">
|
||||||
<select class="gps-device-select" style="flex: 1; font-size: 11px;">
|
<select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%; font-size: 11px;">
|
||||||
<option value="">Select GPS Device...</option>
|
<option value="serial">Serial Device</option>
|
||||||
|
<option value="gpsd">gpsd (daemon)</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
<div class="gps-serial-controls">
|
||||||
<select class="gps-baudrate-select" style="flex: 1; font-size: 11px;">
|
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||||
<option value="4800">4800</option>
|
<select class="gps-device-select" style="flex: 1; font-size: 11px;">
|
||||||
<option value="9600" selected>9600</option>
|
<option value="">Select GPS Device...</option>
|
||||||
<option value="38400">38400</option>
|
</select>
|
||||||
<option value="115200">115200</option>
|
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
|
||||||
</select>
|
</div>
|
||||||
|
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||||
|
<select class="gps-baudrate-select" style="flex: 1; font-size: 11px;">
|
||||||
|
<option value="4800">4800</option>
|
||||||
|
<option value="9600" selected>9600</option>
|
||||||
|
<option value="38400">38400</option>
|
||||||
|
<option value="115200">115200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gps-gpsd-controls" style="display: none;">
|
||||||
|
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||||
|
<input type="text" class="gpsd-host-input" value="localhost" placeholder="Host" style="flex: 2; font-size: 11px;">
|
||||||
|
<input type="number" class="gpsd-port-input" value="2947" placeholder="Port" style="flex: 1; font-size: 11px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value, parseInt(this.closest('.gps-dongle-section').querySelector('.gps-baudrate-select').value))" style="flex: 1; font-size: 10px; padding: 4px;">
|
<button class="preset-btn gps-connect-btn" onclick="startGpsFromSection(this.closest('.gps-dongle-section'))" style="flex: 1; font-size: 10px; padding: 4px;">
|
||||||
Connect
|
Connect
|
||||||
</button>
|
</button>
|
||||||
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; font-size: 10px; padding: 4px; background: rgba(255,0,0,0.1); border-color: #ff4444;">
|
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; font-size: 10px; padding: 4px; background: rgba(255,0,0,0.1); border-color: #ff4444;">
|
||||||
@@ -865,25 +880,44 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;">
|
<div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;">
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
<label style="font-size: 11px;">GPS Device</label>
|
<label style="font-size: 11px;">GPS Source</label>
|
||||||
<div style="display: flex; gap: 5px;">
|
<select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%;">
|
||||||
<select class="gps-device-select" style="flex: 1;">
|
<option value="serial">Serial Device</option>
|
||||||
<option value="">Select GPS Device...</option>
|
<option value="gpsd">gpsd (daemon)</option>
|
||||||
</select>
|
|
||||||
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 4px 8px;" title="Refresh">🔄</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" style="margin-bottom: 8px;">
|
|
||||||
<label style="font-size: 11px;">Baud Rate</label>
|
|
||||||
<select class="gps-baudrate-select" style="width: 100%;">
|
|
||||||
<option value="4800">4800</option>
|
|
||||||
<option value="9600" selected>9600</option>
|
|
||||||
<option value="38400">38400</option>
|
|
||||||
<option value="115200">115200</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="gps-serial-controls">
|
||||||
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
|
<label style="font-size: 11px;">GPS Device</label>
|
||||||
|
<div style="display: flex; gap: 5px;">
|
||||||
|
<select class="gps-device-select" style="flex: 1;">
|
||||||
|
<option value="">Select GPS Device...</option>
|
||||||
|
</select>
|
||||||
|
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 4px 8px;" title="Refresh">🔄</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
|
<label style="font-size: 11px;">Baud Rate</label>
|
||||||
|
<select class="gps-baudrate-select" style="width: 100%;">
|
||||||
|
<option value="4800">4800</option>
|
||||||
|
<option value="9600" selected>9600</option>
|
||||||
|
<option value="38400">38400</option>
|
||||||
|
<option value="115200">115200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gps-gpsd-controls" style="display: none;">
|
||||||
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
|
<label style="font-size: 11px;">gpsd Host</label>
|
||||||
|
<input type="text" class="gpsd-host-input" value="localhost" style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom: 8px;">
|
||||||
|
<label style="font-size: 11px;">gpsd Port</label>
|
||||||
|
<input type="number" class="gpsd-port-input" value="2947" style="width: 100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value, parseInt(this.closest('.gps-dongle-section').querySelector('.gps-baudrate-select').value))" style="flex: 1;">
|
<button class="preset-btn gps-connect-btn" onclick="startGpsFromSection(this.closest('.gps-dongle-section'))" style="flex: 1;">
|
||||||
Connect GPS
|
Connect GPS
|
||||||
</button>
|
</button>
|
||||||
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; background: rgba(255,0,0,0.1); border-color: #ff4444;">
|
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; background: rgba(255,0,0,0.1); border-color: #ff4444;">
|
||||||
@@ -1472,8 +1506,17 @@
|
|||||||
sessionStart: null // When tracking started
|
sessionStart: null // When tracking started
|
||||||
};
|
};
|
||||||
|
|
||||||
// Observer location for distance calculations
|
// Observer location for distance calculations (load from localStorage or default to London)
|
||||||
let observerLocation = { lat: 51.5074, lon: -0.1278 }; // Default London
|
let observerLocation = (function() {
|
||||||
|
const saved = localStorage.getItem('observerLocation');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
if (parsed.lat && parsed.lon) return parsed;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
|
})();
|
||||||
let rangeRingsLayer = null;
|
let rangeRingsLayer = null;
|
||||||
let observerMarkerAdsb = null;
|
let observerMarkerAdsb = null;
|
||||||
|
|
||||||
@@ -1804,6 +1847,16 @@
|
|||||||
this.parentElement.classList.toggle('collapsed');
|
this.parentElement.classList.toggle('collapsed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize observer location input fields from saved location
|
||||||
|
const adsbLatInput = document.getElementById('adsbObsLat');
|
||||||
|
const adsbLonInput = document.getElementById('adsbObsLon');
|
||||||
|
const obsLatInput = document.getElementById('obsLat');
|
||||||
|
const obsLonInput = document.getElementById('obsLon');
|
||||||
|
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
|
||||||
|
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
|
||||||
|
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||||
|
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle section collapse
|
// Toggle section collapse
|
||||||
@@ -6025,6 +6078,9 @@
|
|||||||
observerLocation.lat = lat;
|
observerLocation.lat = lat;
|
||||||
observerLocation.lon = lon;
|
observerLocation.lon = lon;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||||
|
|
||||||
// Center map on location
|
// Center map on location
|
||||||
if (aircraftMap) {
|
if (aircraftMap) {
|
||||||
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
|
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
|
||||||
@@ -6058,6 +6114,9 @@
|
|||||||
observerLocation.lat = position.coords.latitude;
|
observerLocation.lat = position.coords.latitude;
|
||||||
observerLocation.lon = position.coords.longitude;
|
observerLocation.lon = position.coords.longitude;
|
||||||
|
|
||||||
|
// Save to localStorage for persistence
|
||||||
|
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
|
||||||
|
|
||||||
// Update input fields
|
// Update input fields
|
||||||
const latInput = document.getElementById('adsbObsLat');
|
const latInput = document.getElementById('adsbObsLat');
|
||||||
const lonInput = document.getElementById('adsbObsLon');
|
const lonInput = document.getElementById('adsbObsLon');
|
||||||
@@ -6310,6 +6369,63 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleGpsSourceMode(selectElement) {
|
||||||
|
// Toggle between serial and gpsd controls
|
||||||
|
const section = selectElement.closest('.gps-dongle-section');
|
||||||
|
const serialControls = section.querySelector('.gps-serial-controls');
|
||||||
|
const gpsdControls = section.querySelector('.gps-gpsd-controls');
|
||||||
|
const source = selectElement.value;
|
||||||
|
|
||||||
|
if (source === 'gpsd') {
|
||||||
|
serialControls.style.display = 'none';
|
||||||
|
gpsdControls.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
serialControls.style.display = 'block';
|
||||||
|
gpsdControls.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGpsFromSection(section) {
|
||||||
|
// Start GPS based on the selected source in the section
|
||||||
|
const sourceSelect = section.querySelector('.gps-source-select');
|
||||||
|
const source = sourceSelect ? sourceSelect.value : 'serial';
|
||||||
|
|
||||||
|
if (source === 'gpsd') {
|
||||||
|
const host = section.querySelector('.gpsd-host-input').value || 'localhost';
|
||||||
|
const port = parseInt(section.querySelector('.gpsd-port-input').value) || 2947;
|
||||||
|
return await startGpsd(host, port);
|
||||||
|
} else {
|
||||||
|
const devicePath = section.querySelector('.gps-device-select').value;
|
||||||
|
const baudrate = parseInt(section.querySelector('.gps-baudrate-select').value) || 9600;
|
||||||
|
return await startGpsDongle(devicePath, baudrate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGpsd(host = 'localhost', port = 2947) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/gps/gpsd/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ host: host, port: port })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'started') {
|
||||||
|
gpsConnected = true;
|
||||||
|
startGpsStream();
|
||||||
|
updateGpsStatus(true);
|
||||||
|
showInfo(`Connected to gpsd at ${host}:${port}`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showError('Failed to connect to gpsd: ' + data.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError('gpsd connection error: ' + e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startGpsDongle(devicePath, baudrate = 9600) {
|
async function startGpsDongle(devicePath, baudrate = 9600) {
|
||||||
if (!devicePath) {
|
if (!devicePath) {
|
||||||
showError('Please select a GPS device');
|
showError('Please select a GPS device');
|
||||||
|
|||||||
+280
-6
@@ -15,7 +15,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Callable
|
from typing import Optional, Callable, Union
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.gps')
|
logger = logging.getLogger('intercept.gps')
|
||||||
|
|
||||||
@@ -457,24 +457,264 @@ class GPSReader:
|
|||||||
logger.error(f"GPS callback error: {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
|
# Global GPS reader instance
|
||||||
_gps_reader: Optional[GPSReader] = None
|
_gps_reader: Optional[GPSSource] = None
|
||||||
_gps_lock = threading.Lock()
|
_gps_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_gps_reader() -> Optional[GPSReader]:
|
def get_gps_reader() -> Optional[GPSSource]:
|
||||||
"""Get the global GPS reader instance."""
|
"""Get the global GPS reader/client instance."""
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
return _gps_reader
|
return _gps_reader
|
||||||
|
|
||||||
|
|
||||||
def start_gps(device_path: str, baudrate: int = 9600) -> bool:
|
def start_gps(device_path: str, baudrate: int = 9600,
|
||||||
|
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Start the global GPS reader.
|
Start the global GPS reader.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
device_path: Path to the GPS serial device
|
device_path: Path to the GPS serial device
|
||||||
baudrate: Serial baudrate (default 9600)
|
baudrate: Serial baudrate (default 9600)
|
||||||
|
callback: Optional callback for position updates (registered before start to avoid race condition)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if started successfully
|
True if started successfully
|
||||||
@@ -487,11 +727,45 @@ def start_gps(device_path: str, baudrate: int = 9600) -> bool:
|
|||||||
_gps_reader.stop()
|
_gps_reader.stop()
|
||||||
|
|
||||||
_gps_reader = GPSReader(device_path, baudrate)
|
_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()
|
return _gps_reader.start()
|
||||||
|
|
||||||
|
|
||||||
def stop_gps() -> None:
|
def stop_gps() -> None:
|
||||||
"""Stop the global GPS reader."""
|
"""Stop the global GPS reader/client."""
|
||||||
global _gps_reader
|
global _gps_reader
|
||||||
|
|
||||||
with _gps_lock:
|
with _gps_lock:
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from .detection import detect_all_devices
|
|||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
from .limesdr import LimeSDRCommandBuilder
|
from .limesdr import LimeSDRCommandBuilder
|
||||||
from .hackrf import HackRFCommandBuilder
|
from .hackrf import HackRFCommandBuilder
|
||||||
|
from .airspy import AirspyCommandBuilder
|
||||||
from .validation import (
|
from .validation import (
|
||||||
SDRValidationError,
|
SDRValidationError,
|
||||||
validate_frequency,
|
validate_frequency,
|
||||||
@@ -49,6 +50,7 @@ class SDRFactory:
|
|||||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||||
SDRType.HACKRF: HackRFCommandBuilder,
|
SDRType.HACKRF: HackRFCommandBuilder,
|
||||||
|
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -214,6 +216,7 @@ __all__ = [
|
|||||||
'RTLSDRCommandBuilder',
|
'RTLSDRCommandBuilder',
|
||||||
'LimeSDRCommandBuilder',
|
'LimeSDRCommandBuilder',
|
||||||
'HackRFCommandBuilder',
|
'HackRFCommandBuilder',
|
||||||
|
'AirspyCommandBuilder',
|
||||||
# Validation
|
# Validation
|
||||||
'SDRValidationError',
|
'SDRValidationError',
|
||||||
'validate_frequency',
|
'validate_frequency',
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Airspy command builder implementation.
|
||||||
|
|
||||||
|
Uses SoapySDR-based tools for FM demodulation and signal capture.
|
||||||
|
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
|
||||||
|
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
|
||||||
|
|
||||||
|
|
||||||
|
class AirspyCommandBuilder(CommandBuilder):
|
||||||
|
"""Airspy command builder using SoapySDR tools."""
|
||||||
|
|
||||||
|
# Airspy R2/Mini capabilities (most common)
|
||||||
|
# HF+ has different range but same interface
|
||||||
|
CAPABILITIES = SDRCapabilities(
|
||||||
|
sdr_type=SDRType.AIRSPY,
|
||||||
|
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
|
||||||
|
freq_max_mhz=1800.0, # 1.8 GHz
|
||||||
|
gain_min=0.0,
|
||||||
|
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
|
||||||
|
sample_rates=[2500000, 3000000, 6000000, 10000000],
|
||||||
|
supports_bias_t=True,
|
||||||
|
supports_ppm=False, # Airspy has TCXO, no PPM needed
|
||||||
|
tx_capable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_device_string(self, device: SDRDevice) -> str:
|
||||||
|
"""Build SoapySDR device string for Airspy."""
|
||||||
|
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
|
||||||
|
if device.serial and device.serial != 'N/A':
|
||||||
|
return f'driver={driver},serial={device.serial}'
|
||||||
|
return f'driver={driver}'
|
||||||
|
|
||||||
|
def _format_gain(self, gain: float) -> str:
|
||||||
|
"""
|
||||||
|
Format gain string for Airspy.
|
||||||
|
|
||||||
|
Airspy has three gain stages:
|
||||||
|
- LNA: 0-15 dB
|
||||||
|
- Mixer: 0-15 dB
|
||||||
|
- VGA: 0-15 dB
|
||||||
|
|
||||||
|
This distributes the requested gain across stages.
|
||||||
|
"""
|
||||||
|
if gain <= 15:
|
||||||
|
return f'LNA={int(gain)},MIX=0,VGA=0'
|
||||||
|
elif gain <= 30:
|
||||||
|
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
|
||||||
|
else:
|
||||||
|
vga = min(15, int(gain - 30))
|
||||||
|
return f'LNA=15,MIX=15,VGA={vga}'
|
||||||
|
|
||||||
|
def build_fm_demod_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float,
|
||||||
|
sample_rate: int = 22050,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None,
|
||||||
|
modulation: str = "fm",
|
||||||
|
squelch: Optional[int] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build SoapySDR rx_fm command for FM demodulation.
|
||||||
|
|
||||||
|
For pager decoding with Airspy.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rx_fm',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', f'{frequency_mhz}M',
|
||||||
|
'-M', modulation,
|
||||||
|
'-s', str(sample_rate),
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', self._format_gain(gain)])
|
||||||
|
|
||||||
|
if squelch is not None and squelch > 0:
|
||||||
|
cmd.extend(['-l', str(squelch)])
|
||||||
|
|
||||||
|
# Output to stdout
|
||||||
|
cmd.append('-')
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def build_adsb_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
gain: Optional[float] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
|
||||||
|
|
||||||
|
Uses readsb which has better SoapySDR support.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'readsb',
|
||||||
|
'--net',
|
||||||
|
'--device-type', 'soapysdr',
|
||||||
|
'--device', device_str,
|
||||||
|
'--quiet'
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None:
|
||||||
|
cmd.extend(['--gain', str(int(gain))])
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def build_ism_command(
|
||||||
|
self,
|
||||||
|
device: SDRDevice,
|
||||||
|
frequency_mhz: float = 433.92,
|
||||||
|
gain: Optional[float] = None,
|
||||||
|
ppm: Optional[int] = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Build rtl_433 command with SoapySDR support for ISM band decoding.
|
||||||
|
|
||||||
|
rtl_433 has native SoapySDR support via -d flag.
|
||||||
|
"""
|
||||||
|
device_str = self._build_device_string(device)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'rtl_433',
|
||||||
|
'-d', device_str,
|
||||||
|
'-f', f'{frequency_mhz}M',
|
||||||
|
'-F', 'json'
|
||||||
|
]
|
||||||
|
|
||||||
|
if gain is not None and gain > 0:
|
||||||
|
cmd.extend(['-g', str(int(gain))])
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def get_capabilities(self) -> SDRCapabilities:
|
||||||
|
"""Return Airspy capabilities."""
|
||||||
|
return self.CAPABILITIES
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_sdr_type(cls) -> SDRType:
|
||||||
|
"""Return SDR type."""
|
||||||
|
return SDRType.AIRSPY
|
||||||
@@ -18,6 +18,7 @@ class SDRType(Enum):
|
|||||||
RTL_SDR = "rtlsdr"
|
RTL_SDR = "rtlsdr"
|
||||||
LIME_SDR = "limesdr"
|
LIME_SDR = "limesdr"
|
||||||
HACKRF = "hackrf"
|
HACKRF = "hackrf"
|
||||||
|
AIRSPY = "airspy"
|
||||||
# Future support
|
# Future support
|
||||||
# USRP = "usrp"
|
# USRP = "usrp"
|
||||||
# BLADE_RF = "bladerf"
|
# BLADE_RF = "bladerf"
|
||||||
|
|||||||
+30
-17
@@ -28,11 +28,13 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
|
|||||||
from .rtlsdr import RTLSDRCommandBuilder
|
from .rtlsdr import RTLSDRCommandBuilder
|
||||||
from .limesdr import LimeSDRCommandBuilder
|
from .limesdr import LimeSDRCommandBuilder
|
||||||
from .hackrf import HackRFCommandBuilder
|
from .hackrf import HackRFCommandBuilder
|
||||||
|
from .airspy import AirspyCommandBuilder
|
||||||
|
|
||||||
builders = {
|
builders = {
|
||||||
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
SDRType.RTL_SDR: RTLSDRCommandBuilder,
|
||||||
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
SDRType.LIME_SDR: LimeSDRCommandBuilder,
|
||||||
SDRType.HACKRF: HackRFCommandBuilder,
|
SDRType.HACKRF: HackRFCommandBuilder,
|
||||||
|
SDRType.AIRSPY: AirspyCommandBuilder,
|
||||||
}
|
}
|
||||||
|
|
||||||
builder_class = builders.get(sdr_type)
|
builder_class = builders.get(sdr_type)
|
||||||
@@ -60,6 +62,8 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
|
|||||||
'lime': SDRType.LIME_SDR,
|
'lime': SDRType.LIME_SDR,
|
||||||
'limesdr': SDRType.LIME_SDR,
|
'limesdr': SDRType.LIME_SDR,
|
||||||
'hackrf': SDRType.HACKRF,
|
'hackrf': SDRType.HACKRF,
|
||||||
|
'airspy': SDRType.AIRSPY,
|
||||||
|
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
|
||||||
# Future support
|
# Future support
|
||||||
# 'uhd': SDRType.USRP,
|
# 'uhd': SDRType.USRP,
|
||||||
# 'bladerf': SDRType.BLADE_RF,
|
# 'bladerf': SDRType.BLADE_RF,
|
||||||
@@ -140,15 +144,17 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def detect_soapy_devices() -> list[SDRDevice]:
|
def detect_soapy_devices(skip_types: Optional[set[SDRType]] = None) -> list[SDRDevice]:
|
||||||
"""
|
"""
|
||||||
Detect SDR devices via SoapySDR.
|
Detect SDR devices via SoapySDR.
|
||||||
|
|
||||||
This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible
|
This detects LimeSDR, HackRF, Airspy, and other SoapySDR-compatible devices.
|
||||||
devices. RTL-SDR devices may also appear here but we prefer the native
|
|
||||||
detection for those.
|
Args:
|
||||||
|
skip_types: Set of SDRType values to skip (e.g., if already found via native detection)
|
||||||
"""
|
"""
|
||||||
devices: list[SDRDevice] = []
|
devices: list[SDRDevice] = []
|
||||||
|
skip_types = skip_types or set()
|
||||||
|
|
||||||
if not _check_tool('SoapySDRUtil'):
|
if not _check_tool('SoapySDRUtil'):
|
||||||
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
logger.debug("SoapySDRUtil not found, skipping SoapySDR detection")
|
||||||
@@ -177,7 +183,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
|||||||
# Start of new device block
|
# Start of new device block
|
||||||
if line.startswith('Found device'):
|
if line.startswith('Found device'):
|
||||||
if current_device.get('driver'):
|
if current_device.get('driver'):
|
||||||
_add_soapy_device(devices, current_device, device_counts)
|
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||||
current_device = {}
|
current_device = {}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -190,7 +196,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
|||||||
|
|
||||||
# Don't forget the last device
|
# Don't forget the last device
|
||||||
if current_device.get('driver'):
|
if current_device.get('driver'):
|
||||||
_add_soapy_device(devices, current_device, device_counts)
|
_add_soapy_device(devices, current_device, device_counts, skip_types)
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logger.warning("SoapySDRUtil timed out")
|
logger.warning("SoapySDRUtil timed out")
|
||||||
@@ -203,7 +209,8 @@ def detect_soapy_devices() -> list[SDRDevice]:
|
|||||||
def _add_soapy_device(
|
def _add_soapy_device(
|
||||||
devices: list[SDRDevice],
|
devices: list[SDRDevice],
|
||||||
device_info: dict,
|
device_info: dict,
|
||||||
device_counts: dict[SDRType, int]
|
device_counts: dict[SDRType, int],
|
||||||
|
skip_types: set[SDRType]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Add a device from SoapySDR detection to the list."""
|
"""Add a device from SoapySDR detection to the list."""
|
||||||
driver = device_info.get('driver', '').lower()
|
driver = device_info.get('driver', '').lower()
|
||||||
@@ -213,8 +220,9 @@ def _add_soapy_device(
|
|||||||
logger.debug(f"Unknown SoapySDR driver: {driver}")
|
logger.debug(f"Unknown SoapySDR driver: {driver}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Skip RTL-SDR devices from SoapySDR (we use native detection)
|
# Skip device types that were already found via native detection
|
||||||
if sdr_type == SDRType.RTL_SDR:
|
if sdr_type in skip_types:
|
||||||
|
logger.debug(f"Skipping {driver} from SoapySDR (already found via native detection)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Track device index per type
|
# Track device index per type
|
||||||
@@ -294,19 +302,24 @@ def detect_all_devices() -> list[SDRDevice]:
|
|||||||
Returns a unified list of SDRDevice objects sorted by type and index.
|
Returns a unified list of SDRDevice objects sorted by type and index.
|
||||||
"""
|
"""
|
||||||
devices: list[SDRDevice] = []
|
devices: list[SDRDevice] = []
|
||||||
|
skip_in_soapy: set[SDRType] = set()
|
||||||
|
|
||||||
# RTL-SDR via native tool (primary method)
|
# RTL-SDR via native tool (primary method)
|
||||||
devices.extend(detect_rtlsdr_devices())
|
rtlsdr_devices = detect_rtlsdr_devices()
|
||||||
|
devices.extend(rtlsdr_devices)
|
||||||
|
if rtlsdr_devices:
|
||||||
|
skip_in_soapy.add(SDRType.RTL_SDR)
|
||||||
|
|
||||||
# SoapySDR devices (LimeSDR, HackRF, etc.)
|
# Native HackRF detection (primary method)
|
||||||
soapy_devices = detect_soapy_devices()
|
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)
|
devices.extend(soapy_devices)
|
||||||
|
|
||||||
# Native HackRF detection (fallback if SoapySDR didn't find it)
|
|
||||||
hackrf_from_soapy = any(d.sdr_type == SDRType.HACKRF for d in soapy_devices)
|
|
||||||
if not hackrf_from_soapy:
|
|
||||||
devices.extend(detect_hackrf_devices())
|
|
||||||
|
|
||||||
# Sort by type name, then index
|
# Sort by type name, then index
|
||||||
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
|
devices.sort(key=lambda d: (d.sdr_type.value, d.index))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user