mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
198
routes/gps.py
198
routes/gps.py
@@ -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
394
setup-dev.sh
Normal 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 "$@"
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
511
utils/gps.py
511
utils/gps.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user