mirror of
https://github.com/smittix/intercept.git
synced 2026-05-04 03:09:10 -07:00
v2.26.0: fix SSE fanout crash and branded logo FOUC
- Fix SSE fanout thread AttributeError when source queue is None during interpreter shutdown by snapshotting to local variable with null guard - Fix branded "i" logo rendering oversized on first page load (FOUC) by adding inline width/height to SVG elements across 10 templates - Bump version to 2.26.0 in config.py, pyproject.toml, and CHANGELOG.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,40 +8,40 @@ 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 (legacy)
|
||||
RANGE_VERY_CLOSE,
|
||||
RANGE_CLOSE,
|
||||
RANGE_NEARBY,
|
||||
RANGE_FAR,
|
||||
RANGE_UNKNOWN,
|
||||
# Proximity bands (new)
|
||||
PROXIMITY_IMMEDIATE,
|
||||
PROXIMITY_NEAR,
|
||||
PROXIMITY_FAR,
|
||||
PROXIMITY_UNKNOWN,
|
||||
# Protocols
|
||||
PROTOCOL_BLE,
|
||||
PROTOCOL_CLASSIC,
|
||||
PROTOCOL_AUTO,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
# Address types
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
PROTOCOL_AUTO,
|
||||
# Protocols
|
||||
PROTOCOL_BLE,
|
||||
PROTOCOL_CLASSIC,
|
||||
PROXIMITY_FAR,
|
||||
# Proximity bands (new)
|
||||
PROXIMITY_IMMEDIATE,
|
||||
PROXIMITY_NEAR,
|
||||
PROXIMITY_UNKNOWN,
|
||||
RANGE_CLOSE,
|
||||
RANGE_FAR,
|
||||
RANGE_NEARBY,
|
||||
RANGE_UNKNOWN,
|
||||
# Range bands (legacy)
|
||||
RANGE_VERY_CLOSE,
|
||||
)
|
||||
from .device_key import generate_device_key, is_randomized_mac, extract_key_type
|
||||
from .device_key import extract_key_type, generate_device_key, is_randomized_mac
|
||||
from .distance import DistanceEstimator, ProximityBand, get_distance_estimator
|
||||
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
|
||||
from .heuristics import HeuristicsEngine, evaluate_all_devices, evaluate_device_heuristics
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer, reset_ring_buffer
|
||||
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
TrackerDetectionResult,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
DeviceFingerprint,
|
||||
TrackerConfidence,
|
||||
TrackerDetectionResult,
|
||||
TrackerSignatureEngine,
|
||||
TrackerType,
|
||||
detect_tracker,
|
||||
get_tracker_engine,
|
||||
)
|
||||
|
||||
@@ -9,40 +9,37 @@ 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_NRPA,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
CONFIDENCE_CLOSE,
|
||||
CONFIDENCE_FAR,
|
||||
CONFIDENCE_NEARBY,
|
||||
CONFIDENCE_VERY_CLOSE,
|
||||
DEVICE_STALE_TIMEOUT,
|
||||
MANUFACTURER_NAMES,
|
||||
MAX_RSSI_SAMPLES,
|
||||
PROTOCOL_BLE,
|
||||
PROTOCOL_CLASSIC,
|
||||
RANGE_CLOSE,
|
||||
RANGE_FAR,
|
||||
RANGE_NEARBY,
|
||||
RANGE_UNKNOWN,
|
||||
RANGE_VERY_CLOSE,
|
||||
RSSI_CLOSE,
|
||||
RSSI_FAR,
|
||||
RSSI_NEARBY,
|
||||
RSSI_VERY_CLOSE,
|
||||
)
|
||||
from .models import BTObservation, BTDeviceAggregate
|
||||
from .device_key import generate_device_key, is_randomized_mac
|
||||
from .distance import DistanceEstimator, get_distance_estimator
|
||||
from .distance import get_distance_estimator
|
||||
from .models import BTDeviceAggregate, BTObservation
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
get_tracker_engine,
|
||||
TrackerDetectionResult,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,7 +56,7 @@ class DeviceAggregator:
|
||||
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
|
||||
self._baseline_set_time: datetime | None = None
|
||||
|
||||
# Proximity estimation components
|
||||
self._distance_estimator = get_distance_estimator()
|
||||
@@ -382,9 +379,8 @@ class DeviceAggregator:
|
||||
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
|
||||
if observation.name and (not device.name or len(observation.name) > len(device.name)):
|
||||
device.name = observation.name
|
||||
|
||||
# Manufacturer
|
||||
if observation.manufacturer_id is not None:
|
||||
@@ -416,7 +412,7 @@ class DeviceAggregator:
|
||||
device.is_paired = observation.is_paired
|
||||
device.is_connected = observation.is_connected
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
|
||||
def get_device(self, device_id: str) -> BTDeviceAggregate | None:
|
||||
"""Get a device by ID."""
|
||||
with self._lock:
|
||||
return self._devices.get(device_id)
|
||||
@@ -511,7 +507,7 @@ class DeviceAggregator:
|
||||
"""Access the ring buffer for timeseries data."""
|
||||
return self._ring_buffer
|
||||
|
||||
def get_device_by_key(self, device_key: str) -> Optional[BTDeviceAggregate]:
|
||||
def get_device_by_key(self, device_key: str) -> BTDeviceAggregate | None:
|
||||
"""Get a device by its stable device key."""
|
||||
with self._lock:
|
||||
# Find device_id from device_key
|
||||
|
||||
@@ -10,11 +10,10 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
BLUEZ_SERVICE,
|
||||
BLUEZ_PATH,
|
||||
BLUEZ_SERVICE,
|
||||
SUBPROCESS_TIMEOUT_SHORT,
|
||||
)
|
||||
from .models import SystemCapabilities
|
||||
@@ -82,7 +81,7 @@ def _check_bluez(caps: SystemCapabilities) -> None:
|
||||
|
||||
# Check if BlueZ service exists
|
||||
try:
|
||||
obj = bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
|
||||
bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
|
||||
caps.has_bluez = True
|
||||
|
||||
# Try to get BlueZ version from bluetoothd
|
||||
@@ -296,17 +295,16 @@ def _determine_recommended_backend(caps: SystemCapabilities) -> None:
|
||||
|
||||
# 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:
|
||||
if not caps.is_soft_blocked and not caps.is_hard_blocked:
|
||||
caps.recommended_backend = 'dbus'
|
||||
return
|
||||
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'
|
||||
return
|
||||
|
||||
caps.recommended_backend = 'none'
|
||||
if not caps.issues:
|
||||
caps.issues.append('No suitable Bluetooth scanning backend available')
|
||||
|
||||
|
||||
def quick_adapter_check() -> Optional[str]:
|
||||
def quick_adapter_check() -> str | None:
|
||||
"""
|
||||
Quick check to find a working adapter.
|
||||
|
||||
|
||||
@@ -9,25 +9,22 @@ from __future__ import annotations
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
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,
|
||||
BLUEZ_ADAPTER_INTERFACE,
|
||||
BLUEZ_DEVICE_INTERFACE,
|
||||
BLUEZ_SERVICE,
|
||||
DBUS_OBJECT_MANAGER_INTERFACE,
|
||||
DBUS_PROPERTIES_INTERFACE,
|
||||
DISCOVERY_FILTER_DUPLICATE_DATA,
|
||||
MAJOR_DEVICE_CLASSES,
|
||||
MINOR_AUDIO_VIDEO,
|
||||
MINOR_PHONE,
|
||||
MINOR_COMPUTER,
|
||||
MINOR_PERIPHERAL,
|
||||
MINOR_PHONE,
|
||||
MINOR_WEARABLE,
|
||||
)
|
||||
from .models import BTObservation
|
||||
@@ -44,8 +41,8 @@ class DBusScanner:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter_path: Optional[str] = None,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
adapter_path: str | None = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize DBus scanner.
|
||||
@@ -59,7 +56,7 @@ class DBusScanner:
|
||||
self._bus = None
|
||||
self._adapter = None
|
||||
self._mainloop = None
|
||||
self._mainloop_thread: Optional[threading.Thread] = None
|
||||
self._mainloop_thread: threading.Thread | None = None
|
||||
self._is_scanning = False
|
||||
self._lock = threading.Lock()
|
||||
self._known_devices: set[str] = set()
|
||||
@@ -98,7 +95,7 @@ class DBusScanner:
|
||||
|
||||
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)
|
||||
dbus.Interface(adapter_obj, DBUS_PROPERTIES_INTERFACE)
|
||||
|
||||
# Set up signal handlers
|
||||
self._bus.add_signal_receiver(
|
||||
@@ -200,7 +197,7 @@ class DBusScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Mainloop error: {e}")
|
||||
|
||||
def _find_default_adapter(self) -> Optional[str]:
|
||||
def _find_default_adapter(self) -> str | None:
|
||||
"""Find the default Bluetooth adapter via DBus."""
|
||||
try:
|
||||
import dbus
|
||||
@@ -307,11 +304,7 @@ class DBusScanner:
|
||||
manufacturer_id = int(mid)
|
||||
# Handle various DBus data types safely
|
||||
try:
|
||||
if isinstance(mdata, (bytes, bytearray)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, dbus.Array):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, (list, tuple)):
|
||||
if isinstance(mdata, (bytes, bytearray, dbus.Array, list, tuple)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, str):
|
||||
manufacturer_data = bytes.fromhex(mdata)
|
||||
@@ -330,11 +323,7 @@ class DBusScanner:
|
||||
if 'ServiceData' in props:
|
||||
for uuid, data in props['ServiceData'].items():
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, dbus.Array):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
if isinstance(data, (bytes, bytearray, dbus.Array, list, tuple)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, str):
|
||||
service_data[str(uuid)] = bytes.fromhex(data)
|
||||
@@ -389,7 +378,7 @@ class DBusScanner:
|
||||
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]]:
|
||||
def _decode_class_of_device(self, cod: int) -> tuple[str | None, str | None]:
|
||||
"""Decode Bluetooth Class of Device."""
|
||||
# Major class is bits 12-8 (5 bits)
|
||||
major_num = (cod >> 8) & 0x1F
|
||||
|
||||
@@ -7,7 +7,6 @@ Generates consistent identifiers for devices even when MAC addresses rotate.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
@@ -19,10 +18,10 @@ from .constants import (
|
||||
def generate_device_key(
|
||||
address: str,
|
||||
address_type: str,
|
||||
identity_address: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
identity_address: str | None = None,
|
||||
name: str | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
service_uuids: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a stable device key for identifying a Bluetooth device.
|
||||
@@ -61,9 +60,9 @@ def generate_device_key(
|
||||
|
||||
def _generate_fingerprint_key(
|
||||
address: str,
|
||||
name: Optional[str],
|
||||
manufacturer_id: Optional[int],
|
||||
service_uuids: Optional[list[str]],
|
||||
name: str | None,
|
||||
manufacturer_id: int | None,
|
||||
service_uuids: list[str] | None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a fingerprint-based key for devices with random addresses.
|
||||
|
||||
@@ -8,7 +8,6 @@ and EMA smoothing for RSSI values.
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ProximityBand(str, Enum):
|
||||
@@ -70,9 +69,9 @@ class DistanceEstimator:
|
||||
def estimate_distance(
|
||||
self,
|
||||
rssi: float,
|
||||
tx_power: Optional[int] = None,
|
||||
variance: Optional[float] = None,
|
||||
) -> tuple[Optional[float], float]:
|
||||
tx_power: int | None = None,
|
||||
variance: float | None = None,
|
||||
) -> tuple[float | None, float]:
|
||||
"""
|
||||
Estimate distance to a device based on RSSI.
|
||||
|
||||
@@ -143,7 +142,7 @@ class DistanceEstimator:
|
||||
else:
|
||||
return 15.0 # Very far: ~15m
|
||||
|
||||
def _calculate_variance_confidence(self, variance: Optional[float]) -> float:
|
||||
def _calculate_variance_confidence(self, variance: float | None) -> float:
|
||||
"""
|
||||
Calculate confidence based on RSSI variance.
|
||||
|
||||
@@ -169,8 +168,8 @@ class DistanceEstimator:
|
||||
|
||||
def classify_proximity_band(
|
||||
self,
|
||||
distance_m: Optional[float] = None,
|
||||
rssi_ema: Optional[float] = None,
|
||||
distance_m: float | None = None,
|
||||
rssi_ema: float | None = None,
|
||||
) -> ProximityBand:
|
||||
"""
|
||||
Classify device into a proximity band.
|
||||
@@ -209,8 +208,8 @@ class DistanceEstimator:
|
||||
def apply_ema_smoothing(
|
||||
self,
|
||||
current: int,
|
||||
prev_ema: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
prev_ema: float | None = None,
|
||||
alpha: float | None = None,
|
||||
) -> float:
|
||||
"""
|
||||
Apply Exponential Moving Average smoothing to RSSI.
|
||||
@@ -237,7 +236,7 @@ class DistanceEstimator:
|
||||
self,
|
||||
rssi_samples: list[tuple],
|
||||
window_seconds: int = 60,
|
||||
) -> tuple[Optional[int], Optional[int]]:
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""
|
||||
Get min/max RSSI from the last N seconds.
|
||||
|
||||
@@ -263,7 +262,7 @@ class DistanceEstimator:
|
||||
|
||||
|
||||
# Module-level instance for convenience
|
||||
_default_estimator: Optional[DistanceEstimator] = None
|
||||
_default_estimator: DistanceEstimator | None = None
|
||||
|
||||
|
||||
def get_distance_estimator() -> DistanceEstimator:
|
||||
|
||||
@@ -16,20 +16,19 @@ import re
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
from .constants import (
|
||||
BLEAK_SCAN_TIMEOUT,
|
||||
HCITOOL_TIMEOUT,
|
||||
BLUETOOTHCTL_TIMEOUT,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_UUID,
|
||||
MANUFACTURER_NAMES,
|
||||
BLEAK_SCAN_TIMEOUT,
|
||||
)
|
||||
|
||||
# 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}$')
|
||||
import contextlib
|
||||
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -44,12 +43,12 @@ class BleakScanner:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._on_observation = on_observation
|
||||
self._scanner = None
|
||||
self._is_scanning = False
|
||||
self._scan_thread: Optional[threading.Thread] = None
|
||||
self._scan_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self, duration: float = BLEAK_SCAN_TIMEOUT) -> bool:
|
||||
@@ -153,9 +152,7 @@ class BleakScanner:
|
||||
manufacturer_id = mid
|
||||
# Handle various data types safely
|
||||
try:
|
||||
if isinstance(mdata, (bytes, bytearray)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, (list, tuple)):
|
||||
if isinstance(mdata, (bytes, bytearray, list, tuple)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, str):
|
||||
manufacturer_data = bytes.fromhex(mdata)
|
||||
@@ -170,9 +167,7 @@ class BleakScanner:
|
||||
if adv_data.service_data:
|
||||
for uuid, data in adv_data.service_data.items():
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
if isinstance(data, (bytes, bytearray, list, tuple)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, str):
|
||||
service_data[str(uuid)] = bytes.fromhex(data)
|
||||
@@ -206,13 +201,13 @@ class HcitoolScanner:
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def start(self) -> bool:
|
||||
@@ -275,14 +270,12 @@ class HcitoolScanner:
|
||||
try:
|
||||
# Also start hcidump in parallel for RSSI values
|
||||
dump_process = None
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
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()
|
||||
@@ -323,12 +316,12 @@ class BluetoothctlScanner:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._devices: dict[str, dict] = {}
|
||||
|
||||
@@ -379,10 +372,8 @@ class BluetoothctlScanner:
|
||||
self._process.stdin.flush()
|
||||
self._process.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
self._process = None
|
||||
|
||||
if self._reader_thread:
|
||||
@@ -498,12 +489,12 @@ class FallbackScanner:
|
||||
def __init__(
|
||||
self,
|
||||
adapter: str = 'hci0',
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
self._adapter = adapter
|
||||
self._on_observation = on_observation
|
||||
self._active_scanner: Optional[object] = None
|
||||
self._backend: Optional[str] = None
|
||||
self._active_scanner: object | None = None
|
||||
self._backend: str | None = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start scanning with best available backend."""
|
||||
@@ -563,5 +554,5 @@ class FallbackScanner:
|
||||
return self._active_scanner.is_scanning if self._active_scanner else False
|
||||
|
||||
@property
|
||||
def backend(self) -> Optional[str]:
|
||||
def backend(self) -> str | None:
|
||||
return self._backend
|
||||
|
||||
@@ -7,15 +7,13 @@ 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 (
|
||||
BEACON_INTERVAL_MAX_VARIANCE,
|
||||
PERSISTENT_MIN_SEEN_COUNT,
|
||||
PERSISTENT_WINDOW_SECONDS,
|
||||
BEACON_INTERVAL_MAX_VARIANCE,
|
||||
STRONG_RSSI_THRESHOLD,
|
||||
STABLE_VARIANCE_THRESHOLD,
|
||||
STRONG_RSSI_THRESHOLD,
|
||||
)
|
||||
from .models import BTDeviceAggregate
|
||||
|
||||
@@ -111,10 +109,7 @@ class HeuristicsEngine:
|
||||
return False
|
||||
|
||||
# Must have reasonable sample count for confidence
|
||||
if len(device.rssi_samples) < 5:
|
||||
return False
|
||||
|
||||
return True
|
||||
return not len(device.rssi_samples) < 5
|
||||
|
||||
def _calculate_intervals(self, device: BTDeviceAggregate) -> list[float]:
|
||||
"""Calculate time intervals between observations."""
|
||||
|
||||
@@ -6,26 +6,22 @@ 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,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
RANGE_UNKNOWN,
|
||||
PROTOCOL_BLE,
|
||||
PROXIMITY_UNKNOWN,
|
||||
get_appearance_name,
|
||||
)
|
||||
|
||||
# Import tracker types (will be available after tracker_signatures module loads)
|
||||
# Use string type hints to avoid circular imports
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
MANUFACTURER_NAMES,
|
||||
PROTOCOL_BLE,
|
||||
PROXIMITY_UNKNOWN,
|
||||
RANGE_UNKNOWN,
|
||||
get_appearance_name,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .tracker_signatures import TrackerDetectionResult, DeviceFingerprint
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,21 +31,21 @@ class BTObservation:
|
||||
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
|
||||
rssi: int | None = None
|
||||
tx_power: int | None = None
|
||||
name: str | None = None
|
||||
manufacturer_id: int | None = None
|
||||
manufacturer_data: bytes | None = None
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||
appearance: Optional[int] = None
|
||||
appearance: int | None = 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
|
||||
class_of_device: int | None = None # Classic BT only
|
||||
major_class: str | None = None
|
||||
minor_class: str | None = None
|
||||
adapter_id: str | None = None
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
@@ -57,7 +53,7 @@ class BTObservation:
|
||||
return f"{self.address}:{self.address_type}"
|
||||
|
||||
@property
|
||||
def manufacturer_name(self) -> Optional[str]:
|
||||
def manufacturer_name(self) -> str | None:
|
||||
"""Look up manufacturer name from ID."""
|
||||
if self.manufacturer_id is not None:
|
||||
return MANUFACTURER_NAMES.get(self.manufacturer_id)
|
||||
@@ -105,11 +101,11 @@ class BTDeviceAggregate:
|
||||
|
||||
# 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_current: int | None = None
|
||||
rssi_median: float | None = None
|
||||
rssi_min: int | None = None
|
||||
rssi_max: int | None = None
|
||||
rssi_variance: float | None = None
|
||||
rssi_confidence: float = 0.0 # 0.0-1.0
|
||||
|
||||
# Range band (very_close/close/nearby/far/unknown) - legacy
|
||||
@@ -117,27 +113,27 @@ class BTDeviceAggregate:
|
||||
range_confidence: float = 0.0
|
||||
|
||||
# Proximity band (new system: immediate/near/far/unknown)
|
||||
device_key: Optional[str] = None
|
||||
device_key: str | None = None
|
||||
proximity_band: str = PROXIMITY_UNKNOWN
|
||||
estimated_distance_m: Optional[float] = None
|
||||
estimated_distance_m: float | None = None
|
||||
distance_confidence: float = 0.0
|
||||
rssi_ema: Optional[float] = None
|
||||
rssi_60s_min: Optional[int] = None
|
||||
rssi_60s_max: Optional[int] = None
|
||||
rssi_ema: float | None = None
|
||||
rssi_60s_min: int | None = None
|
||||
rssi_60s_max: int | None = None
|
||||
is_randomized_mac: bool = False
|
||||
threat_tags: list[str] = field(default_factory=list)
|
||||
|
||||
# Device info (merged from observations)
|
||||
name: Optional[str] = None
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_name: Optional[str] = None
|
||||
manufacturer_bytes: Optional[bytes] = None
|
||||
name: str | None = None
|
||||
manufacturer_id: int | None = None
|
||||
manufacturer_name: str | None = None
|
||||
manufacturer_bytes: bytes | None = 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
|
||||
tx_power: int | None = None
|
||||
appearance: int | None = None
|
||||
class_of_device: int | None = None
|
||||
major_class: str | None = None
|
||||
minor_class: str | None = None
|
||||
is_connectable: bool = False
|
||||
is_paired: bool = False
|
||||
is_connected: bool = False
|
||||
@@ -151,14 +147,14 @@ class BTDeviceAggregate:
|
||||
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
baseline_id: int | None = None
|
||||
seen_before: bool = False
|
||||
|
||||
# Tracker detection fields
|
||||
is_tracker: bool = False
|
||||
tracker_type: Optional[str] = None # 'airtag', 'tile', 'samsung_smarttag', etc.
|
||||
tracker_name: Optional[str] = None
|
||||
tracker_confidence: Optional[str] = None # 'high', 'medium', 'low', 'none'
|
||||
tracker_type: str | None = None # 'airtag', 'tile', 'samsung_smarttag', etc.
|
||||
tracker_name: str | None = None
|
||||
tracker_confidence: str | None = None # 'high', 'medium', 'low', 'none'
|
||||
tracker_confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
tracker_evidence: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -167,11 +163,11 @@ class BTDeviceAggregate:
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
|
||||
# IRK (Identity Resolving Key) from paired device database
|
||||
irk_hex: Optional[str] = None # 32-char hex if known
|
||||
irk_source_name: Optional[str] = None # Name from paired DB
|
||||
irk_hex: str | None = None # 32-char hex if known
|
||||
irk_source_name: str | None = None # Name from paired DB
|
||||
|
||||
# Payload fingerprint (survives MAC randomization)
|
||||
payload_fingerprint_id: Optional[str] = None
|
||||
payload_fingerprint_id: str | None = None
|
||||
payload_fingerprint_stability: float = 0.0
|
||||
|
||||
# Service data (for tracker analysis)
|
||||
@@ -379,22 +375,22 @@ class ScanStatus:
|
||||
|
||||
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
|
||||
backend: str | None = None # Active backend being used
|
||||
adapter_id: str | None = None
|
||||
started_at: datetime | None = None
|
||||
duration_s: int | None = None
|
||||
devices_found: int = 0
|
||||
error: Optional[str] = None
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def elapsed_seconds(self) -> Optional[float]:
|
||||
def elapsed_seconds(self) -> float | None:
|
||||
"""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]:
|
||||
def remaining_seconds(self) -> float | None:
|
||||
"""Seconds remaining if duration was set."""
|
||||
if self.duration_s and self.elapsed_seconds:
|
||||
return max(0, self.duration_s - self.elapsed_seconds)
|
||||
@@ -423,11 +419,11 @@ class SystemCapabilities:
|
||||
# DBus/BlueZ
|
||||
has_dbus: bool = False
|
||||
has_bluez: bool = False
|
||||
bluez_version: Optional[str] = None
|
||||
bluez_version: str | None = None
|
||||
|
||||
# Adapters
|
||||
adapters: list[dict] = field(default_factory=list)
|
||||
default_adapter: Optional[str] = None
|
||||
default_adapter: str | None = None
|
||||
|
||||
# Permissions
|
||||
has_bluetooth_permission: bool = False
|
||||
|
||||
@@ -10,8 +10,6 @@ from __future__ import annotations
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_RETENTION_MINUTES = 30
|
||||
@@ -58,7 +56,7 @@ class RingBuffer:
|
||||
self,
|
||||
device_key: str,
|
||||
rssi: int,
|
||||
timestamp: Optional[datetime] = None,
|
||||
timestamp: datetime | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Ingest an RSSI observation for a device.
|
||||
@@ -99,7 +97,7 @@ class RingBuffer:
|
||||
def get_timeseries(
|
||||
self,
|
||||
device_key: str,
|
||||
window_minutes: Optional[int] = None,
|
||||
window_minutes: int | None = None,
|
||||
downsample_seconds: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
@@ -131,9 +129,9 @@ class RingBuffer:
|
||||
|
||||
def get_all_timeseries(
|
||||
self,
|
||||
window_minutes: Optional[int] = None,
|
||||
window_minutes: int | None = None,
|
||||
downsample_seconds: int = 10,
|
||||
top_n: Optional[int] = None,
|
||||
top_n: int | None = None,
|
||||
sort_by: str = 'recency',
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
@@ -265,7 +263,7 @@ class RingBuffer:
|
||||
with self._lock:
|
||||
return len(self._observations)
|
||||
|
||||
def get_observation_count(self, device_key: Optional[str] = None) -> int:
|
||||
def get_observation_count(self, device_key: str | None = None) -> int:
|
||||
"""
|
||||
Get total observation count.
|
||||
|
||||
@@ -287,7 +285,7 @@ class RingBuffer:
|
||||
self._observations.clear()
|
||||
self._last_ingested.clear()
|
||||
|
||||
def get_device_stats(self, device_key: str) -> Optional[dict]:
|
||||
def get_device_stats(self, device_key: str) -> dict | None:
|
||||
"""
|
||||
Get statistics for a specific device.
|
||||
|
||||
@@ -316,7 +314,7 @@ class RingBuffer:
|
||||
|
||||
|
||||
# Module-level instance for shared access
|
||||
_ring_buffer: Optional[RingBuffer] = None
|
||||
_ring_buffer: RingBuffer | None = None
|
||||
|
||||
|
||||
def get_ring_buffer() -> RingBuffer:
|
||||
|
||||
@@ -9,30 +9,26 @@ from __future__ import annotations
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Callable, Generator, Optional
|
||||
from typing import Callable
|
||||
|
||||
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 .ubertooth_scanner import UbertoothScanner
|
||||
from .heuristics import HeuristicsEngine
|
||||
from .irk_extractor import get_paired_irks
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
from .ubertooth_scanner import UbertoothScanner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global scanner instance
|
||||
_scanner_instance: Optional['BluetoothScanner'] = None
|
||||
_scanner_instance: BluetoothScanner | None = None
|
||||
_scanner_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -43,7 +39,7 @@ class BluetoothScanner:
|
||||
Provides unified API for scanning, device aggregation, and heuristics.
|
||||
"""
|
||||
|
||||
def __init__(self, adapter_id: Optional[str] = None):
|
||||
def __init__(self, adapter_id: str | None = None):
|
||||
"""
|
||||
Initialize Bluetooth scanner.
|
||||
|
||||
@@ -57,27 +53,27 @@ class BluetoothScanner:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Scanner backends
|
||||
self._dbus_scanner: Optional[DBusScanner] = None
|
||||
self._fallback_scanner: Optional[FallbackScanner] = None
|
||||
self._ubertooth_scanner: Optional[UbertoothScanner] = None
|
||||
self._active_backend: Optional[str] = None
|
||||
self._dbus_scanner: DBusScanner | None = None
|
||||
self._fallback_scanner: FallbackScanner | None = None
|
||||
self._ubertooth_scanner: UbertoothScanner | None = None
|
||||
self._active_backend: str | None = None
|
||||
|
||||
# Event queue for SSE streaming
|
||||
self._event_queue: queue.Queue = queue.Queue(maxsize=1000)
|
||||
|
||||
# Duration-based scanning
|
||||
self._scan_timer: Optional[threading.Timer] = None
|
||||
self._scan_timer: threading.Timer | None = None
|
||||
|
||||
# Callbacks
|
||||
self._on_device_updated_callbacks: list[Callable[[BTDeviceAggregate], None]] = []
|
||||
|
||||
# Capability check result
|
||||
self._capabilities: Optional[SystemCapabilities] = None
|
||||
self._capabilities: SystemCapabilities | None = None
|
||||
|
||||
def start_scan(
|
||||
self,
|
||||
mode: str = 'auto',
|
||||
duration_s: Optional[int] = None,
|
||||
duration_s: int | None = None,
|
||||
transport: str = 'auto',
|
||||
rssi_threshold: int = -100,
|
||||
) -> bool:
|
||||
@@ -160,7 +156,7 @@ class BluetoothScanner:
|
||||
adapter: str,
|
||||
transport: str,
|
||||
rssi_threshold: int
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
) -> tuple[bool, str | None]:
|
||||
"""Start DBus scanner."""
|
||||
try:
|
||||
self._dbus_scanner = DBusScanner(
|
||||
@@ -173,7 +169,7 @@ class BluetoothScanner:
|
||||
logger.warning(f"DBus scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
|
||||
def _start_ubertooth(self) -> tuple[bool, str | None]:
|
||||
"""Start Ubertooth One scanner."""
|
||||
try:
|
||||
self._ubertooth_scanner = UbertoothScanner(
|
||||
@@ -185,7 +181,7 @@ class BluetoothScanner:
|
||||
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, str | None]:
|
||||
"""Start fallback scanner."""
|
||||
try:
|
||||
# Extract adapter name from path if needed
|
||||
@@ -342,8 +338,8 @@ class BluetoothScanner:
|
||||
self,
|
||||
sort_by: str = 'last_seen',
|
||||
sort_desc: bool = True,
|
||||
min_rssi: Optional[int] = None,
|
||||
protocol: Optional[str] = None,
|
||||
min_rssi: int | None = None,
|
||||
protocol: str | None = None,
|
||||
max_age_seconds: float = DEVICE_STALE_TIMEOUT,
|
||||
) -> list[BTDeviceAggregate]:
|
||||
"""
|
||||
@@ -382,7 +378,7 @@ class BluetoothScanner:
|
||||
|
||||
return devices
|
||||
|
||||
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
|
||||
def get_device(self, device_id: str) -> BTDeviceAggregate | None:
|
||||
"""Get a specific device by ID."""
|
||||
return self._aggregator.get_device(device_id)
|
||||
|
||||
@@ -491,7 +487,7 @@ class BluetoothScanner:
|
||||
return self._aggregator.has_baseline
|
||||
|
||||
|
||||
def get_bluetooth_scanner(adapter_id: Optional[str] = None) -> BluetoothScanner:
|
||||
def get_bluetooth_scanner(adapter_id: str | None = None) -> BluetoothScanner:
|
||||
"""
|
||||
Get or create the global Bluetooth scanner instance.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
|
||||
|
||||
@@ -108,7 +107,7 @@ class TrackerSignature:
|
||||
tracker_type: TrackerType
|
||||
name: str
|
||||
description: str
|
||||
company_id: Optional[int] = None
|
||||
company_id: int | None = None
|
||||
company_ids: list[int] = field(default_factory=list)
|
||||
manufacturer_data_prefixes: list[bytes] = field(default_factory=list)
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
@@ -218,15 +217,15 @@ class TrackerDetectionResult:
|
||||
confidence: TrackerConfidence = TrackerConfidence.NONE
|
||||
confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
matched_signature: Optional[str] = None
|
||||
matched_signature: str | None = None
|
||||
|
||||
# For suspicious presence heuristics
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
# Raw data used for detection
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_hex: Optional[str] = None
|
||||
manufacturer_id: int | None = None
|
||||
manufacturer_data_hex: str | None = None
|
||||
service_uuids_found: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -264,13 +263,13 @@ class DeviceFingerprint:
|
||||
fingerprint_id: str # SHA256 hash of stable features
|
||||
|
||||
# Features used for fingerprinting
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_prefix: Optional[bytes] = None # First 4 bytes (stable across MACs)
|
||||
manufacturer_id: int | None = None
|
||||
manufacturer_data_prefix: bytes | None = None # First 4 bytes (stable across MACs)
|
||||
manufacturer_data_length: int = 0
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data_keys: list[str] = field(default_factory=list)
|
||||
tx_power_bucket: Optional[str] = None # "high"/"medium"/"low"
|
||||
name_hint: Optional[str] = None
|
||||
tx_power_bucket: str | None = None # "high"/"medium"/"low"
|
||||
name_hint: str | None = None
|
||||
|
||||
# Confidence in this fingerprint's stability
|
||||
stability_confidence: float = 0.5 # 0.0-1.0
|
||||
@@ -291,12 +290,12 @@ class DeviceFingerprint:
|
||||
|
||||
|
||||
def generate_fingerprint(
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
manufacturer_id: int | None,
|
||||
manufacturer_data: bytes | None,
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
tx_power: int | None,
|
||||
name: str | None,
|
||||
) -> DeviceFingerprint:
|
||||
"""
|
||||
Generate a stable fingerprint for a BLE device.
|
||||
@@ -407,12 +406,12 @@ class TrackerSignatureEngine:
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
name: str | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
manufacturer_data: bytes | None = None,
|
||||
service_uuids: list[str] | None = None,
|
||||
service_data: dict[str, bytes] | None = None,
|
||||
tx_power: int | None = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Analyze a BLE device for tracker indicators.
|
||||
@@ -502,9 +501,9 @@ class TrackerSignatureEngine:
|
||||
self,
|
||||
signature: TrackerSignature,
|
||||
address: str,
|
||||
name: Optional[str],
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
name: str | None,
|
||||
manufacturer_id: int | None,
|
||||
manufacturer_data: bytes | None,
|
||||
normalized_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
) -> tuple[float, list[str]]:
|
||||
@@ -517,9 +516,7 @@ class TrackerSignatureEngine:
|
||||
# Many Apple devices (AirPods, Watch, etc.) share the same manufacturer ID
|
||||
company_id_matches = False
|
||||
if manufacturer_id is not None:
|
||||
if signature.company_id == manufacturer_id:
|
||||
company_id_matches = True
|
||||
elif manufacturer_id in signature.company_ids:
|
||||
if signature.company_id == manufacturer_id or manufacturer_id in signature.company_ids:
|
||||
company_id_matches = True
|
||||
|
||||
# For Apple devices, only add company ID score if we also have Find My indicators
|
||||
@@ -592,8 +589,8 @@ class TrackerSignatureEngine:
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
manufacturer_id: int | None,
|
||||
manufacturer_data: bytes | None,
|
||||
normalized_uuids: list[str],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Check for generic tracker-like indicators."""
|
||||
@@ -606,12 +603,11 @@ class TrackerSignatureEngine:
|
||||
evidence.append('Uses Apple Find My network service (fd6f)')
|
||||
|
||||
# Apple manufacturer with Find My advertisement type
|
||||
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data:
|
||||
if len(manufacturer_data) >= 2:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE:
|
||||
score += 0.35
|
||||
evidence.append('Apple Find My network advertisement detected')
|
||||
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data and len(manufacturer_data) >= 2:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE:
|
||||
score += 0.35
|
||||
evidence.append('Apple Find My network advertisement detected')
|
||||
|
||||
# Check for beacon-like service UUIDs
|
||||
for beacon_uuid in BEACON_SERVICE_UUIDS:
|
||||
@@ -628,10 +624,9 @@ class TrackerSignatureEngine:
|
||||
evidence.append('Uses randomized MAC address')
|
||||
|
||||
# Small manufacturer data payload typical of beacons
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30:
|
||||
if score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30 and score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
|
||||
return score, evidence
|
||||
|
||||
@@ -651,12 +646,12 @@ class TrackerSignatureEngine:
|
||||
|
||||
def generate_device_fingerprint(
|
||||
self,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
manufacturer_id: int | None,
|
||||
manufacturer_data: bytes | None,
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
tx_power: int | None,
|
||||
name: str | None,
|
||||
) -> DeviceFingerprint:
|
||||
"""Generate a fingerprint for device tracking across MAC rotations."""
|
||||
return generate_fingerprint(
|
||||
@@ -668,7 +663,7 @@ class TrackerSignatureEngine:
|
||||
name=name,
|
||||
)
|
||||
|
||||
def record_sighting(self, fingerprint_id: str, timestamp: Optional[datetime] = None) -> int:
|
||||
def record_sighting(self, fingerprint_id: str, timestamp: datetime | None = None) -> int:
|
||||
"""
|
||||
Record a device sighting for persistence tracking.
|
||||
|
||||
@@ -704,7 +699,7 @@ class TrackerSignatureEngine:
|
||||
seen_count: int,
|
||||
duration_seconds: float,
|
||||
seen_rate: float,
|
||||
rssi_variance: Optional[float],
|
||||
rssi_variance: float | None,
|
||||
is_new: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""
|
||||
@@ -765,7 +760,7 @@ class TrackerSignatureEngine:
|
||||
# SINGLETON ENGINE INSTANCE
|
||||
# =============================================================================
|
||||
|
||||
_engine_instance: Optional[TrackerSignatureEngine] = None
|
||||
_engine_instance: TrackerSignatureEngine | None = None
|
||||
|
||||
|
||||
def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
@@ -779,12 +774,12 @@ def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
def detect_tracker(
|
||||
address: str,
|
||||
address_type: str = 'public',
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
name: str | None = None,
|
||||
manufacturer_id: int | None = None,
|
||||
manufacturer_data: bytes | None = None,
|
||||
service_uuids: list[str] | None = None,
|
||||
service_data: dict[str, bytes] | None = None,
|
||||
tx_power: int | None = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Convenience function to detect if a BLE device is a tracker.
|
||||
|
||||
@@ -7,13 +7,14 @@ Provides enhanced sniffing capabilities compared to standard Bluetooth adapters.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
@@ -38,7 +39,7 @@ class UbertoothScanner:
|
||||
def __init__(
|
||||
self,
|
||||
device_index: int = 0,
|
||||
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||
on_observation: Callable[[BTObservation], None] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize Ubertooth scanner.
|
||||
@@ -49,9 +50,9 @@ class UbertoothScanner:
|
||||
"""
|
||||
self._device_index = device_index
|
||||
self._on_observation = on_observation
|
||||
self._process: Optional[subprocess.Popen] = None
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._is_scanning = False
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
@staticmethod
|
||||
@@ -177,7 +178,7 @@ class UbertoothScanner:
|
||||
finally:
|
||||
self._is_scanning = False
|
||||
|
||||
def _parse_advertisement(self, line: str) -> Optional[BTObservation]:
|
||||
def _parse_advertisement(self, line: str) -> BTObservation | None:
|
||||
"""
|
||||
Parse a single ubertooth-btle output line into a BTObservation.
|
||||
|
||||
@@ -280,10 +281,8 @@ class UbertoothScanner:
|
||||
|
||||
# 0x08/0x09 = Shortened/Complete Local Name
|
||||
elif ad_type in (0x08, 0x09):
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
name = ad_payload.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 0x0A = TX Power Level
|
||||
elif ad_type == 0x0A and len(ad_payload) >= 1:
|
||||
|
||||
Reference in New Issue
Block a user