Overhaul Bluetooth scanning with DBus-based BlueZ integration

Major changes:
- Add utils/bluetooth/ package with DBus scanner, fallback scanners
  (bleak, hcitool, bluetoothctl), device aggregation, and heuristics
- New unified API at /api/bluetooth/ with REST endpoints and SSE streaming
- Device observation aggregation with RSSI statistics and range bands
- Behavioral heuristics: new, persistent, beacon-like, strong+stable
- Frontend components: DeviceCard, MessageCard, RSSISparkline
- TSCM integration via get_tscm_bluetooth_snapshot() helper
- Unit tests for aggregator, heuristics, and API endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-01-21 15:42:33 +00:00
parent 713c1a3470
commit 54db023520
23 changed files with 7324 additions and 143 deletions

View File

@@ -0,0 +1,205 @@
"""
Heuristics engine for Bluetooth device analysis.
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 (
PERSISTENT_MIN_SEEN_COUNT,
PERSISTENT_WINDOW_SECONDS,
BEACON_INTERVAL_MAX_VARIANCE,
STRONG_RSSI_THRESHOLD,
STABLE_VARIANCE_THRESHOLD,
)
from .models import BTDeviceAggregate
class HeuristicsEngine:
"""
Evaluates observable device behaviors without making tracker detection claims.
Heuristics provided:
- is_new: Device not in baseline (appeared after baseline was set)
- is_persistent: Continuously present over time window
- is_beacon_like: Regular advertising pattern
- is_strong_stable: Very close with consistent signal
- has_random_address: Uses privacy-preserving random address
"""
def evaluate(self, device: BTDeviceAggregate) -> None:
"""
Evaluate all heuristics for a device and update its flags.
Args:
device: The BTDeviceAggregate to evaluate.
"""
# Note: is_new and has_random_address are set by the aggregator
# Here we evaluate the behavioral heuristics
device.is_persistent = self._check_persistent(device)
device.is_beacon_like = self._check_beacon_like(device)
device.is_strong_stable = self._check_strong_stable(device)
def _check_persistent(self, device: BTDeviceAggregate) -> bool:
"""
Check if device is persistently present.
A device is considered persistent if it has been seen frequently
over the analysis window.
"""
if device.seen_count < PERSISTENT_MIN_SEEN_COUNT:
return False
# Check if the observations span a reasonable time window
duration = device.duration_seconds
if duration < PERSISTENT_WINDOW_SECONDS * 0.5: # At least half the window
return False
# Check seen rate (should be reasonably consistent)
# Minimum 2 observations per minute for persistent
min_rate = 2.0
return device.seen_rate >= min_rate
def _check_beacon_like(self, device: BTDeviceAggregate) -> bool:
"""
Check if device has beacon-like advertising pattern.
Beacon-like devices advertise at regular intervals with low variance.
"""
if len(device.rssi_samples) < 10:
return False
# Calculate advertisement intervals
intervals = self._calculate_intervals(device)
if len(intervals) < 5:
return False
# Check interval consistency
mean_interval = statistics.mean(intervals)
if mean_interval <= 0:
return False
try:
stdev_interval = statistics.stdev(intervals)
# Coefficient of variation (CV) = stdev / mean
cv = stdev_interval / mean_interval
return cv < BEACON_INTERVAL_MAX_VARIANCE
except statistics.StatisticsError:
return False
def _check_strong_stable(self, device: BTDeviceAggregate) -> bool:
"""
Check if device has strong and stable signal.
Strong + stable indicates the device is very close and stationary.
"""
if device.rssi_median is None or device.rssi_variance is None:
return False
# Must be strong signal
if device.rssi_median < STRONG_RSSI_THRESHOLD:
return False
# Must have low variance (stable)
if device.rssi_variance > STABLE_VARIANCE_THRESHOLD:
return False
# Must have reasonable sample count for confidence
if len(device.rssi_samples) < 5:
return False
return True
def _calculate_intervals(self, device: BTDeviceAggregate) -> list[float]:
"""Calculate time intervals between observations."""
if len(device.rssi_samples) < 2:
return []
intervals = []
prev_time = device.rssi_samples[0][0]
for timestamp, _ in device.rssi_samples[1:]:
interval = (timestamp - prev_time).total_seconds()
# Filter out unreasonably long intervals (gaps in scanning)
if 0 < interval < 30: # Max 30 seconds between observations
intervals.append(interval)
prev_time = timestamp
return intervals
def get_heuristic_summary(self, device: BTDeviceAggregate) -> dict:
"""
Get a summary of heuristic analysis for a device.
Returns:
Dictionary with heuristic flags and explanations.
"""
summary = {
'flags': [],
'details': {}
}
if device.is_new:
summary['flags'].append('new')
summary['details']['new'] = 'Device appeared after baseline was set'
if device.is_persistent:
summary['flags'].append('persistent')
summary['details']['persistent'] = (
f'Seen {device.seen_count} times over '
f'{device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)'
)
if device.is_beacon_like:
summary['flags'].append('beacon_like')
intervals = self._calculate_intervals(device)
if intervals:
mean_int = statistics.mean(intervals)
summary['details']['beacon_like'] = (
f'Regular advertising interval (~{mean_int:.1f}s)'
)
else:
summary['details']['beacon_like'] = 'Regular advertising pattern'
if device.is_strong_stable:
summary['flags'].append('strong_stable')
summary['details']['strong_stable'] = (
f'Strong signal ({device.rssi_median:.0f} dBm) '
f'with low variance ({device.rssi_variance:.1f})'
)
if device.has_random_address:
summary['flags'].append('random_address')
summary['details']['random_address'] = (
f'Uses {device.address_type} address (privacy-preserving)'
)
return summary
def evaluate_device_heuristics(device: BTDeviceAggregate) -> None:
"""
Convenience function to evaluate heuristics for a single device.
Args:
device: The BTDeviceAggregate to evaluate.
"""
engine = HeuristicsEngine()
engine.evaluate(device)
def evaluate_all_devices(devices: list[BTDeviceAggregate]) -> None:
"""
Evaluate heuristics for multiple devices.
Args:
devices: List of BTDeviceAggregate instances to evaluate.
"""
engine = HeuristicsEngine()
for device in devices:
engine.evaluate(device)