mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
516 lines
17 KiB
Python
516 lines
17 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
|
|
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
|