mirror of
https://github.com/smittix/intercept.git
synced 2026-07-09 01:58:13 -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:
+88
-8
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user