""" Main Bluetooth scanner coordinator. Coordinates DBus and fallback scanners, manages device aggregation and heuristics. """ from __future__ import annotations import logging import queue import threading from collections.abc import Generator from datetime import datetime from typing import Callable from .aggregator import DeviceAggregator from .capability_check import check_capabilities from .constants import ( DEVICE_STALE_TIMEOUT, ) from .dbus_scanner import DBusScanner from .fallback_scanner import FallbackScanner from .heuristics import HeuristicsEngine from .irk_extractor import get_paired_irks from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities from .ubertooth_scanner import UbertoothScanner logger = logging.getLogger(__name__) # Global scanner instance _scanner_instance: BluetoothScanner | None = 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: str | None = 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: DBusScanner | None = None self._fallback_scanner: FallbackScanner | None = None self._ubertooth_scanner: UbertoothScanner | None = None self._active_backend: str | None = None # Event queue for SSE streaming self._event_queue: queue.Queue = queue.Queue(maxsize=1000) # Duration-based scanning self._scan_timer: threading.Timer | None = None # Callbacks self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = [] # Capability check result self._capabilities: SystemCapabilities | None = None def start_scan( self, mode: str = "auto", duration_s: int | None = 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) 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")): 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, str | None]: """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_ubertooth(self) -> tuple[bool, str | None]: """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, str | None]: """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 if self._ubertooth_scanner: self._ubertooth_scanner.stop() self._ubertooth_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 _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: # Ingest into aggregator device = self._aggregator.ingest(observation) # 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": summary, } ) # 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: int | None = None, protocol: str | None = 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) -> BTDeviceAggregate | None: """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) or (self._ubertooth_scanner and self._ubertooth_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: str | None = 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