Files
intercept/utils/bluetooth/scanner.py
Smittix d8d08a8b1e 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>
2026-02-15 21:59:45 +00:00

445 lines
15 KiB
Python

"""
Main Bluetooth scanner coordinator.
Coordinates DBus and fallback scanners, manages device aggregation and heuristics.
"""
from __future__ import annotations
import logging
import queue
import threading
import time
from datetime import datetime
from typing import Callable, Generator, Optional
from .aggregator import DeviceAggregator
from .capability_check import check_capabilities
from .constants import (
DEFAULT_SCAN_DURATION,
DEVICE_STALE_TIMEOUT,
PROTOCOL_AUTO,
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
)
from .dbus_scanner import DBusScanner
from .fallback_scanner import FallbackScanner
from .heuristics import HeuristicsEngine
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
logger = logging.getLogger(__name__)
# Global scanner instance
_scanner_instance: Optional['BluetoothScanner'] = None
_scanner_lock = threading.Lock()
class BluetoothScanner:
"""
Main Bluetooth scanner coordinating DBus and fallback scanners.
Provides unified API for scanning, device aggregation, and heuristics.
"""
def __init__(self, adapter_id: Optional[str] = None):
"""
Initialize Bluetooth scanner.
Args:
adapter_id: Adapter path/name (e.g., '/org/bluez/hci0' or 'hci0').
"""
self._adapter_id = adapter_id
self._aggregator = DeviceAggregator()
self._heuristics = HeuristicsEngine()
self._status = ScanStatus()
self._lock = threading.Lock()
# Scanner backends
self._dbus_scanner: Optional[DBusScanner] = None
self._fallback_scanner: Optional[FallbackScanner] = None
self._active_backend: Optional[str] = None
# Event queue for SSE streaming
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
# Duration-based scanning
self._scan_timer: Optional[threading.Timer] = None
# Callbacks
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
# Capability check result
self._capabilities: Optional[SystemCapabilities] = None
def start_scan(
self,
mode: str = 'auto',
duration_s: Optional[int] = None,
transport: str = 'auto',
rssi_threshold: int = -100,
) -> bool:
"""
Start Bluetooth scanning.
Args:
mode: Scanner mode ('dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto').
duration_s: Scan duration in seconds (None for indefinite).
transport: BLE transport filter ('bredr', 'le', 'auto').
rssi_threshold: Minimum RSSI for device discovery.
Returns:
True if scan started successfully.
"""
with self._lock:
if self._status.is_scanning:
return True
# Check capabilities
self._capabilities = check_capabilities()
# Determine adapter
adapter = self._adapter_id or self._capabilities.default_adapter
if not adapter and mode == 'dbus':
self._status.error = "No Bluetooth adapter found"
return False
# Select and start backend
started = False
backend_used = None
original_mode = mode
if mode == 'auto':
mode = self._capabilities.recommended_backend or 'bleak'
if mode == 'dbus':
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
# 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')):
started, backend_used = self._start_fallback(adapter, original_mode)
if not started:
self._status.error = f"Failed to start scanner with mode '{mode}'"
return False
# Update status
self._active_backend = backend_used
self._status = ScanStatus(
is_scanning=True,
mode=mode,
backend=backend_used,
adapter_id=adapter,
started_at=datetime.now(),
duration_s=duration_s,
)
# Queue status event
self._queue_event({
'type': 'status',
'status': 'started',
'backend': backend_used,
'mode': mode,
})
# Set up timer for duration-based scanning
if duration_s:
self._scan_timer = threading.Timer(duration_s, self.stop_scan)
self._scan_timer.daemon = True
self._scan_timer.start()
logger.info(f"Bluetooth scan started: mode={mode}, backend={backend_used}")
return True
def _start_dbus(
self,
adapter: str,
transport: str,
rssi_threshold: int
) -> tuple[bool, Optional[str]]:
"""Start DBus scanner."""
try:
self._dbus_scanner = DBusScanner(
adapter_path=adapter,
on_observation=self._handle_observation,
)
if self._dbus_scanner.start(transport=transport, rssi_threshold=rssi_threshold):
return True, 'dbus'
except Exception as e:
logger.warning(f"DBus scanner failed: {e}")
return False, None
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
"""Start fallback scanner."""
try:
# Extract adapter name from path if needed
adapter_name = adapter.split('/')[-1] if adapter else 'hci0'
self._fallback_scanner = FallbackScanner(
adapter=adapter_name,
on_observation=self._handle_observation,
)
if self._fallback_scanner.start():
return True, self._fallback_scanner.backend
except Exception as e:
logger.warning(f"Fallback scanner failed: {e}")
return False, None
def stop_scan(self) -> None:
"""Stop Bluetooth scanning."""
with self._lock:
if not self._status.is_scanning:
return
# Cancel timer if running
if self._scan_timer:
self._scan_timer.cancel()
self._scan_timer = None
# Stop active scanner
if self._dbus_scanner:
self._dbus_scanner.stop()
self._dbus_scanner = None
if self._fallback_scanner:
self._fallback_scanner.stop()
self._fallback_scanner = None
# Update status
self._status.is_scanning = False
self._active_backend = None
# Queue status event
self._queue_event({
'type': 'status',
'status': 'stopped',
})
logger.info("Bluetooth scan stopped")
def _handle_observation(self, observation: BTObservation) -> None:
"""Handle incoming observation from scanner backend."""
try:
# Ingest into aggregator
device = self._aggregator.ingest(observation)
# Evaluate heuristics
self._heuristics.evaluate(device)
# Update device count
with self._lock:
self._status.devices_found = self._aggregator.device_count
# Queue event
self._queue_event({
'type': 'device',
'action': 'update',
'device': device.to_summary_dict(),
})
# Callbacks
for cb in self._on_device_updated_callbacks:
try:
cb(device)
except Exception as cb_err:
logger.error(f"Device callback error: {cb_err}")
except Exception as e:
logger.error(f"Error handling observation: {e}")
def _queue_event(self, event: dict) -> None:
"""Add event to queue for SSE streaming."""
try:
self._event_queue.put_nowait(event)
except queue.Full:
# Drop oldest event
try:
self._event_queue.get_nowait()
self._event_queue.put_nowait(event)
except queue.Empty:
pass
def get_status(self) -> ScanStatus:
"""Get current scan status."""
with self._lock:
self._status.devices_found = self._aggregator.device_count
return self._status
def get_devices(
self,
sort_by: str = 'last_seen',
sort_desc: bool = True,
min_rssi: Optional[int] = None,
protocol: Optional[str] = None,
max_age_seconds: float = DEVICE_STALE_TIMEOUT,
) -> list[BTDeviceAggregate]:
"""
Get list of discovered devices with optional filtering.
Args:
sort_by: Field to sort by ('last_seen', 'rssi_current', 'name', 'seen_count').
sort_desc: Sort descending if True.
min_rssi: Minimum RSSI filter.
protocol: Protocol filter ('ble', 'classic', None for all).
max_age_seconds: Maximum age for devices.
Returns:
List of BTDeviceAggregate instances.
"""
devices = self._aggregator.get_active_devices(max_age_seconds)
# Filter by RSSI
if min_rssi is not None:
devices = [d for d in devices if d.rssi_current and d.rssi_current >= min_rssi]
# Filter by protocol
if protocol:
devices = [d for d in devices if d.protocol == protocol]
# Sort
sort_key = {
'last_seen': lambda d: d.last_seen,
'rssi_current': lambda d: d.rssi_current or -999,
'name': lambda d: (d.name or '').lower(),
'seen_count': lambda d: d.seen_count,
'first_seen': lambda d: d.first_seen,
}.get(sort_by, lambda d: d.last_seen)
devices.sort(key=sort_key, reverse=sort_desc)
return devices
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
"""Get a specific device by ID."""
return self._aggregator.get_device(device_id)
def get_snapshot(self) -> list[dict]:
"""Get current device snapshot for TSCM integration."""
devices = self.get_devices()
return [d.to_dict() for d in devices]
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
"""
Generator for SSE event streaming.
Args:
timeout: Queue get timeout in seconds.
Yields:
Event dictionaries.
"""
while True:
try:
event = self._event_queue.get(timeout=timeout)
yield event
except queue.Empty:
yield {'type': 'ping'}
def set_baseline(self) -> int:
"""Set current devices as baseline."""
count = self._aggregator.set_baseline()
self._queue_event({
'type': 'baseline',
'action': 'set',
'device_count': count,
})
return count
def clear_baseline(self) -> None:
"""Clear the baseline."""
self._aggregator.clear_baseline()
self._queue_event({
'type': 'baseline',
'action': 'cleared',
})
def clear_devices(self) -> None:
"""Clear all tracked devices."""
self._aggregator.clear()
self._queue_event({
'type': 'devices',
'action': 'cleared',
})
def prune_stale(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int:
"""Prune stale devices."""
return self._aggregator.prune_stale_devices(max_age_seconds)
def get_capabilities(self) -> SystemCapabilities:
"""Get system capabilities."""
if not self._capabilities:
self._capabilities = check_capabilities()
return self._capabilities
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Set callback for device updates (legacy, adds to callback list)."""
self.add_device_callback(callback)
def add_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Add a callback for device updates."""
if callback not in self._on_device_updated_callbacks:
self._on_device_updated_callbacks.append(callback)
def remove_device_callback(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
"""Remove a device update callback."""
if callback in self._on_device_updated_callbacks:
self._on_device_updated_callbacks.remove(callback)
@property
def is_scanning(self) -> bool:
"""Check if scanning is active.
Cross-checks the backend scanner state, since bleak scans can
expire silently without calling stop_scan().
"""
if not self._status.is_scanning:
return False
# Detect backends that finished on their own (e.g. bleak timeout)
backend_alive = (
(self._dbus_scanner and self._dbus_scanner.is_scanning)
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
)
if not backend_alive:
self._status.is_scanning = False
return False
return True
@property
def device_count(self) -> int:
"""Number of tracked devices."""
return self._aggregator.device_count
@property
def has_baseline(self) -> bool:
"""Whether baseline is set."""
return self._aggregator.has_baseline
def get_bluetooth_scanner(adapter_id: Optional[str] = None) -> BluetoothScanner:
"""
Get or create the global Bluetooth scanner instance.
Args:
adapter_id: Adapter path/name (only used on first call).
Returns:
BluetoothScanner instance.
"""
global _scanner_instance
with _scanner_lock:
if _scanner_instance is None:
_scanner_instance = BluetoothScanner(adapter_id)
return _scanner_instance
def reset_bluetooth_scanner() -> None:
"""Reset the global scanner instance (for testing)."""
global _scanner_instance
with _scanner_lock:
if _scanner_instance:
_scanner_instance.stop_scan()
_scanner_instance = None