Add GPS dongle support and fix Python 3.7/3.8 compatibility

- Add GPS dongle support with NMEA parsing (utils/gps.py, routes/gps.py)
- Add GPS device selector to ADS-B and Satellite observer location sections
- Add GPS dongle option to ADS-B dashboard
- Fix Python 3.7/3.8 compatibility by adding 'from __future__ import annotations'
  to all SDR module files (fixes TypeError: 'type' object is not subscriptable)
- Add pyserial to requirements.txt
- Update README with GPS dongle documentation and troubleshooting

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-01-02 17:19:41 +00:00
parent 3a0a697bac
commit e01c651bb4
13 changed files with 1213 additions and 11 deletions

View File

@@ -3555,13 +3555,37 @@
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Observer Location</label>
<select id="adsbLocationSource" onchange="toggleGpsSection(this.value === 'dongle')" style="margin-bottom: 5px;">
<option value="manual">Manual Entry</option>
<option value="browser">Browser GPS</option>
<option value="dongle">USB GPS Dongle</option>
</select>
<div style="display: flex; gap: 5px;">
<input type="text" id="adsbObsLat" value="51.5074" placeholder="Latitude" style="flex: 1;" onchange="updateObserverLocation()">
<input type="text" id="adsbObsLon" value="-0.1278" placeholder="Longitude" style="flex: 1;" onchange="updateObserverLocation()">
</div>
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
📍 Use GPS Location
📍 Use Browser Location
</button>
<div class="gps-dongle-section" style="display: none; margin-top: 8px; padding: 8px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select class="gps-device-select" style="flex: 1; font-size: 11px;">
<option value="">Select GPS Device...</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 2px 6px; font-size: 10px;" title="Refresh">🔄</button>
</div>
<div style="display: flex; gap: 5px;">
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value)" style="flex: 1; font-size: 10px; padding: 4px;">
Connect
</button>
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; font-size: 10px; padding: 4px; background: rgba(255,0,0,0.1); border-color: #ff4444;">
Disconnect
</button>
</div>
<div class="gps-status-indicator" style="text-align: center; margin-top: 5px; font-size: 10px; color: var(--text-secondary);">
⚪ Disconnected
</div>
</div>
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Aircraft Filter</label>
@@ -3627,6 +3651,14 @@
<div id="predictorTab" class="satellite-content active">
<div class="section">
<h3>Observer Location</h3>
<div class="form-group">
<label>Location Source</label>
<select id="satLocationSource" onchange="toggleGpsSection(this.value === 'dongle')">
<option value="manual">Manual Entry</option>
<option value="browser">Browser GPS</option>
<option value="dongle">USB GPS Dongle</option>
</select>
</div>
<div class="form-group">
<label>Latitude</label>
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074">
@@ -3636,8 +3668,30 @@
<input type="text" id="obsLon" value="-0.1278" placeholder="-0.1278">
</div>
<button class="preset-btn" onclick="getLocation()" style="width: 100%;">
📍 Use My Location
📍 Use Browser Location
</button>
<div class="gps-dongle-section" style="display: none; margin-top: 10px; padding: 10px; background: rgba(0,212,255,0.05); border-radius: 4px;">
<div class="form-group" style="margin-bottom: 8px;">
<label style="font-size: 11px;">GPS Device</label>
<div style="display: flex; gap: 5px;">
<select class="gps-device-select" style="flex: 1;">
<option value="">Select GPS Device...</option>
</select>
<button class="preset-btn" onclick="refreshGpsDevices()" style="padding: 4px 8px;" title="Refresh">🔄</button>
</div>
</div>
<div style="display: flex; gap: 5px;">
<button class="preset-btn gps-connect-btn" onclick="startGpsDongle(this.closest('.gps-dongle-section').querySelector('.gps-device-select').value)" style="flex: 1;">
Connect GPS
</button>
<button class="preset-btn gps-disconnect-btn" onclick="stopGpsDongle()" style="flex: 1; display: none; background: rgba(255,0,0,0.1); border-color: #ff4444;">
Disconnect
</button>
</div>
<div class="gps-status-indicator" style="text-align: center; margin-top: 8px; font-size: 11px; color: var(--text-secondary);">
⚪ Disconnected
</div>
</div>
</div>
<div class="section">
@@ -4279,6 +4333,12 @@
let rangeRingsLayer = null;
let observerMarkerAdsb = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
let gpsLastPosition = null;
// Audio alert system using Web Audio API (uses shared audioContext declared later)
function getAdsbAudioContext() {
if (!window.adsbAudioCtx) {
@@ -8247,10 +8307,23 @@
const mapContainer = document.getElementById('aircraftMap');
if (!mapContainer || aircraftMap) return;
// Use GPS position if available, otherwise use observerLocation or default
let initialLat = observerLocation.lat || 51.5;
let initialLon = observerLocation.lon || -0.1;
// Check if GPS has a recent position
if (gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
initialLat = gpsLastPosition.latitude;
initialLon = gpsLastPosition.longitude;
observerLocation.lat = initialLat;
observerLocation.lon = initialLon;
console.log('GPS: Initializing map with GPS position', initialLat, initialLon);
}
// Initialize Leaflet map
aircraftMap = L.map('aircraftMap', {
center: [51.5, -0.1], // Default to London
zoom: 5,
center: [initialLat, initialLon],
zoom: 8,
zoomControl: true,
attributionControl: true
});
@@ -8294,6 +8367,17 @@
// Initial update
updateAircraftMarkers();
// Update input fields with current position
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
// Draw initial range rings if GPS is connected
if (gpsConnected) {
drawRangeRings();
}
}
function toggleAircraftClustering() {
@@ -8997,6 +9081,207 @@
}
}
// ============================================
// GPS DONGLE FUNCTIONS
// ============================================
async function checkGpsDongleAvailable() {
try {
const response = await fetch('/gps/available');
const data = await response.json();
return data.available;
} catch (e) {
console.warn('GPS dongle check failed:', e);
return false;
}
}
async function refreshGpsDevices() {
try {
const response = await fetch('/gps/devices');
const data = await response.json();
if (data.status === 'ok') {
gpsDevices = data.devices;
updateGpsDeviceSelectors();
return data.devices;
}
} catch (e) {
console.warn('Failed to get GPS devices:', e);
}
return [];
}
function updateGpsDeviceSelectors() {
// Update all GPS device selectors in the UI
const selectors = document.querySelectorAll('.gps-device-select');
selectors.forEach(select => {
const currentValue = select.value;
select.innerHTML = '<option value="">Select GPS Device...</option>';
gpsDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.path;
option.textContent = device.name + (device.accessible ? '' : ' (no access)');
option.disabled = !device.accessible;
select.appendChild(option);
});
if (currentValue && gpsDevices.some(d => d.path === currentValue)) {
select.value = currentValue;
}
});
}
async function startGpsDongle(devicePath) {
if (!devicePath) {
showError('Please select a GPS device');
return false;
}
try {
const response = await fetch('/gps/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: devicePath, baudrate: 9600 })
});
const data = await response.json();
if (data.status === 'started') {
gpsConnected = true;
startGpsStream();
updateGpsStatus(true);
showInfo('GPS dongle connected: ' + devicePath);
return true;
} else {
showError('Failed to start GPS: ' + data.message);
return false;
}
} catch (e) {
showError('GPS connection error: ' + e.message);
return false;
}
}
async function stopGpsDongle() {
try {
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
gpsLastPosition = null;
updateGpsStatus(false);
showInfo('GPS dongle disconnected');
} catch (e) {
console.warn('GPS stop error:', e);
}
}
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('GPS data received:', data);
if (data.type === 'position') {
gpsLastPosition = data;
updateLocationFromGps(data);
// Update status indicator with coordinates
const statusIndicators = document.querySelectorAll('.gps-status-indicator');
statusIndicators.forEach(indicator => {
if (data.latitude && data.longitude) {
indicator.textContent = `🟢 ${data.latitude.toFixed(4)}, ${data.longitude.toFixed(4)}`;
indicator.style.color = 'var(--accent-green)';
}
});
} else if (data.type === 'keepalive') {
console.log('GPS keepalive');
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
console.warn('GPS stream error:', e);
gpsConnected = false;
updateGpsStatus(false);
};
}
function updateLocationFromGps(position) {
if (!position || !position.latitude || !position.longitude) {
console.warn('GPS: Invalid position data', position);
return;
}
console.log('GPS: Updating location to', position.latitude, position.longitude);
// Update satellite observer location
const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
// Update ADS-B observer location
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = position.latitude.toFixed(4);
if (adsbLonInput) adsbLonInput.value = position.longitude.toFixed(4);
// Update observerLocation for ADS-B calculations
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
// Center ADS-B map on new location (only on first fix or significant movement)
if (typeof aircraftMap !== 'undefined' && aircraftMap) {
const currentCenter = aircraftMap.getCenter();
const distance = Math.sqrt(
Math.pow(currentCenter.lat - position.latitude, 2) +
Math.pow(currentCenter.lng - position.longitude, 2)
);
console.log('GPS: Map exists, distance from current center:', distance);
// Only recenter if moved more than ~1km (0.01 degrees)
if (distance > 0.01 || !aircraftMap._gpsInitialized) {
console.log('GPS: Centering map on', position.latitude, position.longitude);
aircraftMap.setView([position.latitude, position.longitude], aircraftMap.getZoom());
aircraftMap._gpsInitialized = true;
}
} else {
console.log('GPS: aircraftMap not available yet');
}
// Trigger map updates
if (typeof drawRangeRings === 'function') {
drawRangeRings();
}
}
function updateGpsStatus(connected) {
const statusIndicators = document.querySelectorAll('.gps-status-indicator');
statusIndicators.forEach(indicator => {
indicator.textContent = connected ? '🟢 Connected' : '⚪ Disconnected';
indicator.style.color = connected ? 'var(--accent-green)' : 'var(--text-secondary)';
});
const connectBtns = document.querySelectorAll('.gps-connect-btn');
const disconnectBtns = document.querySelectorAll('.gps-disconnect-btn');
connectBtns.forEach(btn => btn.style.display = connected ? 'none' : 'block');
disconnectBtns.forEach(btn => btn.style.display = connected ? 'block' : 'none');
}
function toggleGpsSection(show) {
const gpsSections = document.querySelectorAll('.gps-dongle-section');
gpsSections.forEach(section => {
section.style.display = show ? 'block' : 'none';
});
if (show) {
refreshGpsDevices();
}
}
function initPolarPlot() {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;