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:
Smittix
2026-02-15 21:59:38 +00:00
parent c60769f795
commit d8d08a8b1e
26 changed files with 4481 additions and 510 deletions

View File

@@ -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: