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 82a2883f82
commit b2c32173e1
8 changed files with 756 additions and 55 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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');

View File

@@ -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:

View File

@@ -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
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

View File

@@ -18,6 +18,7 @@ class SDRType(Enum):
RTL_SDR = "rtlsdr"
LIME_SDR = "limesdr"
HACKRF = "hackrf"
AIRSPY = "airspy"
# Future support
# USRP = "usrp"
# BLADE_RF = "bladerf"

View File

@@ -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,