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,347 @@
"""
Device aggregator for Bluetooth observations.
Handles RSSI statistics, range band estimation, and device state management.
"""
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_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
MANUFACTURER_NAMES,
PROTOCOL_BLE,
PROTOCOL_CLASSIC,
)
from .models import BTObservation, BTDeviceAggregate
class DeviceAggregator:
"""
Aggregates Bluetooth observations into unified device records.
Maintains RSSI statistics, estimates range bands, and tracks device state
across multiple observations.
"""
def __init__(self, max_rssi_samples: int = MAX_RSSI_SAMPLES):
self._devices: dict[str, BTDeviceAggregate] = {}
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
def ingest(self, observation: BTObservation) -> BTDeviceAggregate:
"""
Ingest a new observation and update the device aggregate.
Args:
observation: The BTObservation to process.
Returns:
The updated BTDeviceAggregate for this device.
"""
device_id = observation.device_id
with self._lock:
if device_id not in self._devices:
# Create new device aggregate
device = BTDeviceAggregate(
device_id=device_id,
address=observation.address,
address_type=observation.address_type,
first_seen=observation.timestamp,
last_seen=observation.timestamp,
protocol=self._infer_protocol(observation),
)
self._devices[device_id] = device
else:
device = self._devices[device_id]
# Update timestamps and counts
device.last_seen = observation.timestamp
device.seen_count += 1
# Calculate seen rate (observations per minute)
duration = device.duration_seconds
if duration > 0:
device.seen_rate = (device.seen_count / duration) * 60
else:
device.seen_rate = 0
# Update RSSI samples
if observation.rssi is not None:
device.rssi_samples.append((observation.timestamp, observation.rssi))
# Prune old samples
if len(device.rssi_samples) > self._max_rssi_samples:
device.rssi_samples = device.rssi_samples[-self._max_rssi_samples:]
# Recalculate RSSI statistics
self._update_rssi_stats(device)
# Merge device info (prefer non-None values)
self._merge_device_info(device, observation)
# Update range band
self._update_range_band(device)
# Check if address is random
device.has_random_address = observation.address_type in (
ADDRESS_TYPE_RANDOM,
ADDRESS_TYPE_RANDOM_STATIC,
ADDRESS_TYPE_RPA,
ADDRESS_TYPE_NRPA,
)
# Check baseline status
device.in_baseline = device_id in self._baseline_device_ids
device.is_new = not device.in_baseline and self._baseline_set_time is not None
return device
def _infer_protocol(self, observation: BTObservation) -> str:
"""Infer the Bluetooth protocol from observation data."""
# If Class of Device is set, it's Classic BT
if observation.class_of_device is not None:
return PROTOCOL_CLASSIC
# If address type is anything other than public, likely BLE
if observation.address_type != 'public':
return PROTOCOL_BLE
# If service UUIDs are present with 16-bit format, likely BLE
if observation.service_uuids:
for uuid in observation.service_uuids:
if len(uuid) == 4 or len(uuid) == 8: # 16-bit or 32-bit
return PROTOCOL_BLE
# Default to BLE as it's more common in modern scanning
return PROTOCOL_BLE
def _update_rssi_stats(self, device: BTDeviceAggregate) -> None:
"""Update RSSI statistics for a device."""
if not device.rssi_samples:
return
rssi_values = [rssi for _, rssi in device.rssi_samples]
# Current is most recent
device.rssi_current = rssi_values[-1]
# Basic statistics
device.rssi_min = min(rssi_values)
device.rssi_max = max(rssi_values)
# Median
device.rssi_median = statistics.median(rssi_values)
# Variance (need at least 2 samples)
if len(rssi_values) >= 2:
device.rssi_variance = statistics.variance(rssi_values)
else:
device.rssi_variance = 0.0
# Confidence based on sample count and variance
device.rssi_confidence = self._calculate_confidence(rssi_values)
def _calculate_confidence(self, rssi_values: list[int]) -> float:
"""
Calculate confidence score for RSSI measurements.
Factors:
- Sample count (more samples = higher confidence)
- Low variance (less variance = higher confidence)
"""
if not rssi_values:
return 0.0
# Sample count factor (logarithmic scaling, max out at ~50 samples)
sample_factor = min(1.0, len(rssi_values) / 20)
# Variance factor (lower variance = higher confidence)
if len(rssi_values) >= 2:
variance = statistics.variance(rssi_values)
# Normalize: 0 variance = 1.0, 100 variance = 0.0
variance_factor = max(0.0, 1.0 - (variance / 100))
else:
variance_factor = 0.5 # Unknown variance
# Combined confidence (weighted average)
confidence = (sample_factor * 0.4) + (variance_factor * 0.6)
return min(1.0, max(0.0, confidence))
def _update_range_band(self, device: BTDeviceAggregate) -> None:
"""Estimate range band from RSSI median and confidence."""
if device.rssi_median is None:
device.range_band = RANGE_UNKNOWN
device.range_confidence = 0.0
return
rssi = device.rssi_median
confidence = device.rssi_confidence
# Determine range band based on RSSI thresholds
if rssi >= RSSI_VERY_CLOSE and confidence >= CONFIDENCE_VERY_CLOSE:
device.range_band = RANGE_VERY_CLOSE
device.range_confidence = confidence
elif rssi >= RSSI_CLOSE and confidence >= CONFIDENCE_CLOSE:
device.range_band = RANGE_CLOSE
device.range_confidence = confidence
elif rssi >= RSSI_NEARBY and confidence >= CONFIDENCE_NEARBY:
device.range_band = RANGE_NEARBY
device.range_confidence = confidence
elif rssi >= RSSI_FAR and confidence >= CONFIDENCE_FAR:
device.range_band = RANGE_FAR
device.range_confidence = confidence
else:
device.range_band = RANGE_UNKNOWN
device.range_confidence = confidence * 0.5 # Reduced confidence for unknown
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
# Manufacturer
if observation.manufacturer_id is not None:
device.manufacturer_id = observation.manufacturer_id
device.manufacturer_name = MANUFACTURER_NAMES.get(
observation.manufacturer_id,
f"Unknown (0x{observation.manufacturer_id:04X})"
)
if observation.manufacturer_data:
device.manufacturer_bytes = observation.manufacturer_data
# Service UUIDs (merge, don't replace)
for uuid in observation.service_uuids:
if uuid not in device.service_uuids:
device.service_uuids.append(uuid)
# Other fields
if observation.tx_power is not None:
device.tx_power = observation.tx_power
if observation.appearance is not None:
device.appearance = observation.appearance
if observation.class_of_device is not None:
device.class_of_device = observation.class_of_device
device.major_class = observation.major_class
device.minor_class = observation.minor_class
# Connection state (use most recent)
device.is_connectable = observation.is_connectable
device.is_paired = observation.is_paired
device.is_connected = observation.is_connected
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
"""Get a device by ID."""
with self._lock:
return self._devices.get(device_id)
def get_all_devices(self) -> list[BTDeviceAggregate]:
"""Get all tracked devices."""
with self._lock:
return list(self._devices.values())
def get_active_devices(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> list[BTDeviceAggregate]:
"""Get devices seen within the specified time window."""
cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
with self._lock:
return [d for d in self._devices.values() if d.last_seen >= cutoff]
def prune_stale_devices(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int:
"""
Remove devices not seen within the specified time window.
Returns:
Number of devices removed.
"""
cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
with self._lock:
stale_ids = [
device_id for device_id, device in self._devices.items()
if device.last_seen < cutoff
]
for device_id in stale_ids:
del self._devices[device_id]
return len(stale_ids)
def clear(self) -> None:
"""Clear all tracked devices."""
with self._lock:
self._devices.clear()
def set_baseline(self) -> int:
"""
Set the current devices as the baseline.
Returns:
Number of devices in baseline.
"""
with self._lock:
self._baseline_device_ids = set(self._devices.keys())
self._baseline_set_time = datetime.now()
# Mark all current devices as in baseline
for device in self._devices.values():
device.in_baseline = True
device.is_new = False
return len(self._baseline_device_ids)
def clear_baseline(self) -> None:
"""Clear the baseline."""
with self._lock:
self._baseline_device_ids.clear()
self._baseline_set_time = None
for device in self._devices.values():
device.in_baseline = False
device.is_new = False
def load_baseline(self, device_ids: set[str], set_time: datetime) -> None:
"""Load a baseline from storage."""
with self._lock:
self._baseline_device_ids = device_ids
self._baseline_set_time = set_time
# Update existing devices
for device_id, device in self._devices.items():
device.in_baseline = device_id in self._baseline_device_ids
device.is_new = not device.in_baseline
@property
def device_count(self) -> int:
"""Number of tracked devices."""
with self._lock:
return len(self._devices)
@property
def baseline_device_count(self) -> int:
"""Number of devices in baseline."""
with self._lock:
return len(self._baseline_device_ids)
@property
def has_baseline(self) -> bool:
"""Whether a baseline is set."""
return self._baseline_set_time is not None