Add GPS dongle support and fix Python 3.7/3.8 compatibility

- Add GPS dongle support with NMEA parsing (utils/gps.py, routes/gps.py)
- Add GPS device selector to ADS-B and Satellite observer location sections
- Add GPS dongle option to ADS-B dashboard
- Fix Python 3.7/3.8 compatibility by adding 'from __future__ import annotations'
  to all SDR module files (fixes TypeError: 'type' object is not subscriptable)
- Add pyserial to requirements.txt
- Update README with GPS dongle documentation and troubleshooting

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-01-02 17:19:41 +00:00
parent 3a0a697bac
commit e01c651bb4
13 changed files with 1213 additions and 11 deletions

View File

@@ -188,6 +188,7 @@ AI is no different from using a power tool instead of a hand screwdriver: it hel
- **Hardware-specific validation** - frequency/gain ranges per device type
- **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location
- **Disclaimer acceptance** on first use
- **Auto-stop** when switching between modes
@@ -204,8 +205,8 @@ AI is no different from using a power tool instead of a hand screwdriver: it hel
- Bluetooth adapter (for Bluetooth features)
### Software
- Python 3.7+
- Flask, skyfield (installed via `requirements.txt`)
- Python 3.9+ recommended (3.7+ may work but is not fully tested)
- Flask, skyfield, pyserial (installed via `requirements.txt`)
- rtl-sdr tools (`rtl_fm`)
- multimon-ng (for pager decoding)
- rtl_433 (for 433MHz sensor decoding)
@@ -325,7 +326,10 @@ python3 intercept.py --help
### Aircraft Mode
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed
3. **Set Location** - Enter observer coordinates or click "Use GPS Location"
3. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation (requires HTTPS)
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information
@@ -334,7 +338,10 @@ python3 intercept.py --help
9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
### Satellite Mode
1. **Set Location** - Enter observer coordinates or click "Use My Location"
1. **Set Location** - Choose location source:
- **Manual Entry** - Type coordinates directly
- **Browser GPS** - Use browser's built-in geolocation
- **USB GPS Dongle** - Connect a USB GPS receiver for continuous updates
2. **Add Satellites** - Click "Add Satellite" to enter TLE data or fetch from Celestrak
3. **Calculate Passes** - Click "Calculate Passes" to predict upcoming passes
4. **View Sky Plot** - Polar plot shows satellite positions in real-time
@@ -355,6 +362,28 @@ python3 intercept.py --help
### Python/pip installation issues
**"ModuleNotFoundError: No module named 'flask'":**
```bash
# You need to install Python dependencies first
pip install -r requirements.txt
# Or with python3 explicitly
python3 -m pip install -r requirements.txt
```
**"TypeError: 'type' object is not subscriptable":**
This error occurs on Python 3.7 or 3.8. Please upgrade to Python 3.9 or later:
```bash
# Check your Python version
python3 --version
# Ubuntu/Debian - install newer Python
sudo apt update
sudo apt install python3.10
# Or use pyenv to manage Python versions
```
**"externally-managed-environment" error (Ubuntu 23.04+, Debian 12+):**
```bash
# Option 1: Use a virtual environment (recommended)
@@ -415,6 +444,16 @@ pip install --user -r requirements.txt
- Check the driver module is loaded: `SoapySDRUtil --find`
- Verify permissions (may need udev rules or run as root)
### GPS dongle not detected
- Ensure pyserial is installed: `pip install pyserial`
- Check the device is connected: `ls /dev/ttyUSB* /dev/ttyACM*` (Linux) or `ls /dev/tty.usb*` (macOS)
- Verify permissions: you may need to add your user to the `dialout` group (Linux):
```bash
sudo usermod -a -G dialout $USER
```
- Most GPS dongles use 9600 baud rate (default in INTERCEPT)
- The GPS needs a clear view of the sky to get a fix
---
## Configuration

View File

@@ -4,6 +4,9 @@ flask>=2.0.0
# Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45
# GPS dongle support (optional - only needed for USB GPS receivers)
pyserial>=3.5
# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-cov>=4.0.0

View File

@@ -9,6 +9,7 @@ def register_blueprints(app):
from .adsb import adsb_bp
from .satellite import satellite_bp
from .iridium import iridium_bp
from .gps import gps_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -17,3 +18,4 @@ def register_blueprints(app):
app.register_blueprint(adsb_bp)
app.register_blueprint(satellite_bp)
app.register_blueprint(iridium_bp)
app.register_blueprint(gps_bp)

218
routes/gps.py Normal file
View File

@@ -0,0 +1,218 @@
"""GPS dongle routes for USB GPS device support."""
from __future__ import annotations
import queue
import threading
import time
from typing import Generator
from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
detect_gps_devices,
is_serial_available,
get_gps_reader,
start_gps,
stop_gps,
get_current_position,
GPSPosition,
)
logger = get_logger('intercept.gps')
gps_bp = Blueprint('gps', __name__, url_prefix='/gps')
# Queue for SSE position updates
_gps_queue: queue.Queue = queue.Queue(maxsize=100)
def _position_callback(position: GPSPosition) -> None:
"""Callback to queue position updates for SSE stream."""
try:
_gps_queue.put_nowait(position.to_dict())
except queue.Full:
# Discard oldest if queue is full
try:
_gps_queue.get_nowait()
_gps_queue.put_nowait(position.to_dict())
except queue.Empty:
pass
@gps_bp.route('/available')
def check_gps_available():
"""Check if GPS dongle support is available."""
return jsonify({
'available': is_serial_available(),
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
})
@gps_bp.route('/devices')
def list_gps_devices():
"""List available GPS serial devices."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
devices = detect_gps_devices()
return jsonify({
'status': 'ok',
'devices': devices
})
@gps_bp.route('/start', methods=['POST'])
def start_gps_reader():
"""Start GPS reader on specified device."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
device_path = data.get('device')
baudrate = data.get('baudrate', 9600)
if not device_path:
return jsonify({
'status': 'error',
'message': 'Device path required'
}), 400
# Validate baudrate
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
if baudrate not in valid_baudrates:
return jsonify({
'status': 'error',
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
}), 400
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the GPS reader
success = start_gps(device_path, baudrate)
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
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to start GPS reader: {error}'
}), 500
@gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader():
"""Stop GPS reader."""
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
stop_gps()
return jsonify({'status': 'stopped'})
@gps_bp.route('/status')
def get_gps_status():
"""Get current GPS reader status."""
reader = get_gps_reader()
if not reader:
return jsonify({
'running': False,
'device': None,
'position': None,
'error': None,
'message': 'GPS reader not started'
})
position = reader.position
return jsonify({
'running': reader.is_running,
'device': reader.device_path,
'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,
'error': reader.error,
'message': 'Waiting for GPS fix - ensure GPS has clear view of sky' if reader.is_running and not position else None
})
@gps_bp.route('/position')
def get_position():
"""Get current GPS position."""
position = get_current_position()
if position:
return jsonify({
'status': 'ok',
'position': position.to_dict()
})
else:
reader = get_gps_reader()
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader not running'
}), 400
else:
return jsonify({
'status': 'waiting',
'message': 'Waiting for GPS fix'
})
@gps_bp.route('/stream')
def stream_gps():
"""SSE stream of GPS position updates."""
def generate() -> Generator[str, None, None]:
last_keepalive = time.time()
keepalive_interval = 30.0
while True:
try:
position = _gps_queue.get(timeout=1)
last_keepalive = time.time()
yield format_sse({'type': 'position', **position})
except queue.Empty:
now = time.time()
if now - last_keepalive >= keepalive_interval:
yield format_sse({'type': 'keepalive'})
last_keepalive = now
response = Response(generate(), mimetype='text/event-stream')
response.headers['Cache-Control'] = 'no-cache'
response.headers['X-Accel-Buffering'] = 'no'
response.headers['Connection'] = 'keep-alive'
return response

View File

@@ -752,7 +752,21 @@
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<button class="gps-btn" id="geolocateBtn" onclick="getGeolocation()">GPS</button>
<select id="gpsSource" onchange="toggleGpsDongleControls()" style="font-size: 10px;">
<option value="manual">Manual</option>
<option value="browser">Browser</option>
<option value="dongle">USB GPS</option>
</select>
</div>
<div class="control-group" id="browserGpsGroup">
<button class="gps-btn" id="geolocateBtn" onclick="getGeolocation()">Locate</button>
</div>
<div class="control-group gps-dongle-controls" style="display: none;">
<select class="gps-device-select" id="gpsDeviceSelect" style="font-size: 10px; max-width: 120px;">
<option value="">GPS Device...</option>
</select>
<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>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
@@ -797,6 +811,11 @@
let rangeRingsLayer = null;
let observerMarker = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
// ============================================
// AUDIO ALERTS
// ============================================
@@ -1443,16 +1462,134 @@
radarMap.setView([observerLocation.lat, observerLocation.lon], 8);
}
drawRangeRings();
btn.textContent = 'GPS';
btn.textContent = 'Locate';
},
(error) => {
alert('Location error: ' + error.message);
btn.textContent = 'GPS';
btn.textContent = 'Locate';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// ============================================
// GPS DONGLE FUNCTIONS
// ============================================
function toggleGpsDongleControls() {
const source = document.getElementById('gpsSource').value;
const browserGroup = document.getElementById('browserGpsGroup');
const dongleControls = document.querySelector('.gps-dongle-controls');
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';
}
}
async function refreshGpsDevices() {
try {
const response = await fetch('/gps/devices');
const data = await response.json();
if (data.status === 'ok') {
gpsDevices = data.devices;
const select = document.getElementById('gpsDeviceSelect');
select.innerHTML = '<option value="">GPS Device...</option>';
gpsDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.path;
option.textContent = device.name;
option.disabled = !device.accessible;
select.appendChild(option);
});
}
} catch (e) {
console.warn('Failed to get GPS devices:', e);
}
}
async function startGpsDongle() {
const devicePath = document.getElementById('gpsDeviceSelect').value;
if (!devicePath) {
alert('Please select a GPS device');
return;
}
try {
const response = await fetch('/gps/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: devicePath, baudrate: 9600 })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
document.querySelector('.gps-connect-btn').style.display = 'none';
document.querySelector('.gps-disconnect-btn').style.display = 'block';
} else {
alert('Failed to start GPS: ' + data.message);
}
} catch (e) {
alert('GPS connection error: ' + e.message);
}
}
async function stopGpsDongle() {
try {
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block';
document.querySelector('.gps-disconnect-btn').style.display = 'none';
} catch (e) {
console.warn('GPS stop error:', e);
}
}
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('GPS data received:', data);
if (data.type === 'position' && data.latitude && data.longitude) {
observerLocation.lat = data.latitude;
observerLocation.lon = data.longitude;
document.getElementById('obsLat').value = data.latitude.toFixed(4);
document.getElementById('obsLon').value = data.longitude.toFixed(4);
if (radarMap) {
console.log('GPS: Updating map to', data.latitude, data.longitude);
radarMap.setView([data.latitude, data.longitude], radarMap.getZoom());
}
drawRangeRings();
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
console.warn('GPS stream error:', e);
gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block';
document.querySelector('.gps-disconnect-btn').style.display = 'none';
};
}
// ============================================
// FILTERING
// ============================================

View File

@@ -3555,13 +3555,37 @@
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Observer Location</label>
<select id="adsbLocationSource" onchange="toggleGpsSection(this.value === 'dongle')" style="margin-bottom: 5px;">
<option value="manual">Manual Entry</option>
<option value="browser">Browser GPS</option>
<option value="dongle">USB GPS Dongle</option>
</select>
<div style="display: flex; gap: 5px;">
<input type="text" id="adsbObsLat" value="51.5074" placeholder="Latitude" style="flex: 1;" onchange="updateObserverLocation()">
<input type="text" id="adsbObsLon" value="-0.1278" placeholder="Longitude" style="flex: 1;" onchange="updateObserverLocation()">
</div>
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
📍 Use GPS Location
📍 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>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</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)" 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;">
Disconnect
</button>
</div>
<div class="gps-status-indicator" style="text-align: center; margin-top: 5px; font-size: 10px; color: var(--text-secondary);">
⚪ Disconnected
</div>
</div>
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Aircraft Filter</label>
@@ -3627,6 +3651,14 @@
<div id="predictorTab" class="satellite-content active">
<div class="section">
<h3>Observer Location</h3>
<div class="form-group">
<label>Location Source</label>
<select id="satLocationSource" onchange="toggleGpsSection(this.value === 'dongle')">
<option value="manual">Manual Entry</option>
<option value="browser">Browser GPS</option>
<option value="dongle">USB GPS Dongle</option>
</select>
</div>
<div class="form-group">
<label>Latitude</label>
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074">
@@ -3636,8 +3668,30 @@
<input type="text" id="obsLon" value="-0.1278" placeholder="-0.1278">
</div>
<button class="preset-btn" onclick="getLocation()" style="width: 100%;">
📍 Use My Location
📍 Use Browser Location
</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 style="display: flex; gap: 5px;">
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value)" 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;">
Disconnect
</button>
</div>
<div class="gps-status-indicator" style="text-align: center; margin-top: 8px; font-size: 11px; color: var(--text-secondary);">
⚪ Disconnected
</div>
</div>
</div>
<div class="section">
@@ -4279,6 +4333,12 @@
let rangeRingsLayer = null;
let observerMarkerAdsb = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
let gpsLastPosition = null;
// Audio alert system using Web Audio API (uses shared audioContext declared later)
function getAdsbAudioContext() {
if (!window.adsbAudioCtx) {
@@ -8247,10 +8307,23 @@
const mapContainer = document.getElementById('aircraftMap');
if (!mapContainer || aircraftMap) return;
// Use GPS position if available, otherwise use observerLocation or default
let initialLat = observerLocation.lat || 51.5;
let initialLon = observerLocation.lon || -0.1;
// Check if GPS has a recent position
if (gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
initialLat = gpsLastPosition.latitude;
initialLon = gpsLastPosition.longitude;
observerLocation.lat = initialLat;
observerLocation.lon = initialLon;
console.log('GPS: Initializing map with GPS position', initialLat, initialLon);
}
// Initialize Leaflet map
aircraftMap = L.map('aircraftMap', {
center: [51.5, -0.1], // Default to London
zoom: 5,
center: [initialLat, initialLon],
zoom: 8,
zoomControl: true,
attributionControl: true
});
@@ -8294,6 +8367,17 @@
// Initial update
updateAircraftMarkers();
// Update input fields with current position
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
// Draw initial range rings if GPS is connected
if (gpsConnected) {
drawRangeRings();
}
}
function toggleAircraftClustering() {
@@ -8997,6 +9081,207 @@
}
}
// ============================================
// GPS DONGLE FUNCTIONS
// ============================================
async function checkGpsDongleAvailable() {
try {
const response = await fetch('/gps/available');
const data = await response.json();
return data.available;
} catch (e) {
console.warn('GPS dongle check failed:', e);
return false;
}
}
async function refreshGpsDevices() {
try {
const response = await fetch('/gps/devices');
const data = await response.json();
if (data.status === 'ok') {
gpsDevices = data.devices;
updateGpsDeviceSelectors();
return data.devices;
}
} catch (e) {
console.warn('Failed to get GPS devices:', e);
}
return [];
}
function updateGpsDeviceSelectors() {
// Update all GPS device selectors in the UI
const selectors = document.querySelectorAll('.gps-device-select');
selectors.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Select GPS Device...</option>';
gpsDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.path;
option.textContent = device.name + (device.accessible ? '' : ' (no access)');
option.disabled = !device.accessible;
select.appendChild(option);
});
if (currentValue && gpsDevices.some(d => d.path === currentValue)) {
select.value = currentValue;
}
});
}
async function startGpsDongle(devicePath) {
if (!devicePath) {
showError('Please select a GPS device');
return false;
}
try {
const response = await fetch('/gps/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: devicePath, baudrate: 9600 })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsStatus(true);
showInfo('GPS dongle connected: ' + devicePath);
return true;
} else {
showError('Failed to start GPS: ' + data.message);
return false;
}
} catch (e) {
showError('GPS connection error: ' + e.message);
return false;
}
}
async function stopGpsDongle() {
try {
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
gpsLastPosition = null;
updateGpsStatus(false);
showInfo('GPS dongle disconnected');
} catch (e) {
console.warn('GPS stop error:', e);
}
}
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('GPS data received:', data);
if (data.type === 'position') {
gpsLastPosition = data;
updateLocationFromGps(data);
// Update status indicator with coordinates
const statusIndicators = document.querySelectorAll('.gps-status-indicator');
statusIndicators.forEach(indicator => {
if (data.latitude && data.longitude) {
indicator.textContent = `🟢 ${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}`;
indicator.style.color = 'var(--accent-green)';
}
});
} else if (data.type === 'keepalive') {
console.log('GPS keepalive');
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
console.warn('GPS stream error:', e);
gpsConnected = false;
updateGpsStatus(false);
};
}
function updateLocationFromGps(position) {
if (!position || !position.latitude || !position.longitude) {
console.warn('GPS: Invalid position data', position);
return;
}
console.log('GPS: Updating location to', position.latitude, position.longitude);
// Update satellite observer location
const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
// Update ADS-B observer location
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = position.latitude.toFixed(4);
if (adsbLonInput) adsbLonInput.value = position.longitude.toFixed(4);
// Update observerLocation for ADS-B calculations
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
// Center ADS-B map on new location (only on first fix or significant movement)
if (typeof aircraftMap !== 'undefined' && aircraftMap) {
const currentCenter = aircraftMap.getCenter();
const distance = Math.sqrt(
Math.pow(currentCenter.lat - position.latitude, 2) +
Math.pow(currentCenter.lng - position.longitude, 2)
);
console.log('GPS: Map exists, distance from current center:', distance);
// Only recenter if moved more than ~1km (0.01 degrees)
if (distance > 0.01 || !aircraftMap._gpsInitialized) {
console.log('GPS: Centering map on', position.latitude, position.longitude);
aircraftMap.setView([position.latitude, position.longitude], aircraftMap.getZoom());
aircraftMap._gpsInitialized = true;
}
} else {
console.log('GPS: aircraftMap not available yet');
}
// Trigger map updates
if (typeof drawRangeRings === 'function') {
drawRangeRings();
}
}
function updateGpsStatus(connected) {
const statusIndicators = document.querySelectorAll('.gps-status-indicator');
statusIndicators.forEach(indicator => {
indicator.textContent = connected ? '🟢 Connected' : '⚪ Disconnected';
indicator.style.color = connected ? 'var(--accent-green)' : 'var(--text-secondary)';
});
const connectBtns = document.querySelectorAll('.gps-connect-btn');
const disconnectBtns = document.querySelectorAll('.gps-disconnect-btn');
connectBtns.forEach(btn => btn.style.display = connected ? 'none' : 'block');
disconnectBtns.forEach(btn => btn.style.display = connected ? 'block' : 'none');
}
function toggleGpsSection(show) {
const gpsSections = document.querySelectorAll('.gps-dongle-section');
gpsSections.forEach(section => {
section.style.display = show ? 'block' : 'none';
});
if (show) {
refreshGpsDevices();
}
}
function initPolarPlot() {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;

506
utils/gps.py Normal file
View File

@@ -0,0 +1,506 @@
"""
GPS dongle support for INTERCEPT.
Provides detection and reading of USB GPS dongles via serial port.
Parses NMEA sentences to extract location data.
"""
from __future__ import annotations
import logging
import os
import re
import glob
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable
logger = logging.getLogger('intercept.gps')
# Try to import serial, but don't fail if not available
try:
import serial
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
logger.warning("pyserial not installed - GPS dongle support disabled")
@dataclass
class GPSPosition:
"""GPS position data."""
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # knots
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
timestamp: Optional[datetime] = None
device: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'latitude': self.latitude,
'longitude': self.longitude,
'altitude': self.altitude,
'speed': self.speed,
'heading': self.heading,
'satellites': self.satellites,
'fix_quality': self.fix_quality,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'device': self.device,
}
def detect_gps_devices() -> list[dict]:
"""
Detect potential GPS serial devices.
Returns a list of device info dictionaries.
"""
devices = []
# Common GPS device patterns by platform
patterns = []
if os.name == 'posix':
# Linux
patterns.extend([
'/dev/ttyUSB*', # USB serial adapters
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
'/dev/gps*', # gpsd symlinks
])
# macOS
patterns.extend([
'/dev/tty.usbserial*',
'/dev/tty.usbmodem*',
'/dev/cu.usbserial*',
'/dev/cu.usbmodem*',
])
for pattern in patterns:
for path in glob.glob(pattern):
# Try to get device info
device_info = {
'path': path,
'name': os.path.basename(path),
'type': 'serial',
}
# Check if it's readable
if os.access(path, os.R_OK):
device_info['accessible'] = True
else:
device_info['accessible'] = False
device_info['error'] = 'Permission denied'
devices.append(device_info)
return devices
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
"""
Parse NMEA coordinate format to decimal degrees.
NMEA format: DDDMM.MMMM or DDMM.MMMM
"""
if not coord or not direction:
return None
try:
# Find the decimal point
dot_pos = coord.index('.')
# Degrees are everything before the last 2 digits before decimal
degrees = int(coord[:dot_pos - 2])
minutes = float(coord[dot_pos - 2:])
result = degrees + (minutes / 60.0)
# Apply direction
if direction in ('S', 'W'):
result = -result
return result
except (ValueError, IndexError):
return None
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
"""
if len(parts) < 10:
return None
try:
fix_quality = int(parts[6]) if parts[6] else 0
# No fix
if fix_quality == 0:
return None
lat = parse_nmea_coordinate(parts[2], parts[3])
lon = parse_nmea_coordinate(parts[4], parts[5])
if lat is None or lon is None:
return None
# Parse optional fields
satellites = int(parts[7]) if parts[7] else None
altitude = float(parts[9]) if parts[9] else None
# Parse time (HHMMSS.sss)
timestamp = None
if parts[1]:
try:
time_str = parts[1].split('.')[0]
if len(time_str) >= 6:
now = datetime.utcnow()
timestamp = now.replace(
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
microsecond=0
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
altitude=altitude,
satellites=satellites,
fix_quality=fix_quality,
timestamp=timestamp,
)
except (ValueError, IndexError) as e:
logger.debug(f"GGA parse error: {e}")
return None
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPRMC/GNRMC sentence (Recommended Minimum).
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
"""
if len(parts) < 8:
return None
try:
# Check status (A=active/valid, V=void/invalid)
if parts[2] != 'A':
return None
lat = parse_nmea_coordinate(parts[3], parts[4])
lon = parse_nmea_coordinate(parts[5], parts[6])
if lat is None or lon is None:
return None
# Parse optional fields
speed = float(parts[7]) if parts[7] else None # knots
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
# Parse timestamp
timestamp = None
if parts[1] and len(parts) > 9 and parts[9]:
try:
time_str = parts[1].split('.')[0]
date_str = parts[9]
if len(time_str) >= 6 and len(date_str) >= 6:
timestamp = datetime(
year=2000 + int(date_str[4:6]),
month=int(date_str[2:4]),
day=int(date_str[0:2]),
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
speed=speed,
heading=heading,
timestamp=timestamp,
fix_quality=1, # RMC with A status means valid fix
)
except (ValueError, IndexError) as e:
logger.debug(f"RMC parse error: {e}")
return None
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
"""
Parse an NMEA sentence and extract position data.
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
"""
sentence = sentence.strip()
# Validate checksum if present
if '*' in sentence:
data, checksum = sentence.rsplit('*', 1)
if data.startswith('$'):
data = data[1:]
# Calculate checksum
calc_checksum = 0
for char in data:
calc_checksum ^= ord(char)
try:
if int(checksum, 16) != calc_checksum:
logger.debug(f"Checksum mismatch: {sentence}")
return None
except ValueError:
pass
# Remove $ prefix if present
if sentence.startswith('$'):
sentence = sentence[1:]
# Remove checksum for parsing
if '*' in sentence:
sentence = sentence.split('*')[0]
parts = sentence.split(',')
if not parts:
return None
msg_type = parts[0]
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
if msg_type.endswith('GGA'):
return parse_gga(parts)
elif msg_type.endswith('RMC'):
return parse_rmc(parts)
return None
class GPSReader:
"""
Reads GPS data from a serial device.
Runs in a background thread and maintains current position.
"""
def __init__(self, device_path: str, baudrate: int = 9600):
self.device_path = device_path
self.baudrate = baudrate
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._serial: Optional['serial.Serial'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
@property
def position(self) -> Optional[GPSPosition]:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def is_running(self) -> bool:
"""Check if the reader is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
"""Get any error message."""
with self._lock:
return self._error
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def start(self) -> bool:
"""Start reading GPS data in a background thread."""
if not SERIAL_AVAILABLE:
self._error = "pyserial not installed"
return False
if self._running:
return True
try:
self._serial = serial.Serial(
self.device_path,
baudrate=self.baudrate,
timeout=1.0
)
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Started GPS reader on {self.device_path}")
return True
except serial.SerialException as e:
self._error = str(e)
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
return False
def stop(self) -> None:
"""Stop reading GPS data."""
self._running = False
if self._serial:
try:
self._serial.close()
except Exception:
pass
self._serial = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Stopped GPS reader on {self.device_path}")
def _read_loop(self) -> None:
"""Background thread loop for reading GPS data."""
buffer = ""
sentence_count = 0
while self._running and self._serial:
try:
# Read available data
if self._serial.in_waiting:
data = self._serial.read(self._serial.in_waiting)
buffer += data.decode('ascii', errors='ignore')
# Process complete lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$'):
sentence_count += 1
# Log first few sentences and periodically after that
if sentence_count <= 5 or sentence_count % 100 == 0:
logger.debug(f"GPS NMEA [{sentence_count}]: {line[:60]}...")
position = parse_nmea_sentence(line)
if position:
logger.info(f"GPS fix: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})")
position.device = self.device_path
self._update_position(position)
else:
time.sleep(0.1)
except serial.SerialException as e:
logger.error(f"GPS read error: {e}")
with self._lock:
self._error = str(e)
break
except Exception as e:
logger.debug(f"GPS parse error: {e}")
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
# Merge data from different sentence types
if self._position:
# Keep altitude from GGA if RMC doesn't have it
if position.altitude is None and self._position.altitude:
position.altitude = self._position.altitude
# Keep satellites from GGA
if position.satellites is None and self._position.satellites:
position.satellites = self._position.satellites
self._position = position
self._last_update = datetime.utcnow()
self._error = None
# Notify callbacks
for callback in self._callbacks:
try:
callback(position)
except Exception as e:
logger.error(f"GPS callback error: {e}")
# Global GPS reader instance
_gps_reader: Optional[GPSReader] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSReader]:
"""Get the global GPS reader instance."""
with _gps_lock:
return _gps_reader
def start_gps(device_path: str, baudrate: int = 9600) -> bool:
"""
Start the global GPS reader.
Args:
device_path: Path to the GPS serial device
baudrate: Serial baudrate (default 9600)
Returns:
True if started successfully
"""
global _gps_reader
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
_gps_reader = GPSReader(device_path, baudrate)
return _gps_reader.start()
def stop_gps() -> None:
"""Stop the global GPS reader."""
global _gps_reader
with _gps_lock:
if _gps_reader:
_gps_reader.stop()
_gps_reader = None
def get_current_position() -> Optional[GPSPosition]:
"""Get the current GPS position from the global reader."""
reader = get_gps_reader()
if reader:
return reader.position
return None
def is_serial_available() -> bool:
"""Check if pyserial is available."""
return SERIAL_AVAILABLE

View File

@@ -21,6 +21,8 @@ Example usage:
cmd = builder.build_fm_demod_command(device, frequency_mhz=153.35)
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType

View File

@@ -5,6 +5,8 @@ This module provides the core abstractions for supporting multiple SDR hardware
types (RTL-SDR, LimeSDR, HackRF, etc.) through a unified interface.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum

View File

@@ -4,6 +4,8 @@ Multi-hardware SDR device detection.
Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
"""
from __future__ import annotations
import logging
import re
import shutil

View File

@@ -5,6 +5,8 @@ Uses SoapySDR-based tools for FM demodulation and signal capture.
HackRF supports 1 MHz to 6 GHz frequency range.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType

View File

@@ -5,6 +5,8 @@ Uses SoapySDR-based tools for FM demodulation and signal capture.
LimeSDR supports 100 kHz to 3.8 GHz frequency range.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType

View File

@@ -5,6 +5,8 @@ Uses native rtl_* tools (rtl_fm, rtl_433) and dump1090 for maximum compatibility
with existing RTL-SDR installations. No SoapySDR dependency required.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType