Files
intercept/utils/gps.py
Smittix 9d0e417f2a 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>
2026-01-07 19:49:58 +00:00

333 lines
10 KiB
Python

"""
GPS support for INTERCEPT via gpsd daemon.
Provides GPS location data by connecting to the gpsd daemon.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Optional, Callable
logger = logging.getLogger('intercept.gps')
@dataclass
class GPSPosition:
"""GPS position data."""
latitude: float
longitude: float
altitude: Optional[float] = None
speed: Optional[float] = None # m/s
heading: Optional[float] = None # degrees
satellites: Optional[int] = None
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: Optional[datetime] = None
device: Optional[str] = None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'latitude': self.latitude,
'longitude': self.longitude,
'altitude': self.altitude,
'speed': self.speed,
'heading': self.heading,
'satellites': self.satellites,
'fix_quality': self.fix_quality,
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
'device': self.device,
}
class GPSDClient:
"""
Connects to gpsd daemon for GPS data.
gpsd provides a unified interface for GPS devices and handles
device management, making it ideal when gpsd is already running.
"""
DEFAULT_HOST = 'localhost'
DEFAULT_PORT = 2947
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
self.host = host
self.port = port
self._position: Optional[GPSPosition] = None
self._lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._socket: Optional['socket.socket'] = None
self._last_update: Optional[datetime] = None
self._error: Optional[str] = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
self._device: Optional[str] = None
@property
def position(self) -> Optional[GPSPosition]:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def is_running(self) -> bool:
"""Check if the client is running."""
return self._running
@property
def last_update(self) -> Optional[datetime]:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> Optional[str]:
"""Get any error message."""
with self._lock:
return self._error
@property
def device_path(self) -> str:
"""Return gpsd connection info."""
return f"gpsd://{self.host}:{self.port}"
def add_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Add a callback to be called on position updates."""
self._callbacks.append(callback)
def remove_callback(self, callback: Callable[[GPSPosition], None]) -> None:
"""Remove a position update callback."""
if callback in self._callbacks:
self._callbacks.remove(callback)
def start(self) -> bool:
"""Start receiving GPS data from gpsd."""
import socket
if self._running:
return True
try:
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(5.0)
self._socket.connect((self.host, self.port))
# Enable JSON watch mode
watch_cmd = '?WATCH={"enable":true,"json":true}\n'
self._socket.send(watch_cmd.encode('ascii'))
self._running = True
self._error = None
self._thread = threading.Thread(target=self._read_loop, daemon=True)
self._thread.start()
logger.info(f"Connected to gpsd at {self.host}:{self.port}")
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}", flush=True)
return True
except Exception as e:
self._error = str(e)
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
if self._socket:
try:
self._socket.close()
except Exception:
pass
self._socket = None
return False
def stop(self) -> None:
"""Stop receiving GPS data."""
self._running = False
if self._socket:
try:
# Disable watch mode
self._socket.send(b'?WATCH={"enable":false}\n')
self._socket.close()
except Exception:
pass
self._socket = None
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
logger.info(f"Disconnected from gpsd at {self.host}:{self.port}")
def _read_loop(self) -> None:
"""Background thread loop for reading gpsd data."""
import json
import socket
buffer = ""
message_count = 0
print(f"[GPS] gpsd read loop started", flush=True)
while self._running and self._socket:
try:
self._socket.settimeout(1.0)
data = self._socket.recv(4096)
if not data:
logger.warning("gpsd connection closed")
with self._lock:
self._error = "Connection closed by gpsd"
break
buffer += data.decode('ascii', errors='ignore')
# Process complete JSON lines
while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
msg_class = msg.get('class', '')
message_count += 1
if message_count <= 5 or message_count % 20 == 0:
print(f"[GPS] gpsd msg [{message_count}]: {msg_class}", flush=True)
if msg_class == 'TPV':
self._handle_tpv(msg)
elif msg_class == 'DEVICES':
# Track connected device
devices = msg.get('devices', [])
if devices:
self._device = devices[0].get('path', 'unknown')
print(f"[GPS] gpsd device: {self._device}", flush=True)
except json.JSONDecodeError:
logger.debug(f"Invalid JSON from gpsd: {line[:50]}")
except socket.timeout:
continue
except Exception as e:
logger.error(f"gpsd read error: {e}")
with self._lock:
self._error = str(e)
break
def _handle_tpv(self, msg: dict) -> None:
"""Handle TPV (Time-Position-Velocity) message from gpsd."""
# mode: 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
mode = msg.get('mode', 0)
if mode < 2:
# No fix yet
return
lat = msg.get('lat')
lon = msg.get('lon')
if lat is None or lon is None:
return
# Parse timestamp
timestamp = None
time_str = msg.get('time')
if time_str:
try:
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
pass
position = GPSPosition(
latitude=lat,
longitude=lon,
altitude=msg.get('alt'),
speed=msg.get('speed'), # m/s in gpsd
heading=msg.get('track'),
fix_quality=mode,
timestamp=timestamp,
device=self._device or f"gpsd://{self.host}:{self.port}",
)
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
self._update_position(position)
def _update_position(self, position: GPSPosition) -> None:
"""Update the current position and notify callbacks."""
with self._lock:
self._position = position
self._last_update = datetime.utcnow()
self._error = None
# Notify callbacks
for callback in self._callbacks:
try:
callback(position)
except Exception as e:
logger.error(f"GPS callback error: {e}")
# Global GPS client instance
_gps_client: Optional[GPSDClient] = None
_gps_lock = threading.Lock()
def get_gps_reader() -> Optional[GPSDClient]:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_client
def start_gpsd(host: str = 'localhost', port: int = 2947,
callback: Optional[Callable[[GPSPosition], None]] = None) -> bool:
"""
Start the global GPS client connected to gpsd.
Args:
host: gpsd host (default localhost)
port: gpsd port (default 2947)
callback: Optional callback for position updates
Returns:
True if started successfully
"""
global _gps_client
with _gps_lock:
# Stop existing client if any
if _gps_client:
_gps_client.stop()
_gps_client = GPSDClient(host, port)
# Register callback BEFORE starting to avoid race condition
if callback:
_gps_client.add_callback(callback)
return _gps_client.start()
def stop_gps() -> None:
"""Stop the global GPS client."""
global _gps_client
with _gps_lock:
if _gps_client:
_gps_client.stop()
_gps_client = None
def get_current_position() -> Optional[GPSPosition]:
"""Get the current GPS position from the global client."""
client = get_gps_reader()
if client:
return client.position
return None