mirror of
https://github.com/smittix/intercept.git
synced 2026-07-06 00:28:12 -07:00
style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote style, trailing commas, and whitespace across 188 Python files. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+46
-58
@@ -48,74 +48,62 @@ from .tracker_signatures import (
|
||||
|
||||
__all__ = [
|
||||
# Main scanner
|
||||
'BluetoothScanner',
|
||||
'get_bluetooth_scanner',
|
||||
'reset_bluetooth_scanner',
|
||||
|
||||
"BluetoothScanner",
|
||||
"get_bluetooth_scanner",
|
||||
"reset_bluetooth_scanner",
|
||||
# Models
|
||||
'BTObservation',
|
||||
'BTDeviceAggregate',
|
||||
'ScanStatus',
|
||||
'SystemCapabilities',
|
||||
|
||||
"BTObservation",
|
||||
"BTDeviceAggregate",
|
||||
"ScanStatus",
|
||||
"SystemCapabilities",
|
||||
# Aggregator
|
||||
'DeviceAggregator',
|
||||
|
||||
"DeviceAggregator",
|
||||
# Device key generation
|
||||
'generate_device_key',
|
||||
'is_randomized_mac',
|
||||
'extract_key_type',
|
||||
|
||||
"generate_device_key",
|
||||
"is_randomized_mac",
|
||||
"extract_key_type",
|
||||
# Distance estimation
|
||||
'DistanceEstimator',
|
||||
'ProximityBand',
|
||||
'get_distance_estimator',
|
||||
|
||||
"DistanceEstimator",
|
||||
"ProximityBand",
|
||||
"get_distance_estimator",
|
||||
# Ring buffer
|
||||
'RingBuffer',
|
||||
'get_ring_buffer',
|
||||
'reset_ring_buffer',
|
||||
|
||||
"RingBuffer",
|
||||
"get_ring_buffer",
|
||||
"reset_ring_buffer",
|
||||
# Heuristics
|
||||
'HeuristicsEngine',
|
||||
'evaluate_device_heuristics',
|
||||
'evaluate_all_devices',
|
||||
|
||||
"HeuristicsEngine",
|
||||
"evaluate_device_heuristics",
|
||||
"evaluate_all_devices",
|
||||
# Capability checks
|
||||
'check_capabilities',
|
||||
'quick_adapter_check',
|
||||
|
||||
"check_capabilities",
|
||||
"quick_adapter_check",
|
||||
# Constants - Range bands (legacy)
|
||||
'RANGE_VERY_CLOSE',
|
||||
'RANGE_CLOSE',
|
||||
'RANGE_NEARBY',
|
||||
'RANGE_FAR',
|
||||
'RANGE_UNKNOWN',
|
||||
|
||||
"RANGE_VERY_CLOSE",
|
||||
"RANGE_CLOSE",
|
||||
"RANGE_NEARBY",
|
||||
"RANGE_FAR",
|
||||
"RANGE_UNKNOWN",
|
||||
# Constants - Proximity bands (new)
|
||||
'PROXIMITY_IMMEDIATE',
|
||||
'PROXIMITY_NEAR',
|
||||
'PROXIMITY_FAR',
|
||||
'PROXIMITY_UNKNOWN',
|
||||
|
||||
"PROXIMITY_IMMEDIATE",
|
||||
"PROXIMITY_NEAR",
|
||||
"PROXIMITY_FAR",
|
||||
"PROXIMITY_UNKNOWN",
|
||||
# Constants - Protocols
|
||||
'PROTOCOL_BLE',
|
||||
'PROTOCOL_CLASSIC',
|
||||
'PROTOCOL_AUTO',
|
||||
|
||||
"PROTOCOL_BLE",
|
||||
"PROTOCOL_CLASSIC",
|
||||
"PROTOCOL_AUTO",
|
||||
# Constants - Address types
|
||||
'ADDRESS_TYPE_PUBLIC',
|
||||
'ADDRESS_TYPE_RANDOM',
|
||||
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||
'ADDRESS_TYPE_RPA',
|
||||
'ADDRESS_TYPE_NRPA',
|
||||
|
||||
"ADDRESS_TYPE_PUBLIC",
|
||||
"ADDRESS_TYPE_RANDOM",
|
||||
"ADDRESS_TYPE_RANDOM_STATIC",
|
||||
"ADDRESS_TYPE_RPA",
|
||||
"ADDRESS_TYPE_NRPA",
|
||||
# Tracker detection
|
||||
'TrackerSignatureEngine',
|
||||
'TrackerDetectionResult',
|
||||
'TrackerType',
|
||||
'TrackerConfidence',
|
||||
'DeviceFingerprint',
|
||||
'detect_tracker',
|
||||
'get_tracker_engine',
|
||||
"TrackerSignatureEngine",
|
||||
"TrackerDetectionResult",
|
||||
"TrackerType",
|
||||
"TrackerConfidence",
|
||||
"DeviceFingerprint",
|
||||
"detect_tracker",
|
||||
"get_tracker_engine",
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -64,10 +65,11 @@ def _check_dbus(caps: SystemCapabilities) -> None:
|
||||
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)')
|
||||
caps.issues.append("Python dbus module not installed (pip install dbus-python)")
|
||||
|
||||
|
||||
def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
@@ -77,6 +79,7 @@ def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
|
||||
try:
|
||||
import dbus
|
||||
|
||||
bus = dbus.SystemBus()
|
||||
|
||||
# Check if BlueZ service exists
|
||||
@@ -87,10 +90,7 @@ def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
# Try to get BlueZ version from bluetoothd
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['bluetoothd', '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
["bluetoothd", "--version"], capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||
)
|
||||
if result.returncode == 0:
|
||||
caps.bluez_version = result.stdout.strip()
|
||||
@@ -99,14 +99,14 @@ def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
|
||||
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)')
|
||||
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}')
|
||||
caps.issues.append(f"BlueZ DBus error: {e}")
|
||||
|
||||
except Exception as e:
|
||||
caps.has_bluez = False
|
||||
caps.issues.append(f'DBus connection error: {e}')
|
||||
caps.issues.append(f"DBus connection error: {e}")
|
||||
|
||||
|
||||
def _check_adapters(caps: SystemCapabilities) -> None:
|
||||
@@ -118,24 +118,22 @@ def _check_adapters(caps: SystemCapabilities) -> None:
|
||||
|
||||
try:
|
||||
import dbus
|
||||
|
||||
bus = dbus.SystemBus()
|
||||
manager = dbus.Interface(
|
||||
bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
'org.freedesktop.DBus.ObjectManager'
|
||||
)
|
||||
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']
|
||||
if "org.bluez.Adapter1" in interfaces:
|
||||
adapter_props = interfaces["org.bluez.Adapter1"]
|
||||
adapter_info = {
|
||||
'id': str(path), # Alias for frontend
|
||||
'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', '')),
|
||||
"id": str(path), # Alias for frontend
|
||||
"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)
|
||||
|
||||
@@ -144,10 +142,10 @@ def _check_adapters(caps: SystemCapabilities) -> None:
|
||||
caps.default_adapter = str(path)
|
||||
|
||||
if not caps.adapters:
|
||||
caps.issues.append('No Bluetooth adapters found')
|
||||
caps.issues.append("No Bluetooth adapters found")
|
||||
|
||||
except Exception as e:
|
||||
caps.issues.append(f'Failed to enumerate adapters: {e}')
|
||||
caps.issues.append(f"Failed to enumerate adapters: {e}")
|
||||
# Fall back to hciconfig
|
||||
_check_adapters_hciconfig(caps)
|
||||
|
||||
@@ -155,42 +153,37 @@ def _check_adapters(caps: SystemCapabilities) -> None:
|
||||
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
|
||||
)
|
||||
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'):
|
||||
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)
|
||||
adapter_match = re.match(r"^(hci\d+):", line)
|
||||
if adapter_match:
|
||||
adapter_name = adapter_match.group(1)
|
||||
current_adapter = {
|
||||
'id': adapter_name, # Alias for frontend
|
||||
'path': f'/org/bluez/{adapter_name}',
|
||||
'name': adapter_name,
|
||||
'address': 'Unknown',
|
||||
'powered': False,
|
||||
'discovering': False,
|
||||
"id": adapter_name, # Alias for frontend
|
||||
"path": f"/org/bluez/{adapter_name}",
|
||||
"name": adapter_name,
|
||||
"address": "Unknown",
|
||||
"powered": False,
|
||||
"discovering": False,
|
||||
}
|
||||
caps.adapters.append(current_adapter)
|
||||
|
||||
if caps.default_adapter is None:
|
||||
caps.default_adapter = current_adapter['path']
|
||||
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)
|
||||
addr_match = re.search(r"BD Address: ([0-9A-F:]+)", line, re.I)
|
||||
if addr_match:
|
||||
current_adapter['address'] = addr_match.group(1)
|
||||
current_adapter["address"] = addr_match.group(1)
|
||||
|
||||
# Check if UP
|
||||
if 'UP RUNNING' in line:
|
||||
current_adapter['powered'] = True
|
||||
if "UP RUNNING" in line:
|
||||
current_adapter["powered"] = True
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
@@ -200,20 +193,17 @@ 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
|
||||
["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
|
||||
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)')
|
||||
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)')
|
||||
caps.issues.append("Bluetooth is hard-blocked (check hardware switch)")
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
@@ -224,21 +214,22 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
||||
# 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
|
||||
caps.has_hcitool = shutil.which("hcitool") is not None
|
||||
|
||||
# Check bluetoothctl
|
||||
caps.has_bluetoothctl = shutil.which('bluetoothctl') is not None
|
||||
caps.has_bluetoothctl = shutil.which("bluetoothctl") is not None
|
||||
|
||||
# Check btmgmt
|
||||
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
||||
caps.has_btmgmt = shutil.which("btmgmt") is not None
|
||||
|
||||
# Check ubertooth tools (Ubertooth One hardware)
|
||||
caps.has_ubertooth = shutil.which('ubertooth-btle') is not None
|
||||
caps.has_ubertooth = shutil.which("ubertooth-btle") is not None
|
||||
|
||||
# Check CAP_NET_ADMIN for non-root users
|
||||
if not caps.is_root:
|
||||
@@ -248,14 +239,9 @@ def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
||||
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
|
||||
)
|
||||
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()
|
||||
caps.has_bluetooth_permission = "cap_net_admin" in result.stdout.lower()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
# Assume no capabilities if capsh not available
|
||||
pass
|
||||
@@ -265,8 +251,9 @@ def _check_capabilities_permission(caps: SystemCapabilities) -> None:
|
||||
try:
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
username = pwd.getpwuid(os.getuid()).pw_name
|
||||
bluetooth_group = grp.getgrnam('bluetooth')
|
||||
bluetooth_group = grp.getgrnam("bluetooth")
|
||||
if username in bluetooth_group.gr_mem:
|
||||
caps.has_bluetooth_permission = True
|
||||
except (KeyError, ImportError):
|
||||
@@ -280,28 +267,28 @@ def _determine_recommended_backend(caps: SystemCapabilities) -> None:
|
||||
|
||||
# Prefer bleak (cross-platform, works in Flask)
|
||||
if caps.has_bleak:
|
||||
caps.recommended_backend = 'bleak'
|
||||
caps.recommended_backend = "bleak"
|
||||
return
|
||||
|
||||
# Fallback to hcitool (requires root on Linux)
|
||||
if caps.has_hcitool and caps.is_root:
|
||||
caps.recommended_backend = 'hcitool'
|
||||
caps.recommended_backend = "hcitool"
|
||||
return
|
||||
|
||||
# Fallback to bluetoothctl
|
||||
if caps.has_bluetoothctl:
|
||||
caps.recommended_backend = 'bluetoothctl'
|
||||
caps.recommended_backend = "bluetoothctl"
|
||||
return
|
||||
|
||||
# DBus is last resort - won't work properly with Flask but keep as option
|
||||
# for potential future use with a separate scanning daemon
|
||||
if caps.has_dbus and caps.has_bluez and caps.adapters and not caps.is_soft_blocked and not caps.is_hard_blocked:
|
||||
caps.recommended_backend = 'dbus'
|
||||
caps.recommended_backend = "dbus"
|
||||
return
|
||||
|
||||
caps.recommended_backend = 'none'
|
||||
caps.recommended_backend = "none"
|
||||
if not caps.issues:
|
||||
caps.issues.append('No suitable Bluetooth scanning backend available')
|
||||
caps.issues.append("No suitable Bluetooth scanning backend available")
|
||||
|
||||
|
||||
def quick_adapter_check() -> str | None:
|
||||
|
||||
+141
-141
@@ -25,10 +25,10 @@ OBSERVATION_HISTORY_RETENTION = 3600 # 1 hour
|
||||
# =============================================================================
|
||||
|
||||
# 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
|
||||
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
|
||||
@@ -59,17 +59,17 @@ NEW_DEVICE_WINDOW = 60
|
||||
# =============================================================================
|
||||
|
||||
# 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'
|
||||
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'
|
||||
BLUEZ_PATH = "/org/bluez"
|
||||
|
||||
# Discovery filter settings
|
||||
DISCOVERY_FILTER_TRANSPORT = 'auto' # 'bredr', 'le', or 'auto'
|
||||
DISCOVERY_FILTER_TRANSPORT = "auto" # 'bredr', 'le', or 'auto'
|
||||
DISCOVERY_FILTER_RSSI = -100 # Minimum RSSI for discovery
|
||||
DISCOVERY_FILTER_DUPLICATE_DATA = True
|
||||
|
||||
@@ -96,44 +96,44 @@ SUBPROCESS_TIMEOUT_SHORT = 5.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
|
||||
ADDRESS_TYPE_UUID = 'uuid' # CoreBluetooth platform UUID (macOS, no real MAC available)
|
||||
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
|
||||
ADDRESS_TYPE_UUID = "uuid" # CoreBluetooth platform UUID (macOS, no real MAC available)
|
||||
|
||||
# =============================================================================
|
||||
# PROTOCOL TYPES
|
||||
# =============================================================================
|
||||
|
||||
PROTOCOL_BLE = 'ble'
|
||||
PROTOCOL_CLASSIC = 'classic'
|
||||
PROTOCOL_AUTO = 'auto'
|
||||
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'
|
||||
RANGE_VERY_CLOSE = "very_close"
|
||||
RANGE_CLOSE = "close"
|
||||
RANGE_NEARBY = "nearby"
|
||||
RANGE_FAR = "far"
|
||||
RANGE_UNKNOWN = "unknown"
|
||||
|
||||
# =============================================================================
|
||||
# PROXIMITY BANDS (new visualization system)
|
||||
# =============================================================================
|
||||
|
||||
PROXIMITY_IMMEDIATE = 'immediate' # < 1m
|
||||
PROXIMITY_NEAR = 'near' # 1-3m
|
||||
PROXIMITY_FAR = 'far' # 3-10m
|
||||
PROXIMITY_UNKNOWN = 'unknown'
|
||||
PROXIMITY_IMMEDIATE = "immediate" # < 1m
|
||||
PROXIMITY_NEAR = "near" # 1-3m
|
||||
PROXIMITY_FAR = "far" # 3-10m
|
||||
PROXIMITY_UNKNOWN = "unknown"
|
||||
|
||||
# RSSI thresholds for proximity band classification (dBm)
|
||||
PROXIMITY_RSSI_IMMEDIATE = -40 # >= -40 dBm -> immediate
|
||||
PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near
|
||||
PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far
|
||||
PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near
|
||||
PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far
|
||||
|
||||
# =============================================================================
|
||||
# DISTANCE ESTIMATION SETTINGS
|
||||
@@ -149,7 +149,7 @@ DISTANCE_RSSI_AT_1M = -59
|
||||
DISTANCE_EMA_ALPHA = 0.3
|
||||
|
||||
# Variance thresholds for confidence scoring (dBm^2)
|
||||
DISTANCE_LOW_VARIANCE = 25.0 # High confidence
|
||||
DISTANCE_LOW_VARIANCE = 25.0 # High confidence
|
||||
DISTANCE_HIGH_VARIANCE = 100.0 # Low confidence
|
||||
|
||||
# =============================================================================
|
||||
@@ -183,22 +183,22 @@ HEATMAP_MAX_DEVICES = 50
|
||||
# =============================================================================
|
||||
|
||||
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',
|
||||
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",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -207,77 +207,77 @@ MANUFACTURER_NAMES = {
|
||||
|
||||
# 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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
0x01: "Wristwatch",
|
||||
0x02: "Pager",
|
||||
0x03: "Jacket",
|
||||
0x04: "Helmet",
|
||||
0x05: "Glasses",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -285,48 +285,48 @@ MINOR_WEARABLE = {
|
||||
# =============================================================================
|
||||
|
||||
BLE_APPEARANCE_NAMES: dict[int, str] = {
|
||||
0: 'Unknown',
|
||||
64: 'Phone',
|
||||
128: 'Computer',
|
||||
192: 'Watch',
|
||||
193: 'Sports Watch',
|
||||
256: 'Clock',
|
||||
320: 'Display',
|
||||
384: 'Remote Control',
|
||||
448: 'Eye Glasses',
|
||||
512: 'Tag',
|
||||
576: 'Keyring',
|
||||
640: 'Media Player',
|
||||
704: 'Barcode Scanner',
|
||||
768: 'Thermometer',
|
||||
832: 'Heart Rate Sensor',
|
||||
896: 'Blood Pressure',
|
||||
960: 'HID',
|
||||
961: 'Keyboard',
|
||||
962: 'Mouse',
|
||||
963: 'Joystick',
|
||||
964: 'Gamepad',
|
||||
965: 'Digitizer Tablet',
|
||||
966: 'Card Reader',
|
||||
967: 'Digital Pen',
|
||||
968: 'Barcode Scanner (HID)',
|
||||
1024: 'Glucose Monitor',
|
||||
1088: 'Running Speed Sensor',
|
||||
1152: 'Cycling',
|
||||
1216: 'Control Device',
|
||||
1280: 'Network Device',
|
||||
1344: 'Sensor',
|
||||
1408: 'Light Fixture',
|
||||
1472: 'Fan',
|
||||
1536: 'HVAC',
|
||||
1600: 'Access Control',
|
||||
1664: 'Motorized Device',
|
||||
1728: 'Power Device',
|
||||
1792: 'Light Source',
|
||||
3136: 'Pulse Oximeter',
|
||||
3200: 'Weight Scale',
|
||||
3264: 'Personal Mobility',
|
||||
5184: 'Outdoor Sports Activity',
|
||||
0: "Unknown",
|
||||
64: "Phone",
|
||||
128: "Computer",
|
||||
192: "Watch",
|
||||
193: "Sports Watch",
|
||||
256: "Clock",
|
||||
320: "Display",
|
||||
384: "Remote Control",
|
||||
448: "Eye Glasses",
|
||||
512: "Tag",
|
||||
576: "Keyring",
|
||||
640: "Media Player",
|
||||
704: "Barcode Scanner",
|
||||
768: "Thermometer",
|
||||
832: "Heart Rate Sensor",
|
||||
896: "Blood Pressure",
|
||||
960: "HID",
|
||||
961: "Keyboard",
|
||||
962: "Mouse",
|
||||
963: "Joystick",
|
||||
964: "Gamepad",
|
||||
965: "Digitizer Tablet",
|
||||
966: "Card Reader",
|
||||
967: "Digital Pen",
|
||||
968: "Barcode Scanner (HID)",
|
||||
1024: "Glucose Monitor",
|
||||
1088: "Running Speed Sensor",
|
||||
1152: "Cycling",
|
||||
1216: "Control Device",
|
||||
1280: "Network Device",
|
||||
1344: "Sensor",
|
||||
1408: "Light Fixture",
|
||||
1472: "Fan",
|
||||
1536: "HVAC",
|
||||
1600: "Access Control",
|
||||
1664: "Motorized Device",
|
||||
1728: "Power Device",
|
||||
1792: "Light Source",
|
||||
3136: "Pulse Oximeter",
|
||||
3200: "Weight Scale",
|
||||
3264: "Personal Mobility",
|
||||
5184: "Outdoor Sports Activity",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class DBusScanner:
|
||||
self._lock = threading.Lock()
|
||||
self._known_devices: set[str] = set()
|
||||
|
||||
def start(self, transport: str = 'auto', rssi_threshold: int = -100) -> bool:
|
||||
def start(self, transport: str = "auto", rssi_threshold: int = -100) -> bool:
|
||||
"""
|
||||
Start DBus discovery.
|
||||
|
||||
@@ -100,26 +100,26 @@ class DBusScanner:
|
||||
# Set up signal handlers
|
||||
self._bus.add_signal_receiver(
|
||||
self._on_interfaces_added,
|
||||
signal_name='InterfacesAdded',
|
||||
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',
|
||||
signal_name="PropertiesChanged",
|
||||
dbus_interface=DBUS_PROPERTIES_INTERFACE,
|
||||
path_keyword='path',
|
||||
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),
|
||||
"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)
|
||||
filter_dict["RSSI"] = dbus.Int16(rssi_threshold)
|
||||
|
||||
self._adapter.SetDiscoveryFilter(filter_dict)
|
||||
except dbus.exceptions.DBusException as e:
|
||||
@@ -129,7 +129,7 @@ class DBusScanner:
|
||||
try:
|
||||
self._adapter.StartDiscovery()
|
||||
except dbus.exceptions.DBusException as e:
|
||||
if 'InProgress' not in str(e):
|
||||
if "InProgress" not in str(e):
|
||||
logger.error(f"Failed to start discovery: {e}")
|
||||
return False
|
||||
|
||||
@@ -138,10 +138,7 @@ class DBusScanner:
|
||||
|
||||
# Start mainloop in background thread
|
||||
self._mainloop = GLib.MainLoop()
|
||||
self._mainloop_thread = threading.Thread(
|
||||
target=self._run_mainloop,
|
||||
daemon=True
|
||||
)
|
||||
self._mainloop_thread = threading.Thread(target=self._run_mainloop, daemon=True)
|
||||
self._mainloop_thread.start()
|
||||
|
||||
self._is_scanning = True
|
||||
@@ -201,10 +198,8 @@ class DBusScanner:
|
||||
"""Find the default Bluetooth adapter via DBus."""
|
||||
try:
|
||||
import dbus
|
||||
manager = dbus.Interface(
|
||||
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
DBUS_OBJECT_MANAGER_INTERFACE
|
||||
)
|
||||
|
||||
manager = dbus.Interface(self._bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OBJECT_MANAGER_INTERFACE)
|
||||
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
@@ -219,10 +214,8 @@ class DBusScanner:
|
||||
"""Process devices that already exist in BlueZ."""
|
||||
try:
|
||||
import dbus
|
||||
manager = dbus.Interface(
|
||||
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||
DBUS_OBJECT_MANAGER_INTERFACE
|
||||
)
|
||||
|
||||
manager = dbus.Interface(self._bus.get_object(BLUEZ_SERVICE, "/"), DBUS_OBJECT_MANAGER_INTERFACE)
|
||||
|
||||
objects = manager.GetManagedObjects()
|
||||
for path, interfaces in objects.items():
|
||||
@@ -239,20 +232,15 @@ class DBusScanner:
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
@@ -265,40 +253,40 @@ class DBusScanner:
|
||||
try:
|
||||
import dbus
|
||||
|
||||
address = str(props.get('Address', ''))
|
||||
address = str(props.get("Address", ""))
|
||||
if not address:
|
||||
return
|
||||
|
||||
# Determine address type
|
||||
address_type = ADDRESS_TYPE_PUBLIC
|
||||
addr_type_raw = props.get('AddressType', '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:
|
||||
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'])
|
||||
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'])
|
||||
if "RSSI" in props:
|
||||
rssi = int(props["RSSI"])
|
||||
|
||||
# Extract TX Power
|
||||
tx_power = None
|
||||
if 'TxPower' in props:
|
||||
tx_power = int(props['TxPower'])
|
||||
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 "ManufacturerData" in props:
|
||||
mfr_data = props["ManufacturerData"]
|
||||
if mfr_data:
|
||||
for mid, mdata in mfr_data.items():
|
||||
manufacturer_id = int(mid)
|
||||
@@ -314,14 +302,14 @@ class DBusScanner:
|
||||
|
||||
# Extract service UUIDs
|
||||
service_uuids = []
|
||||
if 'UUIDs' in props:
|
||||
for uuid in props['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 "ServiceData" in props:
|
||||
for uuid, data in props["ServiceData"].items():
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray, dbus.Array, list, tuple)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
@@ -334,18 +322,18 @@ class DBusScanner:
|
||||
class_of_device = None
|
||||
major_class = None
|
||||
minor_class = None
|
||||
if 'Class' in props:
|
||||
class_of_device = int(props['Class'])
|
||||
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))
|
||||
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'])
|
||||
if "Appearance" in props:
|
||||
appearance = int(props["Appearance"])
|
||||
|
||||
# Create observation
|
||||
observation = BTObservation(
|
||||
|
||||
@@ -119,6 +119,6 @@ def extract_key_type(device_key: str) -> str:
|
||||
Returns:
|
||||
The key type ('id', 'mac', or 'fp').
|
||||
"""
|
||||
if ':' in device_key:
|
||||
return device_key.split(':', 1)[0]
|
||||
return 'unknown'
|
||||
if ":" in device_key:
|
||||
return device_key.split(":", 1)[0]
|
||||
return "unknown"
|
||||
|
||||
@@ -12,10 +12,11 @@ from enum import Enum
|
||||
|
||||
class ProximityBand(str, Enum):
|
||||
"""Proximity band classifications."""
|
||||
IMMEDIATE = 'immediate' # < 1m
|
||||
NEAR = 'near' # 1-3m
|
||||
FAR = 'far' # 3-10m
|
||||
UNKNOWN = 'unknown' # Cannot determine
|
||||
|
||||
IMMEDIATE = "immediate" # < 1m
|
||||
NEAR = "near" # 1-3m
|
||||
FAR = "far" # 3-10m
|
||||
UNKNOWN = "unknown" # Cannot determine
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
@@ -26,8 +27,8 @@ DEFAULT_PATH_LOSS_EXPONENT = 2.5
|
||||
|
||||
# RSSI thresholds for band classification (dBm)
|
||||
RSSI_THRESHOLD_IMMEDIATE = -40 # >= -40 dBm
|
||||
RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm
|
||||
RSSI_THRESHOLD_FAR = -75 # >= -75 dBm
|
||||
RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm
|
||||
RSSI_THRESHOLD_FAR = -75 # >= -75 dBm
|
||||
|
||||
# Default reference RSSI at 1 meter (typical BLE)
|
||||
DEFAULT_RSSI_AT_1M = -59
|
||||
@@ -36,8 +37,8 @@ DEFAULT_RSSI_AT_1M = -59
|
||||
DEFAULT_EMA_ALPHA = 0.3
|
||||
|
||||
# Variance thresholds for confidence scoring
|
||||
LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2
|
||||
HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2
|
||||
LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2
|
||||
HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2
|
||||
|
||||
|
||||
class DistanceEstimator:
|
||||
@@ -117,7 +118,7 @@ class DistanceEstimator:
|
||||
Estimated distance in meters.
|
||||
"""
|
||||
exponent = (tx_power - rssi) / (10 * self.path_loss_exponent)
|
||||
distance = 10 ** exponent
|
||||
distance = 10**exponent
|
||||
# Clamp to reasonable range
|
||||
return max(0.1, min(100.0, distance))
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from .constants import (
|
||||
)
|
||||
|
||||
# CoreBluetooth UUID pattern: 8-4-4-4-12 hex digits
|
||||
_CB_UUID_RE = re.compile(r'^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')
|
||||
_CB_UUID_RE = re.compile(r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$")
|
||||
import contextlib
|
||||
|
||||
from .models import BTObservation
|
||||
@@ -60,11 +60,7 @@ class BleakScanner:
|
||||
return True
|
||||
|
||||
self._stop_event.clear()
|
||||
self._scan_thread = threading.Thread(
|
||||
target=self._scan_loop,
|
||||
args=(duration,),
|
||||
daemon=True
|
||||
)
|
||||
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")
|
||||
@@ -138,9 +134,9 @@ class BleakScanner:
|
||||
if device.address and _CB_UUID_RE.match(device.address):
|
||||
# macOS CoreBluetooth returns a platform UUID instead of a real MAC
|
||||
address_type = ADDRESS_TYPE_UUID
|
||||
elif device.address and ':' in device.address:
|
||||
elif device.address and ":" in device.address:
|
||||
# Check if first byte indicates random address
|
||||
first_byte = int(device.address.split(':')[0], 16)
|
||||
first_byte = int(device.address.split(":")[0], 16)
|
||||
if (first_byte & 0xC0) == 0xC0: # Random static
|
||||
address_type = ADDRESS_TYPE_RANDOM
|
||||
|
||||
@@ -178,7 +174,7 @@ class BleakScanner:
|
||||
|
||||
return BTObservation(
|
||||
timestamp=datetime.now(),
|
||||
address=device.address.upper() if device.address else '',
|
||||
address=device.address.upper() if device.address else "",
|
||||
address_type=address_type,
|
||||
rssi=adv_data.rssi,
|
||||
tx_power=adv_data.tx_power,
|
||||
@@ -187,7 +183,7 @@ class BleakScanner:
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||
service_data=service_data,
|
||||
is_connectable=getattr(adv_data, 'connectable', True) if adv_data else True,
|
||||
is_connectable=getattr(adv_data, "connectable", True) if adv_data else True,
|
||||
)
|
||||
|
||||
|
||||
@@ -200,7 +196,7 @@ class HcitoolScanner:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
adapter: str = "hci0",
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
@@ -218,17 +214,14 @@ class HcitoolScanner:
|
||||
|
||||
# Start hcitool lescan with duplicate reporting
|
||||
self._process = subprocess.Popen(
|
||||
['hcitool', '-i', self._adapter, 'lescan', '--duplicates'],
|
||||
["hcitool", "-i", self._adapter, "lescan", "--duplicates"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
text=True,
|
||||
)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_output,
|
||||
daemon=True
|
||||
)
|
||||
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}")
|
||||
@@ -272,7 +265,7 @@ class HcitoolScanner:
|
||||
dump_process = None
|
||||
with contextlib.suppress(Exception):
|
||||
dump_process = subprocess.Popen(
|
||||
['hcidump', '-i', self._adapter, '--raw'],
|
||||
["hcidump", "-i", self._adapter, "--raw"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
@@ -283,7 +276,7 @@ class HcitoolScanner:
|
||||
break
|
||||
|
||||
# Parse hcitool output: "AA:BB:CC:DD:EE:FF DeviceName"
|
||||
match = re.match(r'^([0-9A-Fa-f:]{17})\s*(.*)$', line.strip())
|
||||
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
|
||||
@@ -292,7 +285,7 @@ class HcitoolScanner:
|
||||
timestamp=datetime.now(),
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=name if name and name != '(unknown)' else None,
|
||||
name=name if name and name != "(unknown)" else None,
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
@@ -332,22 +325,15 @@ class BluetoothctlScanner:
|
||||
return True
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
['bluetoothctl'],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
["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 = 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.write("scan on\n")
|
||||
self._process.stdin.flush()
|
||||
|
||||
self._is_scanning = True
|
||||
@@ -367,8 +353,8 @@ class BluetoothctlScanner:
|
||||
|
||||
if self._process:
|
||||
try:
|
||||
self._process.stdin.write('scan off\n')
|
||||
self._process.stdin.write('quit\n')
|
||||
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:
|
||||
@@ -401,18 +387,15 @@ class BluetoothctlScanner:
|
||||
# [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
|
||||
)
|
||||
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,
|
||||
"address": address,
|
||||
"name": name,
|
||||
"rssi": None,
|
||||
}
|
||||
|
||||
observation = BTObservation(
|
||||
@@ -427,23 +410,20 @@ class BluetoothctlScanner:
|
||||
continue
|
||||
|
||||
# RSSI change
|
||||
rssi_match = re.search(
|
||||
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+RSSI:\s*(-?\d+)',
|
||||
line
|
||||
)
|
||||
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
|
||||
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'),
|
||||
name=device_data.get("name"),
|
||||
rssi=rssi,
|
||||
)
|
||||
|
||||
@@ -452,16 +432,13 @@ class BluetoothctlScanner:
|
||||
continue
|
||||
|
||||
# Name change
|
||||
name_match = re.search(
|
||||
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+Name:\s*(.+)',
|
||||
line
|
||||
)
|
||||
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
|
||||
device_data = self._devices.get(address, {"address": address})
|
||||
device_data["name"] = name
|
||||
self._devices[address] = device_data
|
||||
|
||||
observation = BTObservation(
|
||||
@@ -469,7 +446,7 @@ class BluetoothctlScanner:
|
||||
address=address,
|
||||
address_type=ADDRESS_TYPE_PUBLIC,
|
||||
name=name,
|
||||
rssi=device_data.get('rssi'),
|
||||
rssi=device_data.get("rssi"),
|
||||
)
|
||||
|
||||
if self._on_observation:
|
||||
@@ -488,7 +465,7 @@ class FallbackScanner:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
adapter: str = "hci0",
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
@@ -501,21 +478,19 @@ class FallbackScanner:
|
||||
# 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'
|
||||
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
|
||||
)
|
||||
self._active_scanner = HcitoolScanner(adapter=self._adapter, on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'hcitool'
|
||||
self._backend = "hcitool"
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -524,7 +499,7 @@ class FallbackScanner:
|
||||
try:
|
||||
self._active_scanner = BluetoothctlScanner(on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'bluetoothctl'
|
||||
self._backend = "bluetoothctl"
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -532,9 +507,10 @@ class FallbackScanner:
|
||||
# Try ubertooth (raw packet capture with Ubertooth One hardware)
|
||||
try:
|
||||
from .ubertooth_scanner import UbertoothScanner
|
||||
|
||||
self._active_scanner = UbertoothScanner(on_observation=self._on_observation)
|
||||
if self._active_scanner.start():
|
||||
self._backend = 'ubertooth'
|
||||
self._backend = "ubertooth"
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -11,7 +11,7 @@ import platform
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger('intercept.bt.irk_extractor')
|
||||
logger = logging.getLogger("intercept.bt.irk_extractor")
|
||||
|
||||
# Cache paired IRKs for 30 seconds to avoid repeated disk reads
|
||||
_cache: list[dict] | None = None
|
||||
@@ -38,9 +38,9 @@ def get_paired_irks() -> list[dict]:
|
||||
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == 'Darwin':
|
||||
if system == "Darwin":
|
||||
results = _extract_macos()
|
||||
elif system == 'Linux':
|
||||
elif system == "Linux":
|
||||
results = _extract_linux()
|
||||
else:
|
||||
logger.debug(f"IRK extraction not supported on {system}")
|
||||
@@ -58,24 +58,24 @@ def _extract_macos() -> list[dict]:
|
||||
"""Extract IRKs from macOS Bluetooth plist."""
|
||||
import plistlib
|
||||
|
||||
plist_path = Path('/Library/Preferences/com.apple.Bluetooth.plist')
|
||||
plist_path = Path("/Library/Preferences/com.apple.Bluetooth.plist")
|
||||
if not plist_path.exists():
|
||||
logger.debug("macOS Bluetooth plist not found")
|
||||
return []
|
||||
|
||||
with open(plist_path, 'rb') as f:
|
||||
with open(plist_path, "rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
devices = []
|
||||
|
||||
cache_data = plist.get('CoreBluetoothCache', {})
|
||||
cache_data = plist.get("CoreBluetoothCache", {})
|
||||
|
||||
# CoreBluetoothCache contains BLE device info including IRKs
|
||||
for device_uuid, device_info in cache_data.items():
|
||||
if not isinstance(device_info, dict):
|
||||
continue
|
||||
|
||||
irk = device_info.get('IRK')
|
||||
irk = device_info.get("IRK")
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
@@ -83,57 +83,61 @@ def _extract_macos() -> list[dict]:
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
irk_hex = irk.replace("-", "").replace(" ", "")
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
name = device_info.get('Name') or device_info.get('DeviceName')
|
||||
address = device_info.get('DeviceAddress', device_uuid)
|
||||
addr_type = 'random' if device_info.get('AddressType', 1) == 1 else 'public'
|
||||
name = device_info.get("Name") or device_info.get("DeviceName")
|
||||
address = device_info.get("DeviceAddress", device_uuid)
|
||||
addr_type = "random" if device_info.get("AddressType", 1) == 1 else "public"
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(address),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
devices.append(
|
||||
{
|
||||
"name": name,
|
||||
"address": str(address),
|
||||
"irk_hex": irk_hex,
|
||||
"address_type": addr_type,
|
||||
}
|
||||
)
|
||||
|
||||
# Also check LEPairedDevices / PairedDevices structures
|
||||
for section_key in ('LEPairedDevices', 'PairedDevices'):
|
||||
for section_key in ("LEPairedDevices", "PairedDevices"):
|
||||
section = plist.get(section_key, {})
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for addr, dev_info in section.items():
|
||||
if not isinstance(dev_info, dict):
|
||||
continue
|
||||
irk = dev_info.get('IRK') or dev_info.get('IdentityResolvingKey')
|
||||
irk = dev_info.get("IRK") or dev_info.get("IdentityResolvingKey")
|
||||
if irk is None:
|
||||
continue
|
||||
|
||||
if isinstance(irk, bytes) and len(irk) == 16:
|
||||
irk_hex = irk.hex()
|
||||
elif isinstance(irk, str):
|
||||
irk_hex = irk.replace('-', '').replace(' ', '')
|
||||
irk_hex = irk.replace("-", "").replace(" ", "")
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Skip if we already have this IRK
|
||||
if any(d['irk_hex'] == irk_hex for d in devices):
|
||||
if any(d["irk_hex"] == irk_hex for d in devices):
|
||||
continue
|
||||
|
||||
name = dev_info.get('Name') or dev_info.get('DeviceName')
|
||||
addr_type = 'random' if dev_info.get('AddressType', 1) == 1 else 'public'
|
||||
name = dev_info.get("Name") or dev_info.get("DeviceName")
|
||||
addr_type = "random" if dev_info.get("AddressType", 1) == 1 else "public"
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': str(addr),
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
devices.append(
|
||||
{
|
||||
"name": name,
|
||||
"address": str(addr),
|
||||
"irk_hex": irk_hex,
|
||||
"address_type": addr_type,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from macOS paired devices")
|
||||
return devices
|
||||
@@ -147,7 +151,7 @@ def _extract_linux() -> list[dict]:
|
||||
"""
|
||||
import configparser
|
||||
|
||||
bt_root = Path('/var/lib/bluetooth')
|
||||
bt_root = Path("/var/lib/bluetooth")
|
||||
if not bt_root.exists():
|
||||
logger.debug("BlueZ bluetooth directory not found")
|
||||
return []
|
||||
@@ -161,7 +165,7 @@ def _extract_linux() -> list[dict]:
|
||||
if not device_dir.is_dir():
|
||||
continue
|
||||
|
||||
info_file = device_dir / 'info'
|
||||
info_file = device_dir / "info"
|
||||
if not info_file.exists():
|
||||
continue
|
||||
|
||||
@@ -171,28 +175,30 @@ def _extract_linux() -> list[dict]:
|
||||
except (configparser.Error, OSError):
|
||||
continue
|
||||
|
||||
if not config.has_section('IdentityResolvingKey'):
|
||||
if not config.has_section("IdentityResolvingKey"):
|
||||
continue
|
||||
|
||||
irk_hex = config.get('IdentityResolvingKey', 'Key', fallback=None)
|
||||
irk_hex = config.get("IdentityResolvingKey", "Key", fallback=None)
|
||||
if not irk_hex:
|
||||
continue
|
||||
|
||||
# BlueZ stores as hex string, may or may not have separators
|
||||
irk_hex = irk_hex.replace(' ', '').replace('-', '')
|
||||
irk_hex = irk_hex.replace(" ", "").replace("-", "")
|
||||
if len(irk_hex) != 32:
|
||||
continue
|
||||
|
||||
name = config.get('General', 'Name', fallback=None)
|
||||
name = config.get("General", "Name", fallback=None)
|
||||
address = device_dir.name # Directory name is the MAC address
|
||||
addr_type = config.get('General', 'AddressType', fallback=None)
|
||||
addr_type = config.get("General", "AddressType", fallback=None)
|
||||
|
||||
devices.append({
|
||||
'name': name,
|
||||
'address': address,
|
||||
'irk_hex': irk_hex,
|
||||
'address_type': addr_type,
|
||||
})
|
||||
devices.append(
|
||||
{
|
||||
"name": name,
|
||||
"address": address,
|
||||
"irk_hex": irk_hex,
|
||||
"address_type": addr_type,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Extracted {len(devices)} IRK(s) from BlueZ paired devices")
|
||||
return devices
|
||||
|
||||
+179
-194
@@ -62,25 +62,25 @@ class BTObservation:
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -180,10 +180,7 @@ class BTDeviceAggregate:
|
||||
|
||||
# Downsample if needed
|
||||
samples = self.rssi_samples[-max_points:]
|
||||
return [
|
||||
{'timestamp': ts.isoformat(), 'rssi': rssi}
|
||||
for ts, rssi in samples
|
||||
]
|
||||
return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples]
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float:
|
||||
@@ -200,172 +197,160 @@ class BTDeviceAggregate:
|
||||
"""List of active heuristic flags."""
|
||||
flags = []
|
||||
if self.is_new:
|
||||
flags.append('new')
|
||||
flags.append("new")
|
||||
if self.is_persistent:
|
||||
flags.append('persistent')
|
||||
flags.append("persistent")
|
||||
if self.is_beacon_like:
|
||||
flags.append('beacon_like')
|
||||
flags.append("beacon_like")
|
||||
if self.is_strong_stable:
|
||||
flags.append('strong_stable')
|
||||
flags.append("strong_stable")
|
||||
if self.has_random_address:
|
||||
flags.append('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,
|
||||
|
||||
"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),
|
||||
|
||||
"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(),
|
||||
|
||||
"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 (legacy)
|
||||
'range_band': self.range_band,
|
||||
'range_confidence': round(self.range_confidence, 2),
|
||||
|
||||
"range_band": self.range_band,
|
||||
"range_confidence": round(self.range_confidence, 2),
|
||||
# Proximity (new system)
|
||||
'device_key': self.device_key,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_60s_min': self.rssi_60s_min,
|
||||
'rssi_60s_max': self.rssi_60s_max,
|
||||
'is_randomized_mac': self.is_randomized_mac,
|
||||
'threat_tags': self.threat_tags,
|
||||
|
||||
"device_key": self.device_key,
|
||||
"proximity_band": self.proximity_band,
|
||||
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
"distance_confidence": round(self.distance_confidence, 2),
|
||||
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
"rssi_60s_min": self.rssi_60s_min,
|
||||
"rssi_60s_max": self.rssi_60s_max,
|
||||
"is_randomized_mac": self.is_randomized_mac,
|
||||
"threat_tags": self.threat_tags,
|
||||
# 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,
|
||||
|
||||
"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,
|
||||
"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,
|
||||
|
||||
"heuristic_flags": self.heuristic_flags,
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
'seen_before': self.seen_before,
|
||||
|
||||
"in_baseline": self.in_baseline,
|
||||
"baseline_id": self.baseline_id,
|
||||
"seen_before": self.seen_before,
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
'is_tracker': self.is_tracker,
|
||||
'type': self.tracker_type,
|
||||
'name': self.tracker_name,
|
||||
'confidence': self.tracker_confidence,
|
||||
'confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'evidence': self.tracker_evidence,
|
||||
"tracker": {
|
||||
"is_tracker": self.is_tracker,
|
||||
"type": self.tracker_type,
|
||||
"name": self.tracker_name,
|
||||
"confidence": self.tracker_confidence,
|
||||
"confidence_score": round(self.tracker_confidence_score, 2),
|
||||
"evidence": self.tracker_evidence,
|
||||
},
|
||||
|
||||
# Suspicious presence analysis
|
||||
'risk_analysis': {
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'risk_factors': self.risk_factors,
|
||||
"risk_analysis": {
|
||||
"risk_score": round(self.risk_score, 2),
|
||||
"risk_factors": self.risk_factors,
|
||||
},
|
||||
|
||||
# IRK
|
||||
'has_irk': self.irk_hex is not None,
|
||||
'irk_hex': self.irk_hex,
|
||||
'irk_source_name': self.irk_source_name,
|
||||
|
||||
"has_irk": self.irk_hex is not None,
|
||||
"irk_hex": self.irk_hex,
|
||||
"irk_source_name": self.irk_source_name,
|
||||
# Fingerprint
|
||||
'fingerprint': {
|
||||
'id': self.payload_fingerprint_id,
|
||||
'stability': round(self.payload_fingerprint_stability, 2),
|
||||
"fingerprint": {
|
||||
"id": self.payload_fingerprint_id,
|
||||
"stability": round(self.payload_fingerprint_stability, 2),
|
||||
},
|
||||
|
||||
# Raw service data for investigation
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
"service_data": {k: v.hex() for k, v in self.service_data.items()},
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
"""Compact dictionary for list views."""
|
||||
return {
|
||||
'device_id': self.device_id,
|
||||
'device_key': self.device_key,
|
||||
'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,
|
||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_min': self.rssi_min,
|
||||
'rssi_max': self.rssi_max,
|
||||
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||
'range_band': self.range_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
'is_randomized_mac': self.is_randomized_mac,
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'duration_seconds': self.duration_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'seen_rate': round(self.seen_rate, 2),
|
||||
'tx_power': self.tx_power,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'appearance': self.appearance,
|
||||
'appearance_name': get_appearance_name(self.appearance),
|
||||
'is_connectable': self.is_connectable,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'is_persistent': self.is_persistent,
|
||||
'is_beacon_like': self.is_beacon_like,
|
||||
'is_strong_stable': self.is_strong_stable,
|
||||
'in_baseline': self.in_baseline,
|
||||
'seen_before': self.seen_before,
|
||||
"device_id": self.device_id,
|
||||
"device_key": self.device_key,
|
||||
"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,
|
||||
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
"rssi_min": self.rssi_min,
|
||||
"rssi_max": self.rssi_max,
|
||||
"rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||
"range_band": self.range_band,
|
||||
"proximity_band": self.proximity_band,
|
||||
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
"distance_confidence": round(self.distance_confidence, 2),
|
||||
"is_randomized_mac": self.is_randomized_mac,
|
||||
"last_seen": self.last_seen.isoformat(),
|
||||
"first_seen": self.first_seen.isoformat(),
|
||||
"age_seconds": self.age_seconds,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"seen_count": self.seen_count,
|
||||
"seen_rate": round(self.seen_rate, 2),
|
||||
"tx_power": self.tx_power,
|
||||
"manufacturer_id": self.manufacturer_id,
|
||||
"appearance": self.appearance,
|
||||
"appearance_name": get_appearance_name(self.appearance),
|
||||
"is_connectable": self.is_connectable,
|
||||
"service_uuids": self.service_uuids,
|
||||
"service_data": {k: v.hex() for k, v in self.service_data.items()},
|
||||
"manufacturer_bytes": self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||
"heuristic_flags": self.heuristic_flags,
|
||||
"is_persistent": self.is_persistent,
|
||||
"is_beacon_like": self.is_beacon_like,
|
||||
"is_strong_stable": self.is_strong_stable,
|
||||
"in_baseline": self.in_baseline,
|
||||
"seen_before": self.seen_before,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'tracker_name': self.tracker_name,
|
||||
'tracker_confidence': self.tracker_confidence,
|
||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'tracker_evidence': self.tracker_evidence,
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'risk_factors': self.risk_factors,
|
||||
'has_irk': self.irk_hex is not None,
|
||||
'irk_hex': self.irk_hex,
|
||||
'irk_source_name': self.irk_source_name,
|
||||
'fingerprint_id': self.payload_fingerprint_id,
|
||||
"is_tracker": self.is_tracker,
|
||||
"tracker_type": self.tracker_type,
|
||||
"tracker_name": self.tracker_name,
|
||||
"tracker_confidence": self.tracker_confidence,
|
||||
"tracker_confidence_score": round(self.tracker_confidence_score, 2),
|
||||
"tracker_evidence": self.tracker_evidence,
|
||||
"risk_score": round(self.risk_score, 2),
|
||||
"risk_factors": self.risk_factors,
|
||||
"has_irk": self.irk_hex is not None,
|
||||
"irk_hex": self.irk_hex,
|
||||
"irk_source_name": self.irk_source_name,
|
||||
"fingerprint_id": self.payload_fingerprint_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +359,7 @@ class ScanStatus:
|
||||
"""Current scanning status."""
|
||||
|
||||
is_scanning: bool = False
|
||||
mode: str = 'auto' # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
|
||||
mode: str = "auto" # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
|
||||
backend: str | None = None # Active backend being used
|
||||
adapter_id: str | None = None
|
||||
started_at: datetime | None = None
|
||||
@@ -399,16 +384,16 @@ class ScanStatus:
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +426,7 @@ class SystemCapabilities:
|
||||
has_ubertooth: bool = False
|
||||
|
||||
# Recommended backend
|
||||
recommended_backend: str = 'none'
|
||||
recommended_backend: str = "none"
|
||||
|
||||
# Issues found
|
||||
issues: list[str] = field(default_factory=list)
|
||||
@@ -450,33 +435,33 @@ class SystemCapabilities:
|
||||
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 or
|
||||
self.has_ubertooth
|
||||
(self.has_dbus and self.has_bluez and len(self.adapters) > 0)
|
||||
or self.has_bleak
|
||||
or self.has_hcitool
|
||||
or self.has_bluetoothctl
|
||||
or self.has_ubertooth
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'available': self.can_scan, # Alias for frontend compatibility
|
||||
'can_scan': self.can_scan,
|
||||
'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,
|
||||
'has_ubertooth': self.has_ubertooth,
|
||||
'preferred_backend': self.recommended_backend, # Alias for frontend
|
||||
'recommended_backend': self.recommended_backend,
|
||||
'issues': self.issues,
|
||||
"available": self.can_scan, # Alias for frontend compatibility
|
||||
"can_scan": self.can_scan,
|
||||
"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,
|
||||
"has_ubertooth": self.has_ubertooth,
|
||||
"preferred_backend": self.recommended_backend, # Alias for frontend
|
||||
"recommended_backend": self.recommended_backend,
|
||||
"issues": self.issues,
|
||||
}
|
||||
|
||||
@@ -84,9 +84,7 @@ class RingBuffer:
|
||||
|
||||
# Initialize deque for new device
|
||||
if device_key not in self._observations:
|
||||
self._observations[device_key] = deque(
|
||||
maxlen=self.max_observations_per_device
|
||||
)
|
||||
self._observations[device_key] = deque(maxlen=self.max_observations_per_device)
|
||||
|
||||
# Store observation
|
||||
self._observations[device_key].append((timestamp, rssi))
|
||||
@@ -132,7 +130,7 @@ class RingBuffer:
|
||||
window_minutes: int | None = None,
|
||||
downsample_seconds: int = 10,
|
||||
top_n: int | None = None,
|
||||
sort_by: str = 'recency',
|
||||
sort_by: str = "recency",
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Get downsampled timeseries for all devices.
|
||||
@@ -164,9 +162,9 @@ class RingBuffer:
|
||||
device_info.append((device_key, last_seen, avg_rssi, len(recent)))
|
||||
|
||||
# Sort based on criteria
|
||||
if sort_by == 'strength':
|
||||
if sort_by == "strength":
|
||||
device_info.sort(key=lambda x: x[2], reverse=True) # Higher RSSI first
|
||||
elif sort_by == 'activity':
|
||||
elif sort_by == "activity":
|
||||
device_info.sort(key=lambda x: x[3], reverse=True) # More observations first
|
||||
else: # recency
|
||||
device_info.sort(key=lambda x: x[1], reverse=True) # Most recent first
|
||||
@@ -221,10 +219,12 @@ class RingBuffer:
|
||||
for bucket_ts in sorted(buckets.keys()):
|
||||
rssi_values = buckets[bucket_ts]
|
||||
avg_rssi = sum(rssi_values) / len(rssi_values)
|
||||
result.append({
|
||||
'timestamp': bucket_ts.isoformat(),
|
||||
'rssi': round(avg_rssi, 1),
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"timestamp": bucket_ts.isoformat(),
|
||||
"rssi": round(avg_rssi, 1),
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -304,12 +304,12 @@ class RingBuffer:
|
||||
timestamps = [ts for ts, _ in obs]
|
||||
|
||||
return {
|
||||
'observation_count': len(obs),
|
||||
'first_observation': min(timestamps).isoformat(),
|
||||
'last_observation': max(timestamps).isoformat(),
|
||||
'rssi_min': min(rssi_values),
|
||||
'rssi_max': max(rssi_values),
|
||||
'rssi_avg': sum(rssi_values) / len(rssi_values),
|
||||
"observation_count": len(obs),
|
||||
"first_observation": min(timestamps).isoformat(),
|
||||
"last_observation": max(timestamps).isoformat(),
|
||||
"rssi_min": min(rssi_values),
|
||||
"rssi_max": max(rssi_values),
|
||||
"rssi_avg": sum(rssi_values) / len(rssi_values),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+65
-60
@@ -72,9 +72,9 @@ class BluetoothScanner:
|
||||
|
||||
def start_scan(
|
||||
self,
|
||||
mode: str = 'auto',
|
||||
mode: str = "auto",
|
||||
duration_s: int | None = None,
|
||||
transport: str = 'auto',
|
||||
transport: str = "auto",
|
||||
rssi_threshold: int = -100,
|
||||
) -> bool:
|
||||
"""
|
||||
@@ -98,7 +98,7 @@ class BluetoothScanner:
|
||||
|
||||
# Determine adapter
|
||||
adapter = self._adapter_id or self._capabilities.default_adapter
|
||||
if not adapter and mode == 'dbus':
|
||||
if not adapter and mode == "dbus":
|
||||
self._status.error = "No Bluetooth adapter found"
|
||||
return False
|
||||
|
||||
@@ -107,16 +107,16 @@ class BluetoothScanner:
|
||||
backend_used = None
|
||||
original_mode = mode
|
||||
|
||||
if mode == 'auto':
|
||||
mode = self._capabilities.recommended_backend or 'bleak'
|
||||
if mode == "auto":
|
||||
mode = self._capabilities.recommended_backend or "bleak"
|
||||
|
||||
if mode == 'dbus':
|
||||
if mode == "dbus":
|
||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||
elif mode == 'ubertooth':
|
||||
elif mode == "ubertooth":
|
||||
started, backend_used = self._start_ubertooth()
|
||||
|
||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||
if not started and (original_mode == "auto" or mode in ("bleak", "hcitool", "bluetoothctl")):
|
||||
started, backend_used = self._start_fallback(adapter, original_mode)
|
||||
|
||||
if not started:
|
||||
@@ -135,12 +135,14 @@ class BluetoothScanner:
|
||||
)
|
||||
|
||||
# Queue status event
|
||||
self._queue_event({
|
||||
'type': 'status',
|
||||
'status': 'started',
|
||||
'backend': backend_used,
|
||||
'mode': mode,
|
||||
})
|
||||
self._queue_event(
|
||||
{
|
||||
"type": "status",
|
||||
"status": "started",
|
||||
"backend": backend_used,
|
||||
"mode": mode,
|
||||
}
|
||||
)
|
||||
|
||||
# Set up timer for duration-based scanning
|
||||
if duration_s:
|
||||
@@ -151,12 +153,7 @@ class BluetoothScanner:
|
||||
logger.info(f"Bluetooth scan started: mode={mode}, backend={backend_used}")
|
||||
return True
|
||||
|
||||
def _start_dbus(
|
||||
self,
|
||||
adapter: str,
|
||||
transport: str,
|
||||
rssi_threshold: int
|
||||
) -> tuple[bool, str | None]:
|
||||
def _start_dbus(self, adapter: str, transport: str, rssi_threshold: int) -> tuple[bool, str | None]:
|
||||
"""Start DBus scanner."""
|
||||
try:
|
||||
self._dbus_scanner = DBusScanner(
|
||||
@@ -164,7 +161,7 @@ class BluetoothScanner:
|
||||
on_observation=self._handle_observation,
|
||||
)
|
||||
if self._dbus_scanner.start(transport=transport, rssi_threshold=rssi_threshold):
|
||||
return True, 'dbus'
|
||||
return True, "dbus"
|
||||
except Exception as e:
|
||||
logger.warning(f"DBus scanner failed: {e}")
|
||||
return False, None
|
||||
@@ -176,7 +173,7 @@ class BluetoothScanner:
|
||||
on_observation=self._handle_observation,
|
||||
)
|
||||
if self._ubertooth_scanner.start():
|
||||
return True, 'ubertooth'
|
||||
return True, "ubertooth"
|
||||
except Exception as e:
|
||||
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||
return False, None
|
||||
@@ -185,7 +182,7 @@ class BluetoothScanner:
|
||||
"""Start fallback scanner."""
|
||||
try:
|
||||
# Extract adapter name from path if needed
|
||||
adapter_name = adapter.split('/')[-1] if adapter else 'hci0'
|
||||
adapter_name = adapter.split("/")[-1] if adapter else "hci0"
|
||||
|
||||
self._fallback_scanner = FallbackScanner(
|
||||
adapter=adapter_name,
|
||||
@@ -226,10 +223,12 @@ class BluetoothScanner:
|
||||
self._active_backend = None
|
||||
|
||||
# Queue status event
|
||||
self._queue_event({
|
||||
'type': 'status',
|
||||
'status': 'stopped',
|
||||
})
|
||||
self._queue_event(
|
||||
{
|
||||
"type": "status",
|
||||
"status": "stopped",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("Bluetooth scan stopped")
|
||||
|
||||
@@ -239,11 +238,11 @@ class BluetoothScanner:
|
||||
return # Already matched
|
||||
|
||||
address = device.address
|
||||
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
|
||||
if not address or len(address.replace(":", "").replace("-", "")) not in (12, 32):
|
||||
return
|
||||
|
||||
# Only attempt RPA resolution on 6-byte addresses
|
||||
addr_clean = address.replace(':', '').replace('-', '')
|
||||
addr_clean = address.replace(":", "").replace("-", "")
|
||||
if len(addr_clean) != 12:
|
||||
return
|
||||
|
||||
@@ -261,14 +260,14 @@ class BluetoothScanner:
|
||||
return
|
||||
|
||||
for entry in paired:
|
||||
irk_hex = entry.get('irk_hex', '')
|
||||
irk_hex = entry.get("irk_hex", "")
|
||||
if not irk_hex or len(irk_hex) != 32:
|
||||
continue
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
if resolve_rpa(irk, address):
|
||||
device.irk_hex = irk_hex
|
||||
device.irk_source_name = entry.get('name')
|
||||
device.irk_source_name = entry.get("name")
|
||||
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
|
||||
return
|
||||
except Exception:
|
||||
@@ -293,18 +292,18 @@ class BluetoothScanner:
|
||||
# Build summary with MAC cluster count
|
||||
summary = device.to_summary_dict()
|
||||
if device.payload_fingerprint_id:
|
||||
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
|
||||
device.payload_fingerprint_id
|
||||
)
|
||||
summary["mac_cluster_count"] = self._aggregator.get_fingerprint_mac_count(device.payload_fingerprint_id)
|
||||
else:
|
||||
summary['mac_cluster_count'] = 0
|
||||
summary["mac_cluster_count"] = 0
|
||||
|
||||
# Queue event
|
||||
self._queue_event({
|
||||
'type': 'device',
|
||||
'action': 'update',
|
||||
'device': summary,
|
||||
})
|
||||
self._queue_event(
|
||||
{
|
||||
"type": "device",
|
||||
"action": "update",
|
||||
"device": summary,
|
||||
}
|
||||
)
|
||||
|
||||
# Callbacks
|
||||
for cb in self._on_device_updated_callbacks:
|
||||
@@ -336,7 +335,7 @@ class BluetoothScanner:
|
||||
|
||||
def get_devices(
|
||||
self,
|
||||
sort_by: str = 'last_seen',
|
||||
sort_by: str = "last_seen",
|
||||
sort_desc: bool = True,
|
||||
min_rssi: int | None = None,
|
||||
protocol: str | None = None,
|
||||
@@ -367,11 +366,11 @@ class BluetoothScanner:
|
||||
|
||||
# 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,
|
||||
"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)
|
||||
@@ -402,33 +401,39 @@ class BluetoothScanner:
|
||||
event = self._event_queue.get(timeout=timeout)
|
||||
yield event
|
||||
except queue.Empty:
|
||||
yield {'type': 'ping'}
|
||||
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,
|
||||
})
|
||||
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',
|
||||
})
|
||||
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',
|
||||
})
|
||||
self._queue_event(
|
||||
{
|
||||
"type": "devices",
|
||||
"action": "cleared",
|
||||
}
|
||||
)
|
||||
|
||||
def prune_stale(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int:
|
||||
"""Prune stale devices."""
|
||||
|
||||
@@ -58,7 +58,7 @@ class UbertoothScanner:
|
||||
@staticmethod
|
||||
def is_available() -> bool:
|
||||
"""Check if ubertooth-btle is available on the system."""
|
||||
return shutil.which('ubertooth-btle') is not None
|
||||
return shutil.which("ubertooth-btle") is not None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""
|
||||
@@ -81,9 +81,9 @@ class UbertoothScanner:
|
||||
# Build command: ubertooth-btle -n -U <device_index>
|
||||
# -n = advertisements only (no follow mode)
|
||||
# -U = device index for multiple Ubertooths
|
||||
cmd = ['ubertooth-btle', '-n']
|
||||
cmd = ["ubertooth-btle", "-n"]
|
||||
if self._device_index > 0:
|
||||
cmd.extend(['-U', str(self._device_index)])
|
||||
cmd.extend(["-U", str(self._device_index)])
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
@@ -94,11 +94,7 @@ class UbertoothScanner:
|
||||
)
|
||||
|
||||
self._stop_event.clear()
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._read_output,
|
||||
daemon=True,
|
||||
name='ubertooth-reader'
|
||||
)
|
||||
self._reader_thread = threading.Thread(target=self._read_output, daemon=True, name="ubertooth-reader")
|
||||
self._reader_thread.start()
|
||||
self._is_scanning = True
|
||||
logger.info(f"Ubertooth scanner started (device index: {self._device_index})")
|
||||
@@ -162,7 +158,7 @@ class UbertoothScanner:
|
||||
continue
|
||||
|
||||
# Skip non-packet lines (errors, status messages)
|
||||
if not line.startswith('systime='):
|
||||
if not line.startswith("systime="):
|
||||
# Log errors from stderr would go here if needed
|
||||
continue
|
||||
|
||||
@@ -198,17 +194,14 @@ class UbertoothScanner:
|
||||
"""
|
||||
# Parse the structured prefix
|
||||
# Example: systime=1349412883 freq=2402 addr=8e89bed6 delta_t=38.441 ms 00 17 ab cd ef ...
|
||||
match = re.match(
|
||||
r'systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)',
|
||||
line
|
||||
)
|
||||
match = re.match(r"systime=(\d+)\s+freq=(\d+)\s+addr=([0-9a-fA-F]+)\s+delta_t=[\d.]+\s+ms\s+(.+)", line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Parse hex bytes
|
||||
hex_data = match.group(4).strip()
|
||||
try:
|
||||
raw_bytes = bytes.fromhex(hex_data.replace(' ', ''))
|
||||
raw_bytes = bytes.fromhex(hex_data.replace(" ", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -228,14 +221,14 @@ class UbertoothScanner:
|
||||
# Extract advertiser address (bytes 2-7, reversed)
|
||||
# BLE addresses are transmitted LSB first
|
||||
addr_bytes = raw_bytes[2:8]
|
||||
address = ':'.join(f'{b:02X}' for b in reversed(addr_bytes))
|
||||
address = ":".join(f"{b:02X}" for b in reversed(addr_bytes))
|
||||
|
||||
# Determine address type from PDU type and TxAdd flag
|
||||
tx_add = (raw_bytes[0] >> 6) & 0x01
|
||||
address_type = ADDRESS_TYPE_RANDOM if tx_add else ADDRESS_TYPE_PUBLIC
|
||||
|
||||
# Parse advertising data payload (after MAC address)
|
||||
adv_data = raw_bytes[8:2 + length] if length > 6 else b''
|
||||
adv_data = raw_bytes[8 : 2 + length] if length > 6 else b""
|
||||
|
||||
# Parse advertising data structures
|
||||
name = None
|
||||
@@ -255,34 +248,36 @@ class UbertoothScanner:
|
||||
break
|
||||
|
||||
ad_type = adv_data[i + 1]
|
||||
ad_payload = adv_data[i + 2:i + 1 + ad_len]
|
||||
ad_payload = adv_data[i + 2 : i + 1 + ad_len]
|
||||
|
||||
# 0x01 = Flags
|
||||
# 0x02/0x03 = Incomplete/Complete list of 16-bit UUIDs
|
||||
if ad_type in (0x02, 0x03) and len(ad_payload) >= 2:
|
||||
for j in range(0, len(ad_payload), 2):
|
||||
if j + 2 <= len(ad_payload):
|
||||
uuid16 = int.from_bytes(ad_payload[j:j + 2], 'little')
|
||||
service_uuids.append(f'{uuid16:04X}')
|
||||
uuid16 = int.from_bytes(ad_payload[j : j + 2], "little")
|
||||
service_uuids.append(f"{uuid16:04X}")
|
||||
|
||||
# 0x06/0x07 = Incomplete/Complete list of 128-bit UUIDs
|
||||
elif ad_type in (0x06, 0x07) and len(ad_payload) >= 16:
|
||||
for j in range(0, len(ad_payload), 16):
|
||||
if j + 16 <= len(ad_payload):
|
||||
uuid_bytes = ad_payload[j:j + 16]
|
||||
uuid128 = '-'.join([
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
])
|
||||
uuid_bytes = ad_payload[j : j + 16]
|
||||
uuid128 = "-".join(
|
||||
[
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
]
|
||||
)
|
||||
service_uuids.append(uuid128.upper())
|
||||
|
||||
# 0x08/0x09 = Shortened/Complete Local Name
|
||||
elif ad_type in (0x08, 0x09):
|
||||
with contextlib.suppress(Exception):
|
||||
name = ad_payload.decode('utf-8', errors='replace')
|
||||
name = ad_payload.decode("utf-8", errors="replace")
|
||||
|
||||
# 0x0A = TX Power Level
|
||||
elif ad_type == 0x0A and len(ad_payload) >= 1:
|
||||
@@ -291,29 +286,31 @@ class UbertoothScanner:
|
||||
|
||||
# 0xFF = Manufacturer Specific Data
|
||||
elif ad_type == 0xFF and len(ad_payload) >= 2:
|
||||
manufacturer_id = int.from_bytes(ad_payload[0:2], 'little')
|
||||
manufacturer_id = int.from_bytes(ad_payload[0:2], "little")
|
||||
manufacturer_data = bytes(ad_payload[2:])
|
||||
|
||||
# 0x16 = Service Data (16-bit UUID)
|
||||
elif ad_type == 0x16 and len(ad_payload) >= 2:
|
||||
svc_uuid = f'{int.from_bytes(ad_payload[0:2], "little"):04X}'
|
||||
svc_uuid = f"{int.from_bytes(ad_payload[0:2], 'little'):04X}"
|
||||
service_data[svc_uuid] = bytes(ad_payload[2:])
|
||||
|
||||
# 0x20 = Service Data (32-bit UUID)
|
||||
elif ad_type == 0x20 and len(ad_payload) >= 4:
|
||||
svc_uuid = f'{int.from_bytes(ad_payload[0:4], "little"):08X}'
|
||||
svc_uuid = f"{int.from_bytes(ad_payload[0:4], 'little'):08X}"
|
||||
service_data[svc_uuid] = bytes(ad_payload[4:])
|
||||
|
||||
# 0x21 = Service Data (128-bit UUID)
|
||||
elif ad_type == 0x21 and len(ad_payload) >= 16:
|
||||
uuid_bytes = ad_payload[0:16]
|
||||
svc_uuid = '-'.join([
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
]).upper()
|
||||
svc_uuid = "-".join(
|
||||
[
|
||||
uuid_bytes[15:11:-1].hex(),
|
||||
uuid_bytes[11:9:-1].hex(),
|
||||
uuid_bytes[9:7:-1].hex(),
|
||||
uuid_bytes[7:5:-1].hex(),
|
||||
uuid_bytes[5::-1].hex(),
|
||||
]
|
||||
).upper()
|
||||
service_data[svc_uuid] = bytes(ad_payload[16:])
|
||||
|
||||
i += 1 + ad_len
|
||||
|
||||
Reference in New Issue
Block a user