Files
intercept/utils/gps.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

696 lines
22 KiB
Python

"""
GPS support for INTERCEPT via gpsd daemon.
Provides GPS location data by connecting to the gpsd daemon.
"""
from __future__ import annotations
import contextlib
import logging
import socket as _socket_mod
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Callable
logger = logging.getLogger("intercept.gps")
@dataclass
class GPSSatellite:
"""Individual satellite data from gpsd SKY message."""
prn: int
elevation: float | None = None # degrees
azimuth: float | None = None # degrees
snr: float | None = None # dB-Hz
used: bool = False
constellation: str = "GPS" # GPS, GLONASS, Galileo, BeiDou, SBAS, QZSS
def to_dict(self) -> dict:
return {
"prn": self.prn,
"elevation": self.elevation,
"azimuth": self.azimuth,
"snr": self.snr,
"used": self.used,
"constellation": self.constellation,
}
@dataclass
class GPSSkyData:
"""Sky view data from gpsd SKY message."""
satellites: list[GPSSatellite] = field(default_factory=list)
hdop: float | None = None
vdop: float | None = None
pdop: float | None = None
tdop: float | None = None
gdop: float | None = None
xdop: float | None = None
ydop: float | None = None
nsat: int = 0 # total visible
usat: int = 0 # total used
def to_dict(self) -> dict:
return {
"satellites": [s.to_dict() for s in self.satellites],
"hdop": self.hdop,
"vdop": self.vdop,
"pdop": self.pdop,
"tdop": self.tdop,
"gdop": self.gdop,
"xdop": self.xdop,
"ydop": self.ydop,
"nsat": self.nsat,
"usat": self.usat,
}
@dataclass
class GPSPosition:
"""GPS position data."""
latitude: float
longitude: float
altitude: float | None = None
speed: float | None = None # m/s
heading: float | None = None # degrees
climb: float | None = None # m/s vertical speed
satellites: int | None = None
fix_quality: int = 0 # 0=unknown, 1=no fix, 2=2D fix, 3=3D fix
timestamp: datetime | None = None
device: str | None = None
# Error estimates
epx: float | None = None # lon error (m)
epy: float | None = None # lat error (m)
epv: float | None = None # vertical error (m)
eps: float | None = None # speed error (m/s)
ept: float | None = None # time error (s)
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,
"climb": self.climb,
"satellites": self.satellites,
"fix_quality": self.fix_quality,
"timestamp": self.timestamp.isoformat() if self.timestamp else None,
"device": self.device,
"epx": self.epx,
"epy": self.epy,
"epv": self.epv,
"eps": self.eps,
"ept": self.ept,
}
def _classify_constellation(prn: int, gnssid: int | None = None) -> str:
"""Classify satellite constellation from PRN or gnssid."""
if gnssid is not None:
mapping = {
0: "GPS",
1: "SBAS",
2: "Galileo",
3: "BeiDou",
4: "IMES",
5: "QZSS",
6: "GLONASS",
7: "NavIC",
}
return mapping.get(gnssid, "GPS")
# Fall back to PRN range heuristic
if 1 <= prn <= 32:
return "GPS"
elif 33 <= prn <= 64:
return "SBAS"
elif 65 <= prn <= 96:
return "GLONASS"
elif 120 <= prn <= 158:
return "SBAS"
elif 201 <= prn <= 264:
return "BeiDou"
elif 301 <= prn <= 336:
return "Galileo"
elif 193 <= prn <= 200:
return "QZSS"
return "GPS"
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: GPSPosition | None = None
self._sky: GPSSkyData | None = None
self._lock = threading.Lock()
self._running = False
self._thread: threading.Thread | None = None
self._socket: _socket_mod.socket | None = None
self._last_update: datetime | None = None
self._error: str | None = None
self._callbacks: list[Callable[[GPSPosition], None]] = []
self._sky_callbacks: list[Callable[[GPSSkyData], None]] = []
self._device: str | None = None
@property
def position(self) -> GPSPosition | None:
"""Get the current GPS position."""
with self._lock:
return self._position
@property
def sky(self) -> GPSSkyData | None:
"""Get the current sky view data."""
with self._lock:
return self._sky
@property
def is_running(self) -> bool:
"""Check if the client is running."""
return self._running
@property
def last_update(self) -> datetime | None:
"""Get the time of the last position update."""
with self._lock:
return self._last_update
@property
def error(self) -> str | None:
"""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."""
if callback not in self._callbacks:
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 add_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Add a callback to be called on sky data updates."""
if callback not in self._sky_callbacks:
self._sky_callbacks.append(callback)
def remove_sky_callback(self, callback: Callable[[GPSSkyData], None]) -> None:
"""Remove a sky data update callback."""
if callback in self._sky_callbacks:
self._sky_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:
with contextlib.suppress(Exception):
self._socket.close()
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("[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 == "SKY":
self._handle_sky(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 Exception as parse_err:
logger.error(f"Error handling gpsd {msg_class} message: {parse_err}")
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:
with contextlib.suppress(ValueError, AttributeError):
# gpsd uses ISO format: 2024-01-01T12:00:00.000Z
timestamp = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
position = GPSPosition(
latitude=lat,
longitude=lon,
altitude=msg.get("alt"),
speed=msg.get("speed"), # m/s in gpsd
heading=msg.get("track"),
climb=msg.get("climb"),
fix_quality=mode,
timestamp=timestamp,
device=self._device or f"gpsd://{self.host}:{self.port}",
epx=msg.get("epx"),
epy=msg.get("epy"),
epv=msg.get("epv"),
eps=msg.get("eps"),
ept=msg.get("ept"),
)
print(f"[GPS] gpsd FIX: {lat:.6f}, {lon:.6f} (mode: {mode})", flush=True)
self._update_position(position)
def _handle_sky(self, msg: dict) -> None:
"""Handle SKY (satellite sky view) message from gpsd.
gpsd sends multiple SKY messages per cycle: some contain only DOP
values while others include the full satellites array. When a
DOP-only SKY arrives, preserve the most recent satellite list
instead of overwriting it with an empty one.
"""
raw_sats = msg.get("satellites", [])
has_satellites = len(raw_sats) > 0
if has_satellites:
sats = []
for sat in raw_sats:
prn = sat.get("PRN", 0)
gnssid = sat.get("gnssid")
sats.append(
GPSSatellite(
prn=prn,
elevation=sat.get("el"),
azimuth=sat.get("az"),
snr=sat.get("ss"),
used=sat.get("used", False),
constellation=_classify_constellation(prn, gnssid),
)
)
else:
# DOP-only SKY message — keep existing satellites
with self._lock:
sats = list(self._sky.satellites) if self._sky else []
sky_data = GPSSkyData(
satellites=sats,
hdop=msg.get("hdop"),
vdop=msg.get("vdop"),
pdop=msg.get("pdop"),
tdop=msg.get("tdop"),
gdop=msg.get("gdop"),
xdop=msg.get("xdop"),
ydop=msg.get("ydop"),
nsat=len(sats),
usat=sum(1 for s in sats if s.used),
)
with self._lock:
self._sky = sky_data
# Notify sky callbacks
for callback in self._sky_callbacks:
try:
callback(sky_data)
except Exception as e:
logger.error(f"GPS sky callback error: {e}")
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: GPSDClient | None = None
_gps_lock = threading.Lock()
def get_gps_reader() -> GPSDClient | None:
"""Get the global GPS client instance."""
with _gps_lock:
return _gps_client
def start_gpsd(
host: str = "localhost",
port: int = 2947,
callback: Callable[[GPSPosition], None] | None = None,
sky_callback: Callable[[GPSSkyData], None] | 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
sky_callback: Optional callback for sky data 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 callbacks BEFORE starting to avoid race condition
if callback:
_gps_client.add_callback(callback)
if sky_callback:
_gps_client.add_sky_callback(sky_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() -> GPSPosition | None:
"""Get the current GPS position from the global client."""
client = get_gps_reader()
if client:
return client.position
return None
# ============================================
# GPS device detection and gpsd auto-start
# ============================================
_gpsd_process: subprocess.Popen | None = None
_gpsd_process_lock = threading.RLock()
def detect_gps_devices() -> list[dict]:
"""
Detect connected GPS serial devices.
Returns list of dicts with 'path' and 'description' keys.
"""
import glob
import os
import platform
devices: list[dict] = []
system = platform.system()
if system == "Linux":
# Common USB GPS device paths
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*"]
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_linux(path)
devices.append({"path": path, "description": desc})
# Also check /dev/serial/by-id for descriptive names
serial_dir = "/dev/serial/by-id"
if os.path.isdir(serial_dir):
for name in sorted(os.listdir(serial_dir)):
full = os.path.join(serial_dir, name)
real = os.path.realpath(full)
# Skip if we already found this device
if any(d["path"] == real for d in devices):
# Update description with the more descriptive name
for d in devices:
if d["path"] == real:
d["description"] = name
continue
devices.append({"path": real, "description": name})
elif system == "Darwin":
# macOS: USB serial devices (prefer cu. over tty. for outgoing)
patterns = ["/dev/cu.usbmodem*", "/dev/cu.usbserial*"]
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
desc = _describe_device_macos(path)
devices.append({"path": path, "description": desc})
# Sort: devices with GPS-related descriptions first
gps_keywords = ("gps", "gnss", "u-blox", "ublox", "nmea", "sirf", "navigation")
devices.sort(key=lambda d: (0 if any(k in d["description"].lower() for k in gps_keywords) else 1))
return devices
def _describe_device_linux(path: str) -> str:
"""Get a human-readable description of a Linux serial device."""
import os
basename = os.path.basename(path)
# Try to read from sysfs
try:
# /sys/class/tty/ttyUSB0/device/../product
sysfs = f"/sys/class/tty/{basename}/device/../product"
if os.path.exists(sysfs):
with open(sysfs) as f:
return f.read().strip()
except Exception:
pass
return basename
def _describe_device_macos(path: str) -> str:
"""Get a description of a macOS serial device."""
import os
return os.path.basename(path)
def is_gpsd_running(host: str = "localhost", port: int = 2947) -> bool:
"""Check if gpsd is reachable."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1.0)
sock.connect((host, port))
sock.close()
return True
except Exception:
return False
def start_gpsd_daemon(device_path: str, host: str = "localhost", port: int = 2947) -> tuple[bool, str]:
"""
Start gpsd daemon pointing at the given device.
Returns (success, message) tuple.
"""
import shutil
import subprocess
global _gpsd_process
with _gpsd_process_lock:
# Already running?
if is_gpsd_running(host, port):
return True, "gpsd already running"
gpsd_bin = shutil.which("gpsd")
if not gpsd_bin:
return False, "gpsd not installed"
# Stop any existing managed process
stop_gpsd_daemon()
try:
import os
if not os.path.exists(device_path):
return False, f"Device {device_path} not found"
cmd = [gpsd_bin, "-N", "-n", "-S", str(port), device_path]
logger.info(f"Starting gpsd: {' '.join(cmd)}")
print(f"[GPS] Starting gpsd: {' '.join(cmd)}", flush=True)
_gpsd_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
# Give gpsd a moment to start
import time
time.sleep(1.5)
if _gpsd_process.poll() is not None:
stderr = ""
if _gpsd_process.stderr:
stderr = _gpsd_process.stderr.read().decode("utf-8", errors="ignore").strip()
msg = f"gpsd exited with code {_gpsd_process.returncode}"
if stderr:
msg += f": {stderr}"
return False, msg
# Verify it's listening
if is_gpsd_running(host, port):
return True, f"gpsd started on {device_path}"
else:
return False, "gpsd started but not accepting connections"
except Exception as e:
logger.error(f"Failed to start gpsd: {e}")
return False, str(e)
def stop_gpsd_daemon() -> None:
"""Stop the managed gpsd daemon process."""
global _gpsd_process
with _gpsd_process_lock:
if _gpsd_process and _gpsd_process.poll() is None:
try:
_gpsd_process.terminate()
_gpsd_process.wait(timeout=3.0)
except Exception:
with contextlib.suppress(Exception):
_gpsd_process.kill()
logger.info("Stopped gpsd daemon")
print("[GPS] Stopped gpsd daemon", flush=True)
_gpsd_process = None