Add Airspy SDR support and persist GPS coordinates

Airspy support:
- Add AIRSPY to SDRType enum and driver mappings
- Create AirspyCommandBuilder using SoapySDR tools (rx_fm, readsb, rtl_433)
- Register in SDRFactory and add to hardware type dropdown
- Supports Airspy R2/Mini (24MHz-1.8GHz) and HF+ devices

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-05 20:39:55 +00:00
parent ea54ab4bce
commit 938d021e06
8 changed files with 756 additions and 55 deletions
+88 -8
View File
@@ -16,9 +16,11 @@ from utils.gps import (
is_serial_available, is_serial_available,
get_gps_reader, get_gps_reader,
start_gps, start_gps,
start_gpsd,
stop_gps, stop_gps,
get_current_position, get_current_position,
GPSPosition, GPSPosition,
GPSDClient,
) )
logger = get_logger('intercept.gps') logger = get_logger('intercept.gps')
@@ -51,6 +53,34 @@ def check_gps_available():
}) })
@gps_bp.route('/gpsd/check')
def check_gpsd_available():
"""Check if gpsd is reachable."""
import socket
host = request.args.get('host', 'localhost')
port = int(request.args.get('port', 2947))
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.connect((host, port))
sock.close()
return jsonify({
'available': True,
'host': host,
'port': port,
'message': f'gpsd reachable at {host}:{port}'
})
except Exception as e:
return jsonify({
'available': False,
'host': host,
'port': port,
'message': f'Cannot connect to gpsd at {host}:{port}: {e}'
})
@gps_bp.route('/devices') @gps_bp.route('/devices')
def list_gps_devices(): def list_gps_devices():
"""List available GPS serial devices.""" """List available GPS serial devices."""
@@ -109,19 +139,15 @@ def start_gps_reader():
except queue.Empty: except queue.Empty:
break break
# Start the GPS reader # Start the GPS reader with callback pre-registered (avoids race condition)
success = start_gps(device_path, baudrate) success = start_gps(device_path, baudrate, callback=_position_callback)
if success: if success:
# Register callback for SSE streaming
reader = get_gps_reader()
if reader:
reader.add_callback(_position_callback)
return jsonify({ return jsonify({
'status': 'started', 'status': 'started',
'device': device_path, 'device': device_path,
'baudrate': baudrate 'baudrate': baudrate,
'source': 'serial'
}) })
else: else:
reader = get_gps_reader() reader = get_gps_reader()
@@ -132,6 +158,58 @@ def start_gps_reader():
}), 500 }), 500
@gps_bp.route('/gpsd/start', methods=['POST'])
def start_gpsd_client():
"""Start GPS client connected to gpsd."""
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader already running'
}), 409
data = request.json or {}
host = data.get('host', 'localhost')
port = data.get('port', 2947)
# Validate port
try:
port = int(port)
if not (1 <= port <= 65535):
raise ValueError("Port out of range")
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': 'Invalid port number'
}), 400
# Clear the queue
while not _gps_queue.empty():
try:
_gps_queue.get_nowait()
except queue.Empty:
break
# Start the gpsd client with callback pre-registered
success = start_gpsd(host, port, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'host': host,
'port': port,
'source': 'gpsd'
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to connect to gpsd: {error}'
}), 500
@gps_bp.route('/stop', methods=['POST']) @gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader(): def stop_gps_reader():
"""Stop GPS reader.""" """Stop GPS reader."""
@@ -205,8 +283,10 @@ def debug_gps():
}) })
position = reader.position position = reader.position
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
return jsonify({ return jsonify({
'running': reader.is_running, 'running': reader.is_running,
'source': source,
'device': reader.device_path, 'device': reader.device_path,
'baudrate': reader.baudrate, 'baudrate': reader.baudrate,
'has_position': position is not None, 'has_position': position is not None,
+81 -11
View File
@@ -141,6 +141,7 @@
<option value="manual">Manual</option> <option value="manual">Manual</option>
<option value="browser">Browser</option> <option value="browser">Browser</option>
<option value="dongle">USB GPS</option> <option value="dongle">USB GPS</option>
<option value="gpsd">gpsd</option>
</select> </select>
</div> </div>
<div class="control-group" id="browserGpsGroup"> <div class="control-group" id="browserGpsGroup">
@@ -159,6 +160,13 @@
<button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button> <button class="gps-btn gps-connect-btn" onclick="startGpsDongle()">Connect</button>
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button> <button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
</div> </div>
<div class="control-group gps-gpsd-controls" style="display: none;">
<input type="text" id="gpsdHost" value="localhost" placeholder="Host" style="width: 80px; font-size: 10px;">
<span style="color: #666;">:</span>
<input type="number" id="gpsdPort" value="2947" min="1" max="65535" style="width: 50px; font-size: 10px;">
<button class="gps-btn gps-connect-btn" onclick="startGpsdClient()">Connect</button>
<button class="gps-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="display: none; background: rgba(255,0,0,0.2); border-color: #ff4444;">Stop</button>
</div>
<div class="control-group"> <div class="control-group">
<label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 4px; font-size: 10px; cursor: pointer;">
<input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> <input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()">
@@ -208,8 +216,17 @@
messageTimestamps: [] messageTimestamps: []
}; };
// Observer location and range rings // Observer location and range rings (load from localStorage or default to London)
let observerLocation = { lat: 51.5074, lon: -0.1278 }; let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarker = null; let observerMarker = null;
@@ -834,6 +851,10 @@
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat; observerLocation.lat = lat;
observerLocation.lon = lon; observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (radarMap) { if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom()); radarMap.setView([lat, lon], radarMap.getZoom());
} }
@@ -858,6 +879,10 @@
(position) => { (position) => {
observerLocation.lat = position.coords.latitude; observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude; observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4); document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4); document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
if (radarMap) { if (radarMap) {
@@ -881,18 +906,22 @@
const source = document.getElementById('gpsSource').value; const source = document.getElementById('gpsSource').value;
const browserGroup = document.getElementById('browserGpsGroup'); const browserGroup = document.getElementById('browserGpsGroup');
const dongleControls = document.querySelector('.gps-dongle-controls'); const dongleControls = document.querySelector('.gps-dongle-controls');
const gpsdControls = document.querySelector('.gps-gpsd-controls');
// Hide all first
browserGroup.style.display = 'none';
dongleControls.style.display = 'none';
gpsdControls.style.display = 'none';
if (source === 'dongle') { if (source === 'dongle') {
browserGroup.style.display = 'none';
dongleControls.style.display = 'flex'; dongleControls.style.display = 'flex';
refreshGpsDevices(); refreshGpsDevices();
} else if (source === 'browser') { } else if (source === 'browser') {
browserGroup.style.display = 'flex'; browserGroup.style.display = 'flex';
dongleControls.style.display = 'none'; } else if (source === 'gpsd') {
} else { gpsdControls.style.display = 'flex';
browserGroup.style.display = 'none';
dongleControls.style.display = 'none';
} }
// 'manual' keeps everything hidden
} }
async function refreshGpsDevices() { async function refreshGpsDevices() {
@@ -935,8 +964,7 @@
if (data.status === 'started') { if (data.status === 'started') {
gpsConnected = true; gpsConnected = true;
startGpsStream(); startGpsStream();
document.querySelector('.gps-connect-btn').style.display = 'none'; updateGpsButtons(true, '.gps-dongle-controls');
document.querySelector('.gps-disconnect-btn').style.display = 'block';
} else { } else {
alert('Failed to start GPS: ' + data.message); alert('Failed to start GPS: ' + data.message);
} }
@@ -945,6 +973,41 @@
} }
} }
async function startGpsdClient() {
const host = document.getElementById('gpsdHost').value || 'localhost';
const port = parseInt(document.getElementById('gpsdPort').value) || 2947;
try {
const response = await fetch('/gps/gpsd/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: host, port: port })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsButtons(true, '.gps-gpsd-controls');
} else {
alert('Failed to connect to gpsd: ' + data.message);
}
} catch (e) {
alert('gpsd connection error: ' + e.message);
}
}
function updateGpsButtons(connected, containerSelector) {
// Update buttons in the specified container
const container = document.querySelector(containerSelector);
if (container) {
const connectBtn = container.querySelector('.gps-connect-btn');
const disconnectBtn = container.querySelector('.gps-disconnect-btn');
if (connectBtn) connectBtn.style.display = connected ? 'none' : 'block';
if (disconnectBtn) disconnectBtn.style.display = connected ? 'block' : 'none';
}
}
async function stopGpsDongle() { async function stopGpsDongle() {
try { try {
if (gpsEventSource) { if (gpsEventSource) {
@@ -953,8 +1016,9 @@
} }
await fetch('/gps/stop', { method: 'POST' }); await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false; gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block'; // Reset buttons in both containers
document.querySelector('.gps-disconnect-btn').style.display = 'none'; updateGpsButtons(false, '.gps-dongle-controls');
updateGpsButtons(false, '.gps-gpsd-controls');
} catch (e) { } catch (e) {
console.warn('GPS stop error:', e); console.warn('GPS stop error:', e);
} }
@@ -1027,6 +1091,12 @@
// INITIALIZATION // INITIALIZATION
// ============================================ // ============================================
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
initMap(); initMap();
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
+146 -30
View File
@@ -275,6 +275,7 @@
<option value="rtlsdr">RTL-SDR</option> <option value="rtlsdr">RTL-SDR</option>
<option value="limesdr">LimeSDR</option> <option value="limesdr">LimeSDR</option>
<option value="hackrf">HackRF</option> <option value="hackrf">HackRF</option>
<option value="airspy">Airspy</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -754,22 +755,36 @@
📍 Use Browser Location 📍 Use Browser Location
</button> </button>
<div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;"> <div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div style="display: flex; gap: 5px; margin-bottom: 5px;"> <div style="margin-bottom: 5px;">
<select class="gps-device-select" style="flex: 1; font-size: 11px;"> <select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%; font-size: 11px;">
<option value="">Select GPS Device...</option> <option value="serial">Serial Device</option>
<option value="gpsd">gpsd (daemon)</option>
</select> </select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</div> </div>
<div style="display: flex; gap: 5px; margin-bottom: 5px;"> <div class="gps-serial-controls">
<select class="gps-baudrate-select" style="flex: 1; font-size: 11px;"> <div style="display: flex; gap: 5px; margin-bottom: 5px;">
<option value="4800">4800</option> <select class="gps-device-select" style="flex: 1; font-size: 11px;">
<option value="9600" selected>9600</option> <option value="">Select GPS Device...</option>
<option value="38400">38400</option> </select>
<option value="115200">115200</option> <button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</select> </div>
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select class="gps-baudrate-select" style="flex: 1; font-size: 11px;">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select>
</div>
</div>
<div class="gps-gpsd-controls" style="display: none;">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<input type="text" class="gpsd-host-input" value="localhost" placeholder="Host" style="flex: 2; font-size: 11px;">
<input type="number" class="gpsd-port-input" value="2947" placeholder="Port" style="flex: 1; font-size: 11px;">
</div>
</div> </div>
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value, parseInt(this.closest('.gps-dongle-section').querySelector('.gps-baudrate-select').value))" style="flex: 1; font-size: 10px; padding: 4px;"> <button class="preset-btn gps-connect-btn" onclick="startGpsFromSection(this.closest('.gps-dongle-section'))" style="flex: 1; font-size: 10px; padding: 4px;">
Connect Connect
</button> </button>
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; font-size: 10px; padding: 4px; background: rgba(255,0,0,0.1); border-color: #ff4444;"> <button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; font-size: 10px; padding: 4px; background: rgba(255,0,0,0.1); border-color: #ff4444;">
@@ -865,25 +880,44 @@
</button> </button>
<div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;"> <div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div class="form-group" style="margin-bottom: 8px;"> <div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">GPS Device</label> <label style="font-size: 11px;">GPS Source</label>
<div style="display: flex; gap: 5px;"> <select class="gps-source-select" onchange="toggleGpsSourceMode(this)" style="width: 100%;">
<select class="gps-device-select" style="flex: 1;"> <option value="serial">Serial Device</option>
<option value="">Select GPS Device...</option> <option value="gpsd">gpsd (daemon)</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 4px 8px;" title="Refresh">🔄</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">Baud Rate</label>
<select class="gps-baudrate-select" style="width: 100%;">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select> </select>
</div> </div>
<div class="gps-serial-controls">
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">GPS Device</label>
<div style="display: flex; gap: 5px;">
<select class="gps-device-select" style="flex: 1;">
<option value="">Select GPS Device...</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 4px 8px;" title="Refresh">🔄</button>
</div>
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">Baud Rate</label>
<select class="gps-baudrate-select" style="width: 100%;">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select>
</div>
</div>
<div class="gps-gpsd-controls" style="display: none;">
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">gpsd Host</label>
<input type="text" class="gpsd-host-input" value="localhost" style="width: 100%;">
</div>
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">gpsd Port</label>
<input type="number" class="gpsd-port-input" value="2947" style="width: 100%;">
</div>
</div>
<div style="display: flex; gap: 5px;"> <div style="display: flex; gap: 5px;">
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value, parseInt(this.closest('.gps-dongle-section').querySelector('.gps-baudrate-select').value))" style="flex: 1;"> <button class="preset-btn gps-connect-btn" onclick="startGpsFromSection(this.closest('.gps-dongle-section'))" style="flex: 1;">
Connect GPS Connect GPS
</button> </button>
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; background: rgba(255,0,0,0.1); border-color: #ff4444;"> <button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; background: rgba(255,0,0,0.1); border-color: #ff4444;">
@@ -1472,8 +1506,17 @@
sessionStart: null // When tracking started sessionStart: null // When tracking started
}; };
// Observer location for distance calculations // Observer location for distance calculations (load from localStorage or default to London)
let observerLocation = { lat: 51.5074, lon: -0.1278 }; // Default London let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null; let rangeRingsLayer = null;
let observerMarkerAdsb = null; let observerMarkerAdsb = null;
@@ -1804,6 +1847,16 @@
this.parentElement.classList.toggle('collapsed'); this.parentElement.classList.toggle('collapsed');
}); });
}); });
// Initialize observer location input fields from saved location
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
}); });
// Toggle section collapse // Toggle section collapse
@@ -6025,6 +6078,9 @@
observerLocation.lat = lat; observerLocation.lat = lat;
observerLocation.lon = lon; observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Center map on location // Center map on location
if (aircraftMap) { if (aircraftMap) {
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8); aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
@@ -6058,6 +6114,9 @@
observerLocation.lat = position.coords.latitude; observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude; observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Update input fields // Update input fields
const latInput = document.getElementById('adsbObsLat'); const latInput = document.getElementById('adsbObsLat');
const lonInput = document.getElementById('adsbObsLon'); const lonInput = document.getElementById('adsbObsLon');
@@ -6310,6 +6369,63 @@
}); });
} }
function toggleGpsSourceMode(selectElement) {
// Toggle between serial and gpsd controls
const section = selectElement.closest('.gps-dongle-section');
const serialControls = section.querySelector('.gps-serial-controls');
const gpsdControls = section.querySelector('.gps-gpsd-controls');
const source = selectElement.value;
if (source === 'gpsd') {
serialControls.style.display = 'none';
gpsdControls.style.display = 'block';
} else {
serialControls.style.display = 'block';
gpsdControls.style.display = 'none';
}
}
async function startGpsFromSection(section) {
// Start GPS based on the selected source in the section
const sourceSelect = section.querySelector('.gps-source-select');
const source = sourceSelect ? sourceSelect.value : 'serial';
if (source === 'gpsd') {
const host = section.querySelector('.gpsd-host-input').value || 'localhost';
const port = parseInt(section.querySelector('.gpsd-port-input').value) || 2947;
return await startGpsd(host, port);
} else {
const devicePath = section.querySelector('.gps-device-select').value;
const baudrate = parseInt(section.querySelector('.gps-baudrate-select').value) || 9600;
return await startGpsDongle(devicePath, baudrate);
}
}
async function startGpsd(host = 'localhost', port = 2947) {
try {
const response = await fetch('/gps/gpsd/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: host, port: port })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsStatus(true);
showInfo(`Connected to gpsd at ${host}:${port}`);
return true;
} else {
showError('Failed to connect to gpsd: ' + data.message);
return false;
}
} catch (e) {
showError('gpsd connection error: ' + e.message);
return false;
}
}
async function startGpsDongle(devicePath, baudrate = 9600) { async function startGpsDongle(devicePath, baudrate = 9600) {
if (!devicePath) { if (!devicePath) {
showError('Please select a GPS device'); showError('Please select a GPS device');
+280 -6
View File
@@ -15,7 +15,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Optional, Callable from typing import Optional, Callable, Union
logger = logging.getLogger('intercept.gps') logger = logging.getLogger('intercept.gps')
@@ -457,24 +457,264 @@ class GPSReader:
logger.error(f"GPS callback error: {e}") logger.error(f"GPS callback error: {e}")
class GPSDClient:
"""
Connects to gpsd daemon for GPS data.
gpsd provides a unified interface for GPS devices and handles
device management, making it ideal when gpsd is already running.
"""
DEFAULT_HOST = 'localhost'
DEFAULT_PORT = 2947
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._socket: Optional['socket.socket'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
self._device: Optional[str] = None
@property
def position(self) -> Optional[GPSPosition]:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def is_running(self) -> bool:
"""Check if the client is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
"""Get any error message."""
with self._lock:
return self._error
@property
def device_path(self) -> str:
"""Return gpsd connection info (for compatibility with GPSReader)."""
return f"gpsd://{self.host}:{self.port}"
@property
def baudrate(self) -> int:
"""Return 0 for gpsd (for compatibility with GPSReader)."""
return 0
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def start(self) -> bool:
"""Start receiving GPS data from gpsd."""
import socket
if self._running:
return True
try:
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(5.0)
self._socket.connect((self.host, self.port))
# Enable JSON watch mode
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
self._socket.send(watch_cmd.encode('ascii'))
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
return True
except Exception as e:
self._error = str(e)
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
return False
def stop(self) -> None:
"""Stop receiving GPS data."""
self._running = False
if self._socket:
try:
# Disable watch mode
self._socket.send(b'?WATCH={"enable":false}\n')
self._socket.close()
except Exception:
pass
self._socket = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
def _read_loop(self) -> None:
"""Background thread loop for reading gpsd data."""
import json
import socket
buffer = ""
message_count = 0
print(f"[GPS] gpsd read loop started", flush=True)
while self._running and self._socket:
try:
self._socket.settimeout(1.0)
data = self._socket.recv(4096)
if not data:
logger.warning("gpsd connection closed")
with self._lock:
self._error = "Connection closed by gpsd"
break
buffer += data.decode('ascii', errors='ignore')
# Process complete JSON lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
msg_class = msg.get('class', '')
message_count += 1
if message_count <= 5 or message_count % 20 == 0:
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
if msg_class == 'TPV':
self._handle_tpv(msg)
elif msg_class == 'DEVICES':
# Track connected device
devices = msg.get('devices', [])
if devices:
self._device = devices[0].get('path', 'unknown')
print(f"[GPS] gpsd device: {self._device}", flush=True)
except json.JSONDecodeError:
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
except socket.timeout:
continue
except Exception as e:
logger.error(f"gpsd read error: {e}")
with self._lock:
self._error = str(e)
break
def _handle_tpv(self, msg: dict) -> None:
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
mode = msg.get('mode', 0)
if mode < 2:
# No fix yet
return
lat = msg.get('lat')
lon = msg.get('lon')
if lat is None or lon is None:
return
# Parse timestamp
timestamp = None
time_str = msg.get('time')
if time_str:
try:
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
pass
position = GPSPosition(
latitude=lat,
longitude=lon,
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd (not knots)
heading=msg.get('track'),
fix_quality=mode,
timestamp=timestamp,
device=self._device or f"gpsd://{self.host}:{self.port}",
)
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
self._update_position(position)
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
self._position = position
self._last_update = datetime.utcnow()
self._error = None
# Notify callbacks
for callback in self._callbacks:
try:
callback(position)
except Exception as e:
logger.error(f"GPS callback error: {e}")
# Type alias for GPS source (either serial reader or gpsd client)
GPSSource = Union[GPSReader, GPSDClient]
# Global GPS reader instance # Global GPS reader instance
_gps_reader: Optional[GPSReader] = None _gps_reader: Optional[GPSSource] = None
_gps_lock = threading.Lock() _gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSReader]: def get_gps_reader() -> Optional[GPSSource]:
"""Get the global GPS reader instance.""" """Get the global GPS reader/client instance."""
with _gps_lock: with _gps_lock:
return _gps_reader return _gps_reader
def start_gps(device_path: str, baudrate: int = 9600) -> bool: def start_gps(device_path: str, baudrate: int = 9600,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
""" """
Start the global GPS reader. Start the global GPS reader.
Args: Args:
device_path: Path to the GPS serial device device_path: Path to the GPS serial device
baudrate: Serial baudrate (default 9600) baudrate: Serial baudrate (default 9600)
callback: Optional callback for position updates (registered before start to avoid race condition)
Returns: Returns:
True if started successfully True if started successfully
@@ -487,11 +727,45 @@ def start_gps(device_path: str, baudrate: int = 9600) -> bool:
_gps_reader.stop() _gps_reader.stop()
_gps_reader = GPSReader(device_path, baudrate) _gps_reader = GPSReader(device_path, baudrate)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
return _gps_reader.start()
def start_gpsd(host: str = 'localhost', port: int = 2947,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
"""
Start the global GPS client connected to gpsd.
Args:
host: gpsd host (default localhost)
port: gpsd port (default 2947)
callback: Optional callback for position updates
Returns:
True if started successfully
"""
global _gps_reader
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
_gps_reader = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
return _gps_reader.start() return _gps_reader.start()
def stop_gps() -> None: def stop_gps() -> None:
"""Stop the global GPS reader.""" """Stop the global GPS reader/client."""
global _gps_reader global _gps_reader
with _gps_lock: with _gps_lock:
+3
View File
@@ -30,6 +30,7 @@ from .detection import detect_all_devices
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
from .validation import ( from .validation import (
SDRValidationError, SDRValidationError,
validate_frequency, validate_frequency,
@@ -49,6 +50,7 @@ class SDRFactory:
SDRType.RTL_SDR: RTLSDRCommandBuilder, SDRType.RTL_SDR: RTLSDRCommandBuilder,
SDRType.LIME_SDR: LimeSDRCommandBuilder, SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
} }
@classmethod @classmethod
@@ -214,6 +216,7 @@ __all__ = [
'RTLSDRCommandBuilder', 'RTLSDRCommandBuilder',
'LimeSDRCommandBuilder', 'LimeSDRCommandBuilder',
'HackRFCommandBuilder', 'HackRFCommandBuilder',
'AirspyCommandBuilder',
# Validation # Validation
'SDRValidationError', 'SDRValidationError',
'validate_frequency', 'validate_frequency',
+153
View File
@@ -0,0 +1,153 @@
"""
Airspy command builder implementation.
Uses SoapySDR-based tools for FM demodulation and signal capture.
Airspy R2/Mini supports 24 MHz to 1.8 GHz frequency range.
Airspy HF+ supports 9 kHz - 31 MHz and 60-260 MHz.
"""
from __future__ import annotations
from typing import Optional
from .base import CommandBuilder, SDRCapabilities, SDRDevice, SDRType
class AirspyCommandBuilder(CommandBuilder):
"""Airspy command builder using SoapySDR tools."""
# Airspy R2/Mini capabilities (most common)
# HF+ has different range but same interface
CAPABILITIES = SDRCapabilities(
sdr_type=SDRType.AIRSPY,
freq_min_mhz=24.0, # 24 MHz (HF+ goes lower)
freq_max_mhz=1800.0, # 1.8 GHz
gain_min=0.0,
gain_max=45.0, # LNA (0-15) + Mixer (0-15) + VGA (0-15)
sample_rates=[2500000, 3000000, 6000000, 10000000],
supports_bias_t=True,
supports_ppm=False, # Airspy has TCXO, no PPM needed
tx_capable=False
)
def _build_device_string(self, device: SDRDevice) -> str:
"""Build SoapySDR device string for Airspy."""
driver = device.driver if device.driver in ('airspy', 'airspyhf') else 'airspy'
if device.serial and device.serial != 'N/A':
return f'driver={driver},serial={device.serial}'
return f'driver={driver}'
def _format_gain(self, gain: float) -> str:
"""
Format gain string for Airspy.
Airspy has three gain stages:
- LNA: 0-15 dB
- Mixer: 0-15 dB
- VGA: 0-15 dB
This distributes the requested gain across stages.
"""
if gain <= 15:
return f'LNA={int(gain)},MIX=0,VGA=0'
elif gain <= 30:
return f'LNA=15,MIX={int(gain - 15)},VGA=0'
else:
vga = min(15, int(gain - 30))
return f'LNA=15,MIX=15,VGA={vga}'
def build_fm_demod_command(
self,
device: SDRDevice,
frequency_mhz: float,
sample_rate: int = 22050,
gain: Optional[float] = None,
ppm: Optional[int] = None,
modulation: str = "fm",
squelch: Optional[int] = None
) -> list[str]:
"""
Build SoapySDR rx_fm command for FM demodulation.
For pager decoding with Airspy.
"""
device_str = self._build_device_string(device)
cmd = [
'rx_fm',
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-M', modulation,
'-s', str(sample_rate),
]
if gain is not None and gain > 0:
cmd.extend(['-g', self._format_gain(gain)])
if squelch is not None and squelch > 0:
cmd.extend(['-l', str(squelch)])
# Output to stdout
cmd.append('-')
return cmd
def build_adsb_command(
self,
device: SDRDevice,
gain: Optional[float] = None
) -> list[str]:
"""
Build dump1090/readsb command with SoapySDR support for ADS-B decoding.
Uses readsb which has better SoapySDR support.
"""
device_str = self._build_device_string(device)
cmd = [
'readsb',
'--net',
'--device-type', 'soapysdr',
'--device', device_str,
'--quiet'
]
if gain is not None:
cmd.extend(['--gain', str(int(gain))])
return cmd
def build_ism_command(
self,
device: SDRDevice,
frequency_mhz: float = 433.92,
gain: Optional[float] = None,
ppm: Optional[int] = None
) -> list[str]:
"""
Build rtl_433 command with SoapySDR support for ISM band decoding.
rtl_433 has native SoapySDR support via -d flag.
"""
device_str = self._build_device_string(device)
cmd = [
'rtl_433',
'-d', device_str,
'-f', f'{frequency_mhz}M',
'-F', 'json'
]
if gain is not None and gain > 0:
cmd.extend(['-g', str(int(gain))])
return cmd
def get_capabilities(self) -> SDRCapabilities:
"""Return Airspy capabilities."""
return self.CAPABILITIES
@classmethod
def get_sdr_type(cls) -> SDRType:
"""Return SDR type."""
return SDRType.AIRSPY
+1
View File
@@ -18,6 +18,7 @@ class SDRType(Enum):
RTL_SDR = "rtlsdr" RTL_SDR = "rtlsdr"
LIME_SDR = "limesdr" LIME_SDR = "limesdr"
HACKRF = "hackrf" HACKRF = "hackrf"
AIRSPY = "airspy"
# Future support # Future support
# USRP = "usrp" # USRP = "usrp"
# BLADE_RF = "bladerf" # BLADE_RF = "bladerf"
+4
View File
@@ -28,11 +28,13 @@ def _get_capabilities_for_type(sdr_type: SDRType) -> SDRCapabilities:
from .rtlsdr import RTLSDRCommandBuilder from .rtlsdr import RTLSDRCommandBuilder
from .limesdr import LimeSDRCommandBuilder from .limesdr import LimeSDRCommandBuilder
from .hackrf import HackRFCommandBuilder from .hackrf import HackRFCommandBuilder
from .airspy import AirspyCommandBuilder
builders = { builders = {
SDRType.RTL_SDR: RTLSDRCommandBuilder, SDRType.RTL_SDR: RTLSDRCommandBuilder,
SDRType.LIME_SDR: LimeSDRCommandBuilder, SDRType.LIME_SDR: LimeSDRCommandBuilder,
SDRType.HACKRF: HackRFCommandBuilder, SDRType.HACKRF: HackRFCommandBuilder,
SDRType.AIRSPY: AirspyCommandBuilder,
} }
builder_class = builders.get(sdr_type) builder_class = builders.get(sdr_type)
@@ -60,6 +62,8 @@ def _driver_to_sdr_type(driver: str) -> Optional[SDRType]:
'lime': SDRType.LIME_SDR, 'lime': SDRType.LIME_SDR,
'limesdr': SDRType.LIME_SDR, 'limesdr': SDRType.LIME_SDR,
'hackrf': SDRType.HACKRF, 'hackrf': SDRType.HACKRF,
'airspy': SDRType.AIRSPY,
'airspyhf': SDRType.AIRSPY, # Airspy HF+ uses same builder
# Future support # Future support
# 'uhd': SDRType.USRP, # 'uhd': SDRType.USRP,
# 'bladerf': SDRType.BLADE_RF, # 'bladerf': SDRType.BLADE_RF,