Compare commits

..

12 Commits

Author SHA1 Message Date
Smittix 68e179bfd2 Fix SoapySDR device detection for RTL-SDR and HackRF
Previously, RTL-SDR devices from SoapySDR were unconditionally skipped,
even if native rtl_test wasn't available. Now:

- Native detection runs first for RTL-SDR and HackRF
- SoapySDR only skips device types that were already found natively
- If native tools aren't available, SoapySDR detection is used as fallback

This fixes the issue where users with only SoapySDR installed couldn't
see their RTL-SDR or HackRF devices.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:30:08 +00:00
Smittix 20d9178159 Bump version to 1.2.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:21:33 +00:00
Smittix b2c32173e1 Add Airspy SDR support and persist GPS coordinates
Airspy support:
- Add AIRSPY to SDRType enum and driver mappings
- Create AirspyCommandBuilder using SoapySDR tools (rx_fm, readsb, rtl_433)
- Register in SDRFactory and add to hardware type dropdown
- Supports Airspy R2/Mini (24MHz-1.8GHz) and HF+ devices

GPS coordinate persistence:
- Save observer location to localStorage when manually entered or via geolocation
- Restore saved coordinates on page load in both index.html and adsb_dashboard.html
- Coordinates are shared between both pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:04:43 +00:00
Smittix 82a2883f82 Update HARDWARE.md 2026-01-05 17:33:10 +00:00
Smittix 1807d736b1 Update HARDWARE.md 2026-01-05 17:23:25 +00:00
Smittix f2b1839fdc Check dump1090 availability per Debian version
- Try dump1090-fa, dump1090-mutability, dump1090 variants
- Use pkg_available() to check actual installability
- Skip gracefully on Debian Trixie and other versions without it
- Only show manual install message if not already installed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:54:48 +00:00
Smittix 564ef3706f Skip dump1090 apt install - not in Debian repos
- dump1090 is not available in standard Debian repositories
- Show manual installation instructions with FlightAware link
- Add pkg_available() helper for reliable package checks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:42:51 +00:00
Smittix 417fa280c3 Handle missing dump1090 and rtl-433 packages gracefully
- Check apt-cache for package availability before installing
- Try dump1090-fa, dump1090-mutability, then dump1090 variants
- Try rtl-433 and rtl433 package names
- Show helpful message with FlightAware install link if dump1090 unavailable
- Update manual instructions with correct package info

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:39:05 +00:00
Smittix 5077e56d76 Handle running as root without sudo on Debian
- Add setup_sudo() to detect root user or check for sudo availability
- Use $SUDO variable instead of hardcoded sudo commands
- Show appropriate commands in error messages based on privilege level
- Exit with helpful message if not root and sudo unavailable

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:35:40 +00:00
Smittix 3a7c429c4b Add automatic tool installation for Debian/Ubuntu
- Automatically install missing SDR, WiFi, and Bluetooth tools on Debian
- Prompt user before installing (can decline for manual instructions)
- Auto-setup RTL-SDR udev rules with user confirmation
- Track missing tools by category (core, wifi, bluetooth)
- Keep manual instructions for macOS, Arch, and other distros

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:31:04 +00:00
Smittix f7ccd56ec0 Fix setup.sh venv detection on fresh Debian installs
- Check for venv/bin/activate file instead of just venv directory
- Add error handling when python3-venv package is not installed
- Clean up incomplete venv directories from failed attempts
- Provide helpful instructions to install python3-venv

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:24:49 +00:00
Smittix 1f2a7ee523 Add uv installation instructions and update pyproject.toml
- Add uv quick start option to README (collapsible section)
- Add Python Dependencies section to HARDWARE.md with multiple options:
  - setup.sh (recommended)
  - pip with requirements.txt
  - uv with uv sync
  - pip with pyproject.toml
- Update pyproject.toml:
  - Bump version to 1.1.0
  - Add pyserial to dependencies
  - Add project URLs
