mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
47
README.md
47
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
218
routes/gps.py
Normal 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
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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
506
utils/gps.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user