mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 15:20:00 -07:00
Overhaul Bluetooth scanning with DBus-based BlueZ integration
Major changes: - Add utils/bluetooth/ package with DBus scanner, fallback scanners (bleak, hcitool, bluetoothctl), device aggregation, and heuristics - New unified API at /api/bluetooth/ with REST endpoints and SSE streaming - Device observation aggregation with RSSI statistics and range bands - Behavioral heuristics: new, persistent, beacon-like, strong+stable - Frontend components: DeviceCard, MessageCard, RSSISparkline - TSCM integration via get_tscm_bluetooth_snapshot() helper - Unit tests for aggregator, heuristics, and API endpoints Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
70
utils/bluetooth/__init__.py
Normal file
70
utils/bluetooth/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Bluetooth scanning package for INTERCEPT.
|
||||
|
||||
Provides unified Bluetooth scanning with DBus/BlueZ and fallback backends,
|
||||
device aggregation, RSSI statistics, and observable heuristics.
|
||||
"""
|
||||
|
||||
from .aggregator import DeviceAggregator
|
||||
from .capability_check import check_capabilities, quick_adapter_check
|
||||
from .constants import (
|
||||
# Range bands
|
||||
RANGE_VERY_CLOSE,
|
||||
RANGE_CLOSE,
|
||||
RANGE_NEARBY,
|
||||
RANGE_FAR,
|
||||
RANGE_UNKNOWN,
|
||||
# Protocols
|
||||
PROTOCOL_BLE,
|
||||
PROTOCOL_CLASSIC,
|
||||
PROTOCOL_AUTO,
|
||||
# Address types
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
)
|
||||
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||
|
||||
__all__ = [
|
||||
# Main scanner
|
||||
'BluetoothScanner',
|
||||
'get_bluetooth_scanner',
|
||||
'reset_bluetooth_scanner',
|
||||
|
||||
# Models
|
||||
'BTObservation',
|
||||
'BTDeviceAggregate',
|
||||
'ScanStatus',
|
||||
'SystemCapabilities',
|
||||
|
||||
# Aggregator
|
||||
'DeviceAggregator',
|
||||
|
||||
# Heuristics
|
||||
'HeuristicsEngine',
|
||||
'evaluate_device_heuristics',
|
||||
'evaluate_all_devices',
|
||||
|
||||
# Capability checks
|
||||
'check_capabilities',
|
||||
'quick_adapter_check',
|
||||
|
||||
# Constants
|
||||
'RANGE_VERY_CLOSE',
|
||||
'RANGE_CLOSE',
|
||||
'RANGE_NEARBY',
|
||||
'RANGE_FAR',
|
||||
'RANGE_UNKNOWN',
|
||||
'PROTOCOL_BLE',
|
||||
'PROTOCOL_CLASSIC',
|
||||
'PROTOCOL_AUTO',
|
||||
'ADDRESS_TYPE_PUBLIC',
|
||||
'ADDRESS_TYPE_RANDOM',
|
||||
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||
'ADDRESS_TYPE_RPA',
|
||||
'ADDRESS_TYPE_NRPA',
|
||||
]
|
||||
347
utils/bluetooth/aggregator.py
Normal file
347
utils/bluetooth/aggregator.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
Device aggregator for Bluetooth observations.
|
||||
|
||||
Handles RSSI statistics, range band estimation, and device state management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
MAX_RSSI_SAMPLES,
|
||||
DEVICE_STALE_TIMEOUT,
|
||||
RSSI_VERY_CLOSE,
|
||||
RSSI_CLOSE,
|
||||
RSSI_NEARBY,
|
||||
RSSI_FAR,
|
||||
CONFIDENCE_VERY_CLOSE,
|
||||
CONFIDENCE_CLOSE,
|
||||
CONFIDENCE_NEARBY,
|
||||
CONFIDENCE_FAR,
|
||||
RANGE_VERY_CLOSE,
|
||||
RANGE_CLOSE,
|
||||
RANGE_NEARBY,
|
||||
RANGE_FAR,
|
||||
RANGE_UNKNOWN,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
MANUFACTURER_NAMES,
|
||||
PROTOCOL_BLE,
|
||||
PROTOCOL_CLASSIC,
|
||||
)
|
||||
from .models import BTObservation, BTDeviceAggregate
|
||||
|
||||
|
||||
class DeviceAggregator:
|
||||
"""
|
||||
Aggregates Bluetooth observations into unified device records.
|
||||
|
||||
Maintains RSSI statistics, estimates range bands, and tracks device state
|
||||
across multiple observations.
|
||||
"""
|
||||
|
||||
def __init__(self, max_rssi_samples: int = MAX_RSSI_SAMPLES):
|
||||
self._devices: dict[str, BTDeviceAggregate] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._max_rssi_samples = max_rssi_samples
|
||||
self._baseline_device_ids: set[str] = set()
|
||||
self._baseline_set_time: Optional[datetime] = None
|
||||
|
||||
def ingest(self, observation: BTObservation) -> BTDeviceAggregate:
|
||||
"""
|
||||
Ingest a new observation and update the device aggregate.
|
||||
|
||||
Args:
|
||||
observation: The BTObservation to process.
|
||||
|
||||
Returns:
|
||||
The updated BTDeviceAggregate for this device.
|
||||
"""
|
||||
device_id = observation.device_id
|
||||
|
||||
with self._lock:
|
||||
if device_id not in self._devices:
|
||||
# Create new device aggregate
|
||||
device = BTDeviceAggregate(
|
||||
device_id=device_id,
|
||||
address=observation.address,
|
||||
address_type=observation.address_type,
|
||||
first_seen=observation.timestamp,
|
||||
last_seen=observation.timestamp,
|
||||
protocol=self._infer_protocol(observation),
|
||||
)
|
||||
self._devices[device_id] = device
|
||||
else:
|
||||
device = self._devices[device_id]
|
||||
|
||||
# Update timestamps and counts
|
||||
device.last_seen = observation.timestamp
|
||||
device.seen_count += 1
|
||||
|
||||
# Calculate seen rate (observations per minute)
|
||||
duration = device.duration_seconds
|
||||
if duration > 0:
|
||||
device.seen_rate = (device.seen_count / duration) * 60
|
||||
else:
|
||||
device.seen_rate = 0
|
||||
|
||||
# Update RSSI samples
|
||||
if observation.rssi is not None:
|
||||
device.rssi_samples.append((observation.timestamp, observation.rssi))
|
||||
# Prune old samples
|
||||
if len(device.rssi_samples) > self._max_rssi_samples:
|
||||
device.rssi_samples = device.rssi_samples[-self._max_rssi_samples:]
|
||||
|
||||
# Recalculate RSSI statistics
|
||||
self._update_rssi_stats(device)
|
||||
|
||||
# Merge device info (prefer non-None values)
|
||||
self._merge_device_info(device, observation)
|
||||
|
||||
# Update range band
|
||||
self._update_range_band(device)
|
||||
|
||||
# Check if address is random
|
||||
device.has_random_address = observation.address_type in (
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
)
|
||||
|
||||
# Check baseline status
|
||||
device.in_baseline = device_id in self._baseline_device_ids
|
||||
device.is_new = not device.in_baseline and self._baseline_set_time is not None
|
||||
|
||||
return device
|
||||
|
||||
def _infer_protocol(self, observation: BTObservation) -> str:
|
||||
"""Infer the Bluetooth protocol from observation data."""
|
||||
# If Class of Device is set, it's Classic BT
|
||||
if observation.class_of_device is not None:
|
||||
return PROTOCOL_CLASSIC
|
||||
|
||||
# If address type is anything other than public, likely BLE
|
||||
if observation.address_type != 'public':
|
||||
return PROTOCOL_BLE
|
||||
|
||||
# If service UUIDs are present with 16-bit format, likely BLE
|
||||
if observation.service_uuids:
|
||||
for uuid in observation.service_uuids:
|
||||
if len(uuid) == 4 or len(uuid) == 8: # 16-bit or 32-bit
|
||||
return PROTOCOL_BLE
|
||||
|
||||
# Default to BLE as it's more common in modern scanning
|
||||
return PROTOCOL_BLE
|
||||
|
||||
def _update_rssi_stats(self, device: BTDeviceAggregate) -> None:
|
||||
"""Update RSSI statistics for a device."""
|
||||
if not device.rssi_samples:
|
||||
return
|
||||
|
||||
rssi_values = [rssi for _, rssi in device.rssi_samples]
|
||||
|
||||
# Current is most recent
|
||||
device.rssi_current = rssi_values[-1]
|
||||
|
||||
# Basic statistics
|
||||
device.rssi_min = min(rssi_values)
|
||||
device.rssi_max = max(rssi_values)
|
||||
|
||||
# Median
|
||||
device.rssi_median = statistics.median(rssi_values)
|
||||
|
||||
# Variance (need at least 2 samples)
|
||||
if len(rssi_values) >= 2:
|
||||
device.rssi_variance = statistics.variance(rssi_values)
|
||||
else:
|
||||
device.rssi_variance = 0.0
|
||||
|
||||
# Confidence based on sample count and variance
|
||||
device.rssi_confidence = self._calculate_confidence(rssi_values)
|
||||
|
||||
def _calculate_confidence(self, rssi_values: list[int]) -> float:
|
||||
"""
|
||||
Calculate confidence score for RSSI measurements.
|
||||
|
||||
Factors:
|
||||
- Sample count (more samples = higher confidence)
|
||||
- Low variance (less variance = higher confidence)
|
||||
"""
|
||||
if not rssi_values:
|
||||
return 0.0
|
||||
|
||||
# Sample count factor (logarithmic scaling, max out at ~50 samples)
|
||||
sample_factor = min(1.0, len(rssi_values) / 20)
|
||||
|
||||
# Variance factor (lower variance = higher confidence)
|
||||
if len(rssi_values) >= 2:
|
||||
variance = statistics.variance(rssi_values)
|
||||
# Normalize: 0 variance = 1.0, 100 variance = 0.0
|
||||
variance_factor = max(0.0, 1.0 - (variance / 100))
|
||||
else:
|
||||
variance_factor = 0.5 # Unknown variance
|
||||
|
||||
# Combined confidence (weighted average)
|
||||
confidence = (sample_factor * 0.4) + (variance_factor * 0.6)
|
||||
return min(1.0, max(0.0, confidence))
|
||||
|
||||
def _update_range_band(self, device: BTDeviceAggregate) -> None:
|
||||
"""Estimate range band from RSSI median and confidence."""
|
||||
if device.rssi_median is None:
|
||||
device.range_band = RANGE_UNKNOWN
|
||||
device.range_confidence = 0.0
|
||||
return
|
||||
|
||||
rssi = device.rssi_median
|
||||
confidence = device.rssi_confidence
|
||||
|
||||
# Determine range band based on RSSI thresholds
|
||||
if rssi >= RSSI_VERY_CLOSE and confidence >= CONFIDENCE_VERY_CLOSE:
|
||||
device.range_band = RANGE_VERY_CLOSE
|
||||
device.range_confidence = confidence
|
||||
elif rssi >= RSSI_CLOSE and confidence >= CONFIDENCE_CLOSE:
|
||||
device.range_band = RANGE_CLOSE
|
||||
device.range_confidence = confidence
|
||||
elif rssi >= RSSI_NEARBY and confidence >= CONFIDENCE_NEARBY:
|
||||
device.range_band = RANGE_NEARBY
|
||||
device.range_confidence = confidence
|
||||
elif rssi >= RSSI_FAR and confidence >= CONFIDENCE_FAR:
|
||||
device.range_band = RANGE_FAR
|
||||
device.range_confidence = confidence
|
||||
else:
|
||||
device.range_band = RANGE_UNKNOWN
|
||||
device.range_confidence = confidence * 0.5 # Reduced confidence for unknown
|
||||
|
||||
def _merge_device_info(self, device: BTDeviceAggregate, observation: BTObservation) -> None:
|
||||
"""Merge observation data into device aggregate (prefer non-None values)."""
|
||||
# Name (prefer longer names as they're usually more complete)
|
||||
if observation.name:
|
||||
if not device.name or len(observation.name) > len(device.name):
|
||||
device.name = observation.name
|
||||
|
||||
# Manufacturer
|
||||
if observation.manufacturer_id is not None:
|
||||
device.manufacturer_id = observation.manufacturer_id
|
||||
device.manufacturer_name = MANUFACTURER_NAMES.get(
|
||||
observation.manufacturer_id,
|
||||
f"Unknown (0x{observation.manufacturer_id:04X})"
|
||||
)
|
||||
if observation.manufacturer_data:
|
||||
device.manufacturer_bytes = observation.manufacturer_data
|
||||
|
||||
# Service UUIDs (merge, don't replace)
|
||||
for uuid in observation.service_uuids:
|
||||
if uuid not in device.service_uuids:
|
||||
device.service_uuids.append(uuid)
|
||||
|
||||
# Other fields
|
||||
if observation.tx_power is not None:
|
||||
device.tx_power = observation.tx_power
|
||||
if observation.appearance is not None:
|
||||
device.appearance = observation.appearance
|
||||
if observation.class_of_device is not None:
|
||||
device.class_of_device = observation.class_of_device
|
||||
device.major_class = observation.major_class
|
||||
device.minor_class = observation.minor_class
|
||||
|
||||
# Connection state (use most recent)
|
||||
device.is_connectable = observation.is_connectable
|
||||
device.is_paired = observation.is_paired
|
||||
device.is_connected = observation.is_connected
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
|
||||
"""Get a device by ID."""
|
||||
with self._lock:
|
||||
return self._devices.get(device_id)
|
||||
|
||||
def get_all_devices(self) -> list[BTDeviceAggregate]:
|
||||
"""Get all tracked devices."""
|
||||
with self._lock:
|
||||
return list(self._devices.values())
|
||||
|
||||
def get_active_devices(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> list[BTDeviceAggregate]:
|
||||
"""Get devices seen within the specified time window."""
|
||||
cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
|
||||
with self._lock:
|
||||
return [d for d in self._devices.values() if d.last_seen >= cutoff]
|
||||
|
||||
def prune_stale_devices(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int:
|
||||
"""
|
||||
Remove devices not seen within the specified time window.
|
||||
|
||||
Returns:
|
||||
Number of devices removed.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
|
||||
with self._lock:
|
||||
stale_ids = [
|
||||
device_id for device_id, device in self._devices.items()
|
||||
if device.last_seen < cutoff
|
||||
]
|
||||
for device_id in stale_ids:
|
||||
del self._devices[device_id]
|
||||
return len(stale_ids)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all tracked devices."""
|
||||
with self._lock:
|
||||
self._devices.clear()
|
||||
|
||||
def set_baseline(self) -> int:
|
||||
"""
|
||||
Set the current devices as the baseline.
|
||||
|
||||
Returns:
|
||||
Number of devices in baseline.
|
||||
"""
|
||||
with self._lock:
|
||||
self._baseline_device_ids = set(self._devices.keys())
|
||||
self._baseline_set_time = datetime.now()
|
||||
# Mark all current devices as in baseline
|
||||
for device in self._devices.values():
|
||||
device.in_baseline = True
|
||||
device.is_new = False
|
||||
return len(self._baseline_device_ids)
|
||||
|
||||
def clear_baseline(self) -> None:
|
||||
"""Clear the baseline."""
|
||||
with self._lock:
|
||||
self._baseline_device_ids.clear()
|
||||
self._baseline_set_time = None
|
||||
for device in self._devices.values():
|
||||
device.in_baseline = False
|
||||
device.is_new = False
|
||||
|
||||
def load_baseline(self, device_ids: set[str], set_time: datetime) -> None:
|
||||
"""Load a baseline from storage."""
|
||||
with self._lock:
|
||||
self._baseline_device_ids = device_ids
|
||||
self._baseline_set_time = set_time
|
||||
# Update existing devices
|
||||
for device_id, device in self._devices.items():
|
||||
device.in_baseline = device_id in self._baseline_device_ids
|
||||
device.is_new = not device.in_baseline
|
||||
|
||||
@property
|
||||
def device_count(self) -> int:
|
||||
"""Number of tracked devices."""
|
||||
with self._lock:
|
||||
return len(self._devices)
|
||||
|
||||
@property
|
||||
def baseline_device_count(self) -> int:
|
||||
"""Number of devices in baseline."""
|
||||
with self._lock:
|
||||
return len(self._baseline_device_ids)
|
||||
|
||||
@property
|
||||
def has_baseline(self) -> bool:
|
||||
"""Whether a baseline is set."""
|
||||
return self._baseline_set_time is not None
|
||||
307
utils/bluetooth/capability_check.py
Normal file
307
utils/bluetooth/capability_check.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
System capability checks for Bluetooth scanning.
|
||||
|
||||
Checks for DBus, BlueZ, adapters, permissions, and fallback tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
BLUEZ_SERVICE,
|
||||
BLUEZ_PATH,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
from .models import SystemCapabilities
|
||||
|
||||
# Import timeout from parent constants if available
|
||||
try:
|
||||
from ..constants import SUBPROCESS_TIMEOUT_SHORT as PARENT_TIMEOUT
|
||||
SUBPROCESS_TIMEOUT_SHORT = PARENT_TIMEOUT
|
||||
except ImportError:
|
||||
SUBPROCESS_TIMEOUT_SHORT = 5
|
||||
|
||||
|
||||
def check_capabilities() -> SystemCapabilities:
|
||||
"""
|
||||
Check all Bluetooth-related system capabilities.
|
||||
|
||||
Returns:
|
||||
SystemCapabilities object with all checks performed.
|
||||
"""
|
||||
caps = SystemCapabilities()
|
||||
|
||||
# Check permissions
|
||||
caps.is_root = os.geteuid() == 0
|
||||
|
||||
# Check DBus
|
||||
_check_dbus(caps)
|
||||
|
||||
# Check BlueZ
|
||||
_check_bluez(caps)
|
||||
|
||||
# Check adapters
|
||||
_check_adapters(caps)
|
||||
|
||||
# Check rfkill status
|
||||
_check_rfkill(caps)
|
||||
|
||||
# Check fallback tools
|
||||
_check_fallback_tools(caps)
|
||||
|
||||
# Determine recommended backend
|
||||
_determine_recommended_backend(caps)
|
||||
|
||||
return caps
|
||||
|
||||
|
||||
def _check_dbus(caps: SystemCapabilities) -> None:
|
||||
"""Check if DBus is available."""
|
||||
try:
|
||||
# Try to import dbus module
|
||||
import dbus
|
||||
caps.has_dbus = True
|
||||
except ImportError:
|
||||
caps.has_dbus = False
|
||||
caps.issues.append('Python dbus module not installed (pip install dbus-python)')
|
||||
|
||||
|
||||
def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
"""Check if BlueZ service is available via DBus."""
|
||||
if not caps.has_dbus:
|
||||
return
|
||||
|
||||
try:
|
||||
import dbus
|
||||
bus = dbus.SystemBus()
|
||||
|
||||
# Check if BlueZ service exists
|
||||
try:
|
||||
obj = bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
|
||||
caps.has_bluez = True
|
||||
|
||||
# Try to get BlueZ version from bluetoothd
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['bluetoothd', '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
)
|
||||
if result.returncode == 0:
|
||||
caps.bluez_version = result.stdout.strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
except dbus.exceptions.DBusException as e:
|
||||
caps.has_bluez = False
|
||||
if 'org.freedesktop.DBus.Error.ServiceUnknown' in str(e):
|
||||
caps.issues.append('BlueZ service not running (systemctl start bluetooth)')
|
||||
else:
|
||||
caps.issues.append(f'BlueZ DBus error: {e}')
|
||||
|
||||
except Exception as e:
|
||||
caps.has_bluez = False
|
||||
caps.issues.append(f'DBus connection error: {e}')
|
||||
|
||||
|
||||
def _check_adapters(caps: SystemCapabilities) -> None:
|
||||
"""Check available Bluetooth adapters."""
|
||||
if not caps.has_dbus or not caps.has_bluez:
|
||||
# Fall back to hciconfig if available
|
||||
_check_adapters_hciconfig(caps)
|
||||
return
|
||||
|
||||
try:
|
||||
import dbus
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
'org.freedesktop.DBus.ObjectManager'
|
||||
)
|
||||
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if 'org.bluez.Adapter1' in interfaces:
|
||||
adapter_props = interfaces['org.bluez.Adapter1']
|
||||
adapter_info = {
|
||||
'path': str(path),
|
||||
'name': str(adapter_props.get('Name', 'Unknown')),
|
||||
'address': str(adapter_props.get('Address', 'Unknown')),
|
||||
'powered': bool(adapter_props.get('Powered', False)),
|
||||
'discovering': bool(adapter_props.get('Discovering', False)),
|
||||
'alias': str(adapter_props.get('Alias', '')),
|
||||
}
|
||||
caps.adapters.append(adapter_info)
|
||||
|
||||
# Set default adapter if not set
|
||||
if caps.default_adapter is None:
|
||||
caps.default_adapter = str(path)
|
||||
|
||||
if not caps.adapters:
|
||||
caps.issues.append('No Bluetooth adapters found')
|
||||
|
||||
except Exception as e:
|
||||
caps.issues.append(f'Failed to enumerate adapters: {e}')
|
||||
# Fall back to hciconfig
|
||||
_check_adapters_hciconfig(caps)
|
||||
|
||||
|
||||
def _check_adapters_hciconfig(caps: SystemCapabilities) -> None:
|
||||
"""Check adapters using hciconfig (fallback)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['hciconfig', '-a'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Parse hciconfig output
|
||||
current_adapter = None
|
||||
for line in result.stdout.split('\n'):
|
||||
# Match adapter line (e.g., "hci0: Type: Primary Bus: USB")
|
||||
adapter_match = re.match(r'^(hci\d+):', line)
|
||||
if adapter_match:
|
||||
current_adapter = {
|
||||
'path': f'/org/bluez/{adapter_match.group(1)}',
|
||||
'name': adapter_match.group(1),
|
||||
'address': 'Unknown',
|
||||
'powered': False,
|
||||
'discovering': False,
|
||||
}
|
||||
caps.adapters.append(current_adapter)
|
||||
|
||||
if caps.default_adapter is None:
|
||||
caps.default_adapter = current_adapter['path']
|
||||
|
||||
elif current_adapter:
|
||||
# Parse BD Address
|
||||
addr_match = re.search(r'BD Address: ([0-9A-F:]+)', line, re.I)
|
||||
if addr_match:
|
||||
current_adapter['address'] = addr_match.group(1)
|
||||
|
||||
# Check if UP
|
||||
if 'UP RUNNING' in line:
|
||||
current_adapter['powered'] = True
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _check_rfkill(caps: SystemCapabilities) -> None:
|
||||
"""Check rfkill status for Bluetooth."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['rfkill', 'list', 'bluetooth'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
)
|
||||
if result.returncode == 0:
|
||||
output = result.stdout.lower()
|
||||
caps.is_soft_blocked = 'soft blocked: yes' in output
|
||||
caps.is_hard_blocked = 'hard blocked: yes' in output
|
||||
|
||||
if caps.is_soft_blocked:
|
||||
caps.issues.append('Bluetooth is soft-blocked (rfkill unblock bluetooth)')
|
||||
if caps.is_hard_blocked:
|
||||
caps.issues.append('Bluetooth is hard-blocked (check hardware switch)')
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
||||
"""Check for fallback scanning tools."""
|
||||
# Check bleak (Python BLE library)
|
||||
try:
|
||||
import bleak
|
||||
caps.has_bleak = True
|
||||
except ImportError:
|
||||
caps.has_bleak = False
|
||||
|
||||
# Check hcitool
|
||||
caps.has_hcitool = shutil.which('hcitool') is not None
|
||||
|
||||
# Check bluetoothctl
|
||||
caps.has_bluetoothctl = shutil.which('bluetoothctl') is not None
|
||||
|
||||
# Check btmgmt
|
||||
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
||||
|
||||
# Check CAP_NET_ADMIN for non-root users
|
||||
if not caps.is_root:
|
||||
_check_capabilities_permission(caps)
|
||||
|
||||
|
||||
def _check_capabilities_permission(caps: SystemCapabilities) -> None:
|
||||
"""Check if process has CAP_NET_ADMIN capability."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['capsh', '--print'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
)
|
||||
if result.returncode == 0:
|
||||
caps.has_bluetooth_permission = 'cap_net_admin' in result.stdout.lower()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
# Assume no capabilities if capsh not available
|
||||
pass
|
||||
|
||||
if not caps.has_bluetooth_permission and not caps.is_root:
|
||||
# Check if user is in bluetooth group
|
||||
try:
|
||||
import grp
|
||||
import pwd
|
||||
username = pwd.getpwuid(os.getuid()).pw_name
|
||||
bluetooth_group = grp.getgrnam('bluetooth')
|
||||
if username in bluetooth_group.gr_mem:
|
||||
caps.has_bluetooth_permission = True
|
||||
except (KeyError, ImportError):
|
||||
pass
|
||||
|
||||
|
||||
def _determine_recommended_backend(caps: SystemCapabilities) -> None:
|
||||
"""Determine the recommended scanning backend."""
|
||||
# Prefer DBus/BlueZ if available and working
|
||||
if caps.has_dbus and caps.has_bluez and caps.adapters:
|
||||
if not caps.is_soft_blocked and not caps.is_hard_blocked:
|
||||
caps.recommended_backend = 'dbus'
|
||||
return
|
||||
|
||||
# Fallback to bleak (cross-platform)
|
||||
if caps.has_bleak:
|
||||
caps.recommended_backend = 'bleak'
|
||||
return
|
||||
|
||||
# Fallback to hcitool (requires root)
|
||||
if caps.has_hcitool and caps.is_root:
|
||||
caps.recommended_backend = 'hcitool'
|
||||
return
|
||||
|
||||
# Fallback to bluetoothctl
|
||||
if caps.has_bluetoothctl:
|
||||
caps.recommended_backend = 'bluetoothctl'
|
||||
return
|
||||
|
||||
caps.recommended_backend = 'none'
|
||||
if not caps.issues:
|
||||
caps.issues.append('No suitable Bluetooth scanning backend available')
|
||||
|
||||
|
||||
def quick_adapter_check() -> Optional[str]:
|
||||
"""
|
||||
Quick check to find a working adapter.
|
||||
|
||||
Returns:
|
||||
Adapter path/name if found, None otherwise.
|
||||
"""
|
||||
caps = check_capabilities()
|
||||
return caps.default_adapter
|
||||
220
utils/bluetooth/constants.py
Normal file
220
utils/bluetooth/constants.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Bluetooth-specific constants for the unified scanner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# SCANNER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Default scan duration in seconds
|
||||
DEFAULT_SCAN_DURATION = 10
|
||||
|
||||
# Maximum concurrent observations per device before pruning
|
||||
MAX_RSSI_SAMPLES = 300
|
||||
|
||||
# Device expiration time (seconds since last seen)
|
||||
DEVICE_STALE_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
# Observation history retention (seconds)
|
||||
OBSERVATION_HISTORY_RETENTION = 3600 # 1 hour
|
||||
|
||||
# =============================================================================
|
||||
# RSSI THRESHOLDS FOR RANGE BANDS
|
||||
# =============================================================================
|
||||
|
||||
# RSSI ranges for distance estimation (dBm)
|
||||
RSSI_VERY_CLOSE = -40 # >= -40 dBm
|
||||
RSSI_CLOSE = -55 # -40 to -55 dBm
|
||||
RSSI_NEARBY = -70 # -55 to -70 dBm
|
||||
RSSI_FAR = -85 # -70 to -85 dBm
|
||||
|
||||
# Minimum confidence levels for each range band
|
||||
CONFIDENCE_VERY_CLOSE = 0.7
|
||||
CONFIDENCE_CLOSE = 0.6
|
||||
CONFIDENCE_NEARBY = 0.5
|
||||
CONFIDENCE_FAR = 0.4
|
||||
|
||||
# =============================================================================
|
||||
# HEURISTIC THRESHOLDS
|
||||
# =============================================================================
|
||||
|
||||
# Persistent detection: minimum seen count in analysis window
|
||||
PERSISTENT_MIN_SEEN_COUNT = 10
|
||||
PERSISTENT_WINDOW_SECONDS = 300 # 5 minutes
|
||||
|
||||
# Beacon-like detection: maximum advertisement interval variance (ratio)
|
||||
BEACON_INTERVAL_MAX_VARIANCE = 0.10 # 10%
|
||||
|
||||
# Strong + Stable detection thresholds
|
||||
STRONG_RSSI_THRESHOLD = -50 # dBm
|
||||
STABLE_VARIANCE_THRESHOLD = 5 # dBm variance
|
||||
|
||||
# New device window (seconds since baseline set)
|
||||
NEW_DEVICE_WINDOW = 60
|
||||
|
||||
# =============================================================================
|
||||
# DBUS SETTINGS (BlueZ)
|
||||
# =============================================================================
|
||||
|
||||
# BlueZ DBus service names
|
||||
BLUEZ_SERVICE = 'org.bluez'
|
||||
BLUEZ_ADAPTER_INTERFACE = 'org.bluez.Adapter1'
|
||||
BLUEZ_DEVICE_INTERFACE = 'org.bluez.Device1'
|
||||
DBUS_PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
|
||||
DBUS_OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager'
|
||||
|
||||
# DBus paths
|
||||
BLUEZ_PATH = '/org/bluez'
|
||||
|
||||
# Discovery filter settings
|
||||
DISCOVERY_FILTER_TRANSPORT = 'auto' # 'bredr', 'le', or 'auto'
|
||||
DISCOVERY_FILTER_RSSI = -100 # Minimum RSSI for discovery
|
||||
DISCOVERY_FILTER_DUPLICATE_DATA = True
|
||||
|
||||
# =============================================================================
|
||||
# FALLBACK SCANNER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# bleak scan timeout
|
||||
BLEAK_SCAN_TIMEOUT = 10.0
|
||||
|
||||
# hcitool command timeout
|
||||
HCITOOL_TIMEOUT = 15.0
|
||||
|
||||
# bluetoothctl command timeout
|
||||
BLUETOOTHCTL_TIMEOUT = 10.0
|
||||
|
||||
# btmgmt command timeout
|
||||
BTMGMT_TIMEOUT = 10.0
|
||||
|
||||
# =============================================================================
|
||||
# ADDRESS TYPE CLASSIFICATIONS
|
||||
# =============================================================================
|
||||
|
||||
ADDRESS_TYPE_PUBLIC = 'public'
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# PROTOCOL TYPES
|
||||
# =============================================================================
|
||||
|
||||
PROTOCOL_BLE = 'ble'
|
||||
PROTOCOL_CLASSIC = 'classic'
|
||||
PROTOCOL_AUTO = 'auto'
|
||||
|
||||
# =============================================================================
|
||||
# RANGE BAND NAMES
|
||||
# =============================================================================
|
||||
|
||||
RANGE_VERY_CLOSE = 'very_close'
|
||||
RANGE_CLOSE = 'close'
|
||||
RANGE_NEARBY = 'nearby'
|
||||
RANGE_FAR = 'far'
|
||||
RANGE_UNKNOWN = 'unknown'
|
||||
|
||||
# =============================================================================
|
||||
# COMMON MANUFACTURER IDS (OUI -> Name mapping for common vendors)
|
||||
# =============================================================================
|
||||
|
||||
MANUFACTURER_NAMES = {
|
||||
0x004C: 'Apple, Inc.',
|
||||
0x0006: 'Microsoft',
|
||||
0x000F: 'Broadcom',
|
||||
0x0075: 'Samsung Electronics',
|
||||
0x00E0: 'Google',
|
||||
0x0157: 'Xiaomi',
|
||||
0x0310: 'Bose Corporation',
|
||||
0x0059: 'Nordic Semiconductor',
|
||||
0x0046: 'Sony Corporation',
|
||||
0x0002: 'Intel Corporation',
|
||||
0x0087: 'Garmin International',
|
||||
0x00D2: 'Fitbit',
|
||||
0x0154: 'Huawei Technologies',
|
||||
0x038F: 'Tile, Inc.',
|
||||
0x0301: 'Jabra',
|
||||
0x01DA: 'Anker Innovations',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# BLUETOOTH CLASS OF DEVICE DECODING
|
||||
# =============================================================================
|
||||
|
||||
# Major device classes (bits 12-8 of CoD)
|
||||
MAJOR_DEVICE_CLASSES = {
|
||||
0x00: 'Miscellaneous',
|
||||
0x01: 'Computer',
|
||||
0x02: 'Phone',
|
||||
0x03: 'LAN/Network Access Point',
|
||||
0x04: 'Audio/Video',
|
||||
0x05: 'Peripheral',
|
||||
0x06: 'Imaging',
|
||||
0x07: 'Wearable',
|
||||
0x08: 'Toy',
|
||||
0x09: 'Health',
|
||||
0x1F: 'Uncategorized',
|
||||
}
|
||||
|
||||
# Minor device classes for Audio/Video (0x04)
|
||||
MINOR_AUDIO_VIDEO = {
|
||||
0x00: 'Uncategorized',
|
||||
0x01: 'Wearable Headset',
|
||||
0x02: 'Hands-free Device',
|
||||
0x04: 'Microphone',
|
||||
0x05: 'Loudspeaker',
|
||||
0x06: 'Headphones',
|
||||
0x07: 'Portable Audio',
|
||||
0x08: 'Car Audio',
|
||||
0x09: 'Set-top Box',
|
||||
0x0A: 'HiFi Audio Device',
|
||||
0x0B: 'VCR',
|
||||
0x0C: 'Video Camera',
|
||||
0x0D: 'Camcorder',
|
||||
0x0E: 'Video Monitor',
|
||||
0x0F: 'Video Display and Loudspeaker',
|
||||
0x10: 'Video Conferencing',
|
||||
0x12: 'Gaming/Toy',
|
||||
}
|
||||
|
||||
# Minor device classes for Phone (0x02)
|
||||
MINOR_PHONE = {
|
||||
0x00: 'Uncategorized',
|
||||
0x01: 'Cellular',
|
||||
0x02: 'Cordless',
|
||||
0x03: 'Smartphone',
|
||||
0x04: 'Wired Modem',
|
||||
0x05: 'ISDN Access Point',
|
||||
}
|
||||
|
||||
# Minor device classes for Computer (0x01)
|
||||
MINOR_COMPUTER = {
|
||||
0x00: 'Uncategorized',
|
||||
0x01: 'Desktop Workstation',
|
||||
0x02: 'Server-class Computer',
|
||||
0x03: 'Laptop',
|
||||
0x04: 'Handheld PC/PDA',
|
||||
0x05: 'Palm-size PC/PDA',
|
||||
0x06: 'Wearable Computer',
|
||||
0x07: 'Tablet',
|
||||
}
|
||||
|
||||
# Minor device classes for Peripheral (0x05)
|
||||
MINOR_PERIPHERAL = {
|
||||
0x00: 'Not Keyboard/Pointing Device',
|
||||
0x01: 'Keyboard',
|
||||
0x02: 'Pointing Device',
|
||||
0x03: 'Combo Keyboard/Pointing Device',
|
||||
}
|
||||
|
||||
# Minor device classes for Wearable (0x07)
|
||||
MINOR_WEARABLE = {
|
||||
0x01: 'Wristwatch',
|
||||
0x02: 'Pager',
|
||||
0x03: 'Jacket',
|
||||
0x04: 'Helmet',
|
||||
0x05: 'Glasses',
|
||||
}
|
||||
396
utils/bluetooth/dbus_scanner.py
Normal file
396
utils/bluetooth/dbus_scanner.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
DBus-based BlueZ scanner for Bluetooth device discovery.
|
||||
|
||||
Uses org.bluez signals for real-time device discovery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .constants import (
|
||||
BLUEZ_SERVICE,
|
||||
BLUEZ_PATH,
|
||||
BLUEZ_ADAPTER_INTERFACE,
|
||||
BLUEZ_DEVICE_INTERFACE,
|
||||
DBUS_PROPERTIES_INTERFACE,
|
||||
DBUS_OBJECT_MANAGER_INTERFACE,
|
||||
DISCOVERY_FILTER_TRANSPORT,
|
||||
DISCOVERY_FILTER_RSSI,
|
||||
DISCOVERY_FILTER_DUPLICATE_DATA,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
MAJOR_DEVICE_CLASSES,
|
||||
MINOR_AUDIO_VIDEO,
|
||||
MINOR_PHONE,
|
||||
MINOR_COMPUTER,
|
||||
MINOR_PERIPHERAL,
|
||||
MINOR_WEARABLE,
|
||||
)
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DBusScanner:
|
||||
"""
|
||||
BlueZ DBus-based Bluetooth scanner.
|
||||
|
||||
Subscribes to BlueZ signals for real-time device discovery without polling.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter_path: Optional[str] = None,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize DBus scanner.
|
||||
|
||||
Args:
|
||||
adapter_path: DBus path to adapter (e.g., '/org/bluez/hci0').
|
||||
on_observation: Callback for new observations.
|
||||
"""
|
||||
self._adapter_path = adapter_path
|
||||
self._on_observation = on_observation
|
||||
self._bus = None
|
||||
self._adapter = None
|
||||
self._mainloop = None
|
||||
self._mainloop_thread: Optional[threading.Thread] = None
|
||||
self._is_scanning = False
|
||||
self._lock = threading.Lock()
|
||||
self._known_devices: set[str] = set()
|
||||
|
||||
def start(self, transport: str = 'auto', rssi_threshold: int = -100) -> bool:
|
||||
"""
|
||||
Start DBus discovery.
|
||||
|
||||
Args:
|
||||
transport: Discovery transport ('bredr', 'le', or 'auto').
|
||||
rssi_threshold: Minimum RSSI for discovered devices.
|
||||
|
||||
Returns:
|
||||
True if started successfully, False otherwise.
|
||||
"""
|
||||
try:
|
||||
import dbus
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
|
||||
with self._lock:
|
||||
if self._is_scanning:
|
||||
return True
|
||||
|
||||
# Set up DBus mainloop
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
self._bus = dbus.SystemBus()
|
||||
|
||||
# Get adapter
|
||||
if not self._adapter_path:
|
||||
self._adapter_path = self._find_default_adapter()
|
||||
|
||||
if not self._adapter_path:
|
||||
logger.error("No Bluetooth adapter found")
|
||||
return False
|
||||
|
||||
adapter_obj = self._bus.get_object(BLUEZ_SERVICE, self._adapter_path)
|
||||
self._adapter = dbus.Interface(adapter_obj, BLUEZ_ADAPTER_INTERFACE)
|
||||
adapter_props = dbus.Interface(adapter_obj, DBUS_PROPERTIES_INTERFACE)
|
||||
|
||||
# Set up signal handlers
|
||||
self._bus.add_signal_receiver(
|
||||
self._on_interfaces_added,
|
||||
signal_name='InterfacesAdded',
|
||||
dbus_interface=DBUS_OBJECT_MANAGER_INTERFACE,
|
||||
bus_name=BLUEZ_SERVICE,
|
||||
)
|
||||
|
||||
self._bus.add_signal_receiver(
|
||||
self._on_properties_changed,
|
||||
signal_name='PropertiesChanged',
|
||||
dbus_interface=DBUS_PROPERTIES_INTERFACE,
|
||||
path_keyword='path',
|
||||
)
|
||||
|
||||
# Set discovery filter
|
||||
try:
|
||||
filter_dict = {
|
||||
'Transport': dbus.String(transport if transport != 'auto' else 'auto'),
|
||||
'DuplicateData': dbus.Boolean(DISCOVERY_FILTER_DUPLICATE_DATA),
|
||||
}
|
||||
if rssi_threshold > -100:
|
||||
filter_dict['RSSI'] = dbus.Int16(rssi_threshold)
|
||||
|
||||
self._adapter.SetDiscoveryFilter(filter_dict)
|
||||
except dbus.exceptions.DBusException as e:
|
||||
logger.warning(f"Failed to set discovery filter: {e}")
|
||||
|
||||
# Start discovery
|
||||
try:
|
||||
self._adapter.StartDiscovery()
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if 'InProgress' not in str(e):
|
||||
logger.error(f"Failed to start discovery: {e}")
|
||||
return False
|
||||
|
||||
# Process existing devices
|
||||
self._process_existing_devices()
|
||||
|
||||
# Start mainloop in background thread
|
||||
self._mainloop = GLib.MainLoop()
|
||||
self._mainloop_thread = threading.Thread(
|
||||
target=self._run_mainloop,
|
||||
daemon=True
|
||||
)
|
||||
self._mainloop_thread.start()
|
||||
|
||||
self._is_scanning = True
|
||||
logger.info(f"DBus scanner started on {self._adapter_path}")
|
||||
return True
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Missing DBus dependencies: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start DBus scanner: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop DBus discovery."""
|
||||
with self._lock:
|
||||
if not self._is_scanning:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._adapter:
|
||||
try:
|
||||
self._adapter.StopDiscovery()
|
||||
except Exception as e:
|
||||
logger.debug(f"StopDiscovery error (expected): {e}")
|
||||
|
||||
if self._mainloop and self._mainloop.is_running():
|
||||
self._mainloop.quit()
|
||||
|
||||
if self._mainloop_thread:
|
||||
self._mainloop_thread.join(timeout=2.0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping DBus scanner: {e}")
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
self._adapter = None
|
||||
self._bus = None
|
||||
self._mainloop = None
|
||||
self._mainloop_thread = None
|
||||
logger.info("DBus scanner stopped")
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
"""Check if scanner is active."""
|
||||
with self._lock:
|
||||
return self._is_scanning
|
||||
|
||||
def _run_mainloop(self) -> None:
|
||||
"""Run the GLib mainloop."""
|
||||
try:
|
||||
self._mainloop.run()
|
||||
except Exception as e:
|
||||
logger.error(f"Mainloop error: {e}")
|
||||
|
||||
def _find_default_adapter(self) -> Optional[str]:
|
||||
"""Find the default Bluetooth adapter via DBus."""
|
||||
try:
|
||||
import dbus
|
||||
manager = dbus.Interface(
|
||||
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
DBUS_OBJECT_MANAGER_INTERFACE
|
||||
)
|
||||
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if BLUEZ_ADAPTER_INTERFACE in interfaces:
|
||||
return str(path)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to find adapter: {e}")
|
||||
return None
|
||||
|
||||
def _process_existing_devices(self) -> None:
|
||||
"""Process devices that already exist in BlueZ."""
|
||||
try:
|
||||
import dbus
|
||||
manager = dbus.Interface(
|
||||
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
DBUS_OBJECT_MANAGER_INTERFACE
|
||||
)
|
||||
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
if BLUEZ_DEVICE_INTERFACE in interfaces:
|
||||
props = interfaces[BLUEZ_DEVICE_INTERFACE]
|
||||
self._process_device_properties(str(path), props)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process existing devices: {e}")
|
||||
|
||||
def _on_interfaces_added(self, path: str, interfaces: dict) -> None:
|
||||
"""Handle InterfacesAdded signal (new device discovered)."""
|
||||
if BLUEZ_DEVICE_INTERFACE in interfaces:
|
||||
props = interfaces[BLUEZ_DEVICE_INTERFACE]
|
||||
self._process_device_properties(str(path), props)
|
||||
|
||||
def _on_properties_changed(
|
||||
self,
|
||||
interface: str,
|
||||
changed: dict,
|
||||
invalidated: list,
|
||||
path: str = None
|
||||
) -> None:
|
||||
"""Handle PropertiesChanged signal (device properties updated)."""
|
||||
if interface != BLUEZ_DEVICE_INTERFACE:
|
||||
return
|
||||
|
||||
if path and '/dev_' in path:
|
||||
try:
|
||||
import dbus
|
||||
device_obj = self._bus.get_object(BLUEZ_SERVICE, path)
|
||||
props_iface = dbus.Interface(device_obj, DBUS_PROPERTIES_INTERFACE)
|
||||
all_props = props_iface.GetAll(BLUEZ_DEVICE_INTERFACE)
|
||||
self._process_device_properties(path, all_props)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get device properties for {path}: {e}")
|
||||
|
||||
def _process_device_properties(self, path: str, props: dict) -> None:
|
||||
"""Convert BlueZ device properties to BTObservation."""
|
||||
try:
|
||||
import dbus
|
||||
|
||||
address = str(props.get('Address', ''))
|
||||
if not address:
|
||||
return
|
||||
|
||||
# Determine address type
|
||||
address_type = ADDRESS_TYPE_PUBLIC
|
||||
addr_type_raw = props.get('AddressType', 'public')
|
||||
if addr_type_raw:
|
||||
addr_type_str = str(addr_type_raw).lower()
|
||||
if 'random' in addr_type_str:
|
||||
address_type = ADDRESS_TYPE_RANDOM
|
||||
|
||||
# Extract name
|
||||
name = None
|
||||
if 'Name' in props:
|
||||
name = str(props['Name'])
|
||||
elif 'Alias' in props and props['Alias'] != address:
|
||||
name = str(props['Alias'])
|
||||
|
||||
# Extract RSSI
|
||||
rssi = None
|
||||
if 'RSSI' in props:
|
||||
rssi = int(props['RSSI'])
|
||||
|
||||
# Extract TX Power
|
||||
tx_power = None
|
||||
if 'TxPower' in props:
|
||||
tx_power = int(props['TxPower'])
|
||||
|
||||
# Extract manufacturer data
|
||||
manufacturer_id = None
|
||||
manufacturer_data = None
|
||||
if 'ManufacturerData' in props:
|
||||
mfr_data = props['ManufacturerData']
|
||||
if mfr_data:
|
||||
for mid, mdata in mfr_data.items():
|
||||
manufacturer_id = int(mid)
|
||||
if isinstance(mdata, dbus.Array):
|
||||
manufacturer_data = bytes(mdata)
|
||||
break
|
||||
|
||||
# Extract service UUIDs
|
||||
service_uuids = []
|
||||
if 'UUIDs' in props:
|
||||
for uuid in props['UUIDs']:
|
||||
service_uuids.append(str(uuid))
|
||||
|
||||
# Extract service data
|
||||
service_data = {}
|
||||
if 'ServiceData' in props:
|
||||
for uuid, data in props['ServiceData'].items():
|
||||
if isinstance(data, dbus.Array):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
|
||||
# Extract Class of Device (Classic BT)
|
||||
class_of_device = None
|
||||
major_class = None
|
||||
minor_class = None
|
||||
if 'Class' in props:
|
||||
class_of_device = int(props['Class'])
|
||||
major_class, minor_class = self._decode_class_of_device(class_of_device)
|
||||
|
||||
# Connection state
|
||||
is_connected = bool(props.get('Connected', False))
|
||||
is_paired = bool(props.get('Paired', False))
|
||||
|
||||
# Appearance
|
||||
appearance = None
|
||||
if 'Appearance' in props:
|
||||
appearance = int(props['Appearance'])
|
||||
|
||||
# Create observation
|
||||
observation = BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address.upper(),
|
||||
address_type=address_type,
|
||||
rssi=rssi,
|
||||
tx_power=tx_power,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
appearance=appearance,
|
||||
is_connectable=True, # If we see it in BlueZ, it's connectable
|
||||
is_paired=is_paired,
|
||||
is_connected=is_connected,
|
||||
class_of_device=class_of_device,
|
||||
major_class=major_class,
|
||||
minor_class=minor_class,
|
||||
adapter_id=self._adapter_path,
|
||||
)
|
||||
|
||||
# Callback
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
|
||||
self._known_devices.add(address)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process device properties: {e}")
|
||||
|
||||
def _decode_class_of_device(self, cod: int) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Decode Bluetooth Class of Device."""
|
||||
# Major class is bits 12-8 (5 bits)
|
||||
major_num = (cod >> 8) & 0x1F
|
||||
|
||||
# Minor class is bits 7-2 (6 bits)
|
||||
minor_num = (cod >> 2) & 0x3F
|
||||
|
||||
major_class = MAJOR_DEVICE_CLASSES.get(major_num)
|
||||
|
||||
# Get minor class based on major class
|
||||
minor_class = None
|
||||
if major_num == 0x04: # Audio/Video
|
||||
minor_class = MINOR_AUDIO_VIDEO.get(minor_num)
|
||||
elif major_num == 0x02: # Phone
|
||||
minor_class = MINOR_PHONE.get(minor_num)
|
||||
elif major_num == 0x01: # Computer
|
||||
minor_class = MINOR_COMPUTER.get(minor_num)
|
||||
elif major_num == 0x05: # Peripheral
|
||||
minor_class = MINOR_PERIPHERAL.get(minor_num & 0x03)
|
||||
elif major_num == 0x07: # Wearable
|
||||
minor_class = MINOR_WEARABLE.get(minor_num)
|
||||
|
||||
return major_class, minor_class
|
||||
529
utils/bluetooth/fallback_scanner.py
Normal file
529
utils/bluetooth/fallback_scanner.py
Normal file
@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Fallback Bluetooth scanners when DBus/BlueZ is unavailable.
|
||||
|
||||
Supports:
|
||||
- bleak (cross-platform, async)
|
||||
- hcitool lescan (Linux, requires root)
|
||||
- bluetoothctl (Linux)
|
||||
- btmgmt (Linux)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .constants import (
|
||||
BLEAK_SCAN_TIMEOUT,
|
||||
HCITOOL_TIMEOUT,
|
||||
BLUETOOTHCTL_TIMEOUT,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
MANUFACTURER_NAMES,
|
||||
)
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BleakScanner:
|
||||
"""
|
||||
Cross-platform BLE scanner using bleak library.
|
||||
|
||||
Works on Linux, macOS, and Windows.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
self._on_observation = on_observation
|
||||
self._scanner = None
|
||||
self._is_scanning = False
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, duration: float = BLEAK_SCAN_TIMEOUT) -> bool:
|
||||
"""Start bleak scanning in background thread."""
|
||||
try:
|
||||
import bleak
|
||||
|
||||
if self._is_scanning:
|
||||
return True
|
||||
|
||||
self._stop_event.clear()
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._scan_loop,
|
||||
args=(duration,),
|
||||
daemon=True
|
||||
)
|
||||
self._scan_thread.start()
|
||||
self._is_scanning = True
|
||||
logger.info("Bleak scanner started")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.error("bleak library not installed")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start bleak scanner: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop bleak scanning."""
|
||||
self._stop_event.set()
|
||||
if self._scan_thread:
|
||||
self._scan_thread.join(timeout=2.0)
|
||||
self._is_scanning = False
|
||||
logger.info("Bleak scanner stopped")
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
return self._is_scanning
|
||||
|
||||
def _scan_loop(self, duration: float) -> None:
|
||||
"""Run scanning in async event loop."""
|
||||
try:
|
||||
asyncio.run(self._async_scan(duration))
|
||||
except Exception as e:
|
||||
logger.error(f"Bleak scan error: {e}")
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
|
||||
async def _async_scan(self, duration: float) -> None:
|
||||
"""Async scanning coroutine."""
|
||||
try:
|
||||
from bleak import BleakScanner as BleakLib
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
def detection_callback(device: BLEDevice, adv_data: AdvertisementData):
|
||||
if self._stop_event.is_set():
|
||||
return
|
||||
|
||||
try:
|
||||
observation = self._convert_bleak_device(device, adv_data)
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error converting bleak device: {e}")
|
||||
|
||||
scanner = BleakLib(detection_callback=detection_callback)
|
||||
await scanner.start()
|
||||
|
||||
# Wait for duration or stop event
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while not self._stop_event.is_set():
|
||||
await asyncio.sleep(0.1)
|
||||
if duration > 0 and (asyncio.get_event_loop().time() - start_time) >= duration:
|
||||
break
|
||||
|
||||
await scanner.stop()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Async scan error: {e}")
|
||||
|
||||
def _convert_bleak_device(self, device, adv_data) -> BTObservation:
|
||||
"""Convert bleak device to BTObservation."""
|
||||
# Determine address type from address format
|
||||
address_type = ADDRESS_TYPE_PUBLIC
|
||||
if 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
|
||||
address_type = ADDRESS_TYPE_RANDOM
|
||||
|
||||
# Extract manufacturer data
|
||||
manufacturer_id = None
|
||||
manufacturer_data = None
|
||||
if adv_data.manufacturer_data:
|
||||
for mid, mdata in adv_data.manufacturer_data.items():
|
||||
manufacturer_id = mid
|
||||
manufacturer_data = bytes(mdata)
|
||||
break
|
||||
|
||||
# Extract service data
|
||||
service_data = {}
|
||||
if adv_data.service_data:
|
||||
for uuid, data in adv_data.service_data.items():
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
|
||||
return BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=device.address.upper() if device.address else '',
|
||||
address_type=address_type,
|
||||
rssi=adv_data.rssi,
|
||||
tx_power=adv_data.tx_power,
|
||||
name=adv_data.local_name or device.name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||
service_data=service_data,
|
||||
is_connectable=device.metadata.get('connectable', True) if hasattr(device, 'metadata') else True,
|
||||
)
|
||||
|
||||
|
||||
class HcitoolScanner:
|
||||
"""
|
||||
Linux hcitool-based scanner for BLE devices.
|
||||
|
||||
Requires root privileges.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start hcitool lescan."""
|
||||
try:
|
||||
if self._is_scanning:
|
||||
return True
|
||||
|
||||
# Start hcitool lescan with duplicate reporting
|
||||
self._process = subprocess.Popen(
|
||||
['hcitool', '-i', self._adapter, 'lescan', '--duplicates'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_output,
|
||||
daemon=True
|
||||
)
|
||||
self._reader_thread.start()
|
||||
self._is_scanning = True
|
||||
logger.info(f"hcitool scanner started on {self._adapter}")
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("hcitool not found")
|
||||
return False
|
||||
except PermissionError:
|
||||
logger.error("hcitool requires root privileges")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start hcitool scanner: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop hcitool scanning."""
|
||||
self._stop_event.set()
|
||||
if self._process:
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=2.0)
|
||||
except Exception:
|
||||
self._process.kill()
|
||||
self._process = None
|
||||
|
||||
if self._reader_thread:
|
||||
self._reader_thread.join(timeout=2.0)
|
||||
|
||||
self._is_scanning = False
|
||||
logger.info("hcitool scanner stopped")
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
return self._is_scanning
|
||||
|
||||
def _read_output(self) -> None:
|
||||
"""Read hcitool output and parse devices."""
|
||||
try:
|
||||
# Also start hcidump in parallel for RSSI values
|
||||
dump_process = None
|
||||
try:
|
||||
dump_process = subprocess.Popen(
|
||||
['hcidump', '-i', self._adapter, '--raw'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while not self._stop_event.is_set() and self._process:
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
# Parse hcitool output: "AA:BB:CC:DD:EE:FF DeviceName"
|
||||
match = re.match(r'^([0-9A-Fa-f:]{17})\s*(.*)$', line.strip())
|
||||
if match:
|
||||
address = match.group(1).upper()
|
||||
name = match.group(2).strip() or None
|
||||
|
||||
observation = BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=name if name and name != '(unknown)' else None,
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
|
||||
if dump_process:
|
||||
dump_process.terminate()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"hcitool read error: {e}")
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
|
||||
|
||||
class BluetoothctlScanner:
|
||||
"""
|
||||
Linux bluetoothctl-based scanner.
|
||||
|
||||
Works without root but may have limited data.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._devices: dict[str, dict] = {}
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start bluetoothctl scanning."""
|
||||
try:
|
||||
if self._is_scanning:
|
||||
return True
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
['bluetoothctl'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_output,
|
||||
daemon=True
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
# Send scan on command
|
||||
self._process.stdin.write('scan on\n')
|
||||
self._process.stdin.flush()
|
||||
|
||||
self._is_scanning = True
|
||||
logger.info("bluetoothctl scanner started")
|
||||
return True
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.error("bluetoothctl not found")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start bluetoothctl scanner: {e}")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop bluetoothctl scanning."""
|
||||
self._stop_event.set()
|
||||
|
||||
if self._process:
|
||||
try:
|
||||
self._process.stdin.write('scan off\n')
|
||||
self._process.stdin.write('quit\n')
|
||||
self._process.stdin.flush()
|
||||
self._process.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
self._process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = None
|
||||
|
||||
if self._reader_thread:
|
||||
self._reader_thread.join(timeout=2.0)
|
||||
|
||||
self._is_scanning = False
|
||||
logger.info("bluetoothctl scanner stopped")
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
return self._is_scanning
|
||||
|
||||
def _read_output(self) -> None:
|
||||
"""Read bluetoothctl output and parse devices."""
|
||||
try:
|
||||
while not self._stop_event.is_set() and self._process:
|
||||
line = self._process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
line = line.strip()
|
||||
|
||||
# Parse device discovery lines
|
||||
# [NEW] Device AA:BB:CC:DD:EE:FF DeviceName
|
||||
# [CHG] Device AA:BB:CC:DD:EE:FF RSSI: -65
|
||||
# [CHG] Device AA:BB:CC:DD:EE:FF Name: DeviceName
|
||||
|
||||
new_match = re.search(
|
||||
r'\[NEW\]\s+Device\s+([0-9A-Fa-f:]{17})\s*(.*)',
|
||||
line
|
||||
)
|
||||
if new_match:
|
||||
address = new_match.group(1).upper()
|
||||
name = new_match.group(2).strip() or None
|
||||
|
||||
self._devices[address] = {
|
||||
'address': address,
|
||||
'name': name,
|
||||
'rssi': None,
|
||||
}
|
||||
|
||||
observation = BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=name,
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
continue
|
||||
|
||||
# RSSI change
|
||||
rssi_match = re.search(
|
||||
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+RSSI:\s*(-?\d+)',
|
||||
line
|
||||
)
|
||||
if rssi_match:
|
||||
address = rssi_match.group(1).upper()
|
||||
rssi = int(rssi_match.group(2))
|
||||
|
||||
device_data = self._devices.get(address, {'address': address})
|
||||
device_data['rssi'] = rssi
|
||||
self._devices[address] = device_data
|
||||
|
||||
observation = BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=device_data.get('name'),
|
||||
rssi=rssi,
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
continue
|
||||
|
||||
# Name change
|
||||
name_match = re.search(
|
||||
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+Name:\s*(.+)',
|
||||
line
|
||||
)
|
||||
if name_match:
|
||||
address = name_match.group(1).upper()
|
||||
name = name_match.group(2).strip()
|
||||
|
||||
device_data = self._devices.get(address, {'address': address})
|
||||
device_data['name'] = name
|
||||
self._devices[address] = device_data
|
||||
|
||||
observation = BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=name,
|
||||
rssi=device_data.get('rssi'),
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
self._on_observation(observation)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"bluetoothctl read error: {e}")
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
|
||||
|
||||
class FallbackScanner:
|
||||
"""
|
||||
Unified fallback scanner that selects the best available backend.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
self._on_observation = on_observation
|
||||
self._active_scanner: Optional[object] = None
|
||||
self._backend: Optional[str] = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start scanning with best available backend."""
|
||||
# Try bleak first (cross-platform)
|
||||
try:
|
||||
import bleak
|
||||
self._active_scanner = BleakScanner(on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'bleak'
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try hcitool (requires root)
|
||||
try:
|
||||
self._active_scanner = HcitoolScanner(
|
||||
adapter=self._adapter,
|
||||
on_observation=self._on_observation
|
||||
)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'hcitool'
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try bluetoothctl
|
||||
try:
|
||||
self._active_scanner = BluetoothctlScanner(on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'bluetoothctl'
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.error("No fallback scanner available")
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop active scanner."""
|
||||
if self._active_scanner:
|
||||
self._active_scanner.stop()
|
||||
self._active_scanner = None
|
||||
self._backend = None
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
return self._active_scanner.is_scanning if self._active_scanner else False
|
||||
|
||||
@property
|
||||
def backend(self) -> Optional[str]:
|
||||
return self._backend
|
||||
205
utils/bluetooth/heuristics.py
Normal file
205
utils/bluetooth/heuristics.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Heuristics engine for Bluetooth device analysis.
|
||||
|
||||
Provides factual, observable heuristics without making tracker detection claims.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import statistics
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
PERSISTENT_MIN_SEEN_COUNT,
|
||||
PERSISTENT_WINDOW_SECONDS,
|
||||
BEACON_INTERVAL_MAX_VARIANCE,
|
||||
STRONG_RSSI_THRESHOLD,
|
||||
STABLE_VARIANCE_THRESHOLD,
|
||||
)
|
||||
from .models import BTDeviceAggregate
|
||||
|
||||
|
||||
class HeuristicsEngine:
|
||||
"""
|
||||
Evaluates observable device behaviors without making tracker detection claims.
|
||||
|
||||
Heuristics provided:
|
||||
- is_new: Device not in baseline (appeared after baseline was set)
|
||||
- is_persistent: Continuously present over time window
|
||||
- is_beacon_like: Regular advertising pattern
|
||||
- is_strong_stable: Very close with consistent signal
|
||||
- has_random_address: Uses privacy-preserving random address
|
||||
"""
|
||||
|
||||
def evaluate(self, device: BTDeviceAggregate) -> None:
|
||||
"""
|
||||
Evaluate all heuristics for a device and update its flags.
|
||||
|
||||
Args:
|
||||
device: The BTDeviceAggregate to evaluate.
|
||||
"""
|
||||
# Note: is_new and has_random_address are set by the aggregator
|
||||
# Here we evaluate the behavioral heuristics
|
||||
|
||||
device.is_persistent = self._check_persistent(device)
|
||||
device.is_beacon_like = self._check_beacon_like(device)
|
||||
device.is_strong_stable = self._check_strong_stable(device)
|
||||
|
||||
def _check_persistent(self, device: BTDeviceAggregate) -> bool:
|
||||
"""
|
||||
Check if device is persistently present.
|
||||
|
||||
A device is considered persistent if it has been seen frequently
|
||||
over the analysis window.
|
||||
"""
|
||||
if device.seen_count < PERSISTENT_MIN_SEEN_COUNT:
|
||||
return False
|
||||
|
||||
# Check if the observations span a reasonable time window
|
||||
duration = device.duration_seconds
|
||||
if duration < PERSISTENT_WINDOW_SECONDS * 0.5: # At least half the window
|
||||
return False
|
||||
|
||||
# Check seen rate (should be reasonably consistent)
|
||||
# Minimum 2 observations per minute for persistent
|
||||
min_rate = 2.0
|
||||
return device.seen_rate >= min_rate
|
||||
|
||||
def _check_beacon_like(self, device: BTDeviceAggregate) -> bool:
|
||||
"""
|
||||
Check if device has beacon-like advertising pattern.
|
||||
|
||||
Beacon-like devices advertise at regular intervals with low variance.
|
||||
"""
|
||||
if len(device.rssi_samples) < 10:
|
||||
return False
|
||||
|
||||
# Calculate advertisement intervals
|
||||
intervals = self._calculate_intervals(device)
|
||||
if len(intervals) < 5:
|
||||
return False
|
||||
|
||||
# Check interval consistency
|
||||
mean_interval = statistics.mean(intervals)
|
||||
if mean_interval <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
stdev_interval = statistics.stdev(intervals)
|
||||
# Coefficient of variation (CV) = stdev / mean
|
||||
cv = stdev_interval / mean_interval
|
||||
return cv < BEACON_INTERVAL_MAX_VARIANCE
|
||||
except statistics.StatisticsError:
|
||||
return False
|
||||
|
||||
def _check_strong_stable(self, device: BTDeviceAggregate) -> bool:
|
||||
"""
|
||||
Check if device has strong and stable signal.
|
||||
|
||||
Strong + stable indicates the device is very close and stationary.
|
||||
"""
|
||||
if device.rssi_median is None or device.rssi_variance is None:
|
||||
return False
|
||||
|
||||
# Must be strong signal
|
||||
if device.rssi_median < STRONG_RSSI_THRESHOLD:
|
||||
return False
|
||||
|
||||
# Must have low variance (stable)
|
||||
if device.rssi_variance > STABLE_VARIANCE_THRESHOLD:
|
||||
return False
|
||||
|
||||
# Must have reasonable sample count for confidence
|
||||
if len(device.rssi_samples) < 5:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _calculate_intervals(self, device: BTDeviceAggregate) -> list[float]:
|
||||
"""Calculate time intervals between observations."""
|
||||
if len(device.rssi_samples) < 2:
|
||||
return []
|
||||
|
||||
intervals = []
|
||||
prev_time = device.rssi_samples[0][0]
|
||||
for timestamp, _ in device.rssi_samples[1:]:
|
||||
interval = (timestamp - prev_time).total_seconds()
|
||||
# Filter out unreasonably long intervals (gaps in scanning)
|
||||
if 0 < interval < 30: # Max 30 seconds between observations
|
||||
intervals.append(interval)
|
||||
prev_time = timestamp
|
||||
|
||||
return intervals
|
||||
|
||||
def get_heuristic_summary(self, device: BTDeviceAggregate) -> dict:
|
||||
"""
|
||||
Get a summary of heuristic analysis for a device.
|
||||
|
||||
Returns:
|
||||
Dictionary with heuristic flags and explanations.
|
||||
"""
|
||||
summary = {
|
||||
'flags': [],
|
||||
'details': {}
|
||||
}
|
||||
|
||||
if device.is_new:
|
||||
summary['flags'].append('new')
|
||||
summary['details']['new'] = 'Device appeared after baseline was set'
|
||||
|
||||
if device.is_persistent:
|
||||
summary['flags'].append('persistent')
|
||||
summary['details']['persistent'] = (
|
||||
f'Seen {device.seen_count} times over '
|
||||
f'{device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)'
|
||||
)
|
||||
|
||||
if device.is_beacon_like:
|
||||
summary['flags'].append('beacon_like')
|
||||
intervals = self._calculate_intervals(device)
|
||||
if intervals:
|
||||
mean_int = statistics.mean(intervals)
|
||||
summary['details']['beacon_like'] = (
|
||||
f'Regular advertising interval (~{mean_int:.1f}s)'
|
||||
)
|
||||
else:
|
||||
summary['details']['beacon_like'] = 'Regular advertising pattern'
|
||||
|
||||
if device.is_strong_stable:
|
||||
summary['flags'].append('strong_stable')
|
||||
summary['details']['strong_stable'] = (
|
||||
f'Strong signal ({device.rssi_median:.0f} dBm) '
|
||||
f'with low variance ({device.rssi_variance:.1f})'
|
||||
)
|
||||
|
||||
if device.has_random_address:
|
||||
summary['flags'].append('random_address')
|
||||
summary['details']['random_address'] = (
|
||||
f'Uses {device.address_type} address (privacy-preserving)'
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def evaluate_device_heuristics(device: BTDeviceAggregate) -> None:
|
||||
"""
|
||||
Convenience function to evaluate heuristics for a single device.
|
||||
|
||||
Args:
|
||||
device: The BTDeviceAggregate to evaluate.
|
||||
"""
|
||||
engine = HeuristicsEngine()
|
||||
engine.evaluate(device)
|
||||
|
||||
|
||||
def evaluate_all_devices(devices: list[BTDeviceAggregate]) -> None:
|
||||
"""
|
||||
Evaluate heuristics for multiple devices.
|
||||
|
||||
Args:
|
||||
devices: List of BTDeviceAggregate instances to evaluate.
|
||||
"""
|
||||
engine = HeuristicsEngine()
|
||||
for device in devices:
|
||||
engine.evaluate(device)
|
||||
355
utils/bluetooth/models.py
Normal file
355
utils/bluetooth/models.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Bluetooth data models for the unified scanner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
MANUFACTURER_NAMES,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
RANGE_UNKNOWN,
|
||||
PROTOCOL_BLE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BTObservation:
|
||||
"""Represents a single Bluetooth advertisement or inquiry response."""
|
||||
|
||||
timestamp: datetime
|
||||
address: str
|
||||
address_type: str = ADDRESS_TYPE_PUBLIC # public, random, random_static, rpa, nrpa
|
||||
rssi: Optional[int] = None
|
||||
tx_power: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data: Optional[bytes] = None
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||
appearance: Optional[int] = None
|
||||
is_connectable: bool = False
|
||||
is_paired: bool = False
|
||||
is_connected: bool = False
|
||||
class_of_device: Optional[int] = None # Classic BT only
|
||||
major_class: Optional[str] = None
|
||||
minor_class: Optional[str] = None
|
||||
adapter_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Unique device identifier combining address and type."""
|
||||
return f"{self.address}:{self.address_type}"
|
||||
|
||||
@property
|
||||
def manufacturer_name(self) -> Optional[str]:
|
||||
"""Look up manufacturer name from ID."""
|
||||
if self.manufacturer_id is not None:
|
||||
return MANUFACTURER_NAMES.get(self.manufacturer_id)
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'address': self.address,
|
||||
'address_type': self.address_type,
|
||||
'device_id': self.device_id,
|
||||
'rssi': self.rssi,
|
||||
'tx_power': self.tx_power,
|
||||
'name': self.name,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'manufacturer_data': self.manufacturer_data.hex() if self.manufacturer_data else None,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
'appearance': self.appearance,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_paired': self.is_paired,
|
||||
'is_connected': self.is_connected,
|
||||
'class_of_device': self.class_of_device,
|
||||
'major_class': self.major_class,
|
||||
'minor_class': self.minor_class,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BTDeviceAggregate:
|
||||
"""Aggregated Bluetooth device data over time."""
|
||||
|
||||
device_id: str # f"{address}:{address_type}"
|
||||
address: str
|
||||
address_type: str
|
||||
protocol: str = PROTOCOL_BLE # 'ble' or 'classic'
|
||||
|
||||
# Timestamps
|
||||
first_seen: datetime = field(default_factory=datetime.now)
|
||||
last_seen: datetime = field(default_factory=datetime.now)
|
||||
seen_count: int = 0
|
||||
seen_rate: float = 0.0 # observations per minute
|
||||
|
||||
# RSSI aggregation (capped at MAX_RSSI_SAMPLES samples)
|
||||
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
|
||||
rssi_current: Optional[int] = None
|
||||
rssi_median: Optional[float] = None
|
||||
rssi_min: Optional[int] = None
|
||||
rssi_max: Optional[int] = None
|
||||
rssi_variance: Optional[float] = None
|
||||
rssi_confidence: float = 0.0 # 0.0-1.0
|
||||
|
||||
# Range band (very_close/close/nearby/far/unknown)
|
||||
range_band: str = RANGE_UNKNOWN
|
||||
range_confidence: float = 0.0
|
||||
|
||||
# Device info (merged from observations)
|
||||
name: Optional[str] = None
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
manufacturer_bytes: Optional[bytes] = None
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
tx_power: Optional[int] = None
|
||||
appearance: Optional[int] = None
|
||||
class_of_device: Optional[int] = None
|
||||
major_class: Optional[str] = None
|
||||
minor_class: Optional[str] = None
|
||||
is_connectable: bool = False
|
||||
is_paired: bool = False
|
||||
is_connected: bool = False
|
||||
|
||||
# Heuristic flags
|
||||
is_new: bool = False
|
||||
is_persistent: bool = False
|
||||
is_beacon_like: bool = False
|
||||
is_strong_stable: bool = False
|
||||
has_random_address: bool = False
|
||||
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
|
||||
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
|
||||
"""Get RSSI history for sparkline visualization."""
|
||||
if not self.rssi_samples:
|
||||
return []
|
||||
|
||||
# Downsample if needed
|
||||
samples = self.rssi_samples[-max_points:]
|
||||
return [
|
||||
{'timestamp': ts.isoformat(), 'rssi': rssi}
|
||||
for ts, rssi in samples
|
||||
]
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float:
|
||||
"""Seconds since last seen."""
|
||||
return (datetime.now() - self.last_seen).total_seconds()
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
"""Total duration from first to last seen."""
|
||||
return (self.last_seen - self.first_seen).total_seconds()
|
||||
|
||||
@property
|
||||
def heuristic_flags(self) -> list[str]:
|
||||
"""List of active heuristic flags."""
|
||||
flags = []
|
||||
if self.is_new:
|
||||
flags.append('new')
|
||||
if self.is_persistent:
|
||||
flags.append('persistent')
|
||||
if self.is_beacon_like:
|
||||
flags.append('beacon_like')
|
||||
if self.is_strong_stable:
|
||||
flags.append('strong_stable')
|
||||
if self.has_random_address:
|
||||
flags.append('random_address')
|
||||
return flags
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'device_id': self.device_id,
|
||||
'address': self.address,
|
||||
'address_type': self.address_type,
|
||||
'protocol': self.protocol,
|
||||
|
||||
# Timestamps
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'duration_seconds': self.duration_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'seen_rate': round(self.seen_rate, 2),
|
||||
|
||||
# RSSI stats
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median 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,
|
||||
'rssi_confidence': round(self.rssi_confidence, 2),
|
||||
'rssi_history': self.get_rssi_history(),
|
||||
|
||||
# Range
|
||||
'range_band': self.range_band,
|
||||
'range_confidence': round(self.range_confidence, 2),
|
||||
|
||||
# Device info
|
||||
'name': self.name,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||
'service_uuids': self.service_uuids,
|
||||
'tx_power': self.tx_power,
|
||||
'appearance': self.appearance,
|
||||
'class_of_device': self.class_of_device,
|
||||
'major_class': self.major_class,
|
||||
'minor_class': self.minor_class,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_paired': self.is_paired,
|
||||
'is_connected': self.is_connected,
|
||||
|
||||
# Heuristics
|
||||
'heuristics': {
|
||||
'is_new': self.is_new,
|
||||
'is_persistent': self.is_persistent,
|
||||
'is_beacon_like': self.is_beacon_like,
|
||||
'is_strong_stable': self.is_strong_stable,
|
||||
'has_random_address': self.has_random_address,
|
||||
},
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
"""Compact dictionary for list views."""
|
||||
return {
|
||||
'device_id': self.device_id,
|
||||
'address': self.address,
|
||||
'address_type': self.address_type,
|
||||
'protocol': self.protocol,
|
||||
'name': self.name,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||
'range_band': self.range_band,
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanStatus:
|
||||
"""Current scanning status."""
|
||||
|
||||
is_scanning: bool = False
|
||||
mode: str = 'auto' # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
|
||||
backend: Optional[str] = None # Active backend being used
|
||||
adapter_id: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
duration_s: Optional[int] = None
|
||||
devices_found: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
@property
|
||||
def elapsed_seconds(self) -> Optional[float]:
|
||||
"""Seconds since scan started."""
|
||||
if self.started_at:
|
||||
return (datetime.now() - self.started_at).total_seconds()
|
||||
return None
|
||||
|
||||
@property
|
||||
def remaining_seconds(self) -> Optional[float]:
|
||||
"""Seconds remaining if duration was set."""
|
||||
if self.duration_s and self.elapsed_seconds:
|
||||
return max(0, self.duration_s - self.elapsed_seconds)
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'is_scanning': self.is_scanning,
|
||||
'mode': self.mode,
|
||||
'backend': self.backend,
|
||||
'adapter_id': self.adapter_id,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'duration_s': self.duration_s,
|
||||
'elapsed_seconds': round(self.elapsed_seconds, 1) if self.elapsed_seconds else None,
|
||||
'remaining_seconds': round(self.remaining_seconds, 1) if self.remaining_seconds else None,
|
||||
'devices_found': self.devices_found,
|
||||
'error': self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemCapabilities:
|
||||
"""Bluetooth system capabilities check result."""
|
||||
|
||||
# DBus/BlueZ
|
||||
has_dbus: bool = False
|
||||
has_bluez: bool = False
|
||||
bluez_version: Optional[str] = None
|
||||
|
||||
# Adapters
|
||||
adapters: list[dict] = field(default_factory=list)
|
||||
default_adapter: Optional[str] = None
|
||||
|
||||
# Permissions
|
||||
has_bluetooth_permission: bool = False
|
||||
is_root: bool = False
|
||||
|
||||
# rfkill status
|
||||
is_soft_blocked: bool = False
|
||||
is_hard_blocked: bool = False
|
||||
|
||||
# Fallback tools
|
||||
has_bleak: bool = False
|
||||
has_hcitool: bool = False
|
||||
has_bluetoothctl: bool = False
|
||||
has_btmgmt: bool = False
|
||||
|
||||
# Recommended backend
|
||||
recommended_backend: str = 'none'
|
||||
|
||||
# Issues found
|
||||
issues: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def can_scan(self) -> bool:
|
||||
"""Whether scanning is possible with any backend."""
|
||||
return (
|
||||
(self.has_dbus and self.has_bluez and len(self.adapters) > 0) or
|
||||
self.has_bleak or
|
||||
self.has_hcitool or
|
||||
self.has_bluetoothctl
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'has_dbus': self.has_dbus,
|
||||
'has_bluez': self.has_bluez,
|
||||
'bluez_version': self.bluez_version,
|
||||
'adapters': self.adapters,
|
||||
'default_adapter': self.default_adapter,
|
||||
'has_bluetooth_permission': self.has_bluetooth_permission,
|
||||
'is_root': self.is_root,
|
||||
'is_soft_blocked': self.is_soft_blocked,
|
||||
'is_hard_blocked': self.is_hard_blocked,
|
||||
'has_bleak': self.has_bleak,
|
||||
'has_hcitool': self.has_hcitool,
|
||||
'has_bluetoothctl': self.has_bluetoothctl,
|
||||
'has_btmgmt': self.has_btmgmt,
|
||||
'recommended_backend': self.recommended_backend,
|
||||
'can_scan': self.can_scan,
|
||||
'issues': self.issues,
|
||||
}
|
||||
413
utils/bluetooth/scanner.py
Normal file
413
utils/bluetooth/scanner.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
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: Optional[Callable[[BTDeviceAggregate], None]] = 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
|
||||
|
||||
if mode == 'auto':
|
||||
mode = self._capabilities.recommended_backend
|
||||
|
||||
if mode == 'dbus' or (mode == 'auto' and self._capabilities.has_dbus):
|
||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||
|
||||
if not started and mode in ('bleak', 'hcitool', 'bluetoothctl', 'auto'):
|
||||
started, backend_used = self._start_fallback(adapter, 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(),
|
||||
})
|
||||
|
||||
# Callback
|
||||
if self._on_device_updated:
|
||||
self._on_device_updated(device)
|
||||
|
||||
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."""
|
||||
self._on_device_updated = callback
|
||||
|
||||
@property
|
||||
def is_scanning(self) -> bool:
|
||||
"""Check if scanning is active."""
|
||||
return self._status.is_scanning
|
||||
|
||||
@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
|
||||
Reference in New Issue
Block a user