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
+43 -4
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 - **Hardware-specific validation** - frequency/gain ranges per device type
- **Configurable gain and PPM correction** - **Configurable gain and PPM correction**
- **Device intelligence** dashboard with tracking - **Device intelligence** dashboard with tracking
- **GPS dongle support** - USB GPS receivers for precise observer location
- **Disclaimer acceptance** on first use - **Disclaimer acceptance** on first use
- **Auto-stop** when switching between modes - **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) - Bluetooth adapter (for Bluetooth features)
### Software ### Software
- Python 3.7+ - Python 3.9+ recommended (3.7+ may work but is not fully tested)
- Flask, skyfield (installed via `requirements.txt`) - Flask, skyfield, pyserial (installed via `requirements.txt`)
- rtl-sdr tools (`rtl_fm`) - rtl-sdr tools (`rtl_fm`)
- multimon-ng (for pager decoding) - multimon-ng (for pager decoding)
- rtl_433 (for 433MHz sensor decoding) - rtl_433 (for 433MHz sensor decoding)
@@ -325,7 +326,10 @@ python3 intercept.py --help
### Aircraft Mode ### Aircraft Mode
1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb) 1. **Select Hardware** - Choose your SDR type (RTL-SDR uses dump1090, others use readsb)
2. **Check Tools** - Ensure dump1090 or readsb is installed 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 4. **Start Tracking** - Click "Start Tracking" to begin ADS-B reception
5. **View Map** - Aircraft appear on the interactive Leaflet map 5. **View Map** - Aircraft appear on the interactive Leaflet map
6. **Click Aircraft** - Click markers for detailed information 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 9. **Full Dashboard** - Click "Full Screen Dashboard" for dedicated radar view
### Satellite Mode ### 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 2. **Add Satellites** - Click "Add Satellite" to enter TLE data or fetch from Celestrak
3. **Calculate Passes** - Click "Calculate Passes" to predict upcoming passes 3. **Calculate Passes** - Click "Calculate Passes" to predict upcoming passes
4. **View Sky Plot** - Polar plot shows satellite positions in real-time 4. **View Sky Plot** - Polar plot shows satellite positions in real-time
@@ -355,6 +362,28 @@ python3 intercept.py --help
### Python/pip installation issues ### 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+):** **"externally-managed-environment" error (Ubuntu 23.04+, Debian 12+):**
```bash ```bash
# Option 1: Use a virtual environment (recommended) # 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` - Check the driver module is loaded: `SoapySDRUtil --find`
- Verify permissions (may need udev rules or run as root) - 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 ## Configuration
+3
View File
@@ -4,6 +4,9 @@ flask>=2.0.0
# Satellite tracking (optional - only needed for satellite features) # Satellite tracking (optional - only needed for satellite features)
skyfield>=1.45 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) # Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0 # pytest>=7.0.0
# pytest-cov>=4.0.0 # pytest-cov>=4.0.0
+2
View File
@@ -9,6 +9,7 @@ def register_blueprints(app):
from .adsb import adsb_bp from .adsb import adsb_bp
from .satellite import satellite_bp from .satellite import satellite_bp
from .iridium import iridium_bp from .iridium import iridium_bp
from .gps import gps_bp
app.register_blueprint(pager_bp) app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp) app.register_blueprint(sensor_bp)
@@ -17,3 +18,4 @@ def register_blueprints(app):
app.register_blueprint(adsb_bp) app.register_blueprint(adsb_bp)
app.register_blueprint(satellite_bp) app.register_blueprint(satellite_bp)
app.register_blueprint(iridium_bp) app.register_blueprint(iridium_bp)
app.register_blueprint(gps_bp)
+218
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
+140 -3
View File
@@ -752,7 +752,21 @@
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()"> <input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()">
</div> </div>
<div class="control-group"> <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> </div>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button> <button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div> </div>
@@ -797,6 +811,11 @@
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
// ============================================ // ============================================
// AUDIO ALERTS // AUDIO ALERTS
// ============================================ // ============================================
@@ -1443,16 +1462,134 @@
radarMap.setView([observerLocation.lat, observerLocation.lon], 8); radarMap.setView([observerLocation.lat, observerLocation.lon], 8);
} }
drawRangeRings(); drawRangeRings();
btn.textContent = 'GPS'; btn.textContent = 'Locate';
}, },
(error) => { (error) => {
alert('Location error: ' + error.message); alert('Location error: ' + error.message);
btn.textContent = 'GPS'; btn.textContent = 'Locate';
}, },
{ enableHighAccuracy: true, timeout: 10000 } { 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 // FILTERING
// ============================================ // ============================================
+289 -4
View File
@@ -3555,13 +3555,37 @@
</div> </div>
<div class="form-group" style="margin-top: 10px;"> <div class="form-group" style="margin-top: 10px;">
<label>Observer Location</label> <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;"> <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="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()"> <input type="text" id="adsbObsLon" value="-0.1278" placeholder="Longitude" style="flex: 1;" onchange="updateObserverLocation()">
</div> </div>
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;"> <button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
📍 Use GPS Location 📍 Use Browser Location
</button> </button>
<div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div 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>
<div class="form-group" style="margin-top: 10px;"> <div class="form-group" style="margin-top: 10px;">
<label>Aircraft Filter</label> <label>Aircraft Filter</label>
@@ -3627,6 +3651,14 @@
<div id="predictorTab" class="satellite-content active"> <div id="predictorTab" class="satellite-content active">
<div class="section"> <div class="section">
<h3>Observer Location</h3> <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"> <div class="form-group">
<label>Latitude</label> <label>Latitude</label>
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074"> <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"> <input type="text" id="obsLon" value="-0.1278" placeholder="-0.1278">
</div> </div>
<button class="preset-btn" onclick="getLocation()" style="width: 100%;"> <button class="preset-btn" onclick="getLocation()" style="width: 100%;">
📍 Use My Location 📍 Use Browser Location
</button> </button>
<div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div class="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>
<div class="section"> <div class="section">
@@ -4279,6 +4333,12 @@
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarkerAdsb = 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) // Audio alert system using Web Audio API (uses shared audioContext declared later)
function getAdsbAudioContext() { function getAdsbAudioContext() {
if (!window.adsbAudioCtx) { if (!window.adsbAudioCtx) {
@@ -8247,10 +8307,23 @@
const mapContainer = document.getElementById('aircraftMap'); const mapContainer = document.getElementById('aircraftMap');
if (!mapContainer || aircraftMap) return; 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 // Initialize Leaflet map
aircraftMap = L.map('aircraftMap', { aircraftMap = L.map('aircraftMap', {
center: [51.5, -0.1], // Default to London center: [initialLat, initialLon],
zoom: 5, zoom: 8,
zoomControl: true, zoomControl: true,
attributionControl: true attributionControl: true
}); });
@@ -8294,6 +8367,17 @@
// Initial update // Initial update
updateAircraftMarkers(); 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() { 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() { function initPolarPlot() {
const canvas = document.getElementById('polarPlotCanvas'); const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return; if (!canvas) return;
+506
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
+2
View File
@@ -21,6 +21,8 @@ Example usage:
cmd = builder.build_fm_demod_command(device, frequency_mhz=153.35) cmd = builder.build_fm_demod_command(device, frequency_mhz=153.35)
""" """
from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
+2
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. types (RTL-SDR, LimeSDR, HackRF, etc.) through a unified interface.
""" """
from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
+2
View File
@@ -4,6 +4,8 @@ Multi-hardware SDR device detection.
Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR. Detects RTL-SDR devices via rtl_test and other SDR hardware via SoapySDR.
""" """
from __future__ import annotations
import logging import logging
import re import re
import shutil import shutil
+2
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. HackRF supports 1 MHz to 6 GHz frequency range.
""" """
from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
+2
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. LimeSDR supports 100 kHz to 3.8 GHz frequency range.
""" """
from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
+2
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. with existing RTL-SDR installations. No SoapySDR dependency required.
""" """
from __future__ import annotations
from typing import Optional from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType