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