Files
intercept/utils/bluetooth/models.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

468 lines
18 KiB
Python

"""
Bluetooth data models for the unified scanner.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
# 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:
pass
@dataclass
class BTObservation:
"""Represents a single Bluetooth advertisement or inquiry response."""
timestamp: datetime
address: str
address_type: str = ADDRESS_TYPE_PUBLIC # public, random, random_static, rpa, nrpa
rssi: 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: int | None = None
is_connectable: bool = False
is_paired: bool = False
is_connected: bool = False
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:
"""Unique device identifier combining address and type."""
return f"{self.address}:{self.address_type}"
@property
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)
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"timestamp": self.timestamp.isoformat(),
"address": self.address,
"address_type": self.address_type,
"device_id": self.device_id,
"rssi": self.rssi,
"tx_power": self.tx_power,
"name": self.name,
"manufacturer_id": self.manufacturer_id,
"manufacturer_name": self.manufacturer_name,
"manufacturer_data": self.manufacturer_data.hex() if self.manufacturer_data else None,
"service_uuids": self.service_uuids,
"service_data": {k: v.hex() for k, v in self.service_data.items()},
"appearance": self.appearance,
"is_connectable": self.is_connectable,
"is_paired": self.is_paired,
"is_connected": self.is_connected,
"class_of_device": self.class_of_device,
"major_class": self.major_class,
"minor_class": self.minor_class,
}
@dataclass
class BTDeviceAggregate:
"""Aggregated Bluetooth device data over time."""
device_id: str # f"{address}:{address_type}"
address: str
address_type: str
protocol: str = PROTOCOL_BLE # 'ble' or 'classic'
# Timestamps
first_seen: datetime = field(default_factory=datetime.now)
last_seen: datetime = field(default_factory=datetime.now)
seen_count: int = 0
seen_rate: float = 0.0 # observations per minute
# RSSI aggregation (capped at MAX_RSSI_SAMPLES samples)
rssi_samples: list[tuple[datetime, int]] = field(default_factory=list)
rssi_current: 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
range_band: str = RANGE_UNKNOWN
range_confidence: float = 0.0
# Proximity band (new system: immediate/near/far/unknown)
device_key: str | None = None
proximity_band: str = PROXIMITY_UNKNOWN
estimated_distance_m: float | None = None
distance_confidence: float = 0.0
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: 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: 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
# Heuristic flags
is_new: bool = False
is_persistent: bool = False
is_beacon_like: bool = False
is_strong_stable: bool = False
has_random_address: bool = False
# Baseline tracking
in_baseline: bool = False
baseline_id: int | None = None
seen_before: bool = False
# Tracker detection fields
is_tracker: bool = False
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)
# Suspicious presence / following heuristics
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: 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: str | None = None
payload_fingerprint_stability: float = 0.0
# Service data (for tracker analysis)
service_data: dict[str, bytes] = field(default_factory=dict)
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
"""Get RSSI history for sparkline visualization."""
if not self.rssi_samples:
return []
# Downsample if needed
samples = self.rssi_samples[-max_points:]
return [{"timestamp": ts.isoformat(), "rssi": rssi} for ts, rssi in samples]
@property
def age_seconds(self) -> float:
"""Seconds since last seen."""
return (datetime.now() - self.last_seen).total_seconds()
@property
def duration_seconds(self) -> float:
"""Total duration from first to last seen."""
return (self.last_seen - self.first_seen).total_seconds()
@property
def heuristic_flags(self) -> list[str]:
"""List of active heuristic flags."""
flags = []
if self.is_new:
flags.append("new")
if self.is_persistent:
flags.append("persistent")
if self.is_beacon_like:
flags.append("beacon_like")
if self.is_strong_stable:
flags.append("strong_stable")
if self.has_random_address:
flags.append("random_address")
return flags
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"device_id": self.device_id,
"address": self.address,
"address_type": self.address_type,
"protocol": self.protocol,
# Timestamps
"first_seen": self.first_seen.isoformat(),
"last_seen": self.last_seen.isoformat(),
"age_seconds": self.age_seconds,
"duration_seconds": self.duration_seconds,
"seen_count": self.seen_count,
"seen_rate": round(self.seen_rate, 2),
# RSSI stats
"rssi_current": self.rssi_current,
"rssi_median": round(self.rssi_median, 1) if self.rssi_median else None,
"rssi_min": self.rssi_min,
"rssi_max": self.rssi_max,
"rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None,
"rssi_confidence": round(self.rssi_confidence, 2),
"rssi_history": self.get_rssi_history(),
# Range (legacy)
"range_band": self.range_band,
"range_confidence": round(self.range_confidence, 2),
# Proximity (new system)
"device_key": self.device_key,
"proximity_band": self.proximity_band,
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
"distance_confidence": round(self.distance_confidence, 2),
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
"rssi_60s_min": self.rssi_60s_min,
"rssi_60s_max": self.rssi_60s_max,
"is_randomized_mac": self.is_randomized_mac,
"threat_tags": self.threat_tags,
# Device info
"name": self.name,
"manufacturer_id": self.manufacturer_id,
"manufacturer_name": self.manufacturer_name,
"manufacturer_bytes": self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
"service_uuids": self.service_uuids,
"tx_power": self.tx_power,
"appearance": self.appearance,
"class_of_device": self.class_of_device,
"major_class": self.major_class,
"minor_class": self.minor_class,
"is_connectable": self.is_connectable,
"is_paired": self.is_paired,
"is_connected": self.is_connected,
# Heuristics
"heuristics": {
"is_new": self.is_new,
"is_persistent": self.is_persistent,
"is_beacon_like": self.is_beacon_like,
"is_strong_stable": self.is_strong_stable,
"has_random_address": self.has_random_address,
},
"heuristic_flags": self.heuristic_flags,
# Baseline
"in_baseline": self.in_baseline,
"baseline_id": self.baseline_id,
"seen_before": self.seen_before,
# Tracker detection
"tracker": {
"is_tracker": self.is_tracker,
"type": self.tracker_type,
"name": self.tracker_name,
"confidence": self.tracker_confidence,
"confidence_score": round(self.tracker_confidence_score, 2),
"evidence": self.tracker_evidence,
},
# Suspicious presence analysis
"risk_analysis": {
"risk_score": round(self.risk_score, 2),
"risk_factors": self.risk_factors,
},
# IRK
"has_irk": self.irk_hex is not None,
"irk_hex": self.irk_hex,
"irk_source_name": self.irk_source_name,
# Fingerprint
"fingerprint": {
"id": self.payload_fingerprint_id,
"stability": round(self.payload_fingerprint_stability, 2),
},
# Raw service data for investigation
"service_data": {k: v.hex() for k, v in self.service_data.items()},
}
def to_summary_dict(self) -> dict:
"""Compact dictionary for list views."""
return {
"device_id": self.device_id,
"device_key": self.device_key,
"address": self.address,
"address_type": self.address_type,
"protocol": self.protocol,
"name": self.name,
"manufacturer_name": self.manufacturer_name,
"rssi_current": self.rssi_current,
"rssi_median": round(self.rssi_median, 1) if self.rssi_median else None,
"rssi_ema": round(self.rssi_ema, 1) if self.rssi_ema else None,
"rssi_min": self.rssi_min,
"rssi_max": self.rssi_max,
"rssi_variance": round(self.rssi_variance, 2) if self.rssi_variance else None,
"range_band": self.range_band,
"proximity_band": self.proximity_band,
"estimated_distance_m": round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
"distance_confidence": round(self.distance_confidence, 2),
"is_randomized_mac": self.is_randomized_mac,
"last_seen": self.last_seen.isoformat(),
"first_seen": self.first_seen.isoformat(),
"age_seconds": self.age_seconds,
"duration_seconds": self.duration_seconds,
"seen_count": self.seen_count,
"seen_rate": round(self.seen_rate, 2),
"tx_power": self.tx_power,
"manufacturer_id": self.manufacturer_id,
"appearance": self.appearance,
"appearance_name": get_appearance_name(self.appearance),
"is_connectable": self.is_connectable,
"service_uuids": self.service_uuids,
"service_data": {k: v.hex() for k, v in self.service_data.items()},
"manufacturer_bytes": self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
"heuristic_flags": self.heuristic_flags,
"is_persistent": self.is_persistent,
"is_beacon_like": self.is_beacon_like,
"is_strong_stable": self.is_strong_stable,
"in_baseline": self.in_baseline,
"seen_before": self.seen_before,
# 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,
}
@dataclass
class ScanStatus:
"""Current scanning status."""
is_scanning: bool = False
mode: str = "auto" # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
backend: str | None = None # Active backend being used
adapter_id: str | None = None
started_at: datetime | None = None
duration_s: int | None = None
devices_found: int = 0
error: str | None = None
@property
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) -> 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)
return None
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"is_scanning": self.is_scanning,
"mode": self.mode,
"backend": self.backend,
"adapter_id": self.adapter_id,
"started_at": self.started_at.isoformat() if self.started_at else None,
"duration_s": self.duration_s,
"elapsed_seconds": round(self.elapsed_seconds, 1) if self.elapsed_seconds else None,
"remaining_seconds": round(self.remaining_seconds, 1) if self.remaining_seconds else None,
"devices_found": self.devices_found,
"error": self.error,
}
@dataclass
class SystemCapabilities:
"""Bluetooth system capabilities check result."""
# DBus/BlueZ
has_dbus: bool = False
has_bluez: bool = False
bluez_version: str | None = None
# Adapters
adapters: list[dict] = field(default_factory=list)
default_adapter: str | None = None
# Permissions
has_bluetooth_permission: bool = False
is_root: bool = False
# rfkill status
is_soft_blocked: bool = False
is_hard_blocked: bool = False
# Fallback tools
has_bleak: bool = False
has_hcitool: bool = False
has_bluetoothctl: bool = False
has_btmgmt: bool = False
has_ubertooth: bool = False
# Recommended backend
recommended_backend: str = "none"
# Issues found
issues: list[str] = field(default_factory=list)
@property
def can_scan(self) -> bool:
"""Whether scanning is possible with any backend."""
return (
(self.has_dbus and self.has_bluez and len(self.adapters) > 0)
or self.has_bleak
or self.has_hcitool
or self.has_bluetoothctl
or self.has_ubertooth
)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"available": self.can_scan, # Alias for frontend compatibility
"can_scan": self.can_scan,
"has_dbus": self.has_dbus,
"has_bluez": self.has_bluez,
"bluez_version": self.bluez_version,
"adapters": self.adapters,
"default_adapter": self.default_adapter,
"has_bluetooth_permission": self.has_bluetooth_permission,
"is_root": self.is_root,
"is_soft_blocked": self.is_soft_blocked,
"is_hard_blocked": self.is_hard_blocked,
"has_bleak": self.has_bleak,
"has_hcitool": self.has_hcitool,
"has_bluetoothctl": self.has_bluetoothctl,
"has_btmgmt": self.has_btmgmt,
"has_ubertooth": self.has_ubertooth,
"preferred_backend": self.recommended_backend, # Alias for frontend
"recommended_backend": self.recommended_backend,
"issues": self.issues,
}