mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 23:29:59 -07:00
feat: Add BT Locate and GPS modes with IRK auto-detection
New modes: - BT Locate: SAR Bluetooth device location with GPS-tagged signal trail, RSSI-based proximity bands, audio alerts, and IRK auto-extraction from paired devices (macOS plist / Linux BlueZ) - GPS: Real-time position tracking with live map, speed, heading, altitude, satellite info, and track recording via gpsd Bug fixes: - Fix ABBA deadlock between session lock and aggregator lock in BT Locate - Fix bleak scan lifecycle tracking in BluetoothScanner (is_scanning property now cross-checks backend state) - Fix map tile persistence when switching modes - Use 15s max_age window for fresh detections in BT Locate poll loop Documentation: - Update README, FEATURES.md, USAGE.md, and GitHub Pages with new modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
213
utils/gps.py
213
utils/gps.py
@@ -6,28 +6,86 @@ 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
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional, Callable
|
||||
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: Optional[float] = None
|
||||
speed: Optional[float] = None # m/s
|
||||
heading: Optional[float] = None # degrees
|
||||
satellites: Optional[int] = None
|
||||
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: Optional[datetime] = None
|
||||
device: Optional[str] = None
|
||||
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."""
|
||||
@@ -37,13 +95,45 @@ class GPSPosition:
|
||||
'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.
|
||||
@@ -58,35 +148,43 @@ class GPSDClient:
|
||||
def __init__(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._position: Optional[GPSPosition] = None
|
||||
self._position: GPSPosition | None = None
|
||||
self._sky: GPSSkyData | None = 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._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._device: Optional[str] = None
|
||||
self._sky_callbacks: list[Callable[[GPSSkyData], None]] = []
|
||||
self._device: str | None = None
|
||||
|
||||
@property
|
||||
def position(self) -> Optional[GPSPosition]:
|
||||
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) -> Optional[datetime]:
|
||||
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) -> Optional[str]:
|
||||
def error(self) -> str | None:
|
||||
"""Get any error message."""
|
||||
with self._lock:
|
||||
return self._error
|
||||
@@ -105,6 +203,15 @@ class GPSDClient:
|
||||
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."""
|
||||
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
|
||||
@@ -135,10 +242,8 @@ class GPSDClient:
|
||||
self._error = str(e)
|
||||
logger.error(f"Failed to connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
if self._socket:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._socket = None
|
||||
return False
|
||||
|
||||
@@ -169,7 +274,7 @@ class GPSDClient:
|
||||
buffer = ""
|
||||
message_count = 0
|
||||
|
||||
print(f"[GPS] gpsd read loop started", flush=True)
|
||||
print("[GPS] gpsd read loop started", flush=True)
|
||||
|
||||
while self._running and self._socket:
|
||||
try:
|
||||
@@ -202,6 +307,8 @@ class GPSDClient:
|
||||
|
||||
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', [])
|
||||
@@ -239,11 +346,9 @@ class GPSDClient:
|
||||
timestamp = None
|
||||
time_str = msg.get('time')
|
||||
if time_str:
|
||||
try:
|
||||
with contextlib.suppress(ValueError, AttributeError):
|
||||
# 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,
|
||||
@@ -251,14 +356,58 @@ class GPSDClient:
|
||||
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."""
|
||||
sats = []
|
||||
for sat in msg.get('satellites', []):
|
||||
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),
|
||||
))
|
||||
|
||||
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:
|
||||
@@ -275,18 +424,19 @@ class GPSDClient:
|
||||
|
||||
|
||||
# Global GPS client instance
|
||||
_gps_client: Optional[GPSDClient] = None
|
||||
_gps_client: GPSDClient | None = None
|
||||
_gps_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_gps_reader() -> Optional[GPSDClient]:
|
||||
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: Optional[Callable[[GPSPosition], None]] = None) -> bool:
|
||||
callback: Callable[[GPSPosition], None] | None = None,
|
||||
sky_callback: Callable[[GPSSkyData], None] | None = None) -> bool:
|
||||
"""
|
||||
Start the global GPS client connected to gpsd.
|
||||
|
||||
@@ -294,6 +444,7 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
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
|
||||
@@ -307,9 +458,11 @@ def start_gpsd(host: str = 'localhost', port: int = 2947,
|
||||
|
||||
_gps_client = GPSDClient(host, port)
|
||||
|
||||
# Register callback BEFORE starting to avoid race condition
|
||||
# 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()
|
||||
|
||||
@@ -324,7 +477,7 @@ def stop_gps() -> None:
|
||||
_gps_client = None
|
||||
|
||||
|
||||
def get_current_position() -> Optional[GPSPosition]:
|
||||
def get_current_position() -> GPSPosition | None:
|
||||
"""Get the current GPS position from the global client."""
|
||||
client = get_gps_reader()
|
||||
if client:
|
||||
|
||||
Reference in New Issue
Block a user