""" 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