2026-01-05 10:12:03 +00:00
13 changed files with 1064 additions and 102 deletions
+13
View File
@@ -49,6 +49,19 @@ 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).
---
+1 -1
View File
@@ -7,7 +7,7 @@ import os
import sys
# Application version
VERSION = "1.1.0"
VERSION = "1.2.0"
def _get_env(key: str, default: str) -> str:
+41
View File
@@ -49,6 +49,8 @@ INTERCEPT automatically detects connected devices and shows hardware-specific ca
## 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
@@ -123,3 +125,42 @@ 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
```
+7 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "intercept"
version = "1.0.0"
version = "1.2.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
@@ -28,8 +28,14 @@ classifiers = [
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",
+88 -8
View File
@@ -16,9 +16,11 @@ from utils.gps import (
is_serial_available,
get_gps_reader,
start_gps,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
GPSDClient,
)
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')
def list_gps_devices():
"""List available GPS serial devices."""
@@ -109,19 +139,15 @@ def start_gps_reader():
except queue.Empty:
break
# Start the GPS reader
success = start_gps(device_path, baudrate)
# Start the GPS reader with callback pre-registered (avoids race condition)
success = start_gps(device_path, baudrate, callback=_position_callback)
if success:
# Register callback for SSE streaming
reader = get_gps_reader()
if reader:
reader.add_callback(_position_callback)
return jsonify({
'status': 'started',
'device': device_path,
'baudrate': baudrate
'baudrate': baudrate,
'source': 'serial'
})
else:
reader = get_gps_reader()
@@ -132,6 +158,58 @@ def start_gps_reader():
}), 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."""
@@ -205,8 +283,10 @@ def debug_gps():
})
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,
+220 -28
View File
@@ -50,6 +50,30 @@ 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 ""
@@ -72,7 +96,11 @@ install_python_deps() {
echo "You have Python $PYTHON_VERSION"
echo ""
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"
exit 1
fi
@@ -81,7 +109,7 @@ install_python_deps() {
if [ -n "$VIRTUAL_ENV" ]; then
echo "Using virtual environment: $VIRTUAL_ENV"
pip install -r requirements.txt
elif [ -d "venv" ]; then
elif [ -f "venv/bin/activate" ]; then
echo "Found existing venv, activating..."
source venv/bin/activate
pip install -r requirements.txt
@@ -97,14 +125,37 @@ install_python_deps() {
echo ""
echo -e "${YELLOW}System Python is externally managed (PEP 668).${NC}"
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
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"
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
echo -e "${GREEN}Python dependencies installed successfully${NC}"
@@ -117,67 +168,175 @@ check_tools() {
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"
check_tool "rtl_test" "RTL-SDR device detection"
check_tool "multimon-ng" "Pager decoder"
check_tool "rtl_433" "433MHz sensor decoder"
check_tool "dump1090" "ADS-B decoder"
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)"
check_tool "LimeUtil" "LimeSDR tools"
check_tool "hackrf_info" "HackRF tools"
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"
check_tool "airodump-ng" "WiFi scanner"
check_tool "airmon-ng" "WiFi monitor mode" "wifi"
check_tool "airodump-ng" "WiFi scanner" "wifi"
echo ""
echo "Bluetooth Tools:"
check_tool "bluetoothctl" "Bluetooth controller"
check_tool "hcitool" "Bluetooth HCI tool"
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. See installation instructions below.${NC}"
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
}
# Show installation instructions
show_install_instructions() {
# Install tools on Debian/Ubuntu
install_debian_tools() {
echo ""
echo -e "${BLUE}[3/3] Installation instructions for missing tools${NC}"
echo -e "${BLUE}[3/3] Installing tools...${NC}"
echo ""
if [ ${#MISSING_TOOLS[@]} -eq 0 ]; then
echo -e "${GREEN}All tools are installed!${NC}"
echo -e "${GREEN}All tools are already installed!${NC}"
return
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 ""
if [[ "$OS" == "macos" ]]; then
echo -e "${YELLOW}macOS (Homebrew):${NC}"
echo ""
# Check if Homebrew is installed
if ! check_cmd brew; then
echo "First, install Homebrew:"
echo ' /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
@@ -201,7 +360,11 @@ show_install_instructions() {
echo ""
echo "# Core SDR tools"
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 "# LimeSDR support (optional)"
echo "sudo apt install soapysdr-tools limesuite soapysdr-module-lms7"
@@ -240,6 +403,22 @@ show_install_instructions() {
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
@@ -263,10 +442,15 @@ setup_udev_rules() {
# Main
main() {
detect_os
setup_sudo
install_python_deps
check_tools
show_install_instructions
setup_udev_rules
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 "============================================"
@@ -275,9 +459,17 @@ main() {
echo "To start INTERCEPT:"
if [ -d "venv" ]; then
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
echo " sudo python3 intercept.py"
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"
+81 -11
View File
@@ -141,6 +141,7 @@
<option value="manual">Manual</option>
<option value="browser">Browser</option>
<option value="dongle">USB GPS</option>
<option value="gpsd">gpsd</option>
</select>
</div>
<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-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
</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">
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
@@ -208,8 +216,17 @@
messageTimestamps: []
};
// Observer location and range rings
let observerLocation = { lat: 51.5074, lon: -0.1278 };
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
@@ -834,6 +851,10 @@
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom());
}
@@ -858,6 +879,10 @@
(position) => {
observerLocation.lat = position.coords.latitude;
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('obsLon').value = observerLocation.lon.toFixed(4);
if (radarMap) {
@@ -881,18 +906,22 @@
const source = document.getElementById('gpsSource').value;
const browserGroup = document.getElementById('browserGpsGroup');
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') {
browserGroup.style.display = 'none';
dongleControls.style.display = 'flex';
refreshGpsDevices();
} else if (source === 'browser') {
browserGroup.style.display = 'flex';
dongleControls.style.display = 'none';
} else {
browserGroup.style.display = 'none';
dongleControls.style.display = 'none';
} else if (source === 'gpsd') {
gpsdControls.style.display = 'flex';
}
// 'manual' keeps everything hidden
}
async function refreshGpsDevices() {
@@ -935,8 +964,7 @@
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
document.querySelector('.gps-connect-btn').style.display = 'none';
document.querySelector('.gps-disconnect-btn').style.display = 'block';
updateGpsButtons(true, '.gps-dongle-controls');
} else {
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() {
try {
if (gpsEventSource) {
@@ -953,8 +1016,9 @@
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block';
document.querySelector('.gps-disconnect-btn').style.display = 'none';
// Reset buttons in both containers
updateGpsButtons(false, '.gps-dongle-controls');
updateGpsButtons(false, '.gps-gpsd-controls');
} catch (e) {
console.warn('GPS stop error:', e);
}
@@ -1027,6 +1091,12 @@
// INITIALIZATION
// ============================================
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();
updateClock();
setInterval(updateClock, 1000);
+146 -30
View File
@@ -275,6 +275,7 @@
<option value="rtlsdr">RTL-SDR</option>
<option value="limesdr">LimeSDR</option>
<option value="hackrf">HackRF</option>
<option value="airspy">Airspy</option>
</select>
</div>
<div class="form-group">
@@ -754,22 +755,36 @@
📍 Use Browser Location
</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 style="display: flex; gap: 5px; margin-bottom: 5px;">
<select class="gps-device-select" style="flex: 1; font-size: 11px;">
<option value="">Select GPS Device...</option>
<div style="margin-bottom: 5px;">
<select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%; font-size: 11px;">
<option value="serial">Serial Device</option>
<option value="gpsd">gpsd (daemon)</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</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 class="gps-serial-controls">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select class="gps-device-select" style="flex: 1; font-size: 11px;">
<option value="">Select GPS Device...</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</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 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
</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;">
@@ -865,25 +880,44 @@
</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="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>
<label style="font-size: 11px;">GPS Source</label>
<select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%;">
<option value="serial">Serial Device</option>
<option value="gpsd">gpsd (daemon)</option>
</select>
</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;">
<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
</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;">
@@ -1472,8 +1506,17 @@
sessionStart: null // When tracking started
};
// Observer location for distance calculations
let observerLocation = { lat: 51.5074, lon: -0.1278 }; // Default London
// Observer location for distance calculations (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarkerAdsb = null;
@@ -1804,6 +1847,16 @@
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
@@ -6025,6 +6078,9 @@
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Center map on location
if (aircraftMap) {
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
@@ -6058,6 +6114,9 @@
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Update input fields
const latInput = document.getElementById('adsbObsLat');
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) {
if (!devicePath) {
showError('Please select a GPS device');
+280 -6
View File
@@ -15,7 +15,7 @@ import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable
from typing import Optional, Callable, Union
logger = logging.getLogger('intercept.gps')
@@ -457,24 +457,264 @@ class GPSReader:
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[GPSReader] = None
_gps_reader: Optional[GPSSource] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSReader]:
"""Get the global GPS reader instance."""
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) -> bool:
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
@@ -487,11 +727,45 @@ def start_gps(device_path: str, baudrate: int = 9600) -> bool:
_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."""
"""Stop the global GPS reader/client."""
global _gps_reader
with _gps_lock:
+3
View File
@@ -30,6 +30,7 @@ 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,
@@ -49,6 +50,7 @@ class SDRFactory:
SDRType.RTL_SDR: RTLSDRCommandBuilder,
SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
}
@classmethod
@@ -214,6 +216,7 @@ __all__ = [
'RTLSDRCommandBuilder',
'LimeSDRCommandBuilder',
'HackRFCommandBuilder',
'AirspyCommandBuilder',
# Validation
'SDRValidationError',
'validate_frequency',
+153
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ class SDRType(Enum):
RTL_SDR = "rtlsdr"
LIME_SDR = "limesdr"
HACKRF = "hackrf"
AIRSPY = "airspy"
# Future support
# USRP = "usrp"
# BLADE_RF = "bladerf"
+30 -17
View File
@@ -28,11 +28,13 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
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)
@@ -60,6 +62,8 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
'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,
@@ -140,15 +144,17 @@ def detect_rtlsdr_devices() -> list[SDRDevice]:
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.
This detects LimeSDR, HackRF, USRP, BladeRF, and other SoapySDR-compatible
devices. RTL-SDR devices may also appear here but we prefer the native
detection for those.
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")
@@ -177,7 +183,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
# Start of new device block
if line.startswith('Found device'):
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 = {}
continue
@@ -190,7 +196,7 @@ def detect_soapy_devices() -> list[SDRDevice]:
# Don't forget the last device
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:
logger.warning("SoapySDRUtil timed out")
@@ -203,7 +209,8 @@ def detect_soapy_devices() -> list[SDRDevice]:
def _add_soapy_device(
devices: list[SDRDevice],
device_info: dict,
device_counts: dict[SDRType, int]
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()
@@ -213,8 +220,9 @@ def _add_soapy_device(
logger.debug(f"Unknown SoapySDR driver: {driver}")
return
# Skip RTL-SDR devices from SoapySDR (we use native detection)
if sdr_type == SDRType.RTL_SDR:
# 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
@@ -294,19 +302,24 @@ def detect_all_devices() -> list[SDRDevice]:
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)
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.)
soapy_devices = detect_soapy_devices()
# 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)
# 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
devices.sort(key=lambda d: (d.sdr_type.value, d.index))