mirror of
https://github.com/smittix/intercept.git
synced 2026-04-27 16:20:02 -07:00
chore: Bump version to v2.18.0
Bluetooth enhancements (service data inspector, appearance codes, MAC cluster tracking, behavioral flags, IRK badges, distance estimation), ACARS SoapySDR multi-backend support, dump1090 stale process cleanup, GPS error state, and proximity radar/signal card UI improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -606,6 +606,12 @@ class DeviceAggregator:
|
||||
|
||||
return result
|
||||
|
||||
def get_fingerprint_mac_count(self, fingerprint_id: str) -> int:
|
||||
"""Return how many distinct device_ids share a fingerprint."""
|
||||
with self._lock:
|
||||
device_ids = self._fingerprint_to_devices.get(fingerprint_id)
|
||||
return len(device_ids) if device_ids else 0
|
||||
|
||||
def prune_ring_buffer(self) -> int:
|
||||
"""Prune old observations from ring buffer."""
|
||||
return self._ring_buffer.prune_old()
|
||||
|
||||
@@ -101,6 +101,7 @@ 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
|
||||
@@ -278,3 +279,59 @@ MINOR_WEARABLE = {
|
||||
0x04: 'Helmet',
|
||||
0x05: 'Glasses',
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# BLE APPEARANCE CODES (GAP Appearance values)
|
||||
# =============================================================================
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
def get_appearance_name(code: int | None) -> str | None:
|
||||
"""Look up a human-readable name for a BLE appearance code."""
|
||||
if code is None:
|
||||
return None
|
||||
return BLE_APPEARANCE_NAMES.get(code)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Optional
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_UUID,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,10 +47,14 @@ def generate_device_key(
|
||||
if identity_address:
|
||||
return f"id:{identity_address.upper()}"
|
||||
|
||||
# Priority 2: Use public or random_static addresses directly
|
||||
# Priority 2: Use public or random_static addresses directly (not platform UUIDs)
|
||||
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
||||
return f"mac:{address.upper()}"
|
||||
|
||||
# Priority 2b: CoreBluetooth UUIDs are stable per-system, use as identifier
|
||||
if address_type == ADDRESS_TYPE_UUID:
|
||||
return f"uuid:{address.upper()}"
|
||||
|
||||
# Priority 3: Generate fingerprint hash for random addresses
|
||||
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
||||
|
||||
@@ -102,7 +107,7 @@ def is_randomized_mac(address_type: str) -> bool:
|
||||
Returns:
|
||||
True if the address is randomized, False otherwise.
|
||||
"""
|
||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
|
||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC, ADDRESS_TYPE_UUID)
|
||||
|
||||
|
||||
def extract_key_type(device_key: str) -> str:
|
||||
|
||||
@@ -24,8 +24,12 @@ from .constants import (
|
||||
BLUETOOTHCTL_TIMEOUT,
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_UUID,
|
||||
MANUFACTURER_NAMES,
|
||||
)
|
||||
|
||||
# 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}$')
|
||||
from .models import BTObservation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -132,7 +136,10 @@ class BleakScanner:
|
||||
"""Convert bleak device to BTObservation."""
|
||||
# Determine address type from address format
|
||||
address_type = ADDRESS_TYPE_PUBLIC
|
||||
if device.address and ':' in device.address:
|
||||
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:
|
||||
# Check if first byte indicates random address
|
||||
first_byte = int(device.address.split(':')[0], 16)
|
||||
if (first_byte & 0xC0) == 0xC0: # Random static
|
||||
|
||||
@@ -18,6 +18,7 @@ from .constants import (
|
||||
RANGE_UNKNOWN,
|
||||
PROTOCOL_BLE,
|
||||
PROXIMITY_UNKNOWN,
|
||||
get_appearance_name,
|
||||
)
|
||||
|
||||
# Import tracker types (will be available after tracker_signatures module loads)
|
||||
@@ -148,10 +149,10 @@ class BTDeviceAggregate:
|
||||
is_strong_stable: bool = False
|
||||
has_random_address: bool = False
|
||||
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
seen_before: bool = False
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
seen_before: bool = False
|
||||
|
||||
# Tracker detection fields
|
||||
is_tracker: bool = False
|
||||
@@ -165,6 +166,10 @@ class BTDeviceAggregate:
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
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
|
||||
|
||||
# Payload fingerprint (survives MAC randomization)
|
||||
payload_fingerprint_id: Optional[str] = None
|
||||
payload_fingerprint_stability: float = 0.0
|
||||
@@ -275,10 +280,10 @@ class BTDeviceAggregate:
|
||||
},
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
'seen_before': self.seen_before,
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
'seen_before': self.seen_before,
|
||||
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
@@ -296,6 +301,11 @@ class BTDeviceAggregate:
|
||||
'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,
|
||||
|
||||
# Fingerprint
|
||||
'fingerprint': {
|
||||
'id': self.payload_fingerprint_id,
|
||||
@@ -319,24 +329,46 @@ class BTDeviceAggregate:
|
||||
'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,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
'seen_before': self.seen_before,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'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,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,9 @@ from .constants import (
|
||||
)
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -57,6 +59,7 @@ class BluetoothScanner:
|
||||
# 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
|
||||
|
||||
# Event queue for SSE streaming
|
||||
@@ -113,6 +116,8 @@ class BluetoothScanner:
|
||||
|
||||
if mode == 'dbus':
|
||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||
elif mode == 'ubertooth':
|
||||
started, backend_used = self._start_ubertooth()
|
||||
|
||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||
@@ -168,6 +173,18 @@ class BluetoothScanner:
|
||||
logger.warning(f"DBus scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_ubertooth(self) -> tuple[bool, Optional[str]]:
|
||||
"""Start Ubertooth One scanner."""
|
||||
try:
|
||||
self._ubertooth_scanner = UbertoothScanner(
|
||||
on_observation=self._handle_observation,
|
||||
)
|
||||
if self._ubertooth_scanner.start():
|
||||
return True, 'ubertooth'
|
||||
except Exception as e:
|
||||
logger.warning(f"Ubertooth scanner failed: {e}")
|
||||
return False, None
|
||||
|
||||
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||
"""Start fallback scanner."""
|
||||
try:
|
||||
@@ -204,6 +221,10 @@ class BluetoothScanner:
|
||||
self._fallback_scanner.stop()
|
||||
self._fallback_scanner = None
|
||||
|
||||
if self._ubertooth_scanner:
|
||||
self._ubertooth_scanner.stop()
|
||||
self._ubertooth_scanner = None
|
||||
|
||||
# Update status
|
||||
self._status.is_scanning = False
|
||||
self._active_backend = None
|
||||
@@ -216,6 +237,47 @@ class BluetoothScanner:
|
||||
|
||||
logger.info("Bluetooth scan stopped")
|
||||
|
||||
def _match_irk(self, device: BTDeviceAggregate) -> None:
|
||||
"""Check if a device address resolves against any paired IRK."""
|
||||
if device.irk_hex is not None:
|
||||
return # Already matched
|
||||
|
||||
address = device.address
|
||||
if not address or len(address.replace(':', '').replace('-', '')) not in (12, 32):
|
||||
return
|
||||
|
||||
# Only attempt RPA resolution on 6-byte addresses
|
||||
addr_clean = address.replace(':', '').replace('-', '')
|
||||
if len(addr_clean) != 12:
|
||||
return
|
||||
|
||||
try:
|
||||
paired = get_paired_irks()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
if not paired:
|
||||
return
|
||||
|
||||
try:
|
||||
from utils.bt_locate import resolve_rpa
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
for entry in paired:
|
||||
irk_hex = entry.get('irk_hex', '')
|
||||
if not irk_hex or len(irk_hex) != 32:
|
||||
continue
|
||||
try:
|
||||
irk = bytes.fromhex(irk_hex)
|
||||
if resolve_rpa(irk, address):
|
||||
device.irk_hex = irk_hex
|
||||
device.irk_source_name = entry.get('name')
|
||||
logger.debug(f"IRK match for {address}: {entry.get('name', 'unnamed')}")
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _handle_observation(self, observation: BTObservation) -> None:
|
||||
"""Handle incoming observation from scanner backend."""
|
||||
try:
|
||||
@@ -225,15 +287,27 @@ class BluetoothScanner:
|
||||
# Evaluate heuristics
|
||||
self._heuristics.evaluate(device)
|
||||
|
||||
# Check for IRK match
|
||||
self._match_irk(device)
|
||||
|
||||
# Update device count
|
||||
with self._lock:
|
||||
self._status.devices_found = self._aggregator.device_count
|
||||
|
||||
# Build summary with MAC cluster count
|
||||
summary = device.to_summary_dict()
|
||||
if device.payload_fingerprint_id:
|
||||
summary['mac_cluster_count'] = self._aggregator.get_fingerprint_mac_count(
|
||||
device.payload_fingerprint_id
|
||||
)
|
||||
else:
|
||||
summary['mac_cluster_count'] = 0
|
||||
|
||||
# Queue event
|
||||
self._queue_event({
|
||||
'type': 'device',
|
||||
'action': 'update',
|
||||
'device': device.to_summary_dict(),
|
||||
'device': summary,
|
||||
})
|
||||
|
||||
# Callbacks
|
||||
@@ -398,6 +472,7 @@ class BluetoothScanner:
|
||||
backend_alive = (
|
||||
(self._dbus_scanner and self._dbus_scanner.is_scanning)
|
||||
or (self._fallback_scanner and self._fallback_scanner.is_scanning)
|
||||
or (self._ubertooth_scanner and self._ubertooth_scanner.is_scanning)
|
||||
)
|
||||
if not backend_alive:
|
||||
self._status.is_scanning = False
|
||||
|
||||
Reference in New Issue
Block a user