Simplify GPS to gpsd-only and streamline UI controls

Remove direct serial GPS dongle support in favor of gpsd daemon connectivity.
The UI now auto-connects to gpsd on page load and shows a GPS indicator when connected.
Simplify ADS-B dashboard controls bar for a cleaner, more compact layout.
Add setup-dev.sh for streamlined development environment setup.

- Remove GPSReader class and NMEA parsing (utils/gps.py)
- Consolidate to GPSDClient only with auto-connect endpoint
- Add GPS indicator with pulsing dot animation
- Compact controls bar with smaller fonts and tighter spacing
- Add aircraft database download banner/functionality

🤖 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-07 19:49:58 +00:00
parent 40369ccb7b
commit 9d0e417f2a
7 changed files with 933 additions and 1294 deletions

View File

@@ -1,9 +1,8 @@
"""GPS dongle routes for USB GPS device support."""
"""GPS routes for gpsd daemon support."""
from __future__ import annotations
import queue
import threading
import time
from typing import Generator
@@ -12,15 +11,11 @@ from flask import Blueprint, jsonify, request, Response
from utils.logging import get_logger
from utils.sse import format_sse
from utils.gps import (
detect_gps_devices,
is_serial_available,
get_gps_reader,
start_gps,
start_gpsd,
stop_gps,
get_current_position,
GPSPosition,
GPSDClient,
)
logger = get_logger('intercept.gps')
@@ -44,93 +39,42 @@ def _position_callback(position: GPSPosition) -> None:
pass
@gps_bp.route('/available')
def check_gps_available():
"""Check if GPS dongle support is available."""
return jsonify({
'available': is_serial_available(),
'message': None if is_serial_available() else 'pyserial not installed - run: pip install pyserial'
})
@gps_bp.route('/auto-connect', methods=['POST'])
def auto_connect_gps():
"""
Automatically connect to gpsd if available.
@gps_bp.route('/gpsd/check')
def check_gpsd_available():
"""Check if gpsd is reachable."""
Called on page load to seamlessly enable GPS if gpsd is running.
Returns current status if already connected.
"""
import socket
host = request.args.get('host', 'localhost')
port = int(request.args.get('port', 2947))
# Check if already running
reader = get_gps_reader()
if reader and reader.is_running:
position = reader.position
return jsonify({
'status': 'connected',
'source': 'gpsd',
'has_fix': position is not None,
'position': position.to_dict() if position else None
})
# Try to connect to gpsd on localhost:2947
host = 'localhost'
port = 2947
# First check if gpsd is reachable
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2.0)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
except Exception:
return jsonify({
'available': True,
'host': host,
'port': port,
'message': f'gpsd reachable at {host}:{port}'
'status': 'unavailable',
'message': 'gpsd not running'
})
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."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
devices = detect_gps_devices()
return jsonify({
'status': 'ok',
'devices': devices
})
@gps_bp.route('/start', methods=['POST'])
def start_gps_reader():
"""Start GPS reader on specified device."""
if not is_serial_available():
return jsonify({
'status': 'error',
'message': 'pyserial not installed'
}), 503
# 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 {}
device_path = data.get('device')
baudrate = data.get('baudrate', 9600)
if not device_path:
return jsonify({
'status': 'error',
'message': 'Device path required'
}), 400
# Validate baudrate
valid_baudrates = [4800, 9600, 19200, 38400, 57600, 115200]
if baudrate not in valid_baudrates:
return jsonify({
'status': 'error',
'message': f'Invalid baudrate. Valid options: {valid_baudrates}'
}), 400
# Clear the queue
while not _gps_queue.empty():
@@ -139,80 +83,26 @@ def start_gps_reader():
except queue.Empty:
break
# Start the GPS reader with callback pre-registered (avoids race condition)
success = start_gps(device_path, baudrate, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'device': device_path,
'baudrate': baudrate,
'source': 'serial'
})
else:
reader = get_gps_reader()
error = reader.error if reader else 'Unknown error'
return jsonify({
'status': 'error',
'message': f'Failed to start GPS reader: {error}'
}), 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
# Start the gpsd client
success = start_gpsd(host, port, callback=_position_callback)
if success:
return jsonify({
'status': 'started',
'host': host,
'port': port,
'source': 'gpsd'
'status': 'connected',
'source': 'gpsd',
'has_fix': False,
'position': None
})
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
'status': 'unavailable',
'message': 'Failed to connect to gpsd'
})
@gps_bp.route('/stop', methods=['POST'])
def stop_gps_reader():
"""Stop GPS reader."""
"""Stop GPS client."""
reader = get_gps_reader()
if reader:
reader.remove_callback(_position_callback)
@@ -224,7 +114,7 @@ def stop_gps_reader():
@gps_bp.route('/status')
def get_gps_status():
"""Get current GPS reader status."""
"""Get current GPS client status."""
reader = get_gps_reader()
if not reader:
@@ -233,7 +123,7 @@ def get_gps_status():
'device': None,
'position': None,
'error': None,
'message': 'GPS reader not started'
'message': 'GPS client not started'
})
position = reader.position
@@ -262,7 +152,7 @@ def get_position():
if not reader or not reader.is_running:
return jsonify({
'status': 'error',
'message': 'GPS reader not running'
'message': 'GPS client not running'
}), 400
else:
return jsonify({
@@ -273,22 +163,22 @@ def get_position():
@gps_bp.route('/debug')
def debug_gps():
"""Debug endpoint showing GPS reader state."""
"""Debug endpoint showing GPS client state."""
reader = get_gps_reader()
if not reader:
return jsonify({
'reader': None,
'message': 'No GPS reader initialized'
'message': 'No GPS client initialized'
})
position = reader.position
source = 'gpsd' if isinstance(reader, GPSDClient) else 'serial'
return jsonify({
'running': reader.is_running,
'source': source,
'source': 'gpsd',
'device': reader.device_path,
'baudrate': reader.baudrate,
'host': reader.host,
'port': reader.port,
'has_position': position is not None,
'position': position.to_dict() if position else None,
'last_update': reader.last_update.isoformat() if reader.last_update else None,

394
setup-dev.sh Normal file
View File

@@ -0,0 +1,394 @@
#!/usr/bin/env bash
# INTERCEPT Setup Script (best-effort installs, hard-fail verification)
# ---- Force bash even if launched with sh ----
if [ -z "${BASH_VERSION:-}" ]; then
echo "[x] This script must be run with bash (not sh)."
echo " Run: bash $0"
exec bash "$0" "$@"
fi
set -Eeuo pipefail
# Ensure admin paths are searchable (many tools live here)
export PATH="/usr/local/sbin:/usr/sbin:/sbin:/opt/homebrew/sbin:/opt/homebrew/bin:$PATH"
# ----------------------------
# Pretty output
# ----------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[*]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[x]${NC} $*"; }
on_error() {
local line="$1"
local cmd="${2:-unknown}"
fail "Setup failed at line ${line}: ${cmd}"
exit 1
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
# ----------------------------
# Banner
# ----------------------------
echo -e "${BLUE}"
echo " ___ _ _ _____ _____ ____ ____ _____ ____ _____ "
echo " |_ _| \\ | |_ _| ____| _ \\ / ___| ____| _ \\_ _|"
echo " | || \\| | | | | _| | |_) | | | _| | |_) || | "
echo " | || |\\ | | | | |___| _ <| |___| |___| __/ | | "
echo " |___|_| \\_| |_| |_____|_| \\_\\\\____|_____|_| |_| "
echo -e "${NC}"
echo "INTERCEPT - Setup Script"
echo "============================================"
echo
# ----------------------------
# Helpers
# ----------------------------
cmd_exists() {
local c="$1"
command -v "$c" >/dev/null 2>&1 && return 0
[[ -x "/usr/sbin/$c" || -x "/sbin/$c" || -x "/usr/local/sbin/$c" || -x "/opt/homebrew/sbin/$c" ]] && return 0
return 1
}
have_any() {
local c
for c in "$@"; do
cmd_exists "$c" && return 0
done
return 1
}
need_sudo() {
if [[ "$(id -u)" -eq 0 ]]; then
SUDO=""
ok "Running as root"
else
if cmd_exists sudo; then
SUDO="sudo"
else
fail "sudo is not installed and you're not root."
echo "Either run as root or install sudo first."
exit 1
fi
fi
}
detect_os() {
if [[ "${OSTYPE:-}" == "darwin"* ]]; then
OS="macos"
elif [[ -f /etc/debian_version ]]; then
OS="debian"
else
OS="unknown"
fi
info "Detected OS: ${OS}"
[[ "$OS" != "unknown" ]] || { fail "Unsupported OS (macOS + Debian/Ubuntu only)."; exit 1; }
}
# ----------------------------
# Required tool checks (with alternates)
# ----------------------------
missing_required=()
check_required() {
local label="$1"; shift
local desc="$1"; shift
if have_any "$@"; then
ok "${label} - ${desc}"
else
warn "${label} - ${desc} (missing, required)"
missing_required+=("$label")
fi
}
check_tools() {
info "Checking required tools..."
missing_required=()
echo
info "Core SDR:"
check_required "rtl_fm" "RTL-SDR FM demodulator" rtl_fm
check_required "rtl_test" "RTL-SDR device detection" rtl_test
check_required "multimon-ng" "Pager decoder" multimon-ng
check_required "rtl_433" "433MHz sensor decoder" rtl_433 rtl433
check_required "dump1090" "ADS-B decoder" dump1090
echo
info "Audio:"
check_required "ffmpeg" "Audio encoder/decoder" ffmpeg
echo
info "WiFi:"
check_required "airmon-ng" "Monitor mode helper" airmon-ng
check_required "airodump-ng" "WiFi scanner" airodump-ng
check_required "aireplay-ng" "Injection/deauth" aireplay-ng
check_required "hcxdumptool" "PMKID capture" hcxdumptool
check_required "hcxpcapngtool" "PMKID/pcapng conversion" hcxpcapngtool
echo
info "Bluetooth:"
check_required "bluetoothctl" "Bluetooth controller CLI" bluetoothctl
check_required "hcitool" "Bluetooth scan utility" hcitool
check_required "hciconfig" "Bluetooth adapter config" hciconfig
echo
info "SoapySDR:"
check_required "SoapySDRUtil" "SoapySDR CLI utility" SoapySDRUtil
echo
}
# ----------------------------
# Python venv + deps
# ----------------------------
check_python_version() {
if ! cmd_exists python3; then
fail "python3 not found."
[[ "$OS" == "macos" ]] && echo "Install with: brew install python"
[[ "$OS" == "debian" ]] && echo "Install with: sudo apt-get install python3"
exit 1
fi
local ver
ver="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
info "Python version: ${ver}"
python3 - <<'PY'
import sys
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
PY
ok "Python version OK (>= 3.9)"
}
install_python_deps() {
info "Setting up Python virtual environment..."
check_python_version
if [[ ! -f requirements.txt ]]; then
warn "requirements.txt not found; skipping Python dependency install."
return 0
fi
if [[ ! -d venv ]]; then
python3 -m venv venv
ok "Created venv/"
else
ok "Using existing venv/"
fi
# shellcheck disable=SC1091
source venv/bin/activate
python -m pip install --upgrade pip setuptools wheel >/dev/null
ok "Upgraded pip tooling"
info "Installing Python requirements..."
python -m pip install -r requirements.txt
ok "Python dependencies installed"
echo
}
# ----------------------------
# macOS install (Homebrew)
# ----------------------------
ensure_brew() {
cmd_exists brew && return 0
warn "Homebrew not found. Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if [[ -x /opt/homebrew/bin/brew ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -x /usr/local/bin/brew ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
cmd_exists brew || { fail "Homebrew install failed. Install manually then re-run."; exit 1; }
}
brew_install() {
local pkg="$1"
if brew list --formula "$pkg" >/dev/null 2>&1; then
ok "brew: ${pkg} already installed"
return 0
fi
info "brew: installing ${pkg}..."
brew install "$pkg"
ok "brew: installed ${pkg}"
}
install_macos_packages() {
ensure_brew
info "Installing packages via Homebrew..."
brew_install librtlsdr
brew_install multimon-ng
brew_install ffmpeg
brew_install rtl_433
# ADS-B (may not exist)
warn "Attempting dump1090 install via Homebrew (may be unavailable)..."
(brew_install dump1090-mutability) || true
brew_install aircrack-ng
brew_install hcxtools
brew_install soapysdr
warn "macOS note: hcitool/hciconfig are Linux (BlueZ) utilities and often unavailable on macOS."
echo
}
# ----------------------------
# Debian/Ubuntu install (APT)
# ----------------------------
apt_install() { $SUDO apt-get install -y --no-install-recommends "$@" >/dev/null; }
apt_try_install_any() {
local p
for p in "$@"; do
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
ok "apt: installed ${p}"
return 0
fi
done
return 1
}
install_dump1090_from_source_debian() {
info "dump1090 not available via APT. Building from source (required)..."
apt_install build-essential git pkg-config \
librtlsdr-dev libusb-1.0-0-dev \
libncurses-dev tcl-dev python3-dev
local orig_dir tmp_dir
orig_dir="$(pwd)"
tmp_dir="$(mktemp -d)"
cleanup() { cd "$orig_dir" >/dev/null 2>&1 || true; rm -rf "$tmp_dir"; }
trap cleanup EXIT
info "Cloning FlightAware dump1090..."
git clone --depth 1 https://github.com/flightaware/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone FlightAware dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling FlightAware dump1090..."
if make BLADERF=no RTLSDR=yes >/dev/null 2>&1; then
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (FlightAware)."
return 0
fi
warn "FlightAware build failed. Falling back to antirez/dump1090..."
rm -rf "$tmp_dir/dump1090"
git clone --depth 1 https://github.com/antirez/dump1090.git "$tmp_dir/dump1090" >/dev/null 2>&1 \
|| { fail "Failed to clone antirez dump1090"; exit 1; }
cd "$tmp_dir/dump1090"
info "Compiling antirez dump1090..."
make >/dev/null 2>&1 || { fail "Failed to build dump1090 from source (required)."; exit 1; }
$SUDO install -m 0755 dump1090 /usr/local/bin/dump1090
ok "dump1090 installed successfully (antirez)."
}
setup_udev_rules_debian() {
[[ -d /etc/udev/rules.d ]] || { warn "udev not found; skipping RTL-SDR udev rules."; return 0; }
local rules_file="/etc/udev/rules.d/20-rtlsdr.rules"
[[ -f "$rules_file" ]] && { ok "RTL-SDR udev rules already present: $rules_file"; return 0; }
info "Installing RTL-SDR udev rules..."
$SUDO tee "$rules_file" >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2838", MODE="0666"
SUBSYSTEM=="usb", ATTRS{idVendor}=="0bda", ATTRS{idProduct}=="2832", MODE="0666"
EOF
$SUDO udevadm control --reload-rules || true
$SUDO udevadm trigger || true
ok "udev rules installed. Unplug/replug your RTL-SDR if connected."
echo
}
install_debian_packages() {
need_sudo
info "Updating APT package lists..."
$SUDO apt-get update -y >/dev/null
info "Installing required packages via APT..."
apt_install rtl-sdr
apt_install multimon-ng
apt_install ffmpeg
apt_try_install_any rtl-433 rtl433 || true
apt_install aircrack-ng || true
apt_install hcxdumptool || true
apt_install hcxtools || true
apt_install bluez bluetooth || true
apt_install soapysdr-tools || true
# dump1090: apt first; source fallback; hard fail inside if it can't build
if ! cmd_exists dump1090; then
apt_try_install_any dump1090-fa dump1090-mutability dump1090 || true
fi
cmd_exists dump1090 || install_dump1090_from_source_debian
setup_udev_rules_debian
}
# ----------------------------
# Final summary / hard fail
# ----------------------------
final_summary_and_hard_fail() {
check_tools
echo "============================================"
if [[ "${#missing_required[@]}" -eq 0 ]]; then
ok "All REQUIRED tools are installed."
else
fail "Missing REQUIRED tools:"
for t in "${missing_required[@]}"; do echo " - $t"; done
echo
fail "Exiting because required tools are missing."
echo
warn "If you are on macOS: hcitool/hciconfig are Linux (BlueZ) tools and may not be installable."
warn "If you truly require them everywhere, you must restrict supported platforms or provide alternatives."
exit 1
fi
echo
echo "To start INTERCEPT:"
echo " source venv/bin/activate"
echo " sudo python intercept.py"
echo
echo "Then open http://localhost:5050 in your browser"
echo
}
# ----------------------------
# MAIN
# ----------------------------
main() {
detect_os
if [[ "$OS" == "macos" ]]; then
install_macos_packages
else
install_debian_packages
fi
install_python_deps
final_summary_and_hard_fail
}
main "$@"

View File

@@ -478,11 +478,28 @@ body {
grid-row: 2;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 20px;
padding: 10px 20px;
flex-wrap: nowrap;
gap: 8px;
padding: 8px 15px;
background: var(--bg-panel);
border-top: 1px solid rgba(74, 158, 255, 0.3);
font-size: 11px;
overflow-x: auto;
}
.controls-bar label {
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
cursor: pointer;
}
.controls-bar select,
.controls-bar input[type="text"],
.controls-bar input[type="number"] {
padding: 3px 5px;
font-size: 10px;
}
.control-group {
@@ -653,9 +670,11 @@ body {
/* Airband Audio Controls */
.airband-divider {
width: 1px;
height: 24px;
background: var(--border-color);
margin: 0 10px;
height: 20px;
background: var(--accent-cyan);
opacity: 0.4;
margin: 0 5px;
flex-shrink: 0;
}
.airband-controls {
@@ -775,3 +794,32 @@ body {
border-radius: 3px;
background: rgba(0, 0, 0, 0.4);
}
/* GPS Indicator */
.gps-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid #22c55e;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: #22c55e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-indicator .gps-dot {
width: 6px;
height: 6px;
background: #22c55e;
border-radius: 50%;
animation: gps-pulse 2s ease-in-out infinite;
}
@keyframes gps-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}

View File

@@ -3293,3 +3293,32 @@ body::before {
border-radius: 3px;
background: rgba(0, 0, 0, 0.4);
}
/* GPS Indicator */
.gps-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: var(--accent-green-dim);
border: 1px solid var(--accent-green);
border-radius: 12px;
font-size: 10px;
font-weight: 600;
color: var(--accent-green);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gps-indicator .gps-dot {
width: 6px;
height: 6px;
background: var(--accent-green);
border-radius: 50%;
animation: gps-pulse 2s ease-in-out infinite;
}
@keyframes gps-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}

View File

@@ -92,128 +92,51 @@
<!-- Controls Bar -->
<div class="controls-bar">
<div class="control-group">
<label>
<input type="checkbox" id="showTrails" onchange="toggleTrails()">
Trails
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="showRangeRings" onchange="drawRangeRings()">
Range Rings
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()">
Alerts
</label>
</div>
<div class="control-group">
<span class="control-label">Filter:</span>
<select id="aircraftFilter" onchange="applyFilter()">
<option value="all">All</option>
<option value="military">Military</option>
<option value="civil">Civil</option>
<option value="emergency">Emergency</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Range:</span>
<select id="rangeSelect" onchange="updateRange()">
<option value="50">50 nm</option>
<option value="100">100 nm</option>
<option value="200" selected>200 nm</option>
<option value="300">300 nm</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Lat:</span>
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<span class="control-label">Lon:</span>
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<select id="gpsSource" onchange="toggleGpsDongleControls()" style="font-size: 10px;">
<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">
<button class="gps-btn" id="geolocateBtn" onclick="getGeolocation()">Locate</button>
</div>
<div class="control-group gps-dongle-controls" style="display: none;">
<select class="gps-device-select" id="gpsDeviceSelect" style="font-size: 10px; max-width: 120px;">
<option value="">GPS Device...</option>
</select>
<select id="gpsBaudrateSelect" style="font-size: 10px; width: 65px;">
<option value="4800">4800</option>
<option value="9600" selected>9600</option>
<option value="38400">38400</option>
<option value="115200">115200</option>
</select>
<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()">
<span>Remote</span>
</label>
</div>
<div class="control-group remote-dump1090-controls" style="display: none;">
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 90px; font-size: 10px;">
<span style="color: #666;">:</span>
<input type="number" id="remoteSbsPort" value="30003" min="1" max="65535" style="width: 55px; font-size: 10px;">
</div>
<label title="Show aircraft trails"><input type="checkbox" id="showTrails" onchange="toggleTrails()"> Trails</label>
<label title="Show range rings"><input type="checkbox" id="showRangeRings" checked onchange="drawRangeRings()"> Rings</label>
<label title="Audio alerts"><input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()"> Alerts</label>
<select id="aircraftFilter" onchange="applyFilter()" title="Filter aircraft">
<option value="all">All</option>
<option value="military">Military</option>
<option value="civil">Civil</option>
<option value="emergency">Emergency</option>
</select>
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
<option value="50">50nm</option>
<option value="100">100nm</option>
<option value="200" selected>200nm</option>
<option value="300">300nm</option>
</select>
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 65px;" title="Latitude">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 65px;" title="Longitude">
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
<span class="remote-dump1090-controls" style="display: none;">
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 70px;">
<input type="number" id="remoteSbsPort" value="30003" style="width: 50px;">
</span>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
<div class="airband-divider"></div>
<div class="control-group airband-controls">
<span class="control-label" style="color: var(--accent-cyan);">AIRBAND:</span>
<select id="airbandFreqSelect" onchange="updateAirbandFreq()">
<option value="121.5">121.5 MHz (Guard)</option>
<option value="118.0">118.0 MHz</option>
<option value="119.1">119.1 MHz</option>
<option value="120.5">120.5 MHz</option>
<option value="123.45">123.45 MHz (Air-Air)</option>
<option value="127.85">127.85 MHz</option>
<option value="128.825">128.825 MHz</option>
<option value="132.0">132.0 MHz</option>
<option value="134.725">134.725 MHz</option>
<option value="custom">Custom...</option>
</select>
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" style="width: 70px; display: none;">
</div>
<div class="control-group airband-controls">
<span class="control-label">SDR:</span>
<select id="airbandDeviceSelect" style="width: 80px;">
<option value="0">Dev 0</option>
</select>
</div>
<div class="control-group airband-controls">
<span class="control-label">SQ:</span>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" style="width: 60px;">
</div>
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">
<span class="airband-icon"></span> LISTEN
</button>
<div class="airband-status">
<span id="airbandStatus" style="color: var(--text-muted);">OFF</span>
</div>
<select id="airbandFreqSelect" onchange="updateAirbandFreq()" class="airband-controls" title="Airband frequency">
<option value="121.5">121.5 Guard</option>
<option value="118.0">118.0</option>
<option value="119.1">119.1</option>
<option value="120.5">120.5</option>
<option value="123.45">123.45 Air</option>
<option value="127.85">127.85</option>
<option value="128.825">128.825</option>
<option value="132.0">132.0</option>
<option value="134.725">134.725</option>
<option value="custom">Custom</option>
</select>
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" class="airband-controls" style="width: 60px; display: none;">
<select id="airbandDeviceSelect" class="airband-controls" style="width: 90px;" title="SDR for airband (use different device than tracking)">
<option value="0">Loading...</option>
</select>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" class="airband-controls" style="width: 50px;" title="Squelch">
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">▶ LISTEN</button>
<span id="airbandStatus" class="airband-controls" style="color: var(--text-muted); font-size: 9px;">OFF</span>
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
<!-- Airband Visualizer (compact) -->
<div class="airband-visualizer" id="airbandVisualizerContainer" style="display: none;">
<div class="signal-meter">
<div class="meter-bar">
@@ -221,7 +144,7 @@
<div class="meter-peak" id="airbandSignalPeak"></div>
</div>
</div>
<canvas id="airbandSpectrumCanvas" width="120" height="30"></canvas>
<canvas id="airbandSpectrumCanvas" width="100" height="25"></canvas>
</div>
</div>
</main>
@@ -274,8 +197,7 @@
let rangeRingsLayer = null;
let observerMarker = null;
// GPS Dongle state
let gpsDevices = [];
// GPS state
let gpsConnected = false;
let gpsEventSource = null;
@@ -944,163 +866,100 @@
}
// ============================================
// GPS DONGLE FUNCTIONS
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
function toggleGpsDongleControls() {
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') {
dongleControls.style.display = 'flex';
refreshGpsDevices();
} else if (source === 'browser') {
browserGroup.style.display = 'flex';
} else if (source === 'gpsd') {
gpsdControls.style.display = 'flex';
}
// 'manual' keeps everything hidden
}
async function refreshGpsDevices() {
async function autoConnectGps() {
try {
const response = await fetch('/gps/devices');
const data = await response.json();
if (data.status === 'ok') {
gpsDevices = data.devices;
const select = document.getElementById('gpsDeviceSelect');
select.innerHTML = '<option value="">GPS Device...</option>';
gpsDevices.forEach(device => {
const option = document.createElement('option');
option.value = device.path;
option.textContent = device.name;
option.disabled = !device.accessible;
select.appendChild(option);
});
}
} catch (e) {
console.warn('Failed to get GPS devices:', e);
}
}
async function startGpsDongle() {
const devicePath = document.getElementById('gpsDeviceSelect').value;
const baudrate = parseInt(document.getElementById('gpsBaudrateSelect').value) || 9600;
if (!devicePath) {
alert('Please select a GPS device');
return;
}
try {
const response = await fetch('/gps/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: devicePath, baudrate: baudrate })
});
const response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'started') {
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
updateGpsButtons(true, '.gps-dongle-controls');
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
alert('Failed to start GPS: ' + data.message);
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
alert('GPS connection error: ' + e.message);
console.log('GPS: Auto-connect failed -', e.message);
}
}
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) {
gpsEventSource.close();
gpsEventSource = null;
}
await fetch('/gps/stop', { method: 'POST' });
gpsConnected = false;
// Reset buttons in both containers
updateGpsButtons(false, '.gps-dongle-controls');
updateGpsButtons(false, '.gps-gpsd-controls');
} catch (e) {
console.warn('GPS stop error:', e);
}
}
let gpsReconnectTimeout = null;
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
if (gpsReconnectTimeout) {
clearTimeout(gpsReconnectTimeout);
gpsReconnectTimeout = null;
}
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' && data.latitude && data.longitude) {
observerLocation.lat = data.latitude;
observerLocation.lon = data.longitude;
document.getElementById('obsLat').value = data.latitude.toFixed(4);
document.getElementById('obsLon').value = data.longitude.toFixed(4);
if (radarMap) {
console.log('GPS: Updating map to', data.latitude, data.longitude);
radarMap.setView([data.latitude, data.longitude], radarMap.getZoom());
}
drawRangeRings();
updateLocationFromGps(data);
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
console.warn('GPS stream error:', e);
gpsConnected = false;
document.querySelector('.gps-connect-btn').style.display = 'block';
document.querySelector('.gps-disconnect-btn').style.display = 'none';
// Don't log every error - connection suspends are normal
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
// Auto-reconnect after 5 seconds if still connected
if (gpsConnected && !gpsReconnectTimeout) {
gpsReconnectTimeout = setTimeout(() => {
gpsReconnectTimeout = null;
if (gpsConnected) {
startGpsStream();
}
}, 5000);
}
};
}
// Reconnect GPS stream when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && gpsConnected && !gpsEventSource) {
startGpsStream();
}
});
function updateLocationFromGps(position) {
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
// Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) {
radarMap.setView([position.latitude, position.longitude], radarMap.getZoom());
radarMap._gpsInitialized = true;
// Draw range rings immediately after centering
drawRangeRings();
} else {
drawRangeRings();
}
}
function showGpsIndicator(show) {
const indicator = document.getElementById('gpsIndicator');
if (indicator) {
indicator.style.display = show ? 'inline-flex' : 'none';
}
}
// ============================================
// FILTERING
// ============================================
@@ -1146,6 +1005,10 @@
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
checkAdsbTools();
checkAircraftDatabase();
// Auto-connect to gpsd if available
autoConnectGps();
});
function checkAdsbTools() {
@@ -1159,6 +1022,119 @@
.catch(() => {});
}
// ============================================
// AIRCRAFT DATABASE
// ============================================
let aircraftDbStatus = { installed: false };
function checkAircraftDatabase() {
fetch('/adsb/aircraft-db/status')
.then(r => r.json())
.then(status => {
aircraftDbStatus = status;
if (!status.installed) {
showAircraftDbBanner('not_installed');
} else {
// Check for updates in background
fetch('/adsb/aircraft-db/check-updates')
.then(r => r.json())
.then(data => {
if (data.update_available) {
showAircraftDbBanner('update_available', data.latest_version);
}
})
.catch(() => {});
}
})
.catch(() => {});
}
function showAircraftDbBanner(type, version) {
// Remove any existing banner
const existing = document.getElementById('aircraftDbBanner');
if (existing) existing.remove();
const banner = document.createElement('div');
banner.id = 'aircraftDbBanner';
banner.style.cssText = `
position: fixed;
top: 70px;
right: 20px;
background: ${type === 'not_installed' ? 'rgba(59, 130, 246, 0.95)' : 'rgba(34, 197, 94, 0.95)'};
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
max-width: 320px;
font-family: 'Inter', sans-serif;
`;
if (type === 'not_installed') {
banner.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">Aircraft Database Not Installed</div>
<div style="margin-bottom: 10px; font-size: 11px; opacity: 0.9;">Download to see aircraft types, registrations, and model info.</div>
<button onclick="downloadAircraftDb()" style="background: white; color: #3b82f6; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px;">Download Database</button>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
} else {
banner.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">Database Update Available</div>
<div style="margin-bottom: 10px; font-size: 11px; opacity: 0.9;">New version: ${version || 'latest'}</div>
<button onclick="downloadAircraftDb()" style="background: white; color: #22c55e; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px;">Update Now</button>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
document.body.appendChild(banner);
}
function downloadAircraftDb() {
const banner = document.getElementById('aircraftDbBanner');
if (banner) {
banner.innerHTML = `
<div style="font-weight: bold;">Downloading...</div>
<div style="font-size: 11px; opacity: 0.9;">This may take a moment</div>
`;
}
fetch('/adsb/aircraft-db/download', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
if (banner) {
banner.style.background = 'rgba(34, 197, 94, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Database Installed</div>
<div style="font-size: 11px; opacity: 0.9;">${data.message}</div>
`;
setTimeout(() => banner.remove(), 3000);
}
aircraftDbStatus.installed = true;
} else {
if (banner) {
banner.style.background = 'rgba(239, 68, 68, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Download Failed</div>
<div style="font-size: 11px;">${data.error || 'Unknown error'}</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
}
})
.catch(err => {
if (banner) {
banner.style.background = 'rgba(239, 68, 68, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Download Failed</div>
<div style="font-size: 11px;">${err.message}</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
});
}
function showReadsbWarning(sdrTypes) {
const typeList = sdrTypes.join(', ') || 'SoapySDR device';
const warning = document.createElement('div');
@@ -1205,7 +1181,7 @@ sudo make install</code>
function initMap() {
radarMap = L.map('radarMap', {
center: [51.5, -0.1],
center: [observerLocation.lat, observerLocation.lon],
zoom: 7,
minZoom: 3,
maxZoom: 15
@@ -1215,15 +1191,8 @@ sudo make install</code>
attribution: '© OpenStreetMap contributors'
}).addTo(radarMap);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
radarMap.setView([pos.coords.latitude, pos.coords.longitude], 8);
observerLocation.lat = pos.coords.latitude;
observerLocation.lon = pos.coords.longitude;
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
}, () => {}, { timeout: 5000 });
}
// Draw range rings after map is ready
setTimeout(() => drawRangeRings(), 100);
}
// ============================================
@@ -1312,16 +1281,36 @@ sudo make install</code>
function startEventStream() {
if (eventSource) eventSource.close();
console.log('Starting ADS-B event stream...');
eventSource = new EventSource('/adsb/stream');
eventSource.onopen = () => {
console.log('ADS-B stream connected');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'aircraft') {
updateAircraft(data);
} else if (data.type === 'status') {
console.log('ADS-B status:', data.message);
} else if (data.type === 'keepalive') {
// Keepalive received
} else {
console.log('ADS-B data:', data);
}
} catch (err) {}
} catch (err) {
console.error('ADS-B parse error:', err, event.data);
}
};
eventSource.onerror = (e) => {
console.error('ADS-B stream error:', e);
if (eventSource.readyState === EventSource.CLOSED) {
console.log('ADS-B stream closed, will not auto-reconnect');
}
};
eventSource.onerror = () => {};
}
function stopEventStream() {
@@ -1521,14 +1510,22 @@ sudo make install</code>
const alt = ac.altitude ? ac.altitude.toLocaleString() : '---';
const speed = ac.speed || '---';
const heading = ac.heading ? ac.heading + '°' : '---';
const typeCode = ac.type_code || '';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<span style="background:#556b2f;color:#fff;padding:1px 4px;border-radius:2px;font-size:8px;margin-left:4px;">MIL</span>` : '';
// Vertical rate indicator: arrow up (climbing), arrow down (descending), or dash (level)
let vsIndicator = '-';
let vsColor = '';
if (ac.vertical_rate !== undefined) {
if (ac.vertical_rate > 300) { vsIndicator = '↑'; vsColor = 'color:#00ff88;'; }
else if (ac.vertical_rate < -300) { vsIndicator = '↓'; vsColor = 'color:#ff6b6b;'; }
}
return `
<div class="aircraft-header">
<span class="aircraft-callsign">${callsign}${badge}</span>
<span class="aircraft-icao">${ac.icao}</span>
<span class="aircraft-icao">${typeCode ? typeCode + ' • ' : ''}${ac.icao}</span>
</div>
<div class="aircraft-details">
<div class="aircraft-detail">
@@ -1543,6 +1540,10 @@ sudo make install</code>
<div class="aircraft-detail-value">${heading}</div>
<div class="aircraft-detail-label">HDG</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value" style="${vsColor}">${vsIndicator}</div>
<div class="aircraft-detail-label">V/S</div>
</div>
</div>
`;
}
@@ -1578,12 +1579,20 @@ sudo make install</code>
const speed = ac.speed ? ac.speed + ' kts' : 'N/A';
const heading = ac.heading ? ac.heading + '°' : 'N/A';
const squawk = ac.squawk || 'N/A';
const vrate = ac.vertical_rate !== undefined ? (ac.vertical_rate >= 0 ? '+' : '') + ac.vertical_rate.toLocaleString() + ' ft/min' : 'N/A';
const registration = ac.registration || '';
const typeCode = ac.type_code || '';
const typeDesc = ac.type_desc || '';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<div style="background:#556b2f;color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;text-align:center;margin-bottom:8px;">MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>` : '';
// Aircraft type info line (shown if available from database)
const typeInfo = (typeCode || typeDesc) ?
`<div style="color:#00d4ff;font-size:11px;margin-bottom:6px;">${typeDesc || typeCode}${registration ? ' • ' + registration : ''}</div>` : '';
container.innerHTML = `
<div class="selected-callsign">${callsign}</div>
${typeInfo}
${badge}
<div class="telemetry-grid">
<div class="telemetry-item">
@@ -1614,6 +1623,10 @@ sudo make install</code>
<div class="telemetry-label">Heading</div>
<div class="telemetry-value">${heading}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">V/S</div>
<div class="telemetry-value" style="${ac.vertical_rate > 0 ? 'color: #00ff88;' : ac.vertical_rate < 0 ? 'color: #ff6b6b;' : ''}">${vrate}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
@@ -1755,24 +1768,46 @@ sudo make install</code>
}
function initAirband() {
// Populate device selector
// Populate device selector with available SDRs
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR</option>';
select.innerHTML = '<option value="0">No SDR found</option>';
select.disabled = true;
} else if (devices.length === 1) {
// Only one device - warn user they need two
const dev = devices[0];
const name = dev.name || dev.type || `RTL-SDR`;
const opt = document.createElement('option');
opt.value = dev.index || 0;
opt.textContent = `${dev.index || 0}: ${name}`;
select.appendChild(opt);
// Show warning about needing second SDR
document.getElementById('airbandStatus').textContent = '1 SDR (need 2)';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
} else {
// Multiple devices - let user choose which for airband
devices.forEach((dev, i) => {
const opt = document.createElement('option');
opt.value = dev.index || i;
opt.textContent = `Dev ${dev.index || i}`;
const idx = dev.index !== undefined ? dev.index : i;
const name = dev.name || dev.type || `RTL-SDR`;
opt.value = idx;
opt.textContent = `${idx}: ${name}`;
select.appendChild(opt);
});
// Default to second device (first is likely used for ADS-B)
if (devices.length > 1) {
select.value = devices[1].index !== undefined ? devices[1].index : 1;
}
}
})
.catch(() => {});
.catch(() => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '<option value="0">No SDR</option>';
});
// Check if audio tools are available
fetch('/listening/tools')
@@ -1891,6 +1926,18 @@ sudo make install</code>
const device = parseInt(document.getElementById('airbandDeviceSelect').value);
const squelch = parseInt(document.getElementById('airbandSquelch').value);
// Check if ADS-B tracking is using this device (ADS-B uses device 0 by default)
if (isTracking && device === 0) {
const useAnyway = confirm(
'Warning: ADS-B tracking is using SDR device 0.\n\n' +
'Using the same device for airband will stop ADS-B tracking.\n\n' +
'Select a different SDR device for airband listening, or click OK to stop tracking and listen.'
);
if (!useAnyway) {
return;
}
}
document.getElementById('airbandStatus').textContent = 'STARTING...';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';

View File

@@ -743,17 +743,17 @@
Cluster Markers
</label>
<label>
<input type="checkbox" id="adsbShowRangeRings" onchange="drawRangeRings()">
<input type="checkbox" id="adsbShowRangeRings" checked onchange="drawRangeRings()">
Show Range Rings
</label>
</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>
<label style="display: flex; align-items: center; gap: 8px;">
Observer Location
<span id="adsbGpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd">
<span class="gps-dot"></span> GPS
</span>
</label>
<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()">
@@ -761,47 +761,6 @@
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
📍 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="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>
</div>
<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="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;">
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>
@@ -864,15 +823,12 @@
<!-- Pass Predictor Sub-tab -->
<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>
<h3 style="display: flex; align-items: center; gap: 8px;">
Observer Location
<span id="satGpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd">
<span class="gps-dot"></span> GPS
</span>
</h3>
<div class="form-group">
<label>Latitude</label>
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074">
@@ -884,56 +840,6 @@
<button class="preset-btn" onclick="getLocation()" style="width: 100%;">
📍 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 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="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;">
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">
@@ -1414,7 +1320,7 @@
<!-- Aircraft Visualizations - Leaflet Map -->
<div class="wifi-visuals" id="aircraftVisuals" style="display: none;">
<div class="wifi-visual-panel" style="grid-column: span 2;">
<div class="wifi-visual-panel" style="grid-column: span 4;">
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan);">ADS-B AIRCRAFT TRACKING</h5>
<div class="aircraft-map-container">
<div class="map-header">
@@ -1428,13 +1334,6 @@
</div>
</div>
</div>
<div class="wifi-visual-panel signal-graph-panel" style="grid-column: span 2;">
<div class="signal-graph-header">
<h4>📊 Aircraft Count Over Time</h4>
<span class="signal-graph-device" id="adsbStatsLabel">Tracking history</span>
</div>
<canvas id="adsbStatsChart"></canvas>
</div>
</div>
<!-- Listening Post Visualizations -->
@@ -1891,7 +1790,6 @@
let observerMarkerAdsb = null;
// GPS Dongle state
let gpsDevices = [];
let gpsConnected = false;
let gpsEventSource = null;
let gpsLastPosition = null;
@@ -2235,6 +2133,9 @@
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Auto-connect to gpsd if available
autoConnectGps();
});
// Toggle section collapse
@@ -2279,6 +2180,10 @@
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Show signal meter only for modes that use it (pager, sensor, wifi, bluetooth)
const signalMeterModes = ['pager', 'sensor', 'wifi', 'bluetooth'];
document.getElementById('signalMeter').style.display = signalMeterModes.includes(mode) ? 'flex' : 'none';
// Update header stats groups
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
@@ -4285,84 +4190,6 @@
btSignalChart.update('none');
}
// ADS-B Aircraft Count Chart
let adsbStatsChart = null;
let adsbCountHistory = [];
const maxAdsbPoints = 60;
function trackAdsbCount(count) {
adsbCountHistory.push({
time: Date.now(),
count: count
});
if (adsbCountHistory.length > maxAdsbPoints) {
adsbCountHistory.shift();
}
updateAdsbStatsChart();
// Persist to server
fetch('/settings/signal-history', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'adsb', device_id: 'aircraft_count', signal_strength: count })
}).catch(() => {});
}
function initAdsbStatsChart() {
const canvas = document.getElementById('adsbStatsChart');
if (!canvas || adsbStatsChart) return;
const ctx = canvas.getContext('2d');
adsbStatsChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Aircraft',
data: [],
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
plugins: { legend: { display: false } },
scales: {
x: { display: false },
y: {
min: 0,
suggestedMax: 20,
grid: { color: 'rgba(255, 255, 255, 0.1)' },
ticks: {
color: '#666',
font: { size: 10, family: 'monospace' },
stepSize: 5
}
}
}
}
});
}
function updateAdsbStatsChart() {
if (!adsbStatsChart) {
initAdsbStatsChart();
}
if (!adsbStatsChart || adsbCountHistory.length === 0) return;
adsbStatsChart.data.labels = adsbCountHistory.map((_, i) => i);
adsbStatsChart.data.datasets[0].data = adsbCountHistory.map(p => p.count);
adsbStatsChart.update('none');
const lastCount = adsbCountHistory[adsbCountHistory.length - 1].count;
document.getElementById('adsbStatsLabel').textContent = `${lastCount} aircraft tracked`;
}
// Network Topology Graph
function drawNetworkGraph() {
const canvas = document.getElementById('networkGraph');
@@ -6993,8 +6820,6 @@
document.getElementById('aircraftCount').textContent = count;
document.getElementById('adsbMsgCount').textContent = adsbMsgCount;
document.getElementById('icaoCount').textContent = count;
// Track aircraft count for chart
trackAdsbCount(count);
}
function addAircraftToOutput(aircraft) {
@@ -7054,200 +6879,84 @@
}
// ============================================
// GPS DONGLE FUNCTIONS
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
async function checkGpsDongleAvailable() {
async function autoConnectGps() {
// Automatically try to connect to gpsd on page load
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;
}
});
}
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 response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'started') {
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
updateGpsStatus(true);
showInfo(`Connected to gpsd at ${host}:${port}`);
return true;
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
showError('Failed to connect to gpsd: ' + data.message);
return false;
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
showError('gpsd connection error: ' + e.message);
return false;
console.log('GPS: Auto-connect failed -', e.message);
}
}
async function startGpsDongle(devicePath, baudrate = 9600) {
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: baudrate })
});
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);
}
}
let gpsReconnectTimeout = null;
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
if (gpsReconnectTimeout) {
clearTimeout(gpsReconnectTimeout);
gpsReconnectTimeout = null;
}
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);
// Don't log every error - connection suspends are normal
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
// Auto-reconnect after 5 seconds if still connected
if (gpsConnected && !gpsReconnectTimeout) {
gpsReconnectTimeout = setTimeout(() => {
gpsReconnectTimeout = null;
if (gpsConnected) {
startGpsStream();
}
}, 5000);
}
};
}
// Reconnect GPS stream when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && gpsConnected && !gpsEventSource) {
startGpsStream();
}
});
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');
@@ -7264,51 +6973,28 @@
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');
// Center ADS-B map on GPS location (on first fix)
if (typeof aircraftMap !== 'undefined' && aircraftMap && !aircraftMap._gpsInitialized) {
aircraftMap.setView([position.latitude, position.longitude], aircraftMap.getZoom());
aircraftMap._gpsInitialized = true;
}
// Trigger map updates
// Trigger range rings update
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)';
function showGpsIndicator(show) {
// Show/hide all GPS indicators (by class and by ID)
document.querySelectorAll('.gps-indicator').forEach(el => {
el.style.display = show ? 'inline-flex' : 'none';
});
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';
// Also target specific IDs in case class selector doesn't work
['adsbGpsIndicator', 'satGpsIndicator'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? 'inline-flex' : 'none';
});
if (show) {
refreshGpsDevices();
}
}
function initPolarPlot() {

View File

@@ -1,32 +1,20 @@
"""
GPS dongle support for INTERCEPT.
GPS support for INTERCEPT via gpsd daemon.
Provides detection and reading of USB GPS dongles via serial port.
Parses NMEA sentences to extract location data.
Provides GPS location data by connecting to the gpsd daemon.
"""
from __future__ import annotations
import logging
import os
import re
import glob
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable, Union
from typing import Optional, Callable
logger = logging.getLogger('intercept.gps')
# Try to import serial, but don't fail if not available
try:
import serial
SERIAL_AVAILABLE = True
except ImportError:
SERIAL_AVAILABLE = False
logger.warning("pyserial not installed - GPS dongle support disabled")
@dataclass
class GPSPosition:
@@ -34,10 +22,10 @@ class GPSPosition:
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # knots
speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
fix_quality: int = 0 # 0=invalid, 1=GPS, 2=DGPS
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: Optional[datetime] = None
device: Optional[str] = None
@@ -56,407 +44,6 @@ class GPSPosition:
}
def detect_gps_devices() -> list[dict]:
"""
Detect potential GPS serial devices.
Returns a list of device info dictionaries.
"""
devices = []
# Common GPS device patterns by platform
patterns = []
if os.name == 'posix':
# Linux
patterns.extend([
'/dev/ttyUSB*', # USB serial adapters
'/dev/ttyACM*', # USB CDC ACM devices (many GPS)
'/dev/gps*', # gpsd symlinks
])
# macOS
patterns.extend([
'/dev/tty.usbserial*',
'/dev/tty.usbmodem*',
'/dev/cu.usbserial*',
'/dev/cu.usbmodem*',
])
for pattern in patterns:
for path in glob.glob(pattern):
# Try to get device info
device_info = {
'path': path,
'name': os.path.basename(path),
'type': 'serial',
}
# Check if it's readable
if os.access(path, os.R_OK):
device_info['accessible'] = True
else:
device_info['accessible'] = False
device_info['error'] = 'Permission denied'
devices.append(device_info)
return devices
def parse_nmea_coordinate(coord: str, direction: str) -> Optional[float]:
"""
Parse NMEA coordinate format to decimal degrees.
NMEA format: DDDMM.MMMM or DDMM.MMMM
"""
if not coord or not direction:
return None
try:
# Find the decimal point
dot_pos = coord.index('.')
# Degrees are everything before the last 2 digits before decimal
degrees = int(coord[:dot_pos - 2])
minutes = float(coord[dot_pos - 2:])
result = degrees + (minutes / 60.0)
# Apply direction
if direction in ('S', 'W'):
result = -result
return result
except (ValueError, IndexError):
return None
def parse_gga(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPGGA/GNGGA sentence (Global Positioning System Fix Data).
Format: $GPGGA,time,lat,N/S,lon,E/W,quality,satellites,hdop,altitude,M,...
"""
if len(parts) < 10:
return None
try:
fix_quality = int(parts[6]) if parts[6] else 0
# No fix
if fix_quality == 0:
return None
lat = parse_nmea_coordinate(parts[2], parts[3])
lon = parse_nmea_coordinate(parts[4], parts[5])
if lat is None or lon is None:
return None
# Parse optional fields
satellites = int(parts[7]) if parts[7] else None
altitude = float(parts[9]) if parts[9] else None
# Parse time (HHMMSS.sss)
timestamp = None
if parts[1]:
try:
time_str = parts[1].split('.')[0]
if len(time_str) >= 6:
now = datetime.utcnow()
timestamp = now.replace(
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
microsecond=0
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
altitude=altitude,
satellites=satellites,
fix_quality=fix_quality,
timestamp=timestamp,
)
except (ValueError, IndexError) as e:
logger.debug(f"GGA parse error: {e}")
return None
def parse_rmc(parts: list[str]) -> Optional[GPSPosition]:
"""
Parse GPRMC/GNRMC sentence (Recommended Minimum).
Format: $GPRMC,time,status,lat,N/S,lon,E/W,speed,heading,date,...
"""
if len(parts) < 8:
return None
try:
# Check status (A=active/valid, V=void/invalid)
if parts[2] != 'A':
return None
lat = parse_nmea_coordinate(parts[3], parts[4])
lon = parse_nmea_coordinate(parts[5], parts[6])
if lat is None or lon is None:
return None
# Parse optional fields
speed = float(parts[7]) if parts[7] else None # knots
heading = float(parts[8]) if len(parts) > 8 and parts[8] else None
# Parse timestamp
timestamp = None
if parts[1] and len(parts) > 9 and parts[9]:
try:
time_str = parts[1].split('.')[0]
date_str = parts[9]
if len(time_str) >= 6 and len(date_str) >= 6:
timestamp = datetime(
year=2000 + int(date_str[4:6]),
month=int(date_str[2:4]),
day=int(date_str[0:2]),
hour=int(time_str[0:2]),
minute=int(time_str[2:4]),
second=int(time_str[4:6]),
)
except (ValueError, IndexError):
pass
return GPSPosition(
latitude=lat,
longitude=lon,
speed=speed,
heading=heading,
timestamp=timestamp,
fix_quality=1, # RMC with A status means valid fix
)
except (ValueError, IndexError) as e:
logger.debug(f"RMC parse error: {e}")
return None
def parse_nmea_sentence(sentence: str) -> Optional[GPSPosition]:
"""
Parse an NMEA sentence and extract position data.
Supports: GGA, RMC sentences (with GP, GN, GL prefixes)
"""
sentence = sentence.strip()
# Validate checksum if present
if '*' in sentence:
data, checksum = sentence.rsplit('*', 1)
if data.startswith('$'):
data = data[1:]
# Calculate checksum
calc_checksum = 0
for char in data:
calc_checksum ^= ord(char)
try:
if int(checksum, 16) != calc_checksum:
logger.debug(f"Checksum mismatch: {sentence}")
return None
except ValueError:
pass
# Remove $ prefix if present
if sentence.startswith('$'):
sentence = sentence[1:]
# Remove checksum for parsing
if '*' in sentence:
sentence = sentence.split('*')[0]
parts = sentence.split(',')
if not parts:
return None
msg_type = parts[0]
# Handle various NMEA talker IDs (GP=GPS, GN=GNSS, GL=GLONASS, GA=Galileo)
if msg_type.endswith('GGA'):
return parse_gga(parts)
elif msg_type.endswith('RMC'):
return parse_rmc(parts)
return None
class GPSReader:
"""
Reads GPS data from a serial device.
Runs in a background thread and maintains current position.
"""
def __init__(self, device_path: str, baudrate: int = 9600):
self.device_path = device_path
self.baudrate = baudrate
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._serial: Optional['serial.Serial'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], 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 reader 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
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 reading GPS data in a background thread."""
if not SERIAL_AVAILABLE:
self._error = "pyserial not installed"
return False
if self._running:
return True
try:
self._serial = serial.Serial(
self.device_path,
baudrate=self.baudrate,
timeout=1.0
)
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Started GPS reader on {self.device_path}")
return True
except serial.SerialException as e:
self._error = str(e)
logger.error(f"Failed to open GPS device {self.device_path}: {e}")
return False
def stop(self) -> None:
"""Stop reading GPS data."""
self._running = False
if self._serial:
try:
self._serial.close()
except Exception:
pass
self._serial = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Stopped GPS reader on {self.device_path}")
def _read_loop(self) -> None:
"""Background thread loop for reading GPS data."""
buffer = ""
sentence_count = 0
bytes_read = 0
print(f"[GPS] Read loop started on {self.device_path} at {self.baudrate} baud", flush=True)
while self._running and self._serial:
try:
# Read available data
waiting = self._serial.in_waiting
if waiting:
data = self._serial.read(waiting)
bytes_read += len(data)
if bytes_read <= 500 or bytes_read % 1000 == 0:
print(f"[GPS] Read {len(data)} bytes (total: {bytes_read})", flush=True)
buffer += data.decode('ascii', errors='ignore')
# Process complete lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if line.startswith('$'):
sentence_count += 1
# Log first few sentences and periodically after that
if sentence_count <= 10 or sentence_count % 50 == 0:
print(f"[GPS] NMEA [{sentence_count}]: {line[:70]}", flush=True)
position = parse_nmea_sentence(line)
if position:
print(f"[GPS] FIX: {position.latitude:.6f}, {position.longitude:.6f} (sats: {position.satellites}, quality: {position.fix_quality})", flush=True)
position.device = self.device_path
self._update_position(position)
else:
time.sleep(0.1)
except serial.SerialException as e:
logger.error(f"GPS read error: {e}")
with self._lock:
self._error = str(e)
break
except Exception as e:
logger.debug(f"GPS parse error: {e}")
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
# Merge data from different sentence types
if self._position:
# Keep altitude from GGA if RMC doesn't have it
if position.altitude is None and self._position.altitude:
position.altitude = self._position.altitude
# Keep satellites from GGA
if position.satellites is None and self._position.satellites:
position.satellites = self._position.satellites
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}")
class GPSDClient:
"""
Connects to gpsd daemon for GPS data.
@@ -506,14 +93,9 @@ class GPSDClient:
@property
def device_path(self) -> str:
"""Return gpsd connection info (for compatibility with GPSReader)."""
"""Return gpsd connection info."""
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)
@@ -667,7 +249,7 @@ class GPSDClient:
latitude=lat,
longitude=lon,
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd (not knots)
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
fix_quality=mode,
timestamp=timestamp,
@@ -692,47 +274,15 @@ class GPSDClient:
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[GPSSource] = None
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSSource]:
"""Get the global GPS reader/client instance."""
def get_gps_reader() -> Optional[GPSDClient]:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_reader
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
"""
global _gps_reader
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_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()
return _gps_client
def start_gpsd(host: str = 'localhost', port: int = 2947,
@@ -748,40 +298,35 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
Returns:
True if started successfully
"""
global _gps_reader
global _gps_client
with _gps_lock:
# Stop existing reader if any
if _gps_reader:
_gps_reader.stop()
# Stop existing client if any
if _gps_client:
_gps_client.stop()
_gps_reader = GPSDClient(host, port)
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_reader.add_callback(callback)
_gps_client.add_callback(callback)
return _gps_reader.start()
return _gps_client.start()
def stop_gps() -> None:
"""Stop the global GPS reader/client."""
global _gps_reader
"""Stop the global GPS client."""
global _gps_client
with _gps_lock:
if _gps_reader:
_gps_reader.stop()
_gps_reader = None
if _gps_client:
_gps_client.stop()
_gps_client = None
def get_current_position() -> Optional[GPSPosition]:
"""Get the current GPS position from the global reader."""
reader = get_gps_reader()
if reader:
return reader.position
"""Get the current GPS position from the global client."""
client = get_gps_reader()
if client:
return client.position
return None
def is_serial_available() -> bool:
"""Check if pyserial is available."""
return SERIAL_AVAILABLE