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:
Smittix
2026-03-13 11:51:27 +00:00
parent 00362bcd57
commit e00fbfddc1
183 changed files with 2006 additions and 4243 deletions

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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: