mirror of
https://github.com/smittix/intercept.git
synced 2026-06-09 22:43:32 -07:00
chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC cluster tracking, behavioral flags, IRK badges, distance estimation), ACARS SoapySDR multi-backend support, dump1090 stale process cleanup, GPS error state, and proximity radar/signal card UI improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -606,6 +606,12 @@ class DeviceAggregator:
|
||||
|
||||
return result
|
||||
|
||||
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
|
||||
"""Return how many distinct device_ids share a fingerprint."""
|
||||
with self._lock:
|
||||
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
|
||||
return len(device_ids) if device_ids else 0
|
||||
|
||||
def prune_ring_buffer(self) -> int:
|
||||
"""Prune old observations from ring buffer."""
|
||||
return self._ring_buffer.prune_old()
|
||||
|
||||
@@ -101,6 +101,7 @@ ADDRESS_TYPE_RANDOM = 'random'
|
||||
ADDRESS_TYPE_RANDOM_STATIC = 'random_static'
|
||||
ADDRESS_TYPE_RPA = 'rpa' # Resolvable Private Address
|
||||
ADDRESS_TYPE_NRPA = 'nrpa' # Non-Resolvable Private Address
|
||||
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
|
||||
|
||||
# =============================================================================
|
||||
# PROTOCOL TYPES
|
||||
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
|
||||
0x04: 'Helmet',
|
||||
0x05: 'Glasses',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# BLE APPEARANCE CODES (GAP Appearance values)
|
||||
# =============================================================================
|
||||
|
||||
BLE_APPEARANCE_NAMES: dict[int, str] = {
|
||||
0: 'Unknown',
|
||||
64: 'Phone',
|
||||
128: 'Computer',
|
||||
192: 'Watch',
|
||||
193: 'Sports Watch',
|
||||
256: 'Clock',
|
||||
320: 'Display',
|
||||
384: 'Remote Control',
|
||||
448: 'Eye Glasses',
|
||||
512: 'Tag',
|
||||
576: 'Keyring',
|
||||
640: 'Media Player',
|
||||
704: 'Barcode Scanner',
|
||||
768: 'Thermometer',
|
||||
832: 'Heart Rate Sensor',
|
||||
896: 'Blood Pressure',
|
||||
960: 'HID',
|
||||
961: 'Keyboard',
|
||||
962: 'Mouse',
|
||||
963: 'Joystick',
|
||||
964: 'Gamepad',
|
||||
965: 'Digitizer Tablet',
|
||||
966: 'Card Reader',
|
||||
967: 'Digital Pen',
|
||||
968: 'Barcode Scanner (HID)',
|
||||
1024: 'Glucose Monitor',
|
||||
1088: 'Running Speed Sensor',
|
||||
1152: 'Cycling',
|
||||
1216: 'Control Device',
|
||||
1280: 'Network Device',
|
||||
1344: 'Sensor',
|
||||
1408: 'Light Fixture',
|
||||
1472: 'Fan',
|
||||
1536: 'HVAC',
|
||||
1600: 'Access Control',
|
||||
1664: 'Motorized Device',
|
||||
1728: 'Power Device',
|
||||
1792: 'Light Source',
|
||||
3136: 'Pulse Oximeter',
|
||||
3200: 'Weight Scale',
|
||||
3264: 'Personal Mobility',
|
||||
5184: 'Outdoor Sports Activity',
|
||||
}
|
||||
|
||||
|
||||
def get_appearance_name(code: int | None) -> str | None:
|
||||
"""Look up a human-readable name for a BLE appearance code."""
|
||||
if code is None:
|
||||
return None
|
||||
return BLE_APPEARANCE_NAMES.get(code)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Optional
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_UUID,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,10 +47,14 @@ def generate_device_key(
|
||||
if identity_address:
|
||||
return f"id:{identity_address.upper()}"
|
||||
|
||||
# Priority 2: Use public or random_static addresses directly
|
||||
# Priority 2: Use public or random_static addresses directly (not platform UUIDs)
|
||||
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
||||
return f"mac:{address.upper()}"
|
||||
|
||||
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
|
||||
if address_type == ADDRESS_TYPE_UUID:
|
||||
return f"uuid:{address.upper()}"
|
||||
|
||||
# Priority 3: Generate fingerprint hash for random addresses
|
||||
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
||||
|
||||
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
|
||||
Returns:
|
||||
True if the address is randomized, False otherwise.
|
||||
"""
|
||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
|
||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
|
||||
|
||||
|
||||
def extract_key_type(device_key: str) -> str:
|
||||
|
||||
@@ -24,8 +24,12 @@ from .constants import (
|
||||
BLUETOOTHCTL_TIMEOUT,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_UUID,
|
||||
MANUFACTURER_NAMES,
|
||||
)
|
||||
|
||||
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
|
||||
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -132,7 +136,10 @@ class BleakScanner:
|
||||
"""Convert bleak device to BTObservation."""
|
||||
# Determine address type from address format
|
||||
address_type = ADDRESS_TYPE_PUBLIC
|
||||
if device.address and ':' in device.address:
|
||||
if device.address and _CB_UUID_RE.match(device.address):
|
||||
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
|
||||
address_type = ADDRESS_TYPE_UUID
|
||||
elif device.address and ':' in device.address:
|
||||
# Check if first byte indicates random address
|
||||
first_byte = int(device.address.split(':')[0], 16)
|
||||
if (first_byte & 0xC0) == 0xC0: # Random static
|
||||
|
||||
+45
-13
@@ -18,6 +18,7 @@ from .constants import (
|
||||
RANGE_UNKNOWN,
|
||||
PROTOCOL_BLE,
|
||||
PROXIMITY_UNKNOWN,
|
||||
get_appearance_name,
|
||||
)
|
||||
|
||||
# Import tracker types (will be available after tracker_signatures module loads)
|
||||
@@ -148,10 +149,10 @@ class BTDeviceAggregate:
|
||||
is_strong_stable: bool = False
|
||||
has_random_address: bool = False
|
||||
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
seen_before: bool = False
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
seen_before: bool = False
|
||||
|
||||
# Tracker detection fields
|
||||
is_tracker: bool = False
|
||||
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
|
||||
# IRK (Identity Resolving Key) from paired device database
|
||||
irk_hex: Optional[str] = None # 32-char hex if known
|
||||
irk_source_name: Optional[str] = None # Name from paired DB
|
||||
|
||||
# Payload fingerprint (survives MAC randomization)
|
||||
payload_fingerprint_id: Optional[str] = None
|
||||
payload_fingerprint_stability: float = 0.0
|
||||
@@ -275,10 +280,10 @@ class BTDeviceAggregate:
|
||||
},
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
'seen_before': self.seen_before,
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
'seen_before': self.seen_before,
|
||||
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
|
||||
'risk_factors': self.risk_factors,
|
||||
},
|
||||
|
||||
# IRK
|
||||
'has_irk': self.irk_hex is not None,
|
||||
'irk_hex': self.irk_hex,
|
||||
'irk_source_name': self.irk_source_name,
|
||||
|
||||
# Fingerprint
|
||||
'fingerprint': {
|
||||
'id': self.payload_fingerprint_id,
|
||||
@@ -319,24 +329,46 @@ class BTDeviceAggregate:
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_min': self.rssi_min,
|
||||
'rssi_max': self.rssi_max,
|
||||
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||
'range_band': self.range_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
'is_randomized_mac': self.is_randomized_mac,
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'duration_seconds': self.duration_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
'seen_before': self.seen_before,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'seen_rate': round(self.seen_rate, 2),
|
||||
'tx_power': self.tx_power,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'appearance': self.appearance,
|
||||
'appearance_name': get_appearance_name(self.appearance),
|
||||
'is_connectable': self.is_connectable,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'is_persistent': self.is_persistent,
|
||||
'is_beacon_like': self.is_beacon_like,
|
||||
'is_strong_stable': self.is_strong_stable,
|
||||
'in_baseline': self.in_baseline,
|
||||
'seen_before': self.seen_before,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'tracker_name': self.tracker_name,
|
||||
'tracker_confidence': self.tracker_confidence,
|
||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'tracker_evidence': self.tracker_evidence,
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'risk_factors': self.risk_factors,
|
||||
'has_irk': self.irk_hex is not None,
|
||||
'irk_hex': self.irk_hex,
|
||||
'irk_source_name': self.irk_source_name,
|
||||
'fingerprint_id': self.payload_fingerprint_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ from .constants import (
|
||||
)
|
||||
from .dbus_scanner import DBusScanner
|
||||
from .fallback_scanner import FallbackScanner
|
||||
from .ubertooth_scanner import UbertoothScanner
|
||||
from .heuristics import HeuristicsEngine
|
||||
from .irk_extractor import get_paired_irks
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -57,6 +59,7 @@ class BluetoothScanner:
|
||||
# Scanner backends
|
||||
self._dbus_scanner: Optional[DBusScanner] = None
|
||||
self._fallback_scanner: Optional[FallbackScanner] = None
|
||||
self._ubertooth_scanner: Optional[UbertoothScanner] = None
|
||||
self._active_backend: Optional[str] = None
|
||||
|
||||
# Event queue for SSE streaming
|
||||
@@ -113,6 +116,8 @@ class BluetoothScanner:
|
||||
|
||||
if mode == 'dbus':
|
||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||
elif mode == 'ubertooth':
|
||||
started, backend_used = self._start_ubertooth()
|
||||
|
||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||
@@ -168,6 +173,18 @@ class BluetoothScanner:
|
||||
logger.warning(f"DBus scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
|
||||
"""Start Ubertooth One scanner."""
|
||||
try:
|
||||
self._ubertooth_scanner = UbertoothScanner(
|
||||
on_observation=self._handle_observation,
|
||||
)
|
||||
if self._ubertooth_scanner.start():
|
||||
return True, 'ubertooth'
|
||||
except Exception as e:
|
||||
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||
"""Start fallback scanner."""
|
||||
try:
|
||||
@@ -204,6 +221,10 @@ class BluetoothScanner:
|
||||
self._fallback_scanner.stop()
|
||||
self._fallback_scanner = None
|
||||
|
||||
if self._ubertooth_scanner:
|
||||
self._ubertooth_scanner.stop()
|
||||
self._ubertooth_scanner = None
|
||||
|
||||
# Update status
|
||||
self._status.is_scanning = False
|
||||
self._active_backend = None
|
||||
@@ -216,6 +237,47 @@ class BluetoothScanner:
|
||||
|
||||
logger.info("Bluetooth scan stopped")
|
||||
|
||||
def _match_irk(self, device: BTDeviceAggregate) -> None:
|
||||
"""Check if a device address resolves against any paired IRK."""
|
||||
if device.irk_hex is not None:
|
||||
return # Already matched
|
||||
|
||||
address = device.address
|
||||
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
|
||||
return
|
||||
|
||||
# Only attempt RPA resolution on 6-byte addresses
|
||||
addr_clean = address.replace(':', '').replace('-', '')
|
||||
if len(addr_clean) != 12:
|
||||
return
|
||||
|
||||
try:
|
||||
paired = get_paired_irks()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not paired:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils.bt_locate import resolve_rpa
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
for entry in paired:
|
||||
irk_hex = entry.get('irk_hex', '')
|
||||
if not irk_hex or len(irk_hex) != 32:
|
||||
continue
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
if resolve_rpa(irk, address):
|
||||
device.irk_hex = irk_hex
|
||||
device.irk_source_name = entry.get('name')
|
||||
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _handle_observation(self, observation: BTObservation) -> None:
|
||||
"""Handle incoming observation from scanner backend."""
|
||||
try:
|
||||
@@ -225,15 +287,27 @@ class BluetoothScanner:
|
||||
# Evaluate heuristics
|
||||
self._heuristics.evaluate(device)
|
||||
|
||||
# Check for IRK match
|
||||
self._match_irk(device)
|
||||
|
||||
# Update device count
|
||||
with self._lock:
|
||||
self._status.devices_found = self._aggregator.device_count
|
||||
|
||||
# Build summary with MAC cluster count
|
||||
summary = device.to_summary_dict()
|
||||
if device.payload_fingerprint_id:
|
||||
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
|
||||
device.payload_fingerprint_id
|
||||
)
|
||||
else:
|
||||
summary['mac_cluster_count'] = 0
|
||||
|
||||
# Queue event
|
||||
self._queue_event({
|
||||
'type': 'device',
|
||||
'action': 'update',
|
||||
'device': device.to_summary_dict(),
|
||||
'device': summary,
|
||||
})
|
||||
|
||||
# Callbacks
|
||||
@@ -398,6 +472,7 @@ class BluetoothScanner:
|
||||
backend_alive = (
|
||||
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
||||
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
||||
or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning)
|
||||
)
|
||||
if not backend_alive:
|
||||
self._status.is_scanning = False
|
||||
|
||||
+178
@@ -483,3 +483,181 @@ def get_current_position() -> GPSPosition | None:
|
||||
if client:
|
||||
return client.position
|
||||
return None
|
||||
|
||||
|
||||
# ============================================
|
||||
# GPS device detection and gpsd auto-start
|
||||
# ============================================
|
||||
|
||||
_gpsd_process: 'subprocess.Popen | None' = None
|
||||
_gpsd_process_lock = threading.Lock()
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
_gpsd_process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Stopped gpsd daemon")
|
||||
print("[GPS] Stopped gpsd daemon", flush=True)
|
||||
_gpsd_process = None
|
||||
|
||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from .dependencies import check_tool
|
||||
@@ -117,6 +120,93 @@ def cleanup_stale_processes() -> None:
|
||||
pass
|
||||
|
||||
|
||||
_DUMP1090_PID_FILE = Path(__file__).resolve().parent.parent / 'instance' / 'dump1090.pid'
|
||||
|
||||
|
||||
def write_dump1090_pid(pid: int) -> None:
|
||||
"""Write the PID of an app-spawned dump1090 process to a PID file."""
|
||||
try:
|
||||
_DUMP1090_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_DUMP1090_PID_FILE.write_text(str(pid))
|
||||
logger.debug(f"Wrote dump1090 PID file: {pid}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to write dump1090 PID file: {e}")
|
||||
|
||||
|
||||
def clear_dump1090_pid() -> None:
|
||||
"""Remove the dump1090 PID file."""
|
||||
try:
|
||||
_DUMP1090_PID_FILE.unlink(missing_ok=True)
|
||||
logger.debug("Cleared dump1090 PID file")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to clear dump1090 PID file: {e}")
|
||||
|
||||
|
||||
def _is_dump1090_process(pid: int) -> bool:
|
||||
"""Check if the given PID is actually a dump1090/readsb process."""
|
||||
try:
|
||||
if platform.system() == 'Linux':
|
||||
cmdline_path = Path(f'/proc/{pid}/cmdline')
|
||||
if cmdline_path.exists():
|
||||
cmdline = cmdline_path.read_bytes().replace(b'\x00', b' ').decode('utf-8', errors='ignore')
|
||||
return 'dump1090' in cmdline or 'readsb' in cmdline
|
||||
# macOS or fallback
|
||||
result = subprocess.run(
|
||||
['ps', '-p', str(pid), '-o', 'comm='],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
comm = result.stdout.strip()
|
||||
return 'dump1090' in comm or 'readsb' in comm
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_stale_dump1090() -> None:
|
||||
"""Kill a stale app-spawned dump1090 using the PID file.
|
||||
|
||||
Safe no-op if no PID file exists, process is dead, or PID was reused
|
||||
by another program.
|
||||
"""
|
||||
if not _DUMP1090_PID_FILE.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
pid = int(_DUMP1090_PID_FILE.read_text().strip())
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"Invalid dump1090 PID file: {e}")
|
||||
clear_dump1090_pid()
|
||||
return
|
||||
|
||||
# Verify this PID is still a dump1090/readsb process
|
||||
if not _is_dump1090_process(pid):
|
||||
logger.debug(f"PID {pid} is not dump1090/readsb (dead or reused), removing stale PID file")
|
||||
clear_dump1090_pid()
|
||||
return
|
||||
|
||||
# Kill the process group
|
||||
logger.info(f"Killing stale app-spawned dump1090 (PID {pid})")
|
||||
try:
|
||||
pgid = os.getpgid(pid)
|
||||
os.killpg(pgid, signal.SIGTERM)
|
||||
# Brief wait for graceful shutdown
|
||||
for _ in range(10):
|
||||
try:
|
||||
os.kill(pid, 0) # Check if still alive
|
||||
time.sleep(0.2)
|
||||
except OSError:
|
||||
break
|
||||
else:
|
||||
# Still alive, force kill
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError as e:
|
||||
logger.debug(f"Error killing stale dump1090 PID {pid}: {e}")
|
||||
|
||||
clear_dump1090_pid()
|
||||
|
||||
|
||||
def is_valid_mac(mac: str | None) -> bool:
|
||||
"""Validate MAC address format."""
|
||||
if not mac:
|
||||
|
||||
Reference in New Issue
Block a user