mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -7,6 +7,7 @@ def register_blueprints(app):
|
|||||||
from .rtlamr import rtlamr_bp
|
from .rtlamr import rtlamr_bp
|
||||||
from .wifi import wifi_bp
|
from .wifi import wifi_bp
|
||||||
from .bluetooth import bluetooth_bp
|
from .bluetooth import bluetooth_bp
|
||||||
|
from .bluetooth_v2 import bluetooth_v2_bp
|
||||||
from .adsb import adsb_bp
|
from .adsb import adsb_bp
|
||||||
from .acars import acars_bp
|
from .acars import acars_bp
|
||||||
from .aprs import aprs_bp
|
from .aprs import aprs_bp
|
||||||
@@ -22,6 +23,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(rtlamr_bp)
|
app.register_blueprint(rtlamr_bp)
|
||||||
app.register_blueprint(wifi_bp)
|
app.register_blueprint(wifi_bp)
|
||||||
app.register_blueprint(bluetooth_bp)
|
app.register_blueprint(bluetooth_bp)
|
||||||
|
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||||
app.register_blueprint(adsb_bp)
|
app.register_blueprint(adsb_bp)
|
||||||
app.register_blueprint(acars_bp)
|
app.register_blueprint(acars_bp)
|
||||||
app.register_blueprint(aprs_bp)
|
app.register_blueprint(aprs_bp)
|
||||||
|
|||||||
659
routes/bluetooth_v2.py
Normal file
659
routes/bluetooth_v2.py
Normal file
@@ -0,0 +1,659 @@
|
|||||||
|
"""
|
||||||
|
Bluetooth API v2 - Unified scanning with DBus/BlueZ and fallbacks.
|
||||||
|
|
||||||
|
Provides REST endpoints and SSE streaming for Bluetooth device discovery,
|
||||||
|
aggregation, and heuristics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, jsonify, request, session
|
||||||
|
|
||||||
|
from utils.bluetooth import (
|
||||||
|
BluetoothScanner,
|
||||||
|
BTDeviceAggregate,
|
||||||
|
get_bluetooth_scanner,
|
||||||
|
check_capabilities,
|
||||||
|
RANGE_UNKNOWN,
|
||||||
|
)
|
||||||
|
from utils.database import get_db
|
||||||
|
from utils.sse import format_sse
|
||||||
|
|
||||||
|
logger = logging.getLogger('intercept.bluetooth_v2')
|
||||||
|
|
||||||
|
# Blueprint
|
||||||
|
bluetooth_v2_bp = Blueprint('bluetooth_v2', __name__, url_prefix='/api/bluetooth')
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE FUNCTIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def init_bt_tables() -> None:
|
||||||
|
"""Initialize Bluetooth-specific database tables."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Bluetooth baselines
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS bt_baselines (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
device_count INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT 0
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Baseline device snapshots
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS bt_baseline_devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
baseline_id INTEGER NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
address_type TEXT,
|
||||||
|
name TEXT,
|
||||||
|
manufacturer_id INTEGER,
|
||||||
|
manufacturer_name TEXT,
|
||||||
|
protocol TEXT,
|
||||||
|
FOREIGN KEY (baseline_id) REFERENCES bt_baselines(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(baseline_id, device_id)
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Observation history for long-term tracking
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS bt_observation_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
rssi INTEGER,
|
||||||
|
seen_count INTEGER
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bt_obs_device_time
|
||||||
|
ON bt_observation_history(device_id, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bt_baseline_devices_baseline
|
||||||
|
ON bt_baseline_devices(baseline_id)
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_baseline_id() -> int | None:
|
||||||
|
"""Get the ID of the active baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT id FROM bt_baselines WHERE is_active = 1 LIMIT 1'
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row['id'] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_baseline_device_ids(baseline_id: int) -> set[str]:
|
||||||
|
"""Get device IDs from a baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
'SELECT device_id FROM bt_baseline_devices WHERE baseline_id = ?',
|
||||||
|
(baseline_id,)
|
||||||
|
)
|
||||||
|
return {row['device_id'] for row in cursor}
|
||||||
|
|
||||||
|
|
||||||
|
def save_baseline(name: str, devices: list[BTDeviceAggregate]) -> int:
|
||||||
|
"""Save current devices as a new baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Deactivate existing baselines
|
||||||
|
conn.execute('UPDATE bt_baselines SET is_active = 0')
|
||||||
|
|
||||||
|
# Create new baseline
|
||||||
|
cursor = conn.execute(
|
||||||
|
'INSERT INTO bt_baselines (name, device_count, is_active) VALUES (?, ?, 1)',
|
||||||
|
(name, len(devices))
|
||||||
|
)
|
||||||
|
baseline_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Save device snapshots
|
||||||
|
for device in devices:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO bt_baseline_devices
|
||||||
|
(baseline_id, device_id, address, address_type, name,
|
||||||
|
manufacturer_id, manufacturer_name, protocol)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
baseline_id,
|
||||||
|
device.device_id,
|
||||||
|
device.address,
|
||||||
|
device.address_type,
|
||||||
|
device.name,
|
||||||
|
device.manufacturer_id,
|
||||||
|
device.manufacturer_name,
|
||||||
|
device.protocol,
|
||||||
|
))
|
||||||
|
|
||||||
|
return baseline_id
|
||||||
|
|
||||||
|
|
||||||
|
def clear_active_baseline() -> bool:
|
||||||
|
"""Clear the active baseline."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('UPDATE bt_baselines SET is_active = 0 WHERE is_active = 1')
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_baselines() -> list[dict]:
|
||||||
|
"""Get all baselines."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
SELECT id, name, created_at, device_count, is_active
|
||||||
|
FROM bt_baselines
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
''')
|
||||||
|
return [dict(row) for row in cursor]
|
||||||
|
|
||||||
|
|
||||||
|
def save_observation_history(device: BTDeviceAggregate) -> None:
|
||||||
|
"""Save device observation to history."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO bt_observation_history (device_id, rssi, seen_count)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
''', (device.device_id, device.rssi_current, device.seen_count))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API ENDPOINTS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/capabilities', methods=['GET'])
|
||||||
|
def get_capabilities():
|
||||||
|
"""
|
||||||
|
Get Bluetooth system capabilities.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with capability information including adapters, backends, and issues.
|
||||||
|
"""
|
||||||
|
caps = check_capabilities()
|
||||||
|
return jsonify(caps.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/scan/start', methods=['POST'])
|
||||||
|
def start_scan():
|
||||||
|
"""
|
||||||
|
Start Bluetooth scanning.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- mode: Scanner mode ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
||||||
|
- duration_s: Scan duration in seconds (optional, None for indefinite)
|
||||||
|
- adapter_id: Adapter path/name (optional)
|
||||||
|
- transport: BLE transport ('auto', 'bredr', 'le')
|
||||||
|
- rssi_threshold: Minimum RSSI for discovery
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with scan status.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
mode = data.get('mode', 'auto')
|
||||||
|
duration_s = data.get('duration_s')
|
||||||
|
adapter_id = data.get('adapter_id')
|
||||||
|
transport = data.get('transport', 'auto')
|
||||||
|
rssi_threshold = data.get('rssi_threshold', -100)
|
||||||
|
|
||||||
|
# Validate mode
|
||||||
|
valid_modes = ('auto', 'dbus', 'bleak', 'hcitool', 'bluetoothctl')
|
||||||
|
if mode not in valid_modes:
|
||||||
|
return jsonify({'error': f'Invalid mode. Must be one of: {valid_modes}'}), 400
|
||||||
|
|
||||||
|
# Get scanner instance
|
||||||
|
scanner = get_bluetooth_scanner(adapter_id)
|
||||||
|
|
||||||
|
# Check if already scanning
|
||||||
|
if scanner.is_scanning:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'scan_status': scanner.get_status().to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize database tables if needed
|
||||||
|
init_bt_tables()
|
||||||
|
|
||||||
|
# Load active baseline if exists
|
||||||
|
baseline_id = get_active_baseline_id()
|
||||||
|
if baseline_id:
|
||||||
|
device_ids = get_baseline_device_ids(baseline_id)
|
||||||
|
if device_ids:
|
||||||
|
scanner._aggregator.load_baseline(device_ids, datetime.now())
|
||||||
|
|
||||||
|
# Start scan
|
||||||
|
success = scanner.start_scan(
|
||||||
|
mode=mode,
|
||||||
|
duration_s=duration_s,
|
||||||
|
transport=transport,
|
||||||
|
rssi_threshold=rssi_threshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'mode': status.mode,
|
||||||
|
'backend': status.backend,
|
||||||
|
'adapter_id': status.adapter_id,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'failed',
|
||||||
|
'error': status.error or 'Failed to start scan',
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/scan/stop', methods=['POST'])
|
||||||
|
def stop_scan():
|
||||||
|
"""
|
||||||
|
Stop Bluetooth scanning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with status.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
scanner.stop_scan()
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/scan/status', methods=['GET'])
|
||||||
|
def get_scan_status():
|
||||||
|
"""
|
||||||
|
Get current scan status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with scan status including elapsed time and device count.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
status = scanner.get_status()
|
||||||
|
return jsonify(status.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/devices', methods=['GET'])
|
||||||
|
def list_devices():
|
||||||
|
"""
|
||||||
|
List discovered Bluetooth devices.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- sort: Sort field ('last_seen', 'rssi_current', 'name', 'seen_count')
|
||||||
|
- order: Sort order ('asc', 'desc')
|
||||||
|
- min_rssi: Minimum RSSI filter
|
||||||
|
- protocol: Protocol filter ('ble', 'classic')
|
||||||
|
- max_age: Maximum age in seconds
|
||||||
|
- heuristic: Filter by heuristic flag ('new', 'persistent', etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON array of device summaries.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
|
||||||
|
# Parse query parameters
|
||||||
|
sort_by = request.args.get('sort', 'last_seen')
|
||||||
|
sort_desc = request.args.get('order', 'desc').lower() != 'asc'
|
||||||
|
min_rssi = request.args.get('min_rssi', type=int)
|
||||||
|
protocol = request.args.get('protocol')
|
||||||
|
max_age = request.args.get('max_age', 300, type=float)
|
||||||
|
heuristic_filter = request.args.get('heuristic')
|
||||||
|
|
||||||
|
# Get devices
|
||||||
|
devices = scanner.get_devices(
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_desc=sort_desc,
|
||||||
|
min_rssi=min_rssi,
|
||||||
|
protocol=protocol,
|
||||||
|
max_age_seconds=max_age,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply heuristic filter if specified
|
||||||
|
if heuristic_filter:
|
||||||
|
devices = [d for d in devices if heuristic_filter in d.heuristic_flags]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'count': len(devices),
|
||||||
|
'devices': [d.to_summary_dict() for d in devices],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/devices/<device_id>', methods=['GET'])
|
||||||
|
def get_device(device_id: str):
|
||||||
|
"""
|
||||||
|
Get detailed information about a specific device.
|
||||||
|
|
||||||
|
Path parameters:
|
||||||
|
- device_id: Device identifier (address:address_type)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with full device details including RSSI history.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
device = scanner.get_device(device_id)
|
||||||
|
|
||||||
|
if not device:
|
||||||
|
return jsonify({'error': 'Device not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(device.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/baseline/set', methods=['POST'])
|
||||||
|
def set_baseline():
|
||||||
|
"""
|
||||||
|
Set current devices as baseline.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- name: Baseline name (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with baseline info.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name', f'Baseline {datetime.now().strftime("%Y-%m-%d %H:%M")}')
|
||||||
|
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
|
||||||
|
# Initialize tables if needed
|
||||||
|
init_bt_tables()
|
||||||
|
|
||||||
|
# Get current devices and save to database
|
||||||
|
devices = scanner.get_devices()
|
||||||
|
baseline_id = save_baseline(name, devices)
|
||||||
|
|
||||||
|
# Update scanner's in-memory baseline
|
||||||
|
device_count = scanner.set_baseline()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'baseline_id': baseline_id,
|
||||||
|
'name': name,
|
||||||
|
'device_count': device_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/baseline/clear', methods=['POST'])
|
||||||
|
def clear_baseline():
|
||||||
|
"""
|
||||||
|
Clear the active baseline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with status.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
|
||||||
|
# Clear in database
|
||||||
|
init_bt_tables()
|
||||||
|
cleared = clear_active_baseline()
|
||||||
|
|
||||||
|
# Clear in scanner
|
||||||
|
scanner.clear_baseline()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'cleared' if cleared else 'no_baseline',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/baseline/list', methods=['GET'])
|
||||||
|
def list_baselines():
|
||||||
|
"""
|
||||||
|
List all saved baselines.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON array of baselines.
|
||||||
|
"""
|
||||||
|
init_bt_tables()
|
||||||
|
baselines = get_all_baselines()
|
||||||
|
return jsonify({
|
||||||
|
'count': len(baselines),
|
||||||
|
'baselines': baselines,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/export', methods=['GET'])
|
||||||
|
def export_devices():
|
||||||
|
"""
|
||||||
|
Export devices in CSV or JSON format.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
- format: Export format ('csv', 'json')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSV or JSON file download.
|
||||||
|
"""
|
||||||
|
export_format = request.args.get('format', 'json').lower()
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
devices = scanner.get_devices()
|
||||||
|
|
||||||
|
if export_format == 'csv':
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
writer.writerow([
|
||||||
|
'device_id', 'address', 'address_type', 'protocol', 'name',
|
||||||
|
'manufacturer_name', 'rssi_current', 'rssi_median', 'range_band',
|
||||||
|
'first_seen', 'last_seen', 'seen_count', 'heuristic_flags',
|
||||||
|
'in_baseline'
|
||||||
|
])
|
||||||
|
|
||||||
|
# Data rows
|
||||||
|
for device in devices:
|
||||||
|
writer.writerow([
|
||||||
|
device.device_id,
|
||||||
|
device.address,
|
||||||
|
device.address_type,
|
||||||
|
device.protocol,
|
||||||
|
device.name or '',
|
||||||
|
device.manufacturer_name or '',
|
||||||
|
device.rssi_current or '',
|
||||||
|
round(device.rssi_median, 1) if device.rssi_median else '',
|
||||||
|
device.range_band,
|
||||||
|
device.first_seen.isoformat(),
|
||||||
|
device.last_seen.isoformat(),
|
||||||
|
device.seen_count,
|
||||||
|
','.join(device.heuristic_flags),
|
||||||
|
'yes' if device.in_baseline else 'no',
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return Response(
|
||||||
|
output.getvalue(),
|
||||||
|
mimetype='text/csv',
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
else: # JSON
|
||||||
|
data = {
|
||||||
|
'exported_at': datetime.now().isoformat(),
|
||||||
|
'device_count': len(devices),
|
||||||
|
'devices': [d.to_dict() for d in devices],
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, indent=2),
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': f'attachment; filename=bluetooth_devices_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/stream', methods=['GET'])
|
||||||
|
def stream_events():
|
||||||
|
"""
|
||||||
|
SSE event stream for real-time device updates.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Server-Sent Events stream.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
|
||||||
|
def event_generator() -> Generator[str, None, None]:
|
||||||
|
"""Generate SSE events from scanner."""
|
||||||
|
for event in scanner.stream_events(timeout=1.0):
|
||||||
|
yield format_sse(event)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
event_generator(),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/clear', methods=['POST'])
|
||||||
|
def clear_devices():
|
||||||
|
"""
|
||||||
|
Clear all tracked devices (does not affect baseline).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with status.
|
||||||
|
"""
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
scanner.clear_devices()
|
||||||
|
|
||||||
|
return jsonify({'status': 'cleared'})
|
||||||
|
|
||||||
|
|
||||||
|
@bluetooth_v2_bp.route('/prune', methods=['POST'])
|
||||||
|
def prune_stale():
|
||||||
|
"""
|
||||||
|
Prune stale devices.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
- max_age: Maximum age in seconds (default: 300)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with count of pruned devices.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
max_age = data.get('max_age', 300)
|
||||||
|
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
pruned = scanner.prune_stale(max_age_seconds=max_age)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'pruned_count': pruned,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TSCM INTEGRATION HELPER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def get_tscm_bluetooth_snapshot(duration: int = 8) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get Bluetooth snapshot for TSCM integration.
|
||||||
|
|
||||||
|
This is called from routes/tscm.py to get unified Bluetooth data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
duration: Scan duration in seconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device dictionaries in TSCM format.
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
scanner = get_bluetooth_scanner()
|
||||||
|
|
||||||
|
# Start scan if not running
|
||||||
|
if not scanner.is_scanning:
|
||||||
|
scanner.start_scan(mode='auto', duration_s=duration)
|
||||||
|
time.sleep(duration + 1)
|
||||||
|
|
||||||
|
devices = scanner.get_devices()
|
||||||
|
|
||||||
|
# Convert to TSCM format
|
||||||
|
tscm_devices = []
|
||||||
|
for device in devices:
|
||||||
|
tscm_devices.append({
|
||||||
|
'mac': device.address,
|
||||||
|
'address_type': device.address_type,
|
||||||
|
'name': device.name or 'Unknown',
|
||||||
|
'rssi': device.rssi_current or -100,
|
||||||
|
'rssi_median': device.rssi_median,
|
||||||
|
'type': _classify_device_type(device),
|
||||||
|
'manufacturer': device.manufacturer_name,
|
||||||
|
'protocol': device.protocol,
|
||||||
|
'first_seen': device.first_seen.isoformat(),
|
||||||
|
'last_seen': device.last_seen.isoformat(),
|
||||||
|
'seen_count': device.seen_count,
|
||||||
|
'range_band': device.range_band,
|
||||||
|
'heuristics': {
|
||||||
|
'is_new': device.is_new,
|
||||||
|
'is_persistent': device.is_persistent,
|
||||||
|
'is_beacon_like': device.is_beacon_like,
|
||||||
|
'is_strong_stable': device.is_strong_stable,
|
||||||
|
'has_random_address': device.has_random_address,
|
||||||
|
},
|
||||||
|
'in_baseline': device.in_baseline,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tscm_devices
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_device_type(device: BTDeviceAggregate) -> str:
|
||||||
|
"""Classify device type from available data."""
|
||||||
|
name_lower = (device.name or '').lower()
|
||||||
|
manufacturer_lower = (device.manufacturer_name or '').lower()
|
||||||
|
|
||||||
|
# Check by name patterns
|
||||||
|
if any(x in name_lower for x in ['airpods', 'headphone', 'earbuds', 'buds', 'beats']):
|
||||||
|
return 'audio'
|
||||||
|
if any(x in name_lower for x in ['watch', 'band', 'fitbit', 'garmin']):
|
||||||
|
return 'wearable'
|
||||||
|
if any(x in name_lower for x in ['iphone', 'pixel', 'galaxy', 'phone']):
|
||||||
|
return 'phone'
|
||||||
|
if any(x in name_lower for x in ['macbook', 'laptop', 'thinkpad', 'surface']):
|
||||||
|
return 'computer'
|
||||||
|
if any(x in name_lower for x in ['mouse', 'keyboard', 'trackpad']):
|
||||||
|
return 'peripheral'
|
||||||
|
if any(x in name_lower for x in ['tile', 'airtag', 'smarttag', 'chipolo']):
|
||||||
|
return 'tracker'
|
||||||
|
if any(x in name_lower for x in ['speaker', 'sonos', 'echo', 'home']):
|
||||||
|
return 'speaker'
|
||||||
|
if any(x in name_lower for x in ['tv', 'chromecast', 'roku', 'firestick']):
|
||||||
|
return 'media'
|
||||||
|
|
||||||
|
# Check by manufacturer
|
||||||
|
if 'apple' in manufacturer_lower:
|
||||||
|
return 'apple_device'
|
||||||
|
if 'samsung' in manufacturer_lower:
|
||||||
|
return 'samsung_device'
|
||||||
|
|
||||||
|
# Check by class of device
|
||||||
|
if device.major_class:
|
||||||
|
major = device.major_class.lower()
|
||||||
|
if 'audio' in major:
|
||||||
|
return 'audio'
|
||||||
|
if 'phone' in major:
|
||||||
|
return 'phone'
|
||||||
|
if 'computer' in major:
|
||||||
|
return 'computer'
|
||||||
|
if 'peripheral' in major:
|
||||||
|
return 'peripheral'
|
||||||
|
if 'wearable' in major:
|
||||||
|
return 'wearable'
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
197
routes/tscm.py
197
routes/tscm.py
@@ -54,6 +54,13 @@ from utils.tscm.device_identity import (
|
|||||||
ingest_wifi_dict,
|
ingest_wifi_dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Import unified Bluetooth scanner helper for TSCM integration
|
||||||
|
try:
|
||||||
|
from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot
|
||||||
|
_USE_UNIFIED_BT_SCANNER = True
|
||||||
|
except ImportError:
|
||||||
|
_USE_UNIFIED_BT_SCANNER = False
|
||||||
|
|
||||||
logger = logging.getLogger('intercept.tscm')
|
logger = logging.getLogger('intercept.tscm')
|
||||||
|
|
||||||
tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm')
|
tscm_bp = Blueprint('tscm', __name__, url_prefix='/tscm')
|
||||||
@@ -298,20 +305,20 @@ def _check_available_devices(wifi: bool, bt: bool, rf: bool) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@tscm_bp.route('/sweep/start', methods=['POST'])
|
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||||
def start_sweep():
|
def start_sweep():
|
||||||
"""Start a TSCM sweep."""
|
"""Start a TSCM sweep."""
|
||||||
global _sweep_running, _sweep_thread, _current_sweep_id
|
global _sweep_running, _sweep_thread, _current_sweep_id
|
||||||
|
|
||||||
if _sweep_running:
|
if _sweep_running:
|
||||||
return jsonify({'status': 'error', 'message': 'Sweep already running'})
|
return jsonify({'status': 'error', 'message': 'Sweep already running'})
|
||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
sweep_type = data.get('sweep_type', 'standard')
|
sweep_type = data.get('sweep_type', 'standard')
|
||||||
baseline_id = data.get('baseline_id')
|
baseline_id = data.get('baseline_id')
|
||||||
wifi_enabled = data.get('wifi', True)
|
wifi_enabled = data.get('wifi', True)
|
||||||
bt_enabled = data.get('bluetooth', True)
|
bt_enabled = data.get('bluetooth', True)
|
||||||
rf_enabled = data.get('rf', True)
|
rf_enabled = data.get('rf', True)
|
||||||
verbose_results = bool(data.get('verbose_results', False))
|
verbose_results = bool(data.get('verbose_results', False))
|
||||||
|
|
||||||
# Get interface selections
|
# Get interface selections
|
||||||
wifi_interface = data.get('wifi_interface', '')
|
wifi_interface = data.get('wifi_interface', '')
|
||||||
@@ -349,12 +356,12 @@ def start_sweep():
|
|||||||
_sweep_running = True
|
_sweep_running = True
|
||||||
|
|
||||||
# Start sweep thread
|
# Start sweep thread
|
||||||
_sweep_thread = threading.Thread(
|
_sweep_thread = threading.Thread(
|
||||||
target=_run_sweep,
|
target=_run_sweep,
|
||||||
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
||||||
wifi_interface, bt_interface, sdr_device, verbose_results),
|
wifi_interface, bt_interface, sdr_device, verbose_results),
|
||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
_sweep_thread.start()
|
_sweep_thread.start()
|
||||||
|
|
||||||
logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}")
|
logger.info(f"Started TSCM sweep: type={sweep_type}, id={_current_sweep_id}")
|
||||||
@@ -1194,17 +1201,17 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
|
|||||||
return signals
|
return signals
|
||||||
|
|
||||||
|
|
||||||
def _run_sweep(
|
def _run_sweep(
|
||||||
sweep_type: str,
|
sweep_type: str,
|
||||||
baseline_id: int | None,
|
baseline_id: int | None,
|
||||||
wifi_enabled: bool,
|
wifi_enabled: bool,
|
||||||
bt_enabled: bool,
|
bt_enabled: bool,
|
||||||
rf_enabled: bool,
|
rf_enabled: bool,
|
||||||
wifi_interface: str = '',
|
wifi_interface: str = '',
|
||||||
bt_interface: str = '',
|
bt_interface: str = '',
|
||||||
sdr_device: int | None = None,
|
sdr_device: int | None = None,
|
||||||
verbose_results: bool = False
|
verbose_results: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Run the TSCM sweep in a background thread.
|
Run the TSCM sweep in a background thread.
|
||||||
|
|
||||||
@@ -1322,7 +1329,11 @@ def _run_sweep(
|
|||||||
# Perform Bluetooth scan
|
# Perform Bluetooth scan
|
||||||
if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval:
|
if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval:
|
||||||
try:
|
try:
|
||||||
bt_devices = _scan_bluetooth_devices(bt_interface, duration=8)
|
# Use unified Bluetooth scanner if available
|
||||||
|
if _USE_UNIFIED_BT_SCANNER:
|
||||||
|
bt_devices = get_tscm_bluetooth_snapshot(bt_interface, duration=8)
|
||||||
|
else:
|
||||||
|
bt_devices = _scan_bluetooth_devices(bt_interface, duration=8)
|
||||||
for device in bt_devices:
|
for device in bt_devices:
|
||||||
mac = device.get('mac', '')
|
mac = device.get('mac', '')
|
||||||
if mac and mac not in all_bt:
|
if mac and mac not in all_bt:
|
||||||
@@ -1472,61 +1483,61 @@ def _run_sweep(
|
|||||||
identity_summary = identity_engine.get_summary()
|
identity_summary = identity_engine.get_summary()
|
||||||
identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()]
|
identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()]
|
||||||
|
|
||||||
if verbose_results:
|
if verbose_results:
|
||||||
wifi_payload = list(all_wifi.values())
|
wifi_payload = list(all_wifi.values())
|
||||||
bt_payload = list(all_bt.values())
|
bt_payload = list(all_bt.values())
|
||||||
rf_payload = list(all_rf)
|
rf_payload = list(all_rf)
|
||||||
else:
|
else:
|
||||||
wifi_payload = [
|
wifi_payload = [
|
||||||
{
|
{
|
||||||
'bssid': d.get('bssid') or d.get('mac'),
|
'bssid': d.get('bssid') or d.get('mac'),
|
||||||
'essid': d.get('essid') or d.get('ssid'),
|
'essid': d.get('essid') or d.get('ssid'),
|
||||||
'ssid': d.get('ssid') or d.get('essid'),
|
'ssid': d.get('ssid') or d.get('essid'),
|
||||||
'channel': d.get('channel'),
|
'channel': d.get('channel'),
|
||||||
'power': d.get('power', d.get('signal')),
|
'power': d.get('power', d.get('signal')),
|
||||||
'privacy': d.get('privacy', d.get('encryption')),
|
'privacy': d.get('privacy', d.get('encryption')),
|
||||||
'encryption': d.get('encryption', d.get('privacy')),
|
'encryption': d.get('encryption', d.get('privacy')),
|
||||||
}
|
}
|
||||||
for d in all_wifi.values()
|
for d in all_wifi.values()
|
||||||
]
|
]
|
||||||
bt_payload = [
|
bt_payload = [
|
||||||
{
|
{
|
||||||
'mac': d.get('mac') or d.get('address'),
|
'mac': d.get('mac') or d.get('address'),
|
||||||
'name': d.get('name'),
|
'name': d.get('name'),
|
||||||
'rssi': d.get('rssi'),
|
'rssi': d.get('rssi'),
|
||||||
'manufacturer': d.get('manufacturer', d.get('manufacturer_name')),
|
'manufacturer': d.get('manufacturer', d.get('manufacturer_name')),
|
||||||
}
|
}
|
||||||
for d in all_bt.values()
|
for d in all_bt.values()
|
||||||
]
|
]
|
||||||
rf_payload = [
|
rf_payload = [
|
||||||
{
|
{
|
||||||
'frequency': s.get('frequency'),
|
'frequency': s.get('frequency'),
|
||||||
'power': s.get('power', s.get('level')),
|
'power': s.get('power', s.get('level')),
|
||||||
'modulation': s.get('modulation'),
|
'modulation': s.get('modulation'),
|
||||||
'band': s.get('band'),
|
'band': s.get('band'),
|
||||||
}
|
}
|
||||||
for s in all_rf
|
for s in all_rf
|
||||||
]
|
]
|
||||||
|
|
||||||
update_tscm_sweep(
|
update_tscm_sweep(
|
||||||
_current_sweep_id,
|
_current_sweep_id,
|
||||||
status='completed',
|
status='completed',
|
||||||
results={
|
results={
|
||||||
'wifi_devices': wifi_payload,
|
'wifi_devices': wifi_payload,
|
||||||
'bt_devices': bt_payload,
|
'bt_devices': bt_payload,
|
||||||
'rf_signals': rf_payload,
|
'rf_signals': rf_payload,
|
||||||
'wifi_count': len(all_wifi),
|
'wifi_count': len(all_wifi),
|
||||||
'bt_count': len(all_bt),
|
'bt_count': len(all_bt),
|
||||||
'rf_count': len(all_rf),
|
'rf_count': len(all_rf),
|
||||||
'severity_counts': severity_counts,
|
'severity_counts': severity_counts,
|
||||||
'correlation_summary': findings.get('summary', {}),
|
'correlation_summary': findings.get('summary', {}),
|
||||||
'identity_summary': identity_summary.get('statistics', {}),
|
'identity_summary': identity_summary.get('statistics', {}),
|
||||||
'baseline_comparison': baseline_comparison,
|
'baseline_comparison': baseline_comparison,
|
||||||
'results_detail_level': 'full' if verbose_results else 'compact',
|
'results_detail_level': 'full' if verbose_results else 'compact',
|
||||||
},
|
},
|
||||||
threats_found=threats_found,
|
threats_found=threats_found,
|
||||||
completed=True
|
completed=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Emit correlation findings
|
# Emit correlation findings
|
||||||
_emit_event('correlation_findings', {
|
_emit_event('correlation_findings', {
|
||||||
@@ -1548,13 +1559,13 @@ def _run_sweep(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Emit device identity cluster findings (MAC-randomization resistant)
|
# Emit device identity cluster findings (MAC-randomization resistant)
|
||||||
_emit_event('identity_clusters', {
|
_emit_event('identity_clusters', {
|
||||||
'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0),
|
'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 0),
|
||||||
'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0),
|
'high_risk_count': identity_summary.get('statistics', {}).get('high_risk_count', 0),
|
||||||
'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0),
|
'medium_risk_count': identity_summary.get('statistics', {}).get('medium_risk_count', 0),
|
||||||
'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0),
|
'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0),
|
||||||
'clusters': identity_clusters,
|
'clusters': identity_clusters,
|
||||||
})
|
})
|
||||||
|
|
||||||
_emit_event('sweep_completed', {
|
_emit_event('sweep_completed', {
|
||||||
'sweep_id': _current_sweep_id,
|
'sweep_id': _current_sweep_id,
|
||||||
@@ -2465,9 +2476,9 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
|||||||
import json
|
import json
|
||||||
results = json.loads(results)
|
results = json.loads(results)
|
||||||
|
|
||||||
current_wifi = results.get('wifi_devices', [])
|
current_wifi = results.get('wifi_devices', [])
|
||||||
current_bt = results.get('bt_devices', [])
|
current_bt = results.get('bt_devices', [])
|
||||||
current_rf = results.get('rf_signals', [])
|
current_rf = results.get('rf_signals', [])
|
||||||
|
|
||||||
diff = calculate_baseline_diff(
|
diff = calculate_baseline_diff(
|
||||||
baseline=baseline,
|
baseline=baseline,
|
||||||
|
|||||||
565
static/css/components/device-cards.css
Normal file
565
static/css/components/device-cards.css
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
/**
|
||||||
|
* Device Cards Component CSS
|
||||||
|
* Styling for Bluetooth device cards, heuristic badges, range bands, and sparklines
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
CSS VARIABLES
|
||||||
|
============================================ */
|
||||||
|
:root {
|
||||||
|
/* Protocol colors */
|
||||||
|
--proto-ble: #3b82f6;
|
||||||
|
--proto-ble-bg: rgba(59, 130, 246, 0.15);
|
||||||
|
--proto-classic: #8b5cf6;
|
||||||
|
--proto-classic-bg: rgba(139, 92, 246, 0.15);
|
||||||
|
|
||||||
|
/* Range band colors */
|
||||||
|
--range-very-close: #ef4444;
|
||||||
|
--range-close: #f97316;
|
||||||
|
--range-nearby: #eab308;
|
||||||
|
--range-far: #6b7280;
|
||||||
|
--range-unknown: #374151;
|
||||||
|
|
||||||
|
/* Heuristic badge colors */
|
||||||
|
--heuristic-new: #3b82f6;
|
||||||
|
--heuristic-persistent: #22c55e;
|
||||||
|
--heuristic-beacon: #f59e0b;
|
||||||
|
--heuristic-strong: #ef4444;
|
||||||
|
--heuristic-random: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE CARD BASE
|
||||||
|
============================================ */
|
||||||
|
.device-card {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-card:active {
|
||||||
|
transform: scale(0.995);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE IDENTITY
|
||||||
|
============================================ */
|
||||||
|
.device-identity {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address .address-value {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-address .address-type {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PROTOCOL BADGES
|
||||||
|
============================================ */
|
||||||
|
.signal-proto-badge.device-protocol {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEURISTIC BADGES
|
||||||
|
============================================ */
|
||||||
|
.device-heuristic-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
|
||||||
|
color: var(--badge-color);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--badge-color) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.new {
|
||||||
|
--badge-color: var(--heuristic-new);
|
||||||
|
animation: heuristicPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.persistent {
|
||||||
|
--badge-color: var(--heuristic-persistent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.beacon_like {
|
||||||
|
--badge-color: var(--heuristic-beacon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.strong_stable {
|
||||||
|
--badge-color: var(--heuristic-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristic-badge.random_address {
|
||||||
|
--badge-color: var(--heuristic-random);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heuristicPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SIGNAL ROW & RSSI DISPLAY
|
||||||
|
============================================ */
|
||||||
|
.device-signal-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-current {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RSSI SPARKLINE
|
||||||
|
============================================ */
|
||||||
|
.rssi-sparkline,
|
||||||
|
.rssi-sparkline-svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-sparkline-empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-sparkline-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-current-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sparkline-dot {
|
||||||
|
animation: sparklinePulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparklinePulse {
|
||||||
|
0%, 100% { r: 2; opacity: 1; }
|
||||||
|
50% { r: 3; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RANGE BAND INDICATOR
|
||||||
|
============================================ */
|
||||||
|
.device-range-band {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: color-mix(in srgb, var(--range-color) 15%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid var(--range-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-label {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--range-color);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-estimate {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band .range-confidence {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MANUFACTURER INFO
|
||||||
|
============================================ */
|
||||||
|
.device-manufacturer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-manufacturer .mfr-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-manufacturer .mfr-name {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
META ROW
|
||||||
|
============================================ */
|
||||||
|
.device-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-seen-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-seen-count .seen-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-timestamp {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
SERVICE UUIDS
|
||||||
|
============================================ */
|
||||||
|
.device-uuids {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-uuid {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
HEURISTICS DETAIL VIEW
|
||||||
|
============================================ */
|
||||||
|
.device-heuristics-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item.active {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: rgba(34, 197, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item .heuristic-name {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item .heuristic-status {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item.active .heuristic-status {
|
||||||
|
color: var(--accent-green, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heuristic-item:not(.active) .heuristic-status {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MESSAGE CARDS
|
||||||
|
============================================ */
|
||||||
|
.message-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--message-bg);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: messageSlideIn 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card.message-card-hiding {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--message-color);
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--message-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-icon svg.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-title {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-details {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-dismiss svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #1a1a1a);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn:hover {
|
||||||
|
background: var(--bg-tertiary, #252525);
|
||||||
|
border-color: var(--border-light, #444);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn.primary {
|
||||||
|
background: color-mix(in srgb, var(--message-color) 20%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--message-color) 40%, transparent);
|
||||||
|
color: var(--message-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-btn.primary:hover {
|
||||||
|
background: color-mix(in srgb, var(--message-color) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DEVICE FILTER BAR
|
||||||
|
============================================ */
|
||||||
|
.device-filter-bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-filter-bar .signal-filter-btn .filter-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
RESPONSIVE ADJUSTMENTS
|
||||||
|
============================================ */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.device-signal-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rssi-display {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-range-band {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-heuristics-detail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-card-text {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
DARK MODE OVERRIDES (if needed)
|
||||||
|
============================================ */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.device-card {
|
||||||
|
--bg-secondary: #1a1a1a;
|
||||||
|
--bg-tertiary: #141414;
|
||||||
|
}
|
||||||
|
}
|
||||||
592
static/js/components/device-card.js
Normal file
592
static/js/components/device-card.js
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
/**
|
||||||
|
* Device Card Component
|
||||||
|
* Unified device display for Bluetooth and TSCM modes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DeviceCard = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Range band configuration
|
||||||
|
const RANGE_BANDS = {
|
||||||
|
very_close: { label: 'Very Close', color: '#ef4444', description: '< 3m' },
|
||||||
|
close: { label: 'Close', color: '#f97316', description: '3-10m' },
|
||||||
|
nearby: { label: 'Nearby', color: '#eab308', description: '10-20m' },
|
||||||
|
far: { label: 'Far', color: '#6b7280', description: '> 20m' },
|
||||||
|
unknown: { label: 'Unknown', color: '#374151', description: 'N/A' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protocol badge colors
|
||||||
|
const PROTOCOL_COLORS = {
|
||||||
|
ble: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3b82f6', border: 'rgba(59, 130, 246, 0.3)' },
|
||||||
|
classic: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', border: 'rgba(139, 92, 246, 0.3)' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Heuristic badge configuration
|
||||||
|
const HEURISTIC_BADGES = {
|
||||||
|
new: { label: 'New', color: '#3b82f6', description: 'Not in baseline' },
|
||||||
|
persistent: { label: 'Persistent', color: '#22c55e', description: 'Continuously present' },
|
||||||
|
beacon_like: { label: 'Beacon', color: '#f59e0b', description: 'Regular advertising' },
|
||||||
|
strong_stable: { label: 'Strong', color: '#ef4444', description: 'Strong stable signal' },
|
||||||
|
random_address: { label: 'Random', color: '#6b7280', description: 'Privacy address' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format relative time
|
||||||
|
*/
|
||||||
|
function formatRelativeTime(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (diff < 10) return 'Just now';
|
||||||
|
if (diff < 60) return `${diff}s ago`;
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create RSSI sparkline SVG
|
||||||
|
*/
|
||||||
|
function createSparkline(rssiHistory, options = {}) {
|
||||||
|
if (!rssiHistory || rssiHistory.length < 2) {
|
||||||
|
return '<span class="rssi-sparkline-empty">--</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = options.width || 60;
|
||||||
|
const height = options.height || 20;
|
||||||
|
const samples = rssiHistory.slice(-20); // Last 20 samples
|
||||||
|
|
||||||
|
// Normalize RSSI values (-100 to -30 range)
|
||||||
|
const minRssi = -100;
|
||||||
|
const maxRssi = -30;
|
||||||
|
const normalizedValues = samples.map(s => {
|
||||||
|
const rssi = s.rssi || s;
|
||||||
|
const normalized = (rssi - minRssi) / (maxRssi - minRssi);
|
||||||
|
return Math.max(0, Math.min(1, normalized));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate path
|
||||||
|
const stepX = width / (normalizedValues.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
normalizedValues.forEach((val, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (val * height);
|
||||||
|
pathD += i === 0 ? `M${x},${y}` : ` L${x},${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine color based on latest value
|
||||||
|
const latestRssi = samples[samples.length - 1].rssi || samples[samples.length - 1];
|
||||||
|
let strokeColor = '#6b7280';
|
||||||
|
if (latestRssi > -50) strokeColor = '#22c55e';
|
||||||
|
else if (latestRssi > -65) strokeColor = '#f59e0b';
|
||||||
|
else if (latestRssi > -80) strokeColor = '#f97316';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="rssi-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create heuristic badges HTML
|
||||||
|
*/
|
||||||
|
function createHeuristicBadges(flags) {
|
||||||
|
if (!flags || flags.length === 0) return '';
|
||||||
|
|
||||||
|
return flags.map(flag => {
|
||||||
|
const config = HEURISTIC_BADGES[flag];
|
||||||
|
if (!config) return '';
|
||||||
|
return `
|
||||||
|
<span class="device-heuristic-badge ${flag}"
|
||||||
|
style="--badge-color: ${config.color}"
|
||||||
|
title="${escapeHtml(config.description)}">
|
||||||
|
${escapeHtml(config.label)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create range band indicator
|
||||||
|
*/
|
||||||
|
function createRangeBand(band, confidence) {
|
||||||
|
const config = RANGE_BANDS[band] || RANGE_BANDS.unknown;
|
||||||
|
const confidencePercent = Math.round((confidence || 0) * 100);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="device-range-band" style="--range-color: ${config.color}">
|
||||||
|
<span class="range-label">${escapeHtml(config.label)}</span>
|
||||||
|
<span class="range-estimate">${escapeHtml(config.description)}</span>
|
||||||
|
${confidence > 0 ? `<span class="range-confidence" title="Confidence">${confidencePercent}%</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create protocol badge
|
||||||
|
*/
|
||||||
|
function createProtocolBadge(protocol) {
|
||||||
|
const config = PROTOCOL_COLORS[protocol] || PROTOCOL_COLORS.ble;
|
||||||
|
const label = protocol === 'classic' ? 'Classic' : 'BLE';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<span class="signal-proto-badge device-protocol"
|
||||||
|
style="background: ${config.bg}; color: ${config.color}; border-color: ${config.border}">
|
||||||
|
${escapeHtml(label)}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Bluetooth device card
|
||||||
|
*/
|
||||||
|
function createDeviceCard(device, options = {}) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'signal-card device-card';
|
||||||
|
card.dataset.deviceId = device.device_id;
|
||||||
|
card.dataset.protocol = device.protocol;
|
||||||
|
card.dataset.address = device.address;
|
||||||
|
|
||||||
|
// Add status classes
|
||||||
|
if (device.heuristic_flags && device.heuristic_flags.includes('new')) {
|
||||||
|
card.dataset.status = 'new';
|
||||||
|
} else if (device.in_baseline) {
|
||||||
|
card.dataset.status = 'baseline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store full device data for details modal
|
||||||
|
card.dataset.deviceData = JSON.stringify(device);
|
||||||
|
|
||||||
|
const relativeTime = formatRelativeTime(device.last_seen);
|
||||||
|
const sparkline = createSparkline(device.rssi_history);
|
||||||
|
const heuristicBadges = createHeuristicBadges(device.heuristic_flags);
|
||||||
|
const rangeBand = createRangeBand(device.range_band, device.range_confidence);
|
||||||
|
const protocolBadge = createProtocolBadge(device.protocol);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="signal-card-header">
|
||||||
|
<div class="signal-card-badges">
|
||||||
|
${protocolBadge}
|
||||||
|
${heuristicBadges}
|
||||||
|
</div>
|
||||||
|
<span class="signal-status-pill" data-status="${device.in_baseline ? 'baseline' : 'new'}">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
${device.in_baseline ? 'Known' : 'New'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-body">
|
||||||
|
<div class="device-identity">
|
||||||
|
<div class="device-name">${escapeHtml(device.name || 'Unknown Device')}</div>
|
||||||
|
<div class="device-address">
|
||||||
|
<span class="address-value">${escapeHtml(device.address)}</span>
|
||||||
|
<span class="address-type">(${escapeHtml(device.address_type)})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-signal-row">
|
||||||
|
<div class="rssi-display">
|
||||||
|
<span class="rssi-current" title="Current RSSI">
|
||||||
|
${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'}
|
||||||
|
</span>
|
||||||
|
${sparkline}
|
||||||
|
</div>
|
||||||
|
${rangeBand}
|
||||||
|
</div>
|
||||||
|
${device.manufacturer_name ? `
|
||||||
|
<div class="device-manufacturer">
|
||||||
|
<span class="mfr-icon">🏭</span>
|
||||||
|
<span class="mfr-name">${escapeHtml(device.manufacturer_name)}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="device-meta-row">
|
||||||
|
<span class="device-seen-count" title="Observation count">
|
||||||
|
<span class="seen-icon">👁</span>
|
||||||
|
${device.seen_count}×
|
||||||
|
</span>
|
||||||
|
<span class="device-timestamp" data-timestamp="${escapeHtml(device.last_seen)}">
|
||||||
|
${escapeHtml(relativeTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-footer">
|
||||||
|
<button class="signal-advanced-toggle" onclick="DeviceCard.toggleAdvanced(this)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M6 9l6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<div class="signal-card-actions">
|
||||||
|
<button class="signal-action-btn" onclick="DeviceCard.copyAddress('${escapeHtml(device.address)}')">Copy</button>
|
||||||
|
${options.showInvestigate ? `
|
||||||
|
<button class="signal-action-btn primary" onclick="DeviceCard.investigate('${escapeHtml(device.device_id)}')">Investigate</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-panel">
|
||||||
|
<div class="signal-advanced-inner">
|
||||||
|
${createAdvancedPanel(device)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Make card clickable
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('button') || e.target.closest('.signal-advanced-toggle')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDeviceDetails(device);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create advanced panel content
|
||||||
|
*/
|
||||||
|
function createAdvancedPanel(device) {
|
||||||
|
return `
|
||||||
|
<div class="signal-advanced-content">
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Device Details</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Address</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(device.address)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Address Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(device.address_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Protocol</span>
|
||||||
|
<span class="signal-advanced-value">${device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth'}</span>
|
||||||
|
</div>
|
||||||
|
${device.manufacturer_id ? `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Manufacturer ID</span>
|
||||||
|
<span class="signal-advanced-value">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Signal Statistics</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Current RSSI</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_current !== null ? device.rssi_current + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Median RSSI</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Min/Max</span>
|
||||||
|
<span class="signal-advanced-value">${device.rssi_min || 'N/A'} / ${device.rssi_max || 'N/A'} dBm</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Confidence</span>
|
||||||
|
<span class="signal-advanced-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Observation Times</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">First Seen</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.first_seen))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Last Seen</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(formatRelativeTime(device.last_seen))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Seen Count</span>
|
||||||
|
<span class="signal-advanced-value">${device.seen_count} observations</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Rate</span>
|
||||||
|
<span class="signal-advanced-value">${device.seen_rate ? device.seen_rate.toFixed(1) : '0'}/min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${device.service_uuids && device.service_uuids.length > 0 ? `
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Service UUIDs</div>
|
||||||
|
<div class="device-uuids">
|
||||||
|
${device.service_uuids.map(uuid => `<span class="device-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${device.heuristics ? `
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Behavioral Analysis</div>
|
||||||
|
<div class="device-heuristics-detail">
|
||||||
|
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||||
|
<div class="heuristic-item ${value ? 'active' : ''}">
|
||||||
|
<span class="heuristic-name">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||||
|
<span class="heuristic-status">${value ? '✓' : '−'}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show device details in modal
|
||||||
|
*/
|
||||||
|
function showDeviceDetails(device) {
|
||||||
|
let modal = document.getElementById('deviceDetailsModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'deviceDetailsModal';
|
||||||
|
modal.className = 'signal-details-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="signal-details-modal-backdrop"></div>
|
||||||
|
<div class="signal-details-modal-content">
|
||||||
|
<div class="signal-details-modal-header">
|
||||||
|
<span class="signal-details-modal-title"></span>
|
||||||
|
<button class="signal-details-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="signal-details-modal-body"></div>
|
||||||
|
<div class="signal-details-modal-footer">
|
||||||
|
<button class="signal-details-copy-btn">Copy Device Info</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Close handlers
|
||||||
|
modal.querySelector('.signal-details-modal-backdrop').addEventListener('click', () => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
modal.querySelector('.signal-details-modal-close').addEventListener('click', () => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
modal.querySelector('.signal-details-copy-btn').addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
|
||||||
|
if (typeof SignalCards !== 'undefined') {
|
||||||
|
SignalCards.showToast('Device info copied to clipboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate modal
|
||||||
|
modal.querySelector('.signal-details-modal-title').textContent =
|
||||||
|
device.name || device.address;
|
||||||
|
modal.querySelector('.signal-details-modal-body').innerHTML = createAdvancedPanel(device);
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle advanced panel
|
||||||
|
*/
|
||||||
|
function toggleAdvanced(button) {
|
||||||
|
const card = button.closest('.signal-card');
|
||||||
|
const panel = card.querySelector('.signal-advanced-panel');
|
||||||
|
button.classList.toggle('open');
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy address to clipboard
|
||||||
|
*/
|
||||||
|
function copyAddress(address) {
|
||||||
|
navigator.clipboard.writeText(address).then(() => {
|
||||||
|
if (typeof SignalCards !== 'undefined') {
|
||||||
|
SignalCards.showToast('Address copied');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Investigate device (placeholder for future implementation)
|
||||||
|
*/
|
||||||
|
function investigate(deviceId) {
|
||||||
|
console.log('Investigate device:', deviceId);
|
||||||
|
// Could open service discovery, detailed analysis, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all device timestamps
|
||||||
|
*/
|
||||||
|
function updateTimestamps(container) {
|
||||||
|
container.querySelectorAll('.device-timestamp[data-timestamp]').forEach(el => {
|
||||||
|
const timestamp = el.dataset.timestamp;
|
||||||
|
if (timestamp) {
|
||||||
|
el.textContent = formatRelativeTime(timestamp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create device filter bar for Bluetooth mode
|
||||||
|
*/
|
||||||
|
function createDeviceFilterBar(container, options = {}) {
|
||||||
|
const filterBar = document.createElement('div');
|
||||||
|
filterBar.className = 'signal-filter-bar device-filter-bar';
|
||||||
|
filterBar.id = 'btDeviceFilterBar';
|
||||||
|
|
||||||
|
filterBar.innerHTML = `
|
||||||
|
<button class="signal-filter-btn active" data-filter="status" data-value="all">
|
||||||
|
All
|
||||||
|
<span class="signal-filter-count" data-count="all">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="signal-filter-btn" data-filter="status" data-value="new">
|
||||||
|
<span class="filter-dot" style="background: var(--signal-new)"></span>
|
||||||
|
New
|
||||||
|
<span class="signal-filter-count" data-count="new">0</span>
|
||||||
|
</button>
|
||||||
|
<button class="signal-filter-btn" data-filter="status" data-value="baseline">
|
||||||
|
<span class="filter-dot" style="background: var(--signal-baseline)"></span>
|
||||||
|
Known
|
||||||
|
<span class="signal-filter-count" data-count="baseline">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="signal-filter-divider"></span>
|
||||||
|
|
||||||
|
<span class="signal-filter-label">Protocol</span>
|
||||||
|
<button class="signal-filter-btn protocol-btn active" data-filter="protocol" data-value="all">All</button>
|
||||||
|
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="ble">BLE</button>
|
||||||
|
<button class="signal-filter-btn protocol-btn" data-filter="protocol" data-value="classic">Classic</button>
|
||||||
|
|
||||||
|
<span class="signal-filter-divider"></span>
|
||||||
|
|
||||||
|
<span class="signal-filter-label">Range</span>
|
||||||
|
<button class="signal-filter-btn range-btn active" data-filter="range" data-value="all">All</button>
|
||||||
|
<button class="signal-filter-btn range-btn" data-filter="range" data-value="close">Close</button>
|
||||||
|
<button class="signal-filter-btn range-btn" data-filter="range" data-value="far">Far</button>
|
||||||
|
|
||||||
|
<div class="signal-search-container">
|
||||||
|
<input type="text" class="signal-search-input" id="btSearchInput" placeholder="Search name or address..." />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const filters = { status: 'all', protocol: 'all', range: 'all', search: '' };
|
||||||
|
|
||||||
|
// Apply filters function
|
||||||
|
const applyFilters = () => {
|
||||||
|
const cards = container.querySelectorAll('.device-card');
|
||||||
|
const counts = { all: 0, new: 0, baseline: 0 };
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const cardStatus = card.dataset.status || 'baseline';
|
||||||
|
const cardProtocol = card.dataset.protocol;
|
||||||
|
const deviceData = JSON.parse(card.dataset.deviceData || '{}');
|
||||||
|
const cardName = (deviceData.name || '').toLowerCase();
|
||||||
|
const cardAddress = (deviceData.address || '').toLowerCase();
|
||||||
|
const cardRange = deviceData.range_band || 'unknown';
|
||||||
|
|
||||||
|
counts.all++;
|
||||||
|
if (cardStatus === 'new') counts.new++;
|
||||||
|
else counts.baseline++;
|
||||||
|
|
||||||
|
// Check filters
|
||||||
|
const statusMatch = filters.status === 'all' || cardStatus === filters.status;
|
||||||
|
const protocolMatch = filters.protocol === 'all' || cardProtocol === filters.protocol;
|
||||||
|
const rangeMatch = filters.range === 'all' ||
|
||||||
|
(filters.range === 'close' && ['very_close', 'close'].includes(cardRange)) ||
|
||||||
|
(filters.range === 'far' && ['nearby', 'far', 'unknown'].includes(cardRange));
|
||||||
|
const searchMatch = !filters.search ||
|
||||||
|
cardName.includes(filters.search) ||
|
||||||
|
cardAddress.includes(filters.search);
|
||||||
|
|
||||||
|
if (statusMatch && protocolMatch && rangeMatch && searchMatch) {
|
||||||
|
card.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
card.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update counts
|
||||||
|
Object.keys(counts).forEach(key => {
|
||||||
|
const badge = filterBar.querySelector(`[data-count="${key}"]`);
|
||||||
|
if (badge) badge.textContent = counts[key];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Status filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.status = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Protocol filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.protocol = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Range filter handlers
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
filterBar.querySelectorAll('.signal-filter-btn[data-filter="range"]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filters.range = btn.dataset.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search handler
|
||||||
|
const searchInput = filterBar.querySelector('#btSearchInput');
|
||||||
|
let searchTimeout;
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
filters.search = e.target.value.toLowerCase();
|
||||||
|
applyFilters();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterBar.applyFilters = applyFilters;
|
||||||
|
return filterBar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createDeviceCard,
|
||||||
|
createSparkline,
|
||||||
|
createHeuristicBadges,
|
||||||
|
createRangeBand,
|
||||||
|
createDeviceFilterBar,
|
||||||
|
showDeviceDetails,
|
||||||
|
toggleAdvanced,
|
||||||
|
copyAddress,
|
||||||
|
investigate,
|
||||||
|
updateTimestamps,
|
||||||
|
escapeHtml,
|
||||||
|
formatRelativeTime,
|
||||||
|
RANGE_BANDS,
|
||||||
|
HEURISTIC_BADGES
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.DeviceCard = DeviceCard;
|
||||||
326
static/js/components/message-card.js
Normal file
326
static/js/components/message-card.js
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Message Card Component
|
||||||
|
* Status and alert messages for Bluetooth and TSCM modes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MessageCard = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Message types and their styling
|
||||||
|
const MESSAGE_TYPES = {
|
||||||
|
info: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#3b82f6',
|
||||||
|
bgColor: 'rgba(59, 130, 246, 0.1)'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#22c55e',
|
||||||
|
bgColor: 'rgba(34, 197, 94, 0.1)'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#f59e0b',
|
||||||
|
bgColor: 'rgba(245, 158, 11, 0.1)'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#ef4444',
|
||||||
|
bgColor: 'rgba(239, 68, 68, 0.1)'
|
||||||
|
},
|
||||||
|
scanning: {
|
||||||
|
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>`,
|
||||||
|
color: '#06b6d4',
|
||||||
|
bgColor: 'rgba(6, 182, 212, 0.1)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (text === null || text === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a message card
|
||||||
|
*/
|
||||||
|
function createMessageCard(options) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
actions,
|
||||||
|
dismissible = true,
|
||||||
|
autoHide = 0,
|
||||||
|
id
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `message-card message-card-${type}`;
|
||||||
|
if (id) card.id = id;
|
||||||
|
card.style.setProperty('--message-color', config.color);
|
||||||
|
card.style.setProperty('--message-bg', config.bgColor);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="message-card-icon">
|
||||||
|
${config.icon}
|
||||||
|
</div>
|
||||||
|
<div class="message-card-content">
|
||||||
|
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
|
||||||
|
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
|
||||||
|
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${dismissible ? `
|
||||||
|
<button class="message-card-dismiss" title="Dismiss">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${actions && actions.length > 0 ? `
|
||||||
|
<div class="message-card-actions">
|
||||||
|
${actions.map(action => `
|
||||||
|
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
|
||||||
|
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
|
||||||
|
${escapeHtml(action.label)}
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Dismiss handler
|
||||||
|
if (dismissible) {
|
||||||
|
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
if (actions && actions.length > 0) {
|
||||||
|
actions.forEach(action => {
|
||||||
|
if (action.handler) {
|
||||||
|
const btn = action.id
|
||||||
|
? card.querySelector(`#${action.id}`)
|
||||||
|
: card.querySelector('.message-action-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
action.handler(e, card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide
|
||||||
|
if (autoHide > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (card.parentElement) {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
}
|
||||||
|
}, autoHide);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scanning status card
|
||||||
|
*/
|
||||||
|
function createScanningCard(options = {}) {
|
||||||
|
const {
|
||||||
|
backend = 'auto',
|
||||||
|
adapter = 'hci0',
|
||||||
|
deviceCount = 0,
|
||||||
|
elapsed = 0,
|
||||||
|
remaining = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'scanning',
|
||||||
|
title: 'Scanning for Bluetooth devices...',
|
||||||
|
message: `Backend: ${backend} | Adapter: ${adapter}`,
|
||||||
|
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
|
||||||
|
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
|
||||||
|
dismissible: false,
|
||||||
|
id: 'btScanningStatus'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a capability warning card
|
||||||
|
*/
|
||||||
|
function createCapabilityWarning(issues) {
|
||||||
|
if (!issues || issues.length === 0) return null;
|
||||||
|
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Bluetooth Capability Issues',
|
||||||
|
message: issues.join('. '),
|
||||||
|
dismissible: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Retry Check',
|
||||||
|
handler: (e, card) => {
|
||||||
|
card.remove();
|
||||||
|
if (typeof window.checkBtCapabilities === 'function') {
|
||||||
|
window.checkBtCapabilities();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a baseline status card
|
||||||
|
*/
|
||||||
|
function createBaselineCard(deviceCount, isSet = true) {
|
||||||
|
if (isSet) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Baseline Set',
|
||||||
|
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
|
||||||
|
details: 'New devices will be highlighted',
|
||||||
|
dismissible: true,
|
||||||
|
autoHide: 5000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'info',
|
||||||
|
title: 'No Baseline',
|
||||||
|
message: 'Set a baseline to track new devices',
|
||||||
|
dismissible: true,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Set Baseline',
|
||||||
|
primary: true,
|
||||||
|
handler: () => {
|
||||||
|
if (typeof window.setBtBaseline === 'function') {
|
||||||
|
window.setBtBaseline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a scan complete card
|
||||||
|
*/
|
||||||
|
function createScanCompleteCard(deviceCount, duration) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Scan Complete',
|
||||||
|
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
|
||||||
|
dismissible: true,
|
||||||
|
autoHide: 5000,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Export Results',
|
||||||
|
handler: () => {
|
||||||
|
window.open('/api/bluetooth/export?format=csv', '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error card
|
||||||
|
*/
|
||||||
|
function createErrorCard(error, retryHandler) {
|
||||||
|
return createMessageCard({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Scan Error',
|
||||||
|
message: error,
|
||||||
|
dismissible: true,
|
||||||
|
actions: retryHandler ? [
|
||||||
|
{
|
||||||
|
label: 'Retry',
|
||||||
|
primary: true,
|
||||||
|
handler: retryHandler
|
||||||
|
}
|
||||||
|
] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a message in a container
|
||||||
|
*/
|
||||||
|
function showMessage(container, options) {
|
||||||
|
const card = createMessageCard(options);
|
||||||
|
container.insertBefore(card, container.firstChild);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a message by ID
|
||||||
|
*/
|
||||||
|
function removeMessage(id) {
|
||||||
|
const card = document.getElementById(id);
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('message-card-hiding');
|
||||||
|
setTimeout(() => card.remove(), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update scanning status
|
||||||
|
*/
|
||||||
|
function updateScanningStatus(options) {
|
||||||
|
const existing = document.getElementById('btScanningStatus');
|
||||||
|
if (existing) {
|
||||||
|
const details = existing.querySelector('.message-card-details');
|
||||||
|
if (details) {
|
||||||
|
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
|
||||||
|
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createMessageCard,
|
||||||
|
createScanningCard,
|
||||||
|
createCapabilityWarning,
|
||||||
|
createBaselineCard,
|
||||||
|
createScanCompleteCard,
|
||||||
|
createErrorCard,
|
||||||
|
showMessage,
|
||||||
|
removeMessage,
|
||||||
|
updateScanningStatus,
|
||||||
|
MESSAGE_TYPES
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.MessageCard = MessageCard;
|
||||||
243
static/js/components/rssi-sparkline.js
Normal file
243
static/js/components/rssi-sparkline.js
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* RSSI Sparkline Component
|
||||||
|
* SVG-based real-time RSSI visualization
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RSSISparkline = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
maxSamples: 30,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
minRssi: -100,
|
||||||
|
maxRssi: -30,
|
||||||
|
showCurrentValue: true,
|
||||||
|
showGradient: true,
|
||||||
|
animateUpdates: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color thresholds based on RSSI
|
||||||
|
const RSSI_COLORS = {
|
||||||
|
excellent: { rssi: -50, color: '#22c55e' }, // Green
|
||||||
|
good: { rssi: -60, color: '#84cc16' }, // Lime
|
||||||
|
fair: { rssi: -70, color: '#eab308' }, // Yellow
|
||||||
|
weak: { rssi: -80, color: '#f97316' }, // Orange
|
||||||
|
poor: { rssi: -100, color: '#ef4444' } // Red
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for RSSI value
|
||||||
|
*/
|
||||||
|
function getRssiColor(rssi) {
|
||||||
|
if (rssi >= RSSI_COLORS.excellent.rssi) return RSSI_COLORS.excellent.color;
|
||||||
|
if (rssi >= RSSI_COLORS.good.rssi) return RSSI_COLORS.good.color;
|
||||||
|
if (rssi >= RSSI_COLORS.fair.rssi) return RSSI_COLORS.fair.color;
|
||||||
|
if (rssi >= RSSI_COLORS.weak.rssi) return RSSI_COLORS.weak.color;
|
||||||
|
return RSSI_COLORS.poor.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize RSSI value to 0-1 range
|
||||||
|
*/
|
||||||
|
function normalizeRssi(rssi, min, max) {
|
||||||
|
return Math.max(0, Math.min(1, (rssi - min) / (max - min)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline SVG element
|
||||||
|
*/
|
||||||
|
function createSparklineSvg(samples, config = {}) {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const { width, height, minRssi, maxRssi, strokeWidth, showGradient } = cfg;
|
||||||
|
|
||||||
|
if (!samples || samples.length < 2) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize samples
|
||||||
|
const normalized = samples.map(s => {
|
||||||
|
const rssi = typeof s === 'object' ? s.rssi : s;
|
||||||
|
return {
|
||||||
|
value: normalizeRssi(rssi, minRssi, maxRssi),
|
||||||
|
rssi: rssi
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate path
|
||||||
|
const stepX = width / (normalized.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
let areaD = '';
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
normalized.forEach((sample, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (sample.value * (height - 2)) - 1; // 1px padding top/bottom
|
||||||
|
points.push({ x, y, rssi: sample.rssi });
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
} else {
|
||||||
|
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close area path
|
||||||
|
areaD += ` L${width},${height} Z`;
|
||||||
|
|
||||||
|
// Get current color based on latest value
|
||||||
|
const latestRssi = normalized[normalized.length - 1].rssi;
|
||||||
|
const strokeColor = getRssiColor(latestRssi);
|
||||||
|
|
||||||
|
// Create SVG
|
||||||
|
const gradientId = `sparkline-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
let gradientDef = '';
|
||||||
|
if (showGradient) {
|
||||||
|
gradientDef = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||||
|
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="rssi-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
${gradientDef}
|
||||||
|
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||||
|
r="2" fill="${strokeColor}" class="sparkline-dot" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty sparkline placeholder
|
||||||
|
*/
|
||||||
|
function createEmptySparkline(width, height) {
|
||||||
|
return `
|
||||||
|
<svg class="rssi-sparkline-svg rssi-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||||
|
stroke="#444" stroke-width="1" stroke-dasharray="2,2" />
|
||||||
|
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||||
|
fill="#666" font-size="8" font-family="monospace">No data</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a live sparkline component with update capability
|
||||||
|
*/
|
||||||
|
class LiveSparkline {
|
||||||
|
constructor(container, config = {}) {
|
||||||
|
this.container = typeof container === 'string'
|
||||||
|
? document.querySelector(container)
|
||||||
|
: container;
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
this.samples = [];
|
||||||
|
this.animationFrame = null;
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
addSample(rssi) {
|
||||||
|
this.samples.push({
|
||||||
|
rssi: rssi,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit samples
|
||||||
|
if (this.samples.length > this.config.maxSamples) {
|
||||||
|
this.samples.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSamples(samples) {
|
||||||
|
this.samples = samples.slice(-this.config.maxSamples);
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const svg = createSparklineSvg(this.samples, this.config);
|
||||||
|
this.container.innerHTML = svg;
|
||||||
|
|
||||||
|
// Add current value display if enabled
|
||||||
|
if (this.config.showCurrentValue && this.samples.length > 0) {
|
||||||
|
const latest = this.samples[this.samples.length - 1];
|
||||||
|
const rssi = typeof latest === 'object' ? latest.rssi : latest;
|
||||||
|
const valueEl = document.createElement('span');
|
||||||
|
valueEl.className = 'rssi-current-value';
|
||||||
|
valueEl.textContent = `${rssi} dBm`;
|
||||||
|
valueEl.style.color = getRssiColor(rssi);
|
||||||
|
this.container.appendChild(valueEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.samples = [];
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.animationFrame) {
|
||||||
|
cancelAnimationFrame(this.animationFrame);
|
||||||
|
}
|
||||||
|
if (this.container) {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create inline sparkline HTML (for use in templates)
|
||||||
|
*/
|
||||||
|
function createInlineSparkline(rssiHistory, options = {}) {
|
||||||
|
const samples = rssiHistory.map(h => typeof h === 'object' ? h.rssi : h);
|
||||||
|
return createSparklineSvg(samples, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline with value display
|
||||||
|
*/
|
||||||
|
function createSparklineWithValue(rssiHistory, currentRssi, options = {}) {
|
||||||
|
const { width = 60, height = 20 } = options;
|
||||||
|
const svg = createInlineSparkline(rssiHistory, { ...options, width, height });
|
||||||
|
const color = getRssiColor(currentRssi);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rssi-sparkline-wrapper">
|
||||||
|
${svg}
|
||||||
|
<span class="rssi-value" style="color: ${color}">${currentRssi !== null ? currentRssi : '--'} dBm</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createSparklineSvg,
|
||||||
|
createInlineSparkline,
|
||||||
|
createSparklineWithValue,
|
||||||
|
createEmptySparkline,
|
||||||
|
LiveSparkline,
|
||||||
|
getRssiColor,
|
||||||
|
normalizeRssi,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
RSSI_COLORS
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.RSSISparkline = RSSISparkline;
|
||||||
541
static/js/modes/bluetooth.js
Normal file
541
static/js/modes/bluetooth.js
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
/**
|
||||||
|
* Bluetooth Mode Controller
|
||||||
|
* Uses the new unified Bluetooth API at /api/bluetooth/
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BluetoothMode = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// State
|
||||||
|
let isScanning = false;
|
||||||
|
let eventSource = null;
|
||||||
|
let devices = new Map();
|
||||||
|
let baselineSet = false;
|
||||||
|
let baselineCount = 0;
|
||||||
|
|
||||||
|
// DOM elements (cached)
|
||||||
|
let startBtn, stopBtn, messageContainer, deviceContainer;
|
||||||
|
let adapterSelect, scanModeSelect, transportSelect, durationInput, minRssiInput;
|
||||||
|
let baselineStatusEl, capabilityStatusEl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Bluetooth mode
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
// Cache DOM elements
|
||||||
|
startBtn = document.getElementById('startBtBtn');
|
||||||
|
stopBtn = document.getElementById('stopBtBtn');
|
||||||
|
messageContainer = document.getElementById('btMessageContainer');
|
||||||
|
deviceContainer = document.getElementById('output');
|
||||||
|
adapterSelect = document.getElementById('btAdapterSelect');
|
||||||
|
scanModeSelect = document.getElementById('btScanMode');
|
||||||
|
transportSelect = document.getElementById('btTransport');
|
||||||
|
durationInput = document.getElementById('btScanDuration');
|
||||||
|
minRssiInput = document.getElementById('btMinRssi');
|
||||||
|
baselineStatusEl = document.getElementById('btBaselineStatus');
|
||||||
|
capabilityStatusEl = document.getElementById('btCapabilityStatus');
|
||||||
|
|
||||||
|
// Check capabilities on load
|
||||||
|
checkCapabilities();
|
||||||
|
|
||||||
|
// Check scan status (in case page was reloaded during scan)
|
||||||
|
checkScanStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check system capabilities
|
||||||
|
*/
|
||||||
|
async function checkCapabilities() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bluetooth/capabilities');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.available) {
|
||||||
|
showCapabilityWarning(['Bluetooth not available on this system']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update adapter select
|
||||||
|
if (adapterSelect && data.adapters && data.adapters.length > 0) {
|
||||||
|
adapterSelect.innerHTML = data.adapters.map(a => {
|
||||||
|
const status = a.powered ? 'UP' : 'DOWN';
|
||||||
|
return `<option value="${a.id}">${a.id} - ${a.name || 'Bluetooth Adapter'} [${status}]</option>`;
|
||||||
|
}).join('');
|
||||||
|
} else if (adapterSelect) {
|
||||||
|
adapterSelect.innerHTML = '<option value="">No adapters found</option>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show any issues
|
||||||
|
if (data.issues && data.issues.length > 0) {
|
||||||
|
showCapabilityWarning(data.issues);
|
||||||
|
} else {
|
||||||
|
hideCapabilityWarning();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scan mode based on preferred backend
|
||||||
|
if (scanModeSelect && data.preferred_backend) {
|
||||||
|
const option = scanModeSelect.querySelector(`option[value="${data.preferred_backend}"]`);
|
||||||
|
if (option) option.selected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check capabilities:', err);
|
||||||
|
showCapabilityWarning(['Failed to check Bluetooth capabilities']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show capability warning
|
||||||
|
*/
|
||||||
|
function showCapabilityWarning(issues) {
|
||||||
|
if (!capabilityStatusEl || !messageContainer) return;
|
||||||
|
|
||||||
|
capabilityStatusEl.style.display = 'block';
|
||||||
|
|
||||||
|
if (typeof MessageCard !== 'undefined') {
|
||||||
|
const card = MessageCard.createCapabilityWarning(issues);
|
||||||
|
if (card) {
|
||||||
|
capabilityStatusEl.innerHTML = '';
|
||||||
|
capabilityStatusEl.appendChild(card);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
capabilityStatusEl.innerHTML = `
|
||||||
|
<div class="warning-text" style="color: #f59e0b;">
|
||||||
|
${issues.map(i => `<div>${i}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide capability warning
|
||||||
|
*/
|
||||||
|
function hideCapabilityWarning() {
|
||||||
|
if (capabilityStatusEl) {
|
||||||
|
capabilityStatusEl.style.display = 'none';
|
||||||
|
capabilityStatusEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check current scan status
|
||||||
|
*/
|
||||||
|
async function checkScanStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bluetooth/scan/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.is_scanning) {
|
||||||
|
setScanning(true);
|
||||||
|
startEventStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update baseline status
|
||||||
|
if (data.baseline_count > 0) {
|
||||||
|
baselineSet = true;
|
||||||
|
baselineCount = data.baseline_count;
|
||||||
|
updateBaselineStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check scan status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start scanning
|
||||||
|
*/
|
||||||
|
async function startScan() {
|
||||||
|
const adapter = adapterSelect?.value || '';
|
||||||
|
const mode = scanModeSelect?.value || 'auto';
|
||||||
|
const transport = transportSelect?.value || 'auto';
|
||||||
|
const duration = parseInt(durationInput?.value || '0', 10);
|
||||||
|
const minRssi = parseInt(minRssiInput?.value || '-100', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bluetooth/scan/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: mode,
|
||||||
|
adapter_id: adapter || undefined,
|
||||||
|
duration_s: duration > 0 ? duration : undefined,
|
||||||
|
transport: transport,
|
||||||
|
rssi_threshold: minRssi
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'started' || data.status === 'already_scanning') {
|
||||||
|
setScanning(true);
|
||||||
|
startEventStream();
|
||||||
|
showScanningMessage(mode);
|
||||||
|
} else {
|
||||||
|
showErrorMessage(data.message || 'Failed to start scan');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start scan:', err);
|
||||||
|
showErrorMessage('Failed to start scan: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop scanning
|
||||||
|
*/
|
||||||
|
async function stopScan() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/bluetooth/scan/stop', { method: 'POST' });
|
||||||
|
setScanning(false);
|
||||||
|
stopEventStream();
|
||||||
|
removeScanningMessage();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop scan:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set scanning state
|
||||||
|
*/
|
||||||
|
function setScanning(scanning) {
|
||||||
|
isScanning = scanning;
|
||||||
|
|
||||||
|
if (startBtn) startBtn.style.display = scanning ? 'none' : 'block';
|
||||||
|
if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Update global status if available
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
if (statusDot) statusDot.classList.toggle('running', scanning);
|
||||||
|
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSE event stream
|
||||||
|
*/
|
||||||
|
function startEventStream() {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
|
||||||
|
eventSource = new EventSource('/api/bluetooth/stream');
|
||||||
|
|
||||||
|
eventSource.addEventListener('device_update', (e) => {
|
||||||
|
try {
|
||||||
|
const device = JSON.parse(e.data);
|
||||||
|
handleDeviceUpdate(device);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse device update:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('scan_started', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
setScanning(true);
|
||||||
|
showScanningMessage(data.mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('scan_stopped', (e) => {
|
||||||
|
setScanning(false);
|
||||||
|
removeScanningMessage();
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
showScanCompleteMessage(data.device_count, data.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('error', (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
showErrorMessage(data.message);
|
||||||
|
} catch {
|
||||||
|
// Connection error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
console.warn('Bluetooth SSE connection error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop SSE event stream
|
||||||
|
*/
|
||||||
|
function stopEventStream() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle device update from SSE
|
||||||
|
*/
|
||||||
|
function handleDeviceUpdate(device) {
|
||||||
|
devices.set(device.device_id, device);
|
||||||
|
renderDevice(device);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a device card
|
||||||
|
*/
|
||||||
|
function renderDevice(device) {
|
||||||
|
if (!deviceContainer) return;
|
||||||
|
|
||||||
|
const existingCard = deviceContainer.querySelector(`[data-device-id="${device.device_id}"]`);
|
||||||
|
|
||||||
|
if (typeof DeviceCard !== 'undefined') {
|
||||||
|
const cardHtml = DeviceCard.createDeviceCard(device);
|
||||||
|
|
||||||
|
if (existingCard) {
|
||||||
|
existingCard.outerHTML = cardHtml;
|
||||||
|
} else {
|
||||||
|
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-attach click handler
|
||||||
|
const newCard = deviceContainer.querySelector(`[data-device-id="${device.device_id}"]`);
|
||||||
|
if (newCard) {
|
||||||
|
newCard.addEventListener('click', () => showDeviceDetails(device.device_id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback simple rendering
|
||||||
|
const cardHtml = createSimpleDeviceCard(device);
|
||||||
|
|
||||||
|
if (existingCard) {
|
||||||
|
existingCard.outerHTML = cardHtml;
|
||||||
|
} else {
|
||||||
|
deviceContainer.insertAdjacentHTML('afterbegin', cardHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple device card fallback
|
||||||
|
*/
|
||||||
|
function createSimpleDeviceCard(device) {
|
||||||
|
const protoBadge = device.protocol === 'ble'
|
||||||
|
? '<span class="signal-proto-badge" style="background: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.3);">BLE</span>'
|
||||||
|
: '<span class="signal-proto-badge" style="background: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.3);">CLASSIC</span>';
|
||||||
|
|
||||||
|
const badges = [];
|
||||||
|
if (device.is_new) badges.push('<span class="device-heuristic-badge new">New</span>');
|
||||||
|
if (device.is_persistent) badges.push('<span class="device-heuristic-badge persistent">Persistent</span>');
|
||||||
|
if (device.is_beacon_like) badges.push('<span class="device-heuristic-badge beacon">Beacon-like</span>');
|
||||||
|
|
||||||
|
const rssiColor = getRssiColor(device.rssi_current);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="signal-card device-card" data-device-id="${device.device_id}">
|
||||||
|
<div class="signal-card-header">
|
||||||
|
<div class="signal-card-badges">
|
||||||
|
${protoBadge}
|
||||||
|
${badges.join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-body">
|
||||||
|
<div class="device-name">${escapeHtml(device.name || 'Unknown Device')}</div>
|
||||||
|
<div class="device-address">${escapeHtml(device.address)} (${device.address_type || 'unknown'})</div>
|
||||||
|
<div class="rssi-display">
|
||||||
|
<span class="rssi-current" style="color: ${rssiColor}">${device.rssi_current !== null ? device.rssi_current + ' dBm' : '--'}</span>
|
||||||
|
</div>
|
||||||
|
${device.manufacturer_name ? `<div class="device-manufacturer">${escapeHtml(device.manufacturer_name)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get RSSI color
|
||||||
|
*/
|
||||||
|
function getRssiColor(rssi) {
|
||||||
|
if (rssi === null || rssi === undefined) return '#666';
|
||||||
|
if (rssi >= -50) return '#22c55e';
|
||||||
|
if (rssi >= -60) return '#84cc16';
|
||||||
|
if (rssi >= -70) return '#eab308';
|
||||||
|
if (rssi >= -80) return '#f97316';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(text);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show device details
|
||||||
|
*/
|
||||||
|
async function showDeviceDetails(deviceId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bluetooth/devices/${encodeURIComponent(deviceId)}`);
|
||||||
|
const device = await response.json();
|
||||||
|
|
||||||
|
// Toggle advanced panel or show modal
|
||||||
|
const card = deviceContainer?.querySelector(`[data-device-id="${deviceId}"]`);
|
||||||
|
if (card) {
|
||||||
|
const panel = card.querySelector('.signal-advanced-panel');
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.toggle('show');
|
||||||
|
if (panel.classList.contains('show')) {
|
||||||
|
panel.innerHTML = `<pre style="font-size: 10px; overflow: auto;">${JSON.stringify(device, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get device details:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set baseline
|
||||||
|
*/
|
||||||
|
async function setBaseline() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bluetooth/baseline/set', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
baselineSet = true;
|
||||||
|
baselineCount = data.device_count;
|
||||||
|
updateBaselineStatus();
|
||||||
|
showBaselineSetMessage(data.device_count);
|
||||||
|
} else {
|
||||||
|
showErrorMessage(data.message || 'Failed to set baseline');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set baseline:', err);
|
||||||
|
showErrorMessage('Failed to set baseline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear baseline
|
||||||
|
*/
|
||||||
|
async function clearBaseline() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bluetooth/baseline/clear', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
baselineSet = false;
|
||||||
|
baselineCount = 0;
|
||||||
|
updateBaselineStatus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear baseline:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update baseline status display
|
||||||
|
*/
|
||||||
|
function updateBaselineStatus() {
|
||||||
|
if (!baselineStatusEl) return;
|
||||||
|
|
||||||
|
if (baselineSet) {
|
||||||
|
baselineStatusEl.textContent = `Baseline set: ${baselineCount} device${baselineCount !== 1 ? 's' : ''}`;
|
||||||
|
baselineStatusEl.style.color = '#22c55e';
|
||||||
|
} else {
|
||||||
|
baselineStatusEl.textContent = 'No baseline set';
|
||||||
|
baselineStatusEl.style.color = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export data
|
||||||
|
*/
|
||||||
|
function exportData(format) {
|
||||||
|
window.open(`/api/bluetooth/export?format=${format}`, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show scanning message
|
||||||
|
*/
|
||||||
|
function showScanningMessage(mode) {
|
||||||
|
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||||
|
|
||||||
|
removeScanningMessage();
|
||||||
|
const card = MessageCard.createScanningCard({
|
||||||
|
backend: mode,
|
||||||
|
deviceCount: devices.size
|
||||||
|
});
|
||||||
|
messageContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove scanning message
|
||||||
|
*/
|
||||||
|
function removeScanningMessage() {
|
||||||
|
MessageCard?.removeMessage?.('btScanningStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show scan complete message
|
||||||
|
*/
|
||||||
|
function showScanCompleteMessage(deviceCount, duration) {
|
||||||
|
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||||
|
|
||||||
|
const card = MessageCard.createScanCompleteCard(deviceCount, duration || 0);
|
||||||
|
messageContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show baseline set message
|
||||||
|
*/
|
||||||
|
function showBaselineSetMessage(count) {
|
||||||
|
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||||
|
|
||||||
|
const card = MessageCard.createBaselineCard(count, true);
|
||||||
|
messageContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show error message
|
||||||
|
*/
|
||||||
|
function showErrorMessage(message) {
|
||||||
|
if (!messageContainer || typeof MessageCard === 'undefined') return;
|
||||||
|
|
||||||
|
const card = MessageCard.createErrorCard(message, () => startScan());
|
||||||
|
messageContainer.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
startScan,
|
||||||
|
stopScan,
|
||||||
|
checkCapabilities,
|
||||||
|
setBaseline,
|
||||||
|
clearBaseline,
|
||||||
|
exportData,
|
||||||
|
getDevices: () => Array.from(devices.values()),
|
||||||
|
isScanning: () => isScanning
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Global functions for onclick handlers in HTML
|
||||||
|
function btStartScan() { BluetoothMode.startScan(); }
|
||||||
|
function btStopScan() { BluetoothMode.stopScan(); }
|
||||||
|
function btCheckCapabilities() { BluetoothMode.checkCapabilities(); }
|
||||||
|
function btSetBaseline() { BluetoothMode.setBaseline(); }
|
||||||
|
function btClearBaseline() { BluetoothMode.clearBaseline(); }
|
||||||
|
function btExport(format) { BluetoothMode.exportData(format); }
|
||||||
|
|
||||||
|
// Initialize when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Only init if we're on a page with Bluetooth mode
|
||||||
|
if (document.getElementById('bluetoothMode')) {
|
||||||
|
BluetoothMode.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (document.getElementById('bluetoothMode')) {
|
||||||
|
BluetoothMode.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.BluetoothMode = BluetoothMode;
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
|
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
|
||||||
<span class="remote-dump1090-controls" style="display: none;">
|
<span class="remote-dump1090-controls" style="display: none;">
|
||||||
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 70px;">
|
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 70px;">
|
||||||
<input type="number" id="remoteSbsPort" value="30003" style="width: 55px;">
|
<input type="number" id="remoteSbsPort" value="30003" min="1" max="65535" style="width: 70px;">
|
||||||
</span>
|
</span>
|
||||||
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
|
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
|
||||||
<option value="0">SDR 0</option>
|
<option value="0">SDR 0</option>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -1483,6 +1484,11 @@
|
|||||||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/bluetooth-adapter.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/timeline-adapters/bluetooth-adapter.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
||||||
|
<!-- Bluetooth v2 components -->
|
||||||
|
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,70 +1,83 @@
|
|||||||
<!-- BLUETOOTH MODE -->
|
<!-- BLUETOOTH MODE -->
|
||||||
<div id="bluetoothMode" class="mode-content">
|
<div id="bluetoothMode" class="mode-content">
|
||||||
|
<!-- Capability Status -->
|
||||||
|
<div id="btCapabilityStatus" class="section" style="display: none;">
|
||||||
|
<!-- Populated by JavaScript with capability warnings -->
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Bluetooth Interface</h3>
|
<h3>Scanner Configuration</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<select id="btInterfaceSelect">
|
<label>Adapter</label>
|
||||||
<option value="">Detecting interfaces...</option>
|
<select id="btAdapterSelect">
|
||||||
|
<option value="">Detecting adapters...</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="preset-btn" onclick="refreshBtInterfaces()" style="width: 100%;">
|
<div class="form-group">
|
||||||
Refresh Interfaces
|
<label>Scan Mode</label>
|
||||||
</button>
|
<select id="btScanMode">
|
||||||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="btToolStatus">
|
<option value="auto">Auto (Recommended)</option>
|
||||||
<span>hcitool:</span><span class="tool-status missing">Checking...</span>
|
<option value="dbus">DBus/BlueZ</option>
|
||||||
<span>bluetoothctl:</span><span class="tool-status missing">Checking...</span>
|
<option value="bleak">Bleak Library</option>
|
||||||
</div>
|
<option value="hcitool">hcitool (Legacy)</option>
|
||||||
</div>
|
<option value="bluetoothctl">bluetoothctl</option>
|
||||||
|
</select>
|
||||||
<div class="section">
|
|
||||||
<h3>Scan Mode</h3>
|
|
||||||
<div class="checkbox-group" style="margin-bottom: 10px;">
|
|
||||||
<label><input type="radio" name="btScanMode" value="bluetoothctl" checked> bluetoothctl (Recommended)</label>
|
|
||||||
<label><input type="radio" name="btScanMode" value="hcitool"> hcitool (Legacy)</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Scan Duration (sec)</label>
|
<label>Transport</label>
|
||||||
<input type="text" id="btScanDuration" value="30" placeholder="30">
|
<select id="btTransport">
|
||||||
|
<option value="auto">Auto (BLE + Classic)</option>
|
||||||
|
<option value="le">BLE Only</option>
|
||||||
|
<option value="br_edr">Classic Only</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="btScanBLE" checked>
|
|
||||||
Scan BLE Devices
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="btScanClassic" checked>
|
|
||||||
Scan Classic BT
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="btDetectBeacons" checked>
|
|
||||||
Detect Trackers (AirTag/Tile)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h3>Device Actions</h3>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Target MAC</label>
|
<label>Duration (seconds, 0 = continuous)</label>
|
||||||
<input type="text" id="btTargetMac" placeholder="AA:BB:CC:DD:EE:FF">
|
<input type="number" id="btScanDuration" value="0" min="0" max="300" placeholder="0">
|
||||||
</div>
|
</div>
|
||||||
<button class="preset-btn" onclick="btEnumServices()" style="width: 100%;">
|
<div class="form-group">
|
||||||
Enumerate Services
|
<label>Min RSSI Filter (dBm)</label>
|
||||||
|
<input type="number" id="btMinRssi" value="-100" min="-100" max="-20" placeholder="-100">
|
||||||
|
</div>
|
||||||
|
<button class="preset-btn" onclick="btCheckCapabilities()" style="width: 100%;">
|
||||||
|
Check Capabilities
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tracker Following Alert -->
|
<div class="section">
|
||||||
<div id="trackerFollowingAlert" class="tracker-following-alert" style="display: none;">
|
<h3>Baseline</h3>
|
||||||
<!-- Populated by JavaScript -->
|
<div class="info-text" id="btBaselineStatus" style="margin-bottom: 8px;">
|
||||||
|
No baseline set
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="preset-btn" onclick="btSetBaseline()" style="flex: 1;">
|
||||||
|
Set Baseline
|
||||||
|
</button>
|
||||||
|
<button class="preset-btn" onclick="btClearBaseline()" style="flex: 1;">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="run-btn" id="startBtBtn" onclick="startBtScan()">
|
<!-- Message Container for status cards -->
|
||||||
|
<div id="btMessageContainer"></div>
|
||||||
|
|
||||||
|
<button class="run-btn" id="startBtBtn" onclick="btStartScan()">
|
||||||
Start Scanning
|
Start Scanning
|
||||||
</button>
|
</button>
|
||||||
<button class="stop-btn" id="stopBtBtn" onclick="stopBtScan()" style="display: none;">
|
<button class="stop-btn" id="stopBtBtn" onclick="btStopScan()" style="display: none;">
|
||||||
Stop Scanning
|
Stop Scanning
|
||||||
</button>
|
</button>
|
||||||
<button class="preset-btn" onclick="resetBtAdapter()" style="margin-top: 5px; width: 100%;">
|
|
||||||
Reset Adapter
|
<div class="section" style="margin-top: 10px;">
|
||||||
</button>
|
<h3>Export</h3>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button class="preset-btn" onclick="btExport('csv')" style="flex: 1;">
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button class="preset-btn" onclick="btExport('json')" style="flex: 1;">
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
555
tests/test_bluetooth_aggregator.py
Normal file
555
tests/test_bluetooth_aggregator.py
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
"""Unit tests for Bluetooth device aggregation."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from utils.bluetooth.aggregator import DeviceAggregator
|
||||||
|
from utils.bluetooth.models import BTObservation, BTDeviceAggregate
|
||||||
|
from utils.bluetooth.constants import (
|
||||||
|
RSSI_RANGE_BANDS,
|
||||||
|
MAX_RSSI_SAMPLES,
|
||||||
|
DEVICE_STALE_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def aggregator():
|
||||||
|
"""Create a fresh DeviceAggregator for testing."""
|
||||||
|
return DeviceAggregator()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_observation():
|
||||||
|
"""Create a sample BLE observation."""
|
||||||
|
return BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-55,
|
||||||
|
tx_power=None,
|
||||||
|
name="Test Device",
|
||||||
|
manufacturer_id=76, # Apple
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceAggregator:
|
||||||
|
"""Tests for DeviceAggregator class."""
|
||||||
|
|
||||||
|
def test_ingest_single_observation(self, aggregator, sample_observation):
|
||||||
|
"""Test ingesting a single observation creates device aggregate."""
|
||||||
|
aggregator.ingest(sample_observation)
|
||||||
|
|
||||||
|
devices = aggregator.get_all_devices()
|
||||||
|
assert len(devices) == 1
|
||||||
|
|
||||||
|
device = devices[0]
|
||||||
|
assert device.address == "AA:BB:CC:DD:EE:FF"
|
||||||
|
assert device.name == "Test Device"
|
||||||
|
assert device.rssi_current == -55
|
||||||
|
assert device.seen_count == 1
|
||||||
|
|
||||||
|
def test_ingest_multiple_observations_same_device(self, aggregator, sample_observation):
|
||||||
|
"""Test multiple observations for same device aggregate correctly."""
|
||||||
|
# Ingest multiple observations with varying RSSI
|
||||||
|
rssi_values = [-55, -60, -50, -58, -52]
|
||||||
|
|
||||||
|
for rssi in rssi_values:
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=sample_observation.address,
|
||||||
|
address_type=sample_observation.address_type,
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name=sample_observation.name,
|
||||||
|
manufacturer_id=sample_observation.manufacturer_id,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=sample_observation.service_uuids,
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
devices = aggregator.get_all_devices()
|
||||||
|
assert len(devices) == 1
|
||||||
|
|
||||||
|
device = devices[0]
|
||||||
|
assert device.seen_count == 5
|
||||||
|
assert device.rssi_current == rssi_values[-1]
|
||||||
|
assert len(device.rssi_samples) == 5
|
||||||
|
|
||||||
|
# Check RSSI stats
|
||||||
|
assert device.rssi_min == -60
|
||||||
|
assert device.rssi_max == -50
|
||||||
|
|
||||||
|
def test_rssi_median_calculation(self, aggregator, sample_observation):
|
||||||
|
"""Test RSSI median is calculated correctly."""
|
||||||
|
rssi_values = [-70, -60, -50, -55, -65] # Sorted: -70, -65, -60, -55, -50 -> median -60
|
||||||
|
|
||||||
|
for rssi in rssi_values:
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=sample_observation.address,
|
||||||
|
address_type="public",
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name="Test",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.rssi_median == -60.0
|
||||||
|
|
||||||
|
def test_rssi_samples_limited(self, aggregator, sample_observation):
|
||||||
|
"""Test RSSI samples are limited to MAX_RSSI_SAMPLES."""
|
||||||
|
for i in range(MAX_RSSI_SAMPLES + 50):
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=sample_observation.address,
|
||||||
|
address_type="public",
|
||||||
|
rssi=-50 - (i % 30),
|
||||||
|
tx_power=None,
|
||||||
|
name="Test",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert len(device.rssi_samples) <= MAX_RSSI_SAMPLES
|
||||||
|
|
||||||
|
def test_protocol_detection_ble(self, aggregator):
|
||||||
|
"""Test BLE protocol detection."""
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="random", # Random address indicates BLE
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=-8,
|
||||||
|
name="BLE Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=["0000180a-0000-1000-8000-00805f9b34fb"],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.protocol == "ble"
|
||||||
|
|
||||||
|
def test_protocol_detection_classic(self, aggregator):
|
||||||
|
"""Test Classic Bluetooth protocol detection."""
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=None,
|
||||||
|
name="Classic Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=0x240404, # Audio device
|
||||||
|
major_class="audio_video",
|
||||||
|
minor_class="headphones",
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.protocol == "classic"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRangeBandEstimation:
|
||||||
|
"""Tests for range band estimation."""
|
||||||
|
|
||||||
|
def test_range_band_very_close(self, aggregator):
|
||||||
|
"""Test very close range band detection."""
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-35, # Very strong signal
|
||||||
|
tx_power=None,
|
||||||
|
name="Close Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add multiple samples to build confidence
|
||||||
|
for _ in range(10):
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.range_band == "very_close"
|
||||||
|
|
||||||
|
def test_range_band_close(self, aggregator):
|
||||||
|
"""Test close range band detection."""
|
||||||
|
for rssi in [-45, -48, -50, -47, -49]:
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name="Close Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.range_band in ["very_close", "close"]
|
||||||
|
|
||||||
|
def test_range_band_far(self, aggregator):
|
||||||
|
"""Test far range band detection."""
|
||||||
|
for rssi in [-75, -78, -80, -77, -79]:
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name="Far Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
assert device.range_band in ["nearby", "far"]
|
||||||
|
|
||||||
|
def test_range_band_unknown_low_confidence(self, aggregator):
|
||||||
|
"""Test unknown range band with insufficient data."""
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=None,
|
||||||
|
name="Unknown Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
device = aggregator.get_all_devices()[0]
|
||||||
|
# With only one sample, confidence is low
|
||||||
|
assert device.rssi_confidence < 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaselineManagement:
|
||||||
|
"""Tests for baseline functionality."""
|
||||||
|
|
||||||
|
def test_set_baseline(self, aggregator, sample_observation):
|
||||||
|
"""Test setting a baseline from current devices."""
|
||||||
|
aggregator.ingest(sample_observation)
|
||||||
|
count = aggregator.set_baseline()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
assert aggregator.has_baseline()
|
||||||
|
|
||||||
|
def test_clear_baseline(self, aggregator, sample_observation):
|
||||||
|
"""Test clearing the baseline."""
|
||||||
|
aggregator.ingest(sample_observation)
|
||||||
|
aggregator.set_baseline()
|
||||||
|
aggregator.clear_baseline()
|
||||||
|
|
||||||
|
assert not aggregator.has_baseline()
|
||||||
|
|
||||||
|
def test_is_new_device(self, aggregator, sample_observation):
|
||||||
|
"""Test detection of new devices vs baseline."""
|
||||||
|
# Add first device and set baseline
|
||||||
|
aggregator.ingest(sample_observation)
|
||||||
|
aggregator.set_baseline()
|
||||||
|
|
||||||
|
# Add new device
|
||||||
|
new_obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="11:22:33:44:55:66",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=None,
|
||||||
|
name="New Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(new_obs)
|
||||||
|
|
||||||
|
devices = aggregator.get_all_devices()
|
||||||
|
new_device = next(d for d in devices if d.address == "11:22:33:44:55:66")
|
||||||
|
|
||||||
|
assert new_device.is_new is True
|
||||||
|
|
||||||
|
# Original device should not be new
|
||||||
|
original = next(d for d in devices if d.address == sample_observation.address)
|
||||||
|
assert original.is_new is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestDevicePruning:
|
||||||
|
"""Tests for stale device pruning."""
|
||||||
|
|
||||||
|
def test_prune_stale_devices(self, aggregator):
|
||||||
|
"""Test that stale devices are removed."""
|
||||||
|
# Create an old observation
|
||||||
|
old_time = datetime.now() - timedelta(seconds=DEVICE_STALE_SECONDS + 60)
|
||||||
|
old_obs = BTObservation(
|
||||||
|
timestamp=old_time,
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=None,
|
||||||
|
name="Old Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(old_obs)
|
||||||
|
|
||||||
|
# Create a recent observation for different device
|
||||||
|
recent_obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="11:22:33:44:55:66",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-55,
|
||||||
|
tx_power=None,
|
||||||
|
name="Recent Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(recent_obs)
|
||||||
|
|
||||||
|
# Prune stale devices
|
||||||
|
pruned = aggregator.prune_stale()
|
||||||
|
|
||||||
|
assert pruned == 1
|
||||||
|
devices = aggregator.get_all_devices()
|
||||||
|
assert len(devices) == 1
|
||||||
|
assert devices[0].address == "11:22:33:44:55:66"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceFiltering:
|
||||||
|
"""Tests for device filtering and sorting."""
|
||||||
|
|
||||||
|
def test_filter_by_protocol(self, aggregator):
|
||||||
|
"""Test filtering devices by protocol."""
|
||||||
|
# Add BLE device
|
||||||
|
ble_obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="random",
|
||||||
|
rssi=-60,
|
||||||
|
tx_power=-8,
|
||||||
|
name="BLE Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=["0000180a-0000-1000-8000-00805f9b34fb"],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(ble_obs)
|
||||||
|
|
||||||
|
# Add Classic device
|
||||||
|
classic_obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address="11:22:33:44:55:66",
|
||||||
|
address_type="public",
|
||||||
|
rssi=-55,
|
||||||
|
tx_power=None,
|
||||||
|
name="Classic Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=0x240404,
|
||||||
|
major_class="audio_video",
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(classic_obs)
|
||||||
|
|
||||||
|
# Filter by BLE
|
||||||
|
ble_devices = aggregator.get_all_devices(protocol="ble")
|
||||||
|
assert len(ble_devices) == 1
|
||||||
|
assert ble_devices[0].protocol == "ble"
|
||||||
|
|
||||||
|
# Filter by Classic
|
||||||
|
classic_devices = aggregator.get_all_devices(protocol="classic")
|
||||||
|
assert len(classic_devices) == 1
|
||||||
|
assert classic_devices[0].protocol == "classic"
|
||||||
|
|
||||||
|
def test_filter_by_min_rssi(self, aggregator):
|
||||||
|
"""Test filtering devices by minimum RSSI."""
|
||||||
|
for i, rssi in enumerate([-50, -70, -90]):
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=f"AA:BB:CC:DD:EE:{i:02X}",
|
||||||
|
address_type="public",
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name=f"Device {i}",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
# Filter by min RSSI -60
|
||||||
|
strong_devices = aggregator.get_all_devices(min_rssi=-60)
|
||||||
|
assert len(strong_devices) == 1
|
||||||
|
assert strong_devices[0].rssi_current == -50
|
||||||
|
|
||||||
|
def test_sort_by_rssi(self, aggregator):
|
||||||
|
"""Test sorting devices by RSSI."""
|
||||||
|
for rssi in [-70, -50, -90, -60]:
|
||||||
|
obs = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=f"AA:BB:CC:DD:{abs(rssi):02X}:FF",
|
||||||
|
address_type="public",
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=None,
|
||||||
|
name=f"Device RSSI {rssi}",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_data=None,
|
||||||
|
service_uuids=[],
|
||||||
|
service_data={},
|
||||||
|
appearance=None,
|
||||||
|
is_connectable=True,
|
||||||
|
is_paired=False,
|
||||||
|
is_connected=False,
|
||||||
|
class_of_device=None,
|
||||||
|
major_class=None,
|
||||||
|
minor_class=None,
|
||||||
|
)
|
||||||
|
aggregator.ingest(obs)
|
||||||
|
|
||||||
|
# Sort by RSSI (strongest first)
|
||||||
|
devices = aggregator.get_all_devices(sort_by="rssi")
|
||||||
|
rssi_values = [d.rssi_current for d in devices]
|
||||||
|
assert rssi_values == [-50, -60, -70, -90]
|
||||||
469
tests/test_bluetooth_api.py
Normal file
469
tests/test_bluetooth_api.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
"""API endpoint tests for Bluetooth v2 routes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch, PropertyMock
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from routes.bluetooth_v2 import bluetooth_v2_bp
|
||||||
|
from utils.bluetooth.models import BTDeviceAggregate, ScanStatus, SystemCapabilities
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
"""Create Flask application for testing."""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(bluetooth_v2_bp)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
"""Create test client."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_scanner():
|
||||||
|
"""Create mock BluetoothScanner."""
|
||||||
|
with patch('routes.bluetooth_v2.get_bluetooth_scanner') as mock_get:
|
||||||
|
scanner = MagicMock()
|
||||||
|
scanner.is_scanning = False
|
||||||
|
scanner.scan_mode = None
|
||||||
|
scanner.scan_start_time = None
|
||||||
|
scanner.device_count = 0
|
||||||
|
mock_get.return_value = scanner
|
||||||
|
yield scanner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_device():
|
||||||
|
"""Create sample BTDeviceAggregate."""
|
||||||
|
return BTDeviceAggregate(
|
||||||
|
device_id="AA:BB:CC:DD:EE:FF:public",
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
protocol="ble",
|
||||||
|
first_seen=datetime.now(),
|
||||||
|
last_seen=datetime.now(),
|
||||||
|
seen_count=5,
|
||||||
|
seen_rate=1.0,
|
||||||
|
rssi_samples=[],
|
||||||
|
rssi_current=-55,
|
||||||
|
rssi_median=-57.0,
|
||||||
|
rssi_min=-60,
|
||||||
|
rssi_max=-50,
|
||||||
|
rssi_variance=4.0,
|
||||||
|
rssi_confidence=0.85,
|
||||||
|
range_band="close",
|
||||||
|
range_confidence=0.75,
|
||||||
|
name="Test Device",
|
||||||
|
manufacturer_id=76,
|
||||||
|
manufacturer_name="Apple, Inc.",
|
||||||
|
manufacturer_bytes=None,
|
||||||
|
service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"],
|
||||||
|
is_new=False,
|
||||||
|
is_persistent=True,
|
||||||
|
is_beacon_like=False,
|
||||||
|
is_strong_stable=True,
|
||||||
|
has_random_address=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanEndpoints:
|
||||||
|
"""Tests for scan control endpoints."""
|
||||||
|
|
||||||
|
def test_start_scan_success(self, client, mock_scanner):
|
||||||
|
"""Test starting a scan successfully."""
|
||||||
|
mock_scanner.start_scan.return_value = True
|
||||||
|
mock_scanner.scan_mode = "dbus"
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/scan/start',
|
||||||
|
json={'mode': 'auto', 'duration_s': 30})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'started'
|
||||||
|
mock_scanner.start_scan.assert_called_once()
|
||||||
|
|
||||||
|
def test_start_scan_already_scanning(self, client, mock_scanner):
|
||||||
|
"""Test starting scan when already scanning."""
|
||||||
|
mock_scanner.is_scanning = True
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/scan/start', json={})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'already_scanning'
|
||||||
|
|
||||||
|
def test_start_scan_failed(self, client, mock_scanner):
|
||||||
|
"""Test start scan failure."""
|
||||||
|
mock_scanner.start_scan.return_value = False
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/scan/start', json={})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
def test_stop_scan_success(self, client, mock_scanner):
|
||||||
|
"""Test stopping a scan."""
|
||||||
|
mock_scanner.is_scanning = True
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/scan/stop')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'stopped'
|
||||||
|
mock_scanner.stop_scan.assert_called_once()
|
||||||
|
|
||||||
|
def test_get_scan_status(self, client, mock_scanner):
|
||||||
|
"""Test getting scan status."""
|
||||||
|
mock_scanner.is_scanning = True
|
||||||
|
mock_scanner.scan_mode = "dbus"
|
||||||
|
mock_scanner.device_count = 10
|
||||||
|
mock_scanner.get_baseline_count.return_value = 5
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/scan/status')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['is_scanning'] is True
|
||||||
|
assert data['mode'] == 'dbus'
|
||||||
|
assert data['device_count'] == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceEndpoints:
|
||||||
|
"""Tests for device listing and detail endpoints."""
|
||||||
|
|
||||||
|
def test_list_devices(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test listing all devices."""
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert len(data['devices']) == 1
|
||||||
|
assert data['devices'][0]['address'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
|
||||||
|
def test_list_devices_with_filters(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test listing devices with filters."""
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices?protocol=ble&min_rssi=-60&sort_by=rssi')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_scanner.get_devices.assert_called_with(
|
||||||
|
sort_by='rssi',
|
||||||
|
protocol='ble',
|
||||||
|
min_rssi=-60,
|
||||||
|
new_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_devices_new_only(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test listing only new devices."""
|
||||||
|
sample_device.is_new = True
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices?new_only=true')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_scanner.get_devices.assert_called_with(
|
||||||
|
sort_by='last_seen',
|
||||||
|
protocol=None,
|
||||||
|
min_rssi=None,
|
||||||
|
new_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_device_detail(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test getting device details."""
|
||||||
|
mock_scanner.get_device.return_value = sample_device
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices/AA:BB:CC:DD:EE:FF:public')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['address'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
assert data['manufacturer_name'] == 'Apple, Inc.'
|
||||||
|
|
||||||
|
def test_get_device_not_found(self, client, mock_scanner):
|
||||||
|
"""Test getting non-existent device."""
|
||||||
|
mock_scanner.get_device.return_value = None
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices/NONEXISTENT')
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaselineEndpoints:
|
||||||
|
"""Tests for baseline management endpoints."""
|
||||||
|
|
||||||
|
def test_set_baseline(self, client, mock_scanner):
|
||||||
|
"""Test setting baseline."""
|
||||||
|
mock_scanner.set_baseline.return_value = 15
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/baseline/set')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['device_count'] == 15
|
||||||
|
|
||||||
|
def test_clear_baseline(self, client, mock_scanner):
|
||||||
|
"""Test clearing baseline."""
|
||||||
|
response = client.post('/api/bluetooth/baseline/clear')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
mock_scanner.clear_baseline.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCapabilitiesEndpoint:
|
||||||
|
"""Tests for capabilities check endpoint."""
|
||||||
|
|
||||||
|
def test_get_capabilities(self, client):
|
||||||
|
"""Test getting system capabilities."""
|
||||||
|
mock_caps = SystemCapabilities(
|
||||||
|
available=True,
|
||||||
|
dbus_available=True,
|
||||||
|
bluez_version="5.66",
|
||||||
|
adapters=[],
|
||||||
|
has_root=True,
|
||||||
|
rfkill_blocked=False,
|
||||||
|
fallback_tools=['bleak', 'hcitool'],
|
||||||
|
issues=[],
|
||||||
|
preferred_backend='dbus',
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
|
||||||
|
response = client.get('/api/bluetooth/capabilities')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['available'] is True
|
||||||
|
assert data['dbus_available'] is True
|
||||||
|
|
||||||
|
def test_capabilities_not_available(self, client):
|
||||||
|
"""Test capabilities when Bluetooth not available."""
|
||||||
|
mock_caps = SystemCapabilities(
|
||||||
|
available=False,
|
||||||
|
dbus_available=False,
|
||||||
|
bluez_version=None,
|
||||||
|
adapters=[],
|
||||||
|
has_root=False,
|
||||||
|
rfkill_blocked=False,
|
||||||
|
fallback_tools=[],
|
||||||
|
issues=['No Bluetooth adapter found'],
|
||||||
|
preferred_backend=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
|
||||||
|
response = client.get('/api/bluetooth/capabilities')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['available'] is False
|
||||||
|
assert 'No Bluetooth adapter found' in data['issues']
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportEndpoint:
|
||||||
|
"""Tests for data export endpoint."""
|
||||||
|
|
||||||
|
def test_export_json(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test JSON export."""
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/export?format=json')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.content_type == 'application/json'
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'devices' in data
|
||||||
|
assert 'timestamp' in data
|
||||||
|
|
||||||
|
def test_export_csv(self, client, mock_scanner, sample_device):
|
||||||
|
"""Test CSV export."""
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/export?format=csv')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'text/csv' in response.content_type
|
||||||
|
|
||||||
|
# Check CSV content
|
||||||
|
csv_content = response.data.decode('utf-8')
|
||||||
|
assert 'address' in csv_content.lower()
|
||||||
|
assert 'AA:BB:CC:DD:EE:FF' in csv_content
|
||||||
|
|
||||||
|
def test_export_empty_devices(self, client, mock_scanner):
|
||||||
|
"""Test export with no devices."""
|
||||||
|
mock_scanner.get_devices.return_value = []
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/export?format=json')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['devices'] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamEndpoint:
|
||||||
|
"""Tests for SSE streaming endpoint."""
|
||||||
|
|
||||||
|
def test_stream_headers(self, client, mock_scanner):
|
||||||
|
"""Test SSE stream has correct headers."""
|
||||||
|
mock_scanner.stream_events.return_value = iter([])
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/stream')
|
||||||
|
|
||||||
|
assert response.content_type == 'text/event-stream'
|
||||||
|
assert response.headers.get('Cache-Control') == 'no-cache'
|
||||||
|
|
||||||
|
def test_stream_returns_generator(self, client, mock_scanner):
|
||||||
|
"""Test stream endpoint returns a generator response."""
|
||||||
|
mock_scanner.stream_events.return_value = iter([
|
||||||
|
{'event': 'device_update', 'data': {'address': 'AA:BB:CC:DD:EE:FF'}}
|
||||||
|
])
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/stream')
|
||||||
|
|
||||||
|
# Should be a streaming response
|
||||||
|
assert response.is_streamed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTSCMIntegration:
|
||||||
|
"""Tests for TSCM integration helper."""
|
||||||
|
|
||||||
|
def test_get_tscm_bluetooth_snapshot(self, mock_scanner, sample_device):
|
||||||
|
"""Test TSCM snapshot function."""
|
||||||
|
from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot
|
||||||
|
|
||||||
|
mock_scanner.get_devices.return_value = [sample_device]
|
||||||
|
|
||||||
|
with patch('routes.bluetooth_v2.get_bluetooth_scanner', return_value=mock_scanner):
|
||||||
|
devices = get_tscm_bluetooth_snapshot('hci0', duration=8)
|
||||||
|
|
||||||
|
assert len(devices) == 1
|
||||||
|
device = devices[0]
|
||||||
|
# Should be converted to TSCM format
|
||||||
|
assert 'mac' in device
|
||||||
|
assert device['mac'] == 'AA:BB:CC:DD:EE:FF'
|
||||||
|
|
||||||
|
def test_tscm_snapshot_empty(self, mock_scanner):
|
||||||
|
"""Test TSCM snapshot with no devices."""
|
||||||
|
from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot
|
||||||
|
|
||||||
|
mock_scanner.get_devices.return_value = []
|
||||||
|
|
||||||
|
with patch('routes.bluetooth_v2.get_bluetooth_scanner', return_value=mock_scanner):
|
||||||
|
devices = get_tscm_bluetooth_snapshot('hci0')
|
||||||
|
|
||||||
|
assert devices == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Tests for error handling."""
|
||||||
|
|
||||||
|
def test_invalid_json_body(self, client, mock_scanner):
|
||||||
|
"""Test handling of invalid JSON body."""
|
||||||
|
response = client.post('/api/bluetooth/scan/start',
|
||||||
|
data='not json',
|
||||||
|
content_type='application/json')
|
||||||
|
|
||||||
|
# Should handle gracefully
|
||||||
|
assert response.status_code in [200, 400]
|
||||||
|
|
||||||
|
def test_scanner_exception(self, client, mock_scanner):
|
||||||
|
"""Test handling of scanner exceptions."""
|
||||||
|
mock_scanner.start_scan.side_effect = Exception("Scanner error")
|
||||||
|
|
||||||
|
response = client.post('/api/bluetooth/scan/start', json={})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert data['status'] == 'error'
|
||||||
|
assert 'error' in data['message'].lower() or 'Scanner error' in data['message']
|
||||||
|
|
||||||
|
def test_invalid_device_id_format(self, client, mock_scanner):
|
||||||
|
"""Test handling of invalid device ID format."""
|
||||||
|
mock_scanner.get_device.return_value = None
|
||||||
|
|
||||||
|
response = client.get('/api/bluetooth/devices/invalid-id-format')
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeviceSerialization:
|
||||||
|
"""Tests for device serialization."""
|
||||||
|
|
||||||
|
def test_device_to_dict_complete(self, sample_device):
|
||||||
|
"""Test device serialization includes all fields."""
|
||||||
|
from routes.bluetooth_v2 import device_to_dict
|
||||||
|
|
||||||
|
result = device_to_dict(sample_device)
|
||||||
|
|
||||||
|
assert result['device_id'] == sample_device.device_id
|
||||||
|
assert result['address'] == sample_device.address
|
||||||
|
assert result['address_type'] == sample_device.address_type
|
||||||
|
assert result['protocol'] == sample_device.protocol
|
||||||
|
assert result['rssi_current'] == sample_device.rssi_current
|
||||||
|
assert result['rssi_median'] == sample_device.rssi_median
|
||||||
|
assert result['range_band'] == sample_device.range_band
|
||||||
|
assert result['is_new'] == sample_device.is_new
|
||||||
|
assert result['is_persistent'] == sample_device.is_persistent
|
||||||
|
assert result['manufacturer_name'] == sample_device.manufacturer_name
|
||||||
|
|
||||||
|
def test_device_to_dict_timestamps(self, sample_device):
|
||||||
|
"""Test device serialization handles timestamps correctly."""
|
||||||
|
from routes.bluetooth_v2 import device_to_dict
|
||||||
|
|
||||||
|
result = device_to_dict(sample_device)
|
||||||
|
|
||||||
|
# Timestamps should be ISO format strings
|
||||||
|
assert isinstance(result['first_seen'], str)
|
||||||
|
assert isinstance(result['last_seen'], str)
|
||||||
|
|
||||||
|
def test_device_to_dict_null_values(self):
|
||||||
|
"""Test device serialization handles null values."""
|
||||||
|
from routes.bluetooth_v2 import device_to_dict
|
||||||
|
|
||||||
|
device = BTDeviceAggregate(
|
||||||
|
device_id="test:public",
|
||||||
|
address="test",
|
||||||
|
address_type="public",
|
||||||
|
protocol="ble",
|
||||||
|
first_seen=datetime.now(),
|
||||||
|
last_seen=datetime.now(),
|
||||||
|
seen_count=1,
|
||||||
|
seen_rate=1.0,
|
||||||
|
rssi_samples=[],
|
||||||
|
rssi_current=None,
|
||||||
|
rssi_median=None,
|
||||||
|
rssi_min=None,
|
||||||
|
rssi_max=None,
|
||||||
|
rssi_variance=None,
|
||||||
|
rssi_confidence=0.0,
|
||||||
|
range_band="unknown",
|
||||||
|
range_confidence=0.0,
|
||||||
|
name=None,
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_name=None,
|
||||||
|
manufacturer_bytes=None,
|
||||||
|
service_uuids=[],
|
||||||
|
is_new=False,
|
||||||
|
is_persistent=False,
|
||||||
|
is_beacon_like=False,
|
||||||
|
is_strong_stable=False,
|
||||||
|
has_random_address=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = device_to_dict(device)
|
||||||
|
|
||||||
|
assert result['rssi_current'] is None
|
||||||
|
assert result['name'] is None
|
||||||
|
assert result['manufacturer_name'] is None
|
||||||
357
tests/test_bluetooth_heuristics.py
Normal file
357
tests/test_bluetooth_heuristics.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""Unit tests for Bluetooth heuristic detection."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from utils.bluetooth.heuristics import HeuristicsEngine
|
||||||
|
from utils.bluetooth.models import BTDeviceAggregate
|
||||||
|
from utils.bluetooth.constants import (
|
||||||
|
HEURISTIC_PERSISTENT_MIN_SEEN,
|
||||||
|
HEURISTIC_PERSISTENT_WINDOW_SECONDS,
|
||||||
|
HEURISTIC_BEACON_VARIANCE_THRESHOLD,
|
||||||
|
HEURISTIC_STRONG_STABLE_RSSI,
|
||||||
|
HEURISTIC_STRONG_STABLE_VARIANCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine():
|
||||||
|
"""Create a fresh HeuristicsEngine for testing."""
|
||||||
|
return HeuristicsEngine()
|
||||||
|
|
||||||
|
|
||||||
|
def create_device_aggregate(
|
||||||
|
address="AA:BB:CC:DD:EE:FF",
|
||||||
|
address_type="public",
|
||||||
|
protocol="ble",
|
||||||
|
first_seen=None,
|
||||||
|
last_seen=None,
|
||||||
|
seen_count=1,
|
||||||
|
rssi_current=-60,
|
||||||
|
rssi_median=-60,
|
||||||
|
rssi_variance=5.0,
|
||||||
|
rssi_samples=None,
|
||||||
|
is_new=False,
|
||||||
|
):
|
||||||
|
"""Helper to create BTDeviceAggregate for testing."""
|
||||||
|
now = datetime.now()
|
||||||
|
if first_seen is None:
|
||||||
|
first_seen = now - timedelta(seconds=30)
|
||||||
|
if last_seen is None:
|
||||||
|
last_seen = now
|
||||||
|
if rssi_samples is None:
|
||||||
|
rssi_samples = [(now, rssi_current)]
|
||||||
|
|
||||||
|
return BTDeviceAggregate(
|
||||||
|
device_id=f"{address}:{address_type}",
|
||||||
|
address=address,
|
||||||
|
address_type=address_type,
|
||||||
|
protocol=protocol,
|
||||||
|
first_seen=first_seen,
|
||||||
|
last_seen=last_seen,
|
||||||
|
seen_count=seen_count,
|
||||||
|
seen_rate=seen_count / 60.0,
|
||||||
|
rssi_samples=rssi_samples,
|
||||||
|
rssi_current=rssi_current,
|
||||||
|
rssi_median=rssi_median,
|
||||||
|
rssi_min=rssi_median - 10,
|
||||||
|
rssi_max=rssi_median + 10,
|
||||||
|
rssi_variance=rssi_variance,
|
||||||
|
rssi_confidence=0.8,
|
||||||
|
range_band="nearby",
|
||||||
|
range_confidence=0.7,
|
||||||
|
name="Test Device",
|
||||||
|
manufacturer_id=None,
|
||||||
|
manufacturer_name=None,
|
||||||
|
manufacturer_bytes=None,
|
||||||
|
service_uuids=[],
|
||||||
|
is_new=is_new,
|
||||||
|
is_persistent=False,
|
||||||
|
is_beacon_like=False,
|
||||||
|
is_strong_stable=False,
|
||||||
|
has_random_address=address_type != "public",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistentHeuristic:
|
||||||
|
"""Tests for persistent device detection."""
|
||||||
|
|
||||||
|
def test_persistent_high_seen_count(self, engine):
|
||||||
|
"""Test device with high seen count is marked persistent."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||||
|
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS - 60),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_persistent is True
|
||||||
|
|
||||||
|
def test_not_persistent_low_seen_count(self, engine):
|
||||||
|
"""Test device with low seen count is not persistent."""
|
||||||
|
device = create_device_aggregate(seen_count=2)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_persistent is False
|
||||||
|
|
||||||
|
def test_not_persistent_outside_window(self, engine):
|
||||||
|
"""Test device seen long ago is not persistent."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||||
|
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS + 3600),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
# Should still be considered persistent if high seen count
|
||||||
|
assert result.is_persistent is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestBeaconLikeHeuristic:
|
||||||
|
"""Tests for beacon-like behavior detection."""
|
||||||
|
|
||||||
|
def test_beacon_like_stable_intervals(self, engine):
|
||||||
|
"""Test device with stable advertisement intervals is beacon-like."""
|
||||||
|
now = datetime.now()
|
||||||
|
# Create samples with very stable intervals (every 1 second)
|
||||||
|
rssi_samples = [(now - timedelta(seconds=i), -60) for i in range(20)]
|
||||||
|
|
||||||
|
device = create_device_aggregate(
|
||||||
|
seen_count=20,
|
||||||
|
rssi_samples=rssi_samples,
|
||||||
|
rssi_variance=1.0, # Very low variance
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
# Beacon-like depends on interval analysis
|
||||||
|
# With regular samples, should detect beacon-like behavior
|
||||||
|
assert result.is_beacon_like is True or result.rssi_variance < HEURISTIC_BEACON_VARIANCE_THRESHOLD
|
||||||
|
|
||||||
|
def test_not_beacon_like_irregular_intervals(self, engine):
|
||||||
|
"""Test device with irregular intervals is not beacon-like."""
|
||||||
|
now = datetime.now()
|
||||||
|
# Create samples with irregular intervals
|
||||||
|
rssi_samples = [
|
||||||
|
(now - timedelta(seconds=0), -60),
|
||||||
|
(now - timedelta(seconds=5), -65),
|
||||||
|
(now - timedelta(seconds=7), -58),
|
||||||
|
(now - timedelta(seconds=25), -62),
|
||||||
|
(now - timedelta(seconds=30), -60),
|
||||||
|
]
|
||||||
|
|
||||||
|
device = create_device_aggregate(
|
||||||
|
seen_count=5,
|
||||||
|
rssi_samples=rssi_samples,
|
||||||
|
rssi_variance=15.0, # Higher variance
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
# Irregular intervals should not be beacon-like
|
||||||
|
# (implementation may vary)
|
||||||
|
assert isinstance(result.is_beacon_like, bool)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrongStableHeuristic:
|
||||||
|
"""Tests for strong and stable signal detection."""
|
||||||
|
|
||||||
|
def test_strong_stable_device(self, engine):
|
||||||
|
"""Test device with strong, stable signal."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
rssi_current=HEURISTIC_STRONG_STABLE_RSSI + 5, # Stronger than threshold
|
||||||
|
rssi_median=HEURISTIC_STRONG_STABLE_RSSI + 5,
|
||||||
|
rssi_variance=HEURISTIC_STRONG_STABLE_VARIANCE - 1, # Less variance than threshold
|
||||||
|
seen_count=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_strong_stable is True
|
||||||
|
|
||||||
|
def test_not_strong_weak_signal(self, engine):
|
||||||
|
"""Test device with weak signal is not strong_stable."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
rssi_current=-80,
|
||||||
|
rssi_median=-80,
|
||||||
|
rssi_variance=2.0,
|
||||||
|
seen_count=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_strong_stable is False
|
||||||
|
|
||||||
|
def test_not_stable_high_variance(self, engine):
|
||||||
|
"""Test device with high variance is not strong_stable."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
rssi_current=-45,
|
||||||
|
rssi_median=-45,
|
||||||
|
rssi_variance=HEURISTIC_STRONG_STABLE_VARIANCE + 5,
|
||||||
|
seen_count=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_strong_stable is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRandomAddressHeuristic:
|
||||||
|
"""Tests for random address detection."""
|
||||||
|
|
||||||
|
def test_random_address_detected(self, engine):
|
||||||
|
"""Test random address type is detected."""
|
||||||
|
device = create_device_aggregate(address_type="random")
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.has_random_address is True
|
||||||
|
|
||||||
|
def test_public_address_not_random(self, engine):
|
||||||
|
"""Test public address is not marked random."""
|
||||||
|
device = create_device_aggregate(address_type="public")
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.has_random_address is False
|
||||||
|
|
||||||
|
def test_rpa_address_random(self, engine):
|
||||||
|
"""Test RPA (Resolvable Private Address) is marked random."""
|
||||||
|
device = create_device_aggregate(address_type="rpa")
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.has_random_address is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewDeviceHeuristic:
|
||||||
|
"""Tests for new device detection."""
|
||||||
|
|
||||||
|
def test_new_device_flag_preserved(self, engine):
|
||||||
|
"""Test is_new flag is preserved from input."""
|
||||||
|
device = create_device_aggregate(is_new=True)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_new is True
|
||||||
|
|
||||||
|
def test_not_new_flag_preserved(self, engine):
|
||||||
|
"""Test is_new=False is preserved."""
|
||||||
|
device = create_device_aggregate(is_new=False)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_new is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleHeuristics:
|
||||||
|
"""Tests for combinations of heuristics."""
|
||||||
|
|
||||||
|
def test_multiple_flags_can_be_true(self, engine):
|
||||||
|
"""Test device can have multiple heuristic flags."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
address_type="random",
|
||||||
|
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN + 5,
|
||||||
|
rssi_current=HEURISTIC_STRONG_STABLE_RSSI + 10,
|
||||||
|
rssi_median=HEURISTIC_STRONG_STABLE_RSSI + 10,
|
||||||
|
rssi_variance=1.0,
|
||||||
|
is_new=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
|
||||||
|
# Multiple flags can be true
|
||||||
|
assert result.has_random_address is True
|
||||||
|
assert result.is_new is True
|
||||||
|
# At least some of these should be true
|
||||||
|
assert result.is_persistent is True or result.is_strong_stable is True
|
||||||
|
|
||||||
|
def test_all_flags_false_possible(self, engine):
|
||||||
|
"""Test device can have all heuristic flags false."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
address_type="public",
|
||||||
|
seen_count=1,
|
||||||
|
rssi_current=-85,
|
||||||
|
rssi_median=-85,
|
||||||
|
rssi_variance=20.0,
|
||||||
|
is_new=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
|
||||||
|
assert result.has_random_address is False
|
||||||
|
assert result.is_new is False
|
||||||
|
assert result.is_persistent is False
|
||||||
|
assert result.is_strong_stable is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestHeuristicsBatchEvaluation:
|
||||||
|
"""Tests for batch evaluation of multiple devices."""
|
||||||
|
|
||||||
|
def test_evaluate_multiple_devices(self, engine):
|
||||||
|
"""Test evaluating multiple devices at once."""
|
||||||
|
devices = [
|
||||||
|
create_device_aggregate(
|
||||||
|
address=f"AA:BB:CC:DD:EE:{i:02X}",
|
||||||
|
seen_count=i * 5,
|
||||||
|
)
|
||||||
|
for i in range(1, 6)
|
||||||
|
]
|
||||||
|
|
||||||
|
results = engine.evaluate_batch(devices)
|
||||||
|
|
||||||
|
assert len(results) == 5
|
||||||
|
# Device with highest seen count should be persistent
|
||||||
|
most_seen = max(results, key=lambda d: d.seen_count)
|
||||||
|
# May or may not be persistent depending on exact thresholds
|
||||||
|
assert isinstance(most_seen.is_persistent, bool)
|
||||||
|
|
||||||
|
def test_evaluate_empty_list(self, engine):
|
||||||
|
"""Test evaluating empty device list."""
|
||||||
|
results = engine.evaluate_batch([])
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Tests for edge cases and boundary conditions."""
|
||||||
|
|
||||||
|
def test_null_rssi_values(self, engine):
|
||||||
|
"""Test device with null RSSI values."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
rssi_current=None,
|
||||||
|
rssi_median=None,
|
||||||
|
rssi_variance=None,
|
||||||
|
rssi_samples=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
# Should not crash, strong_stable should be False
|
||||||
|
assert result.is_strong_stable is False
|
||||||
|
|
||||||
|
def test_exactly_at_threshold(self, engine):
|
||||||
|
"""Test device exactly at persistent threshold."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
seen_count=HEURISTIC_PERSISTENT_MIN_SEEN, # Exactly at threshold
|
||||||
|
first_seen=datetime.now() - timedelta(seconds=HEURISTIC_PERSISTENT_WINDOW_SECONDS),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
# At threshold, should be persistent
|
||||||
|
assert isinstance(result.is_persistent, bool)
|
||||||
|
|
||||||
|
def test_zero_seen_count(self, engine):
|
||||||
|
"""Test device with zero seen count (edge case)."""
|
||||||
|
device = create_device_aggregate(seen_count=0)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_persistent is False
|
||||||
|
|
||||||
|
def test_negative_rssi_boundary(self, engine):
|
||||||
|
"""Test RSSI at boundary values."""
|
||||||
|
device = create_device_aggregate(
|
||||||
|
rssi_current=-100, # Very weak
|
||||||
|
rssi_median=-100,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = engine.evaluate(device)
|
||||||
|
assert result.is_strong_stable is False
|
||||||
|
|
||||||
|
# Test strongest possible
|
||||||
|
device2 = create_device_aggregate(
|
||||||
|
rssi_current=-20, # Very strong
|
||||||
|
rssi_median=-20,
|
||||||
|
rssi_variance=1.0,
|
||||||
|
seen_count=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
result2 = engine.evaluate(device2)
|
||||||
|
assert result2.is_strong_stable is True
|
||||||
70
utils/bluetooth/__init__.py
Normal file
70
utils/bluetooth/__init__.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Bluetooth scanning package for INTERCEPT.
|
||||||
|
|
||||||
|
Provides unified Bluetooth scanning with DBus/BlueZ and fallback backends,
|
||||||
|
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
|
||||||
|
RANGE_VERY_CLOSE,
|
||||||
|
RANGE_CLOSE,
|
||||||
|
RANGE_NEARBY,
|
||||||
|
RANGE_FAR,
|
||||||
|
RANGE_UNKNOWN,
|
||||||
|
# Protocols
|
||||||
|
PROTOCOL_BLE,
|
||||||
|
PROTOCOL_CLASSIC,
|
||||||
|
PROTOCOL_AUTO,
|
||||||
|
# Address types
|
||||||
|
ADDRESS_TYPE_PUBLIC,
|
||||||
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
ADDRESS_TYPE_RANDOM_STATIC,
|
||||||
|
ADDRESS_TYPE_RPA,
|
||||||
|
ADDRESS_TYPE_NRPA,
|
||||||
|
)
|
||||||
|
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
|
||||||
|
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||||
|
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Main scanner
|
||||||
|
'BluetoothScanner',
|
||||||
|
'get_bluetooth_scanner',
|
||||||
|
'reset_bluetooth_scanner',
|
||||||
|
|
||||||
|
# Models
|
||||||
|
'BTObservation',
|
||||||
|
'BTDeviceAggregate',
|
||||||
|
'ScanStatus',
|
||||||
|
'SystemCapabilities',
|
||||||
|
|
||||||
|
# Aggregator
|
||||||
|
'DeviceAggregator',
|
||||||
|
|
||||||
|
# Heuristics
|
||||||
|
'HeuristicsEngine',
|
||||||
|
'evaluate_device_heuristics',
|
||||||
|
'evaluate_all_devices',
|
||||||
|
|
||||||
|
# Capability checks
|
||||||
|
'check_capabilities',
|
||||||
|
'quick_adapter_check',
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
'RANGE_VERY_CLOSE',
|
||||||
|
'RANGE_CLOSE',
|
||||||
|
'RANGE_NEARBY',
|
||||||
|
'RANGE_FAR',
|
||||||
|
'RANGE_UNKNOWN',
|
||||||
|
'PROTOCOL_BLE',
|
||||||
|
'PROTOCOL_CLASSIC',
|
||||||
|
'PROTOCOL_AUTO',
|
||||||
|
'ADDRESS_TYPE_PUBLIC',
|
||||||
|
'ADDRESS_TYPE_RANDOM',
|
||||||
|
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||||
|
'ADDRESS_TYPE_RPA',
|
||||||
|
'ADDRESS_TYPE_NRPA',
|
||||||
|
]
|
||||||
347
utils/bluetooth/aggregator.py
Normal file
347
utils/bluetooth/aggregator.py
Normal 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
|
||||||
307
utils/bluetooth/capability_check.py
Normal file
307
utils/bluetooth/capability_check.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
System capability checks for Bluetooth scanning.
|
||||||
|
|
||||||
|
Checks for DBus, BlueZ, adapters, permissions, and fallback tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
BLUEZ_SERVICE,
|
||||||
|
BLUEZ_PATH,
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT,
|
||||||
|
)
|
||||||
|
from .models import SystemCapabilities
|
||||||
|
|
||||||
|
# Import timeout from parent constants if available
|
||||||
|
try:
|
||||||
|
from ..constants import SUBPROCESS_TIMEOUT_SHORT as PARENT_TIMEOUT
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT = PARENT_TIMEOUT
|
||||||
|
except ImportError:
|
||||||
|
SUBPROCESS_TIMEOUT_SHORT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def check_capabilities() -> SystemCapabilities:
|
||||||
|
"""
|
||||||
|
Check all Bluetooth-related system capabilities.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SystemCapabilities object with all checks performed.
|
||||||
|
"""
|
||||||
|
caps = SystemCapabilities()
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
caps.is_root = os.geteuid() == 0
|
||||||
|
|
||||||
|
# Check DBus
|
||||||
|
_check_dbus(caps)
|
||||||
|
|
||||||
|
# Check BlueZ
|
||||||
|
_check_bluez(caps)
|
||||||
|
|
||||||
|
# Check adapters
|
||||||
|
_check_adapters(caps)
|
||||||
|
|
||||||
|
# Check rfkill status
|
||||||
|
_check_rfkill(caps)
|
||||||
|
|
||||||
|
# Check fallback tools
|
||||||
|
_check_fallback_tools(caps)
|
||||||
|
|
||||||
|
# Determine recommended backend
|
||||||
|
_determine_recommended_backend(caps)
|
||||||
|
|
||||||
|
return caps
|
||||||
|
|
||||||
|
|
||||||
|
def _check_dbus(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check if DBus is available."""
|
||||||
|
try:
|
||||||
|
# Try to import dbus module
|
||||||
|
import dbus
|
||||||
|
caps.has_dbus = True
|
||||||
|
except ImportError:
|
||||||
|
caps.has_dbus = False
|
||||||
|
caps.issues.append('Python dbus module not installed (pip install dbus-python)')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bluez(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check if BlueZ service is available via DBus."""
|
||||||
|
if not caps.has_dbus:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
bus = dbus.SystemBus()
|
||||||
|
|
||||||
|
# Check if BlueZ service exists
|
||||||
|
try:
|
||||||
|
obj = bus.get_object(BLUEZ_SERVICE, BLUEZ_PATH)
|
||||||
|
caps.has_bluez = True
|
||||||
|
|
||||||
|
# Try to get BlueZ version from bluetoothd
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['bluetoothd', '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
caps.bluez_version = result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
except dbus.exceptions.DBusException as e:
|
||||||
|
caps.has_bluez = False
|
||||||
|
if 'org.freedesktop.DBus.Error.ServiceUnknown' in str(e):
|
||||||
|
caps.issues.append('BlueZ service not running (systemctl start bluetooth)')
|
||||||
|
else:
|
||||||
|
caps.issues.append(f'BlueZ DBus error: {e}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
caps.has_bluez = False
|
||||||
|
caps.issues.append(f'DBus connection error: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def _check_adapters(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check available Bluetooth adapters."""
|
||||||
|
if not caps.has_dbus or not caps.has_bluez:
|
||||||
|
# Fall back to hciconfig if available
|
||||||
|
_check_adapters_hciconfig(caps)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
bus = dbus.SystemBus()
|
||||||
|
manager = dbus.Interface(
|
||||||
|
bus.get_object(BLUEZ_SERVICE, '/'),
|
||||||
|
'org.freedesktop.DBus.ObjectManager'
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = manager.GetManagedObjects()
|
||||||
|
for path, interfaces in objects.items():
|
||||||
|
if 'org.bluez.Adapter1' in interfaces:
|
||||||
|
adapter_props = interfaces['org.bluez.Adapter1']
|
||||||
|
adapter_info = {
|
||||||
|
'path': str(path),
|
||||||
|
'name': str(adapter_props.get('Name', 'Unknown')),
|
||||||
|
'address': str(adapter_props.get('Address', 'Unknown')),
|
||||||
|
'powered': bool(adapter_props.get('Powered', False)),
|
||||||
|
'discovering': bool(adapter_props.get('Discovering', False)),
|
||||||
|
'alias': str(adapter_props.get('Alias', '')),
|
||||||
|
}
|
||||||
|
caps.adapters.append(adapter_info)
|
||||||
|
|
||||||
|
# Set default adapter if not set
|
||||||
|
if caps.default_adapter is None:
|
||||||
|
caps.default_adapter = str(path)
|
||||||
|
|
||||||
|
if not caps.adapters:
|
||||||
|
caps.issues.append('No Bluetooth adapters found')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
caps.issues.append(f'Failed to enumerate adapters: {e}')
|
||||||
|
# Fall back to hciconfig
|
||||||
|
_check_adapters_hciconfig(caps)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_adapters_hciconfig(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check adapters using hciconfig (fallback)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['hciconfig', '-a'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse hciconfig output
|
||||||
|
current_adapter = None
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
# Match adapter line (e.g., "hci0: Type: Primary Bus: USB")
|
||||||
|
adapter_match = re.match(r'^(hci\d+):', line)
|
||||||
|
if adapter_match:
|
||||||
|
current_adapter = {
|
||||||
|
'path': f'/org/bluez/{adapter_match.group(1)}',
|
||||||
|
'name': adapter_match.group(1),
|
||||||
|
'address': 'Unknown',
|
||||||
|
'powered': False,
|
||||||
|
'discovering': False,
|
||||||
|
}
|
||||||
|
caps.adapters.append(current_adapter)
|
||||||
|
|
||||||
|
if caps.default_adapter is None:
|
||||||
|
caps.default_adapter = current_adapter['path']
|
||||||
|
|
||||||
|
elif current_adapter:
|
||||||
|
# Parse BD Address
|
||||||
|
addr_match = re.search(r'BD Address: ([0-9A-F:]+)', line, re.I)
|
||||||
|
if addr_match:
|
||||||
|
current_adapter['address'] = addr_match.group(1)
|
||||||
|
|
||||||
|
# Check if UP
|
||||||
|
if 'UP RUNNING' in line:
|
||||||
|
current_adapter['powered'] = True
|
||||||
|
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rfkill(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check rfkill status for Bluetooth."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['rfkill', 'list', 'bluetooth'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
output = result.stdout.lower()
|
||||||
|
caps.is_soft_blocked = 'soft blocked: yes' in output
|
||||||
|
caps.is_hard_blocked = 'hard blocked: yes' in output
|
||||||
|
|
||||||
|
if caps.is_soft_blocked:
|
||||||
|
caps.issues.append('Bluetooth is soft-blocked (rfkill unblock bluetooth)')
|
||||||
|
if caps.is_hard_blocked:
|
||||||
|
caps.issues.append('Bluetooth is hard-blocked (check hardware switch)')
|
||||||
|
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _check_fallback_tools(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check for fallback scanning tools."""
|
||||||
|
# Check bleak (Python BLE library)
|
||||||
|
try:
|
||||||
|
import bleak
|
||||||
|
caps.has_bleak = True
|
||||||
|
except ImportError:
|
||||||
|
caps.has_bleak = False
|
||||||
|
|
||||||
|
# Check hcitool
|
||||||
|
caps.has_hcitool = shutil.which('hcitool') is not None
|
||||||
|
|
||||||
|
# Check bluetoothctl
|
||||||
|
caps.has_bluetoothctl = shutil.which('bluetoothctl') is not None
|
||||||
|
|
||||||
|
# Check btmgmt
|
||||||
|
caps.has_btmgmt = shutil.which('btmgmt') is not None
|
||||||
|
|
||||||
|
# Check CAP_NET_ADMIN for non-root users
|
||||||
|
if not caps.is_root:
|
||||||
|
_check_capabilities_permission(caps)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_capabilities_permission(caps: SystemCapabilities) -> None:
|
||||||
|
"""Check if process has CAP_NET_ADMIN capability."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['capsh', '--print'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=SUBPROCESS_TIMEOUT_SHORT
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
caps.has_bluetooth_permission = 'cap_net_admin' in result.stdout.lower()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
# Assume no capabilities if capsh not available
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not caps.has_bluetooth_permission and not caps.is_root:
|
||||||
|
# Check if user is in bluetooth group
|
||||||
|
try:
|
||||||
|
import grp
|
||||||
|
import pwd
|
||||||
|
username = pwd.getpwuid(os.getuid()).pw_name
|
||||||
|
bluetooth_group = grp.getgrnam('bluetooth')
|
||||||
|
if username in bluetooth_group.gr_mem:
|
||||||
|
caps.has_bluetooth_permission = True
|
||||||
|
except (KeyError, ImportError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_recommended_backend(caps: SystemCapabilities) -> None:
|
||||||
|
"""Determine the recommended scanning backend."""
|
||||||
|
# Prefer DBus/BlueZ if available and working
|
||||||
|
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
|
||||||
|
|
||||||
|
# Fallback to bleak (cross-platform)
|
||||||
|
if caps.has_bleak:
|
||||||
|
caps.recommended_backend = 'bleak'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback to hcitool (requires root)
|
||||||
|
if caps.has_hcitool and caps.is_root:
|
||||||
|
caps.recommended_backend = 'hcitool'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback to bluetoothctl
|
||||||
|
if caps.has_bluetoothctl:
|
||||||
|
caps.recommended_backend = 'bluetoothctl'
|
||||||
|
return
|
||||||
|
|
||||||
|
caps.recommended_backend = 'none'
|
||||||
|
if not caps.issues:
|
||||||
|
caps.issues.append('No suitable Bluetooth scanning backend available')
|
||||||
|
|
||||||
|
|
||||||
|
def quick_adapter_check() -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Quick check to find a working adapter.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Adapter path/name if found, None otherwise.
|
||||||
|
"""
|
||||||
|
caps = check_capabilities()
|
||||||
|
return caps.default_adapter
|
||||||
220
utils/bluetooth/constants.py
Normal file
220
utils/bluetooth/constants.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
Bluetooth-specific constants for the unified scanner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SCANNER SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default scan duration in seconds
|
||||||
|
DEFAULT_SCAN_DURATION = 10
|
||||||
|
|
||||||
|
# Maximum concurrent observations per device before pruning
|
||||||
|
MAX_RSSI_SAMPLES = 300
|
||||||
|
|
||||||
|
# Device expiration time (seconds since last seen)
|
||||||
|
DEVICE_STALE_TIMEOUT = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Observation history retention (seconds)
|
||||||
|
OBSERVATION_HISTORY_RETENTION = 3600 # 1 hour
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RSSI THRESHOLDS FOR RANGE BANDS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# RSSI ranges for distance estimation (dBm)
|
||||||
|
RSSI_VERY_CLOSE = -40 # >= -40 dBm
|
||||||
|
RSSI_CLOSE = -55 # -40 to -55 dBm
|
||||||
|
RSSI_NEARBY = -70 # -55 to -70 dBm
|
||||||
|
RSSI_FAR = -85 # -70 to -85 dBm
|
||||||
|
|
||||||
|
# Minimum confidence levels for each range band
|
||||||
|
CONFIDENCE_VERY_CLOSE = 0.7
|
||||||
|
CONFIDENCE_CLOSE = 0.6
|
||||||
|
CONFIDENCE_NEARBY = 0.5
|
||||||
|
CONFIDENCE_FAR = 0.4
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HEURISTIC THRESHOLDS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Persistent detection: minimum seen count in analysis window
|
||||||
|
PERSISTENT_MIN_SEEN_COUNT = 10
|
||||||
|
PERSISTENT_WINDOW_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Beacon-like detection: maximum advertisement interval variance (ratio)
|
||||||
|
BEACON_INTERVAL_MAX_VARIANCE = 0.10 # 10%
|
||||||
|
|
||||||
|
# Strong + Stable detection thresholds
|
||||||
|
STRONG_RSSI_THRESHOLD = -50 # dBm
|
||||||
|
STABLE_VARIANCE_THRESHOLD = 5 # dBm variance
|
||||||
|
|
||||||
|
# New device window (seconds since baseline set)
|
||||||
|
NEW_DEVICE_WINDOW = 60
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DBUS SETTINGS (BlueZ)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# BlueZ DBus service names
|
||||||
|
BLUEZ_SERVICE = 'org.bluez'
|
||||||
|
BLUEZ_ADAPTER_INTERFACE = 'org.bluez.Adapter1'
|
||||||
|
BLUEZ_DEVICE_INTERFACE = 'org.bluez.Device1'
|
||||||
|
DBUS_PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
|
||||||
|
DBUS_OBJECT_MANAGER_INTERFACE = 'org.freedesktop.DBus.ObjectManager'
|
||||||
|
|
||||||
|
# DBus paths
|
||||||
|
BLUEZ_PATH = '/org/bluez'
|
||||||
|
|
||||||
|
# Discovery filter settings
|
||||||
|
DISCOVERY_FILTER_TRANSPORT = 'auto' # 'bredr', 'le', or 'auto'
|
||||||
|
DISCOVERY_FILTER_RSSI = -100 # Minimum RSSI for discovery
|
||||||
|
DISCOVERY_FILTER_DUPLICATE_DATA = True
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FALLBACK SCANNER SETTINGS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# bleak scan timeout
|
||||||
|
BLEAK_SCAN_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# hcitool command timeout
|
||||||
|
HCITOOL_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
# bluetoothctl command timeout
|
||||||
|
BLUETOOTHCTL_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# btmgmt command timeout
|
||||||
|
BTMGMT_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ADDRESS TYPE CLASSIFICATIONS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
ADDRESS_TYPE_PUBLIC = 'public'
|
||||||
|
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
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PROTOCOL TYPES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
PROTOCOL_BLE = 'ble'
|
||||||
|
PROTOCOL_CLASSIC = 'classic'
|
||||||
|
PROTOCOL_AUTO = 'auto'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RANGE BAND NAMES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
RANGE_VERY_CLOSE = 'very_close'
|
||||||
|
RANGE_CLOSE = 'close'
|
||||||
|
RANGE_NEARBY = 'nearby'
|
||||||
|
RANGE_FAR = 'far'
|
||||||
|
RANGE_UNKNOWN = 'unknown'
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# COMMON MANUFACTURER IDS (OUI -> Name mapping for common vendors)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
MANUFACTURER_NAMES = {
|
||||||
|
0x004C: 'Apple, Inc.',
|
||||||
|
0x0006: 'Microsoft',
|
||||||
|
0x000F: 'Broadcom',
|
||||||
|
0x0075: 'Samsung Electronics',
|
||||||
|
0x00E0: 'Google',
|
||||||
|
0x0157: 'Xiaomi',
|
||||||
|
0x0310: 'Bose Corporation',
|
||||||
|
0x0059: 'Nordic Semiconductor',
|
||||||
|
0x0046: 'Sony Corporation',
|
||||||
|
0x0002: 'Intel Corporation',
|
||||||
|
0x0087: 'Garmin International',
|
||||||
|
0x00D2: 'Fitbit',
|
||||||
|
0x0154: 'Huawei Technologies',
|
||||||
|
0x038F: 'Tile, Inc.',
|
||||||
|
0x0301: 'Jabra',
|
||||||
|
0x01DA: 'Anker Innovations',
|
||||||
|
}
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BLUETOOTH CLASS OF DEVICE DECODING
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Major device classes (bits 12-8 of CoD)
|
||||||
|
MAJOR_DEVICE_CLASSES = {
|
||||||
|
0x00: 'Miscellaneous',
|
||||||
|
0x01: 'Computer',
|
||||||
|
0x02: 'Phone',
|
||||||
|
0x03: 'LAN/Network Access Point',
|
||||||
|
0x04: 'Audio/Video',
|
||||||
|
0x05: 'Peripheral',
|
||||||
|
0x06: 'Imaging',
|
||||||
|
0x07: 'Wearable',
|
||||||
|
0x08: 'Toy',
|
||||||
|
0x09: 'Health',
|
||||||
|
0x1F: 'Uncategorized',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minor device classes for Audio/Video (0x04)
|
||||||
|
MINOR_AUDIO_VIDEO = {
|
||||||
|
0x00: 'Uncategorized',
|
||||||
|
0x01: 'Wearable Headset',
|
||||||
|
0x02: 'Hands-free Device',
|
||||||
|
0x04: 'Microphone',
|
||||||
|
0x05: 'Loudspeaker',
|
||||||
|
0x06: 'Headphones',
|
||||||
|
0x07: 'Portable Audio',
|
||||||
|
0x08: 'Car Audio',
|
||||||
|
0x09: 'Set-top Box',
|
||||||
|
0x0A: 'HiFi Audio Device',
|
||||||
|
0x0B: 'VCR',
|
||||||
|
0x0C: 'Video Camera',
|
||||||
|
0x0D: 'Camcorder',
|
||||||
|
0x0E: 'Video Monitor',
|
||||||
|
0x0F: 'Video Display and Loudspeaker',
|
||||||
|
0x10: 'Video Conferencing',
|
||||||
|
0x12: 'Gaming/Toy',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minor device classes for Phone (0x02)
|
||||||
|
MINOR_PHONE = {
|
||||||
|
0x00: 'Uncategorized',
|
||||||
|
0x01: 'Cellular',
|
||||||
|
0x02: 'Cordless',
|
||||||
|
0x03: 'Smartphone',
|
||||||
|
0x04: 'Wired Modem',
|
||||||
|
0x05: 'ISDN Access Point',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minor device classes for Computer (0x01)
|
||||||
|
MINOR_COMPUTER = {
|
||||||
|
0x00: 'Uncategorized',
|
||||||
|
0x01: 'Desktop Workstation',
|
||||||
|
0x02: 'Server-class Computer',
|
||||||
|
0x03: 'Laptop',
|
||||||
|
0x04: 'Handheld PC/PDA',
|
||||||
|
0x05: 'Palm-size PC/PDA',
|
||||||
|
0x06: 'Wearable Computer',
|
||||||
|
0x07: 'Tablet',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minor device classes for Peripheral (0x05)
|
||||||
|
MINOR_PERIPHERAL = {
|
||||||
|
0x00: 'Not Keyboard/Pointing Device',
|
||||||
|
0x01: 'Keyboard',
|
||||||
|
0x02: 'Pointing Device',
|
||||||
|
0x03: 'Combo Keyboard/Pointing Device',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Minor device classes for Wearable (0x07)
|
||||||
|
MINOR_WEARABLE = {
|
||||||
|
0x01: 'Wristwatch',
|
||||||
|
0x02: 'Pager',
|
||||||
|
0x03: 'Jacket',
|
||||||
|
0x04: 'Helmet',
|
||||||
|
0x05: 'Glasses',
|
||||||
|
}
|
||||||
396
utils/bluetooth/dbus_scanner.py
Normal file
396
utils/bluetooth/dbus_scanner.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
DBus-based BlueZ scanner for Bluetooth device discovery.
|
||||||
|
|
||||||
|
Uses org.bluez signals for real-time device discovery.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
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,
|
||||||
|
MAJOR_DEVICE_CLASSES,
|
||||||
|
MINOR_AUDIO_VIDEO,
|
||||||
|
MINOR_PHONE,
|
||||||
|
MINOR_COMPUTER,
|
||||||
|
MINOR_PERIPHERAL,
|
||||||
|
MINOR_WEARABLE,
|
||||||
|
)
|
||||||
|
from .models import BTObservation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DBusScanner:
|
||||||
|
"""
|
||||||
|
BlueZ DBus-based Bluetooth scanner.
|
||||||
|
|
||||||
|
Subscribes to BlueZ signals for real-time device discovery without polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter_path: Optional[str] = None,
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize DBus scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adapter_path: DBus path to adapter (e.g., '/org/bluez/hci0').
|
||||||
|
on_observation: Callback for new observations.
|
||||||
|
"""
|
||||||
|
self._adapter_path = adapter_path
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._bus = None
|
||||||
|
self._adapter = None
|
||||||
|
self._mainloop = None
|
||||||
|
self._mainloop_thread: Optional[threading.Thread] = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._known_devices: set[str] = set()
|
||||||
|
|
||||||
|
def start(self, transport: str = 'auto', rssi_threshold: int = -100) -> bool:
|
||||||
|
"""
|
||||||
|
Start DBus discovery.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transport: Discovery transport ('bredr', 'le', or 'auto').
|
||||||
|
rssi_threshold: Minimum RSSI for discovered devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if started successfully, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
from dbus.mainloop.glib import DBusGMainLoop
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if self._is_scanning:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Set up DBus mainloop
|
||||||
|
DBusGMainLoop(set_as_default=True)
|
||||||
|
self._bus = dbus.SystemBus()
|
||||||
|
|
||||||
|
# Get adapter
|
||||||
|
if not self._adapter_path:
|
||||||
|
self._adapter_path = self._find_default_adapter()
|
||||||
|
|
||||||
|
if not self._adapter_path:
|
||||||
|
logger.error("No Bluetooth adapter found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Set up signal handlers
|
||||||
|
self._bus.add_signal_receiver(
|
||||||
|
self._on_interfaces_added,
|
||||||
|
signal_name='InterfacesAdded',
|
||||||
|
dbus_interface=DBUS_OBJECT_MANAGER_INTERFACE,
|
||||||
|
bus_name=BLUEZ_SERVICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._bus.add_signal_receiver(
|
||||||
|
self._on_properties_changed,
|
||||||
|
signal_name='PropertiesChanged',
|
||||||
|
dbus_interface=DBUS_PROPERTIES_INTERFACE,
|
||||||
|
path_keyword='path',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set discovery filter
|
||||||
|
try:
|
||||||
|
filter_dict = {
|
||||||
|
'Transport': dbus.String(transport if transport != 'auto' else 'auto'),
|
||||||
|
'DuplicateData': dbus.Boolean(DISCOVERY_FILTER_DUPLICATE_DATA),
|
||||||
|
}
|
||||||
|
if rssi_threshold > -100:
|
||||||
|
filter_dict['RSSI'] = dbus.Int16(rssi_threshold)
|
||||||
|
|
||||||
|
self._adapter.SetDiscoveryFilter(filter_dict)
|
||||||
|
except dbus.exceptions.DBusException as e:
|
||||||
|
logger.warning(f"Failed to set discovery filter: {e}")
|
||||||
|
|
||||||
|
# Start discovery
|
||||||
|
try:
|
||||||
|
self._adapter.StartDiscovery()
|
||||||
|
except dbus.exceptions.DBusException as e:
|
||||||
|
if 'InProgress' not in str(e):
|
||||||
|
logger.error(f"Failed to start discovery: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Process existing devices
|
||||||
|
self._process_existing_devices()
|
||||||
|
|
||||||
|
# Start mainloop in background thread
|
||||||
|
self._mainloop = GLib.MainLoop()
|
||||||
|
self._mainloop_thread = threading.Thread(
|
||||||
|
target=self._run_mainloop,
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._mainloop_thread.start()
|
||||||
|
|
||||||
|
self._is_scanning = True
|
||||||
|
logger.info(f"DBus scanner started on {self._adapter_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
logger.error(f"Missing DBus dependencies: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start DBus scanner: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop DBus discovery."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._is_scanning:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._adapter:
|
||||||
|
try:
|
||||||
|
self._adapter.StopDiscovery()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"StopDiscovery error (expected): {e}")
|
||||||
|
|
||||||
|
if self._mainloop and self._mainloop.is_running():
|
||||||
|
self._mainloop.quit()
|
||||||
|
|
||||||
|
if self._mainloop_thread:
|
||||||
|
self._mainloop_thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping DBus scanner: {e}")
|
||||||
|
finally:
|
||||||
|
self._is_scanning = False
|
||||||
|
self._adapter = None
|
||||||
|
self._bus = None
|
||||||
|
self._mainloop = None
|
||||||
|
self._mainloop_thread = None
|
||||||
|
logger.info("DBus scanner stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
"""Check if scanner is active."""
|
||||||
|
with self._lock:
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
def _run_mainloop(self) -> None:
|
||||||
|
"""Run the GLib mainloop."""
|
||||||
|
try:
|
||||||
|
self._mainloop.run()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Mainloop error: {e}")
|
||||||
|
|
||||||
|
def _find_default_adapter(self) -> Optional[str]:
|
||||||
|
"""Find the default Bluetooth adapter via DBus."""
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
manager = dbus.Interface(
|
||||||
|
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||||
|
DBUS_OBJECT_MANAGER_INTERFACE
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = manager.GetManagedObjects()
|
||||||
|
for path, interfaces in objects.items():
|
||||||
|
if BLUEZ_ADAPTER_INTERFACE in interfaces:
|
||||||
|
return str(path)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to find adapter: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_existing_devices(self) -> None:
|
||||||
|
"""Process devices that already exist in BlueZ."""
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
manager = dbus.Interface(
|
||||||
|
self._bus.get_object(BLUEZ_SERVICE, '/'),
|
||||||
|
DBUS_OBJECT_MANAGER_INTERFACE
|
||||||
|
)
|
||||||
|
|
||||||
|
objects = manager.GetManagedObjects()
|
||||||
|
for path, interfaces in objects.items():
|
||||||
|
if BLUEZ_DEVICE_INTERFACE in interfaces:
|
||||||
|
props = interfaces[BLUEZ_DEVICE_INTERFACE]
|
||||||
|
self._process_device_properties(str(path), props)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to process existing devices: {e}")
|
||||||
|
|
||||||
|
def _on_interfaces_added(self, path: str, interfaces: dict) -> None:
|
||||||
|
"""Handle InterfacesAdded signal (new device discovered)."""
|
||||||
|
if BLUEZ_DEVICE_INTERFACE in interfaces:
|
||||||
|
props = interfaces[BLUEZ_DEVICE_INTERFACE]
|
||||||
|
self._process_device_properties(str(path), props)
|
||||||
|
|
||||||
|
def _on_properties_changed(
|
||||||
|
self,
|
||||||
|
interface: str,
|
||||||
|
changed: dict,
|
||||||
|
invalidated: list,
|
||||||
|
path: str = None
|
||||||
|
) -> None:
|
||||||
|
"""Handle PropertiesChanged signal (device properties updated)."""
|
||||||
|
if interface != BLUEZ_DEVICE_INTERFACE:
|
||||||
|
return
|
||||||
|
|
||||||
|
if path and '/dev_' in path:
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
device_obj = self._bus.get_object(BLUEZ_SERVICE, path)
|
||||||
|
props_iface = dbus.Interface(device_obj, DBUS_PROPERTIES_INTERFACE)
|
||||||
|
all_props = props_iface.GetAll(BLUEZ_DEVICE_INTERFACE)
|
||||||
|
self._process_device_properties(path, all_props)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to get device properties for {path}: {e}")
|
||||||
|
|
||||||
|
def _process_device_properties(self, path: str, props: dict) -> None:
|
||||||
|
"""Convert BlueZ device properties to BTObservation."""
|
||||||
|
try:
|
||||||
|
import dbus
|
||||||
|
|
||||||
|
address = str(props.get('Address', ''))
|
||||||
|
if not address:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine address type
|
||||||
|
address_type = ADDRESS_TYPE_PUBLIC
|
||||||
|
addr_type_raw = props.get('AddressType', 'public')
|
||||||
|
if addr_type_raw:
|
||||||
|
addr_type_str = str(addr_type_raw).lower()
|
||||||
|
if 'random' in addr_type_str:
|
||||||
|
address_type = ADDRESS_TYPE_RANDOM
|
||||||
|
|
||||||
|
# Extract name
|
||||||
|
name = None
|
||||||
|
if 'Name' in props:
|
||||||
|
name = str(props['Name'])
|
||||||
|
elif 'Alias' in props and props['Alias'] != address:
|
||||||
|
name = str(props['Alias'])
|
||||||
|
|
||||||
|
# Extract RSSI
|
||||||
|
rssi = None
|
||||||
|
if 'RSSI' in props:
|
||||||
|
rssi = int(props['RSSI'])
|
||||||
|
|
||||||
|
# Extract TX Power
|
||||||
|
tx_power = None
|
||||||
|
if 'TxPower' in props:
|
||||||
|
tx_power = int(props['TxPower'])
|
||||||
|
|
||||||
|
# Extract manufacturer data
|
||||||
|
manufacturer_id = None
|
||||||
|
manufacturer_data = None
|
||||||
|
if 'ManufacturerData' in props:
|
||||||
|
mfr_data = props['ManufacturerData']
|
||||||
|
if mfr_data:
|
||||||
|
for mid, mdata in mfr_data.items():
|
||||||
|
manufacturer_id = int(mid)
|
||||||
|
if isinstance(mdata, dbus.Array):
|
||||||
|
manufacturer_data = bytes(mdata)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract service UUIDs
|
||||||
|
service_uuids = []
|
||||||
|
if 'UUIDs' in props:
|
||||||
|
for uuid in props['UUIDs']:
|
||||||
|
service_uuids.append(str(uuid))
|
||||||
|
|
||||||
|
# Extract service data
|
||||||
|
service_data = {}
|
||||||
|
if 'ServiceData' in props:
|
||||||
|
for uuid, data in props['ServiceData'].items():
|
||||||
|
if isinstance(data, dbus.Array):
|
||||||
|
service_data[str(uuid)] = bytes(data)
|
||||||
|
|
||||||
|
# Extract Class of Device (Classic BT)
|
||||||
|
class_of_device = None
|
||||||
|
major_class = None
|
||||||
|
minor_class = None
|
||||||
|
if 'Class' in props:
|
||||||
|
class_of_device = int(props['Class'])
|
||||||
|
major_class, minor_class = self._decode_class_of_device(class_of_device)
|
||||||
|
|
||||||
|
# Connection state
|
||||||
|
is_connected = bool(props.get('Connected', False))
|
||||||
|
is_paired = bool(props.get('Paired', False))
|
||||||
|
|
||||||
|
# Appearance
|
||||||
|
appearance = None
|
||||||
|
if 'Appearance' in props:
|
||||||
|
appearance = int(props['Appearance'])
|
||||||
|
|
||||||
|
# Create observation
|
||||||
|
observation = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address.upper(),
|
||||||
|
address_type=address_type,
|
||||||
|
rssi=rssi,
|
||||||
|
tx_power=tx_power,
|
||||||
|
name=name,
|
||||||
|
manufacturer_id=manufacturer_id,
|
||||||
|
manufacturer_data=manufacturer_data,
|
||||||
|
service_uuids=service_uuids,
|
||||||
|
service_data=service_data,
|
||||||
|
appearance=appearance,
|
||||||
|
is_connectable=True, # If we see it in BlueZ, it's connectable
|
||||||
|
is_paired=is_paired,
|
||||||
|
is_connected=is_connected,
|
||||||
|
class_of_device=class_of_device,
|
||||||
|
major_class=major_class,
|
||||||
|
minor_class=minor_class,
|
||||||
|
adapter_id=self._adapter_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Callback
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
|
||||||
|
self._known_devices.add(address)
|
||||||
|
|
||||||
|
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]]:
|
||||||
|
"""Decode Bluetooth Class of Device."""
|
||||||
|
# Major class is bits 12-8 (5 bits)
|
||||||
|
major_num = (cod >> 8) & 0x1F
|
||||||
|
|
||||||
|
# Minor class is bits 7-2 (6 bits)
|
||||||
|
minor_num = (cod >> 2) & 0x3F
|
||||||
|
|
||||||
|
major_class = MAJOR_DEVICE_CLASSES.get(major_num)
|
||||||
|
|
||||||
|
# Get minor class based on major class
|
||||||
|
minor_class = None
|
||||||
|
if major_num == 0x04: # Audio/Video
|
||||||
|
minor_class = MINOR_AUDIO_VIDEO.get(minor_num)
|
||||||
|
elif major_num == 0x02: # Phone
|
||||||
|
minor_class = MINOR_PHONE.get(minor_num)
|
||||||
|
elif major_num == 0x01: # Computer
|
||||||
|
minor_class = MINOR_COMPUTER.get(minor_num)
|
||||||
|
elif major_num == 0x05: # Peripheral
|
||||||
|
minor_class = MINOR_PERIPHERAL.get(minor_num & 0x03)
|
||||||
|
elif major_num == 0x07: # Wearable
|
||||||
|
minor_class = MINOR_WEARABLE.get(minor_num)
|
||||||
|
|
||||||
|
return major_class, minor_class
|
||||||
529
utils/bluetooth/fallback_scanner.py
Normal file
529
utils/bluetooth/fallback_scanner.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"""
|
||||||
|
Fallback Bluetooth scanners when DBus/BlueZ is unavailable.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- bleak (cross-platform, async)
|
||||||
|
- hcitool lescan (Linux, requires root)
|
||||||
|
- bluetoothctl (Linux)
|
||||||
|
- btmgmt (Linux)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
BLEAK_SCAN_TIMEOUT,
|
||||||
|
HCITOOL_TIMEOUT,
|
||||||
|
BLUETOOTHCTL_TIMEOUT,
|
||||||
|
ADDRESS_TYPE_PUBLIC,
|
||||||
|
ADDRESS_TYPE_RANDOM,
|
||||||
|
MANUFACTURER_NAMES,
|
||||||
|
)
|
||||||
|
from .models import BTObservation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BleakScanner:
|
||||||
|
"""
|
||||||
|
Cross-platform BLE scanner using bleak library.
|
||||||
|
|
||||||
|
Works on Linux, macOS, and Windows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._scanner = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._scan_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
def start(self, duration: float = BLEAK_SCAN_TIMEOUT) -> bool:
|
||||||
|
"""Start bleak scanning in background thread."""
|
||||||
|
try:
|
||||||
|
import bleak
|
||||||
|
|
||||||
|
if self._is_scanning:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._scan_thread = threading.Thread(
|
||||||
|
target=self._scan_loop,
|
||||||
|
args=(duration,),
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._scan_thread.start()
|
||||||
|
self._is_scanning = True
|
||||||
|
logger.info("Bleak scanner started")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("bleak library not installed")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start bleak scanner: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop bleak scanning."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._scan_thread:
|
||||||
|
self._scan_thread.join(timeout=2.0)
|
||||||
|
self._is_scanning = False
|
||||||
|
logger.info("Bleak scanner stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
def _scan_loop(self, duration: float) -> None:
|
||||||
|
"""Run scanning in async event loop."""
|
||||||
|
try:
|
||||||
|
asyncio.run(self._async_scan(duration))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Bleak scan error: {e}")
|
||||||
|
finally:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
async def _async_scan(self, duration: float) -> None:
|
||||||
|
"""Async scanning coroutine."""
|
||||||
|
try:
|
||||||
|
from bleak import BleakScanner as BleakLib
|
||||||
|
from bleak.backends.device import BLEDevice
|
||||||
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
|
||||||
|
def detection_callback(device: BLEDevice, adv_data: AdvertisementData):
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
observation = self._convert_bleak_device(device, adv_data)
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error converting bleak device: {e}")
|
||||||
|
|
||||||
|
scanner = BleakLib(detection_callback=detection_callback)
|
||||||
|
await scanner.start()
|
||||||
|
|
||||||
|
# Wait for duration or stop event
|
||||||
|
start_time = asyncio.get_event_loop().time()
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
if duration > 0 and (asyncio.get_event_loop().time() - start_time) >= duration:
|
||||||
|
break
|
||||||
|
|
||||||
|
await scanner.stop()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Async scan error: {e}")
|
||||||
|
|
||||||
|
def _convert_bleak_device(self, device, adv_data) -> BTObservation:
|
||||||
|
"""Convert bleak device to BTObservation."""
|
||||||
|
# Determine address type from address format
|
||||||
|
address_type = ADDRESS_TYPE_PUBLIC
|
||||||
|
if 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
|
||||||
|
address_type = ADDRESS_TYPE_RANDOM
|
||||||
|
|
||||||
|
# Extract manufacturer data
|
||||||
|
manufacturer_id = None
|
||||||
|
manufacturer_data = None
|
||||||
|
if adv_data.manufacturer_data:
|
||||||
|
for mid, mdata in adv_data.manufacturer_data.items():
|
||||||
|
manufacturer_id = mid
|
||||||
|
manufacturer_data = bytes(mdata)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract service data
|
||||||
|
service_data = {}
|
||||||
|
if adv_data.service_data:
|
||||||
|
for uuid, data in adv_data.service_data.items():
|
||||||
|
service_data[str(uuid)] = bytes(data)
|
||||||
|
|
||||||
|
return BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=device.address.upper() if device.address else '',
|
||||||
|
address_type=address_type,
|
||||||
|
rssi=adv_data.rssi,
|
||||||
|
tx_power=adv_data.tx_power,
|
||||||
|
name=adv_data.local_name or device.name,
|
||||||
|
manufacturer_id=manufacturer_id,
|
||||||
|
manufacturer_data=manufacturer_data,
|
||||||
|
service_uuids=list(adv_data.service_uuids) if adv_data.service_uuids else [],
|
||||||
|
service_data=service_data,
|
||||||
|
is_connectable=device.metadata.get('connectable', True) if hasattr(device, 'metadata') else True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HcitoolScanner:
|
||||||
|
"""
|
||||||
|
Linux hcitool-based scanner for BLE devices.
|
||||||
|
|
||||||
|
Requires root privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: str = 'hci0',
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
self._adapter = adapter
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start hcitool lescan."""
|
||||||
|
try:
|
||||||
|
if self._is_scanning:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Start hcitool lescan with duplicate reporting
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
['hcitool', '-i', self._adapter, 'lescan', '--duplicates'],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_output,
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
self._is_scanning = True
|
||||||
|
logger.info(f"hcitool scanner started on {self._adapter}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("hcitool not found")
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
logger.error("hcitool requires root privileges")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start hcitool scanner: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop hcitool scanning."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
self._process.kill()
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
if self._reader_thread:
|
||||||
|
self._reader_thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
self._is_scanning = False
|
||||||
|
logger.info("hcitool scanner stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
def _read_output(self) -> None:
|
||||||
|
"""Read hcitool output and parse devices."""
|
||||||
|
try:
|
||||||
|
# Also start hcidump in parallel for RSSI values
|
||||||
|
dump_process = None
|
||||||
|
try:
|
||||||
|
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()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse hcitool output: "AA:BB:CC:DD:EE:FF DeviceName"
|
||||||
|
match = re.match(r'^([0-9A-Fa-f:]{17})\s*(.*)$', line.strip())
|
||||||
|
if match:
|
||||||
|
address = match.group(1).upper()
|
||||||
|
name = match.group(2).strip() or None
|
||||||
|
|
||||||
|
observation = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address,
|
||||||
|
address_type=ADDRESS_TYPE_PUBLIC,
|
||||||
|
name=name if name and name != '(unknown)' else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
|
||||||
|
if dump_process:
|
||||||
|
dump_process.terminate()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"hcitool read error: {e}")
|
||||||
|
finally:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothctlScanner:
|
||||||
|
"""
|
||||||
|
Linux bluetoothctl-based scanner.
|
||||||
|
|
||||||
|
Works without root but may have limited data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._is_scanning = False
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._devices: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start bluetoothctl scanning."""
|
||||||
|
try:
|
||||||
|
if self._is_scanning:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
['bluetoothctl'],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_output,
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Send scan on command
|
||||||
|
self._process.stdin.write('scan on\n')
|
||||||
|
self._process.stdin.flush()
|
||||||
|
|
||||||
|
self._is_scanning = True
|
||||||
|
logger.info("bluetoothctl scanner started")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("bluetoothctl not found")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start bluetoothctl scanner: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop bluetoothctl scanning."""
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.stdin.write('scan off\n')
|
||||||
|
self._process.stdin.write('quit\n')
|
||||||
|
self._process.stdin.flush()
|
||||||
|
self._process.wait(timeout=2.0)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
if self._reader_thread:
|
||||||
|
self._reader_thread.join(timeout=2.0)
|
||||||
|
|
||||||
|
self._is_scanning = False
|
||||||
|
logger.info("bluetoothctl scanner stopped")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
return self._is_scanning
|
||||||
|
|
||||||
|
def _read_output(self) -> None:
|
||||||
|
"""Read bluetoothctl output and parse devices."""
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set() and self._process:
|
||||||
|
line = self._process.stdout.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
# Parse device discovery lines
|
||||||
|
# [NEW] Device AA:BB:CC:DD:EE:FF DeviceName
|
||||||
|
# [CHG] Device AA:BB:CC:DD:EE:FF RSSI: -65
|
||||||
|
# [CHG] Device AA:BB:CC:DD:EE:FF Name: DeviceName
|
||||||
|
|
||||||
|
new_match = re.search(
|
||||||
|
r'\[NEW\]\s+Device\s+([0-9A-Fa-f:]{17})\s*(.*)',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if new_match:
|
||||||
|
address = new_match.group(1).upper()
|
||||||
|
name = new_match.group(2).strip() or None
|
||||||
|
|
||||||
|
self._devices[address] = {
|
||||||
|
'address': address,
|
||||||
|
'name': name,
|
||||||
|
'rssi': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
observation = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address,
|
||||||
|
address_type=ADDRESS_TYPE_PUBLIC,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# RSSI change
|
||||||
|
rssi_match = re.search(
|
||||||
|
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+RSSI:\s*(-?\d+)',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if rssi_match:
|
||||||
|
address = rssi_match.group(1).upper()
|
||||||
|
rssi = int(rssi_match.group(2))
|
||||||
|
|
||||||
|
device_data = self._devices.get(address, {'address': address})
|
||||||
|
device_data['rssi'] = rssi
|
||||||
|
self._devices[address] = device_data
|
||||||
|
|
||||||
|
observation = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address,
|
||||||
|
address_type=ADDRESS_TYPE_PUBLIC,
|
||||||
|
name=device_data.get('name'),
|
||||||
|
rssi=rssi,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Name change
|
||||||
|
name_match = re.search(
|
||||||
|
r'\[CHG\]\s+Device\s+([0-9A-Fa-f:]{17})\s+Name:\s*(.+)',
|
||||||
|
line
|
||||||
|
)
|
||||||
|
if name_match:
|
||||||
|
address = name_match.group(1).upper()
|
||||||
|
name = name_match.group(2).strip()
|
||||||
|
|
||||||
|
device_data = self._devices.get(address, {'address': address})
|
||||||
|
device_data['name'] = name
|
||||||
|
self._devices[address] = device_data
|
||||||
|
|
||||||
|
observation = BTObservation(
|
||||||
|
timestamp=datetime.now(),
|
||||||
|
address=address,
|
||||||
|
address_type=ADDRESS_TYPE_PUBLIC,
|
||||||
|
name=name,
|
||||||
|
rssi=device_data.get('rssi'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._on_observation:
|
||||||
|
self._on_observation(observation)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"bluetoothctl read error: {e}")
|
||||||
|
finally:
|
||||||
|
self._is_scanning = False
|
||||||
|
|
||||||
|
|
||||||
|
class FallbackScanner:
|
||||||
|
"""
|
||||||
|
Unified fallback scanner that selects the best available backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adapter: str = 'hci0',
|
||||||
|
on_observation: Optional[Callable[[BTObservation], None]] = None,
|
||||||
|
):
|
||||||
|
self._adapter = adapter
|
||||||
|
self._on_observation = on_observation
|
||||||
|
self._active_scanner: Optional[object] = None
|
||||||
|
self._backend: Optional[str] = None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
"""Start scanning with best available backend."""
|
||||||
|
# Try bleak first (cross-platform)
|
||||||
|
try:
|
||||||
|
import bleak
|
||||||
|
self._active_scanner = BleakScanner(on_observation=self._on_observation)
|
||||||
|
if self._active_scanner.start():
|
||||||
|
self._backend = 'bleak'
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try hcitool (requires root)
|
||||||
|
try:
|
||||||
|
self._active_scanner = HcitoolScanner(
|
||||||
|
adapter=self._adapter,
|
||||||
|
on_observation=self._on_observation
|
||||||
|
)
|
||||||
|
if self._active_scanner.start():
|
||||||
|
self._backend = 'hcitool'
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try bluetoothctl
|
||||||
|
try:
|
||||||
|
self._active_scanner = BluetoothctlScanner(on_observation=self._on_observation)
|
||||||
|
if self._active_scanner.start():
|
||||||
|
self._backend = 'bluetoothctl'
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.error("No fallback scanner available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop active scanner."""
|
||||||
|
if self._active_scanner:
|
||||||
|
self._active_scanner.stop()
|
||||||
|
self._active_scanner = None
|
||||||
|
self._backend = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
return self._active_scanner.is_scanning if self._active_scanner else False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend(self) -> Optional[str]:
|
||||||
|
return self._backend
|
||||||
205
utils/bluetooth/heuristics.py
Normal file
205
utils/bluetooth/heuristics.py
Normal 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)
|
||||||
355
utils/bluetooth/models.py
Normal file
355
utils/bluetooth/models.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
"""
|
||||||
|
Bluetooth data models for the unified scanner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
RANGE_UNKNOWN,
|
||||||
|
PROTOCOL_BLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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: Optional[int] = None
|
||||||
|
tx_power: Optional[int] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
manufacturer_id: Optional[int] = None
|
||||||
|
manufacturer_data: Optional[bytes] = None
|
||||||
|
service_uuids: list[str] = field(default_factory=list)
|
||||||
|
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||||
|
appearance: Optional[int] = 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
|
||||||
|
|
||||||
|
@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) -> Optional[str]:
|
||||||
|
"""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: Optional[int] = None
|
||||||
|
rssi_median: Optional[float] = None
|
||||||
|
rssi_min: Optional[int] = None
|
||||||
|
rssi_max: Optional[int] = None
|
||||||
|
rssi_variance: Optional[float] = None
|
||||||
|
rssi_confidence: float = 0.0 # 0.0-1.0
|
||||||
|
|
||||||
|
# Range band (very_close/close/nearby/far/unknown)
|
||||||
|
range_band: str = RANGE_UNKNOWN
|
||||||
|
range_confidence: float = 0.0
|
||||||
|
|
||||||
|
# Device info (merged from observations)
|
||||||
|
name: Optional[str] = None
|
||||||
|
manufacturer_id: Optional[int] = None
|
||||||
|
manufacturer_name: Optional[str] = None
|
||||||
|
manufacturer_bytes: Optional[bytes] = 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
|
||||||
|
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: Optional[int] = None
|
||||||
|
|
||||||
|
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
|
||||||
|
'range_band': self.range_band,
|
||||||
|
'range_confidence': round(self.range_confidence, 2),
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_summary_dict(self) -> dict:
|
||||||
|
"""Compact dictionary for list views."""
|
||||||
|
return {
|
||||||
|
'device_id': self.device_id,
|
||||||
|
'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,
|
||||||
|
'range_band': self.range_band,
|
||||||
|
'last_seen': self.last_seen.isoformat(),
|
||||||
|
'age_seconds': self.age_seconds,
|
||||||
|
'seen_count': self.seen_count,
|
||||||
|
'heuristic_flags': self.heuristic_flags,
|
||||||
|
'in_baseline': self.in_baseline,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanStatus:
|
||||||
|
"""Current scanning status."""
|
||||||
|
|
||||||
|
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
|
||||||
|
devices_found: int = 0
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def elapsed_seconds(self) -> Optional[float]:
|
||||||
|
"""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]:
|
||||||
|
"""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: Optional[str] = None
|
||||||
|
|
||||||
|
# Adapters
|
||||||
|
adapters: list[dict] = field(default_factory=list)
|
||||||
|
default_adapter: Optional[str] = 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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
'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,
|
||||||
|
'recommended_backend': self.recommended_backend,
|
||||||
|
'can_scan': self.can_scan,
|
||||||
|
'issues': self.issues,
|
||||||
|
}
|
||||||
413
utils/bluetooth/scanner.py
Normal file
413
utils/bluetooth/scanner.py
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
"""
|
||||||
|
Main Bluetooth scanner coordinator.
|
||||||
|
|
||||||
|
Coordinates DBus and fallback scanners, manages device aggregation and heuristics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Generator, Optional
|
||||||
|
|
||||||
|
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 .heuristics import HeuristicsEngine
|
||||||
|
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Global scanner instance
|
||||||
|
_scanner_instance: Optional['BluetoothScanner'] = None
|
||||||
|
_scanner_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothScanner:
|
||||||
|
"""
|
||||||
|
Main Bluetooth scanner coordinating DBus and fallback scanners.
|
||||||
|
|
||||||
|
Provides unified API for scanning, device aggregation, and heuristics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, adapter_id: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize Bluetooth scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adapter_id: Adapter path/name (e.g., '/org/bluez/hci0' or 'hci0').
|
||||||
|
"""
|
||||||
|
self._adapter_id = adapter_id
|
||||||
|
self._aggregator = DeviceAggregator()
|
||||||
|
self._heuristics = HeuristicsEngine()
|
||||||
|
self._status = ScanStatus()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# Scanner backends
|
||||||
|
self._dbus_scanner: Optional[DBusScanner] = None
|
||||||
|
self._fallback_scanner: Optional[FallbackScanner] = None
|
||||||
|
self._active_backend: Optional[str] = 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
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
self._on_device_updated: Optional[Callable[[BTDeviceAggregate], None]] = None
|
||||||
|
|
||||||
|
# Capability check result
|
||||||
|
self._capabilities: Optional[SystemCapabilities] = None
|
||||||
|
|
||||||
|
def start_scan(
|
||||||
|
self,
|
||||||
|
mode: str = 'auto',
|
||||||
|
duration_s: Optional[int] = None,
|
||||||
|
transport: str = 'auto',
|
||||||
|
rssi_threshold: int = -100,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Start Bluetooth scanning.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Scanner mode ('dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto').
|
||||||
|
duration_s: Scan duration in seconds (None for indefinite).
|
||||||
|
transport: BLE transport filter ('bredr', 'le', 'auto').
|
||||||
|
rssi_threshold: Minimum RSSI for device discovery.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if scan started successfully.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._status.is_scanning:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check capabilities
|
||||||
|
self._capabilities = check_capabilities()
|
||||||
|
|
||||||
|
# Determine adapter
|
||||||
|
adapter = self._adapter_id or self._capabilities.default_adapter
|
||||||
|
if not adapter and mode == 'dbus':
|
||||||
|
self._status.error = "No Bluetooth adapter found"
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Select and start backend
|
||||||
|
started = False
|
||||||
|
backend_used = None
|
||||||
|
|
||||||
|
if mode == 'auto':
|
||||||
|
mode = self._capabilities.recommended_backend
|
||||||
|
|
||||||
|
if mode == 'dbus' or (mode == 'auto' and self._capabilities.has_dbus):
|
||||||
|
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||||
|
|
||||||
|
if not started and mode in ('bleak', 'hcitool', 'bluetoothctl', 'auto'):
|
||||||
|
started, backend_used = self._start_fallback(adapter, mode)
|
||||||
|
|
||||||
|
if not started:
|
||||||
|
self._status.error = f"Failed to start scanner with mode '{mode}'"
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
self._active_backend = backend_used
|
||||||
|
self._status = ScanStatus(
|
||||||
|
is_scanning=True,
|
||||||
|
mode=mode,
|
||||||
|
backend=backend_used,
|
||||||
|
adapter_id=adapter,
|
||||||
|
started_at=datetime.now(),
|
||||||
|
duration_s=duration_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue status event
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'status',
|
||||||
|
'status': 'started',
|
||||||
|
'backend': backend_used,
|
||||||
|
'mode': mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Set up timer for duration-based scanning
|
||||||
|
if duration_s:
|
||||||
|
self._scan_timer = threading.Timer(duration_s, self.stop_scan)
|
||||||
|
self._scan_timer.daemon = True
|
||||||
|
self._scan_timer.start()
|
||||||
|
|
||||||
|
logger.info(f"Bluetooth scan started: mode={mode}, backend={backend_used}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _start_dbus(
|
||||||
|
self,
|
||||||
|
adapter: str,
|
||||||
|
transport: str,
|
||||||
|
rssi_threshold: int
|
||||||
|
) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Start DBus scanner."""
|
||||||
|
try:
|
||||||
|
self._dbus_scanner = DBusScanner(
|
||||||
|
adapter_path=adapter,
|
||||||
|
on_observation=self._handle_observation,
|
||||||
|
)
|
||||||
|
if self._dbus_scanner.start(transport=transport, rssi_threshold=rssi_threshold):
|
||||||
|
return True, 'dbus'
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"DBus scanner failed: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def _start_fallback(self, adapter: str, preferred: str) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Start fallback scanner."""
|
||||||
|
try:
|
||||||
|
# Extract adapter name from path if needed
|
||||||
|
adapter_name = adapter.split('/')[-1] if adapter else 'hci0'
|
||||||
|
|
||||||
|
self._fallback_scanner = FallbackScanner(
|
||||||
|
adapter=adapter_name,
|
||||||
|
on_observation=self._handle_observation,
|
||||||
|
)
|
||||||
|
if self._fallback_scanner.start():
|
||||||
|
return True, self._fallback_scanner.backend
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Fallback scanner failed: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def stop_scan(self) -> None:
|
||||||
|
"""Stop Bluetooth scanning."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._status.is_scanning:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cancel timer if running
|
||||||
|
if self._scan_timer:
|
||||||
|
self._scan_timer.cancel()
|
||||||
|
self._scan_timer = None
|
||||||
|
|
||||||
|
# Stop active scanner
|
||||||
|
if self._dbus_scanner:
|
||||||
|
self._dbus_scanner.stop()
|
||||||
|
self._dbus_scanner = None
|
||||||
|
|
||||||
|
if self._fallback_scanner:
|
||||||
|
self._fallback_scanner.stop()
|
||||||
|
self._fallback_scanner = None
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
self._status.is_scanning = False
|
||||||
|
self._active_backend = None
|
||||||
|
|
||||||
|
# Queue status event
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'status',
|
||||||
|
'status': 'stopped',
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info("Bluetooth scan stopped")
|
||||||
|
|
||||||
|
def _handle_observation(self, observation: BTObservation) -> None:
|
||||||
|
"""Handle incoming observation from scanner backend."""
|
||||||
|
try:
|
||||||
|
# Ingest into aggregator
|
||||||
|
device = self._aggregator.ingest(observation)
|
||||||
|
|
||||||
|
# Evaluate heuristics
|
||||||
|
self._heuristics.evaluate(device)
|
||||||
|
|
||||||
|
# Update device count
|
||||||
|
with self._lock:
|
||||||
|
self._status.devices_found = self._aggregator.device_count
|
||||||
|
|
||||||
|
# Queue event
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'device',
|
||||||
|
'action': 'update',
|
||||||
|
'device': device.to_summary_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Callback
|
||||||
|
if self._on_device_updated:
|
||||||
|
self._on_device_updated(device)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling observation: {e}")
|
||||||
|
|
||||||
|
def _queue_event(self, event: dict) -> None:
|
||||||
|
"""Add event to queue for SSE streaming."""
|
||||||
|
try:
|
||||||
|
self._event_queue.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
# Drop oldest event
|
||||||
|
try:
|
||||||
|
self._event_queue.get_nowait()
|
||||||
|
self._event_queue.put_nowait(event)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_status(self) -> ScanStatus:
|
||||||
|
"""Get current scan status."""
|
||||||
|
with self._lock:
|
||||||
|
self._status.devices_found = self._aggregator.device_count
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
def get_devices(
|
||||||
|
self,
|
||||||
|
sort_by: str = 'last_seen',
|
||||||
|
sort_desc: bool = True,
|
||||||
|
min_rssi: Optional[int] = None,
|
||||||
|
protocol: Optional[str] = None,
|
||||||
|
max_age_seconds: float = DEVICE_STALE_TIMEOUT,
|
||||||
|
) -> list[BTDeviceAggregate]:
|
||||||
|
"""
|
||||||
|
Get list of discovered devices with optional filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sort_by: Field to sort by ('last_seen', 'rssi_current', 'name', 'seen_count').
|
||||||
|
sort_desc: Sort descending if True.
|
||||||
|
min_rssi: Minimum RSSI filter.
|
||||||
|
protocol: Protocol filter ('ble', 'classic', None for all).
|
||||||
|
max_age_seconds: Maximum age for devices.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BTDeviceAggregate instances.
|
||||||
|
"""
|
||||||
|
devices = self._aggregator.get_active_devices(max_age_seconds)
|
||||||
|
|
||||||
|
# Filter by RSSI
|
||||||
|
if min_rssi is not None:
|
||||||
|
devices = [d for d in devices if d.rssi_current and d.rssi_current >= min_rssi]
|
||||||
|
|
||||||
|
# Filter by protocol
|
||||||
|
if protocol:
|
||||||
|
devices = [d for d in devices if d.protocol == protocol]
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
sort_key = {
|
||||||
|
'last_seen': lambda d: d.last_seen,
|
||||||
|
'rssi_current': lambda d: d.rssi_current or -999,
|
||||||
|
'name': lambda d: (d.name or '').lower(),
|
||||||
|
'seen_count': lambda d: d.seen_count,
|
||||||
|
'first_seen': lambda d: d.first_seen,
|
||||||
|
}.get(sort_by, lambda d: d.last_seen)
|
||||||
|
|
||||||
|
devices.sort(key=sort_key, reverse=sort_desc)
|
||||||
|
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def get_device(self, device_id: str) -> Optional[BTDeviceAggregate]:
|
||||||
|
"""Get a specific device by ID."""
|
||||||
|
return self._aggregator.get_device(device_id)
|
||||||
|
|
||||||
|
def get_snapshot(self) -> list[dict]:
|
||||||
|
"""Get current device snapshot for TSCM integration."""
|
||||||
|
devices = self.get_devices()
|
||||||
|
return [d.to_dict() for d in devices]
|
||||||
|
|
||||||
|
def stream_events(self, timeout: float = 1.0) -> Generator[dict, None, None]:
|
||||||
|
"""
|
||||||
|
Generator for SSE event streaming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Queue get timeout in seconds.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Event dictionaries.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = self._event_queue.get(timeout=timeout)
|
||||||
|
yield event
|
||||||
|
except queue.Empty:
|
||||||
|
yield {'type': 'ping'}
|
||||||
|
|
||||||
|
def set_baseline(self) -> int:
|
||||||
|
"""Set current devices as baseline."""
|
||||||
|
count = self._aggregator.set_baseline()
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'baseline',
|
||||||
|
'action': 'set',
|
||||||
|
'device_count': count,
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
|
||||||
|
def clear_baseline(self) -> None:
|
||||||
|
"""Clear the baseline."""
|
||||||
|
self._aggregator.clear_baseline()
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'baseline',
|
||||||
|
'action': 'cleared',
|
||||||
|
})
|
||||||
|
|
||||||
|
def clear_devices(self) -> None:
|
||||||
|
"""Clear all tracked devices."""
|
||||||
|
self._aggregator.clear()
|
||||||
|
self._queue_event({
|
||||||
|
'type': 'devices',
|
||||||
|
'action': 'cleared',
|
||||||
|
})
|
||||||
|
|
||||||
|
def prune_stale(self, max_age_seconds: float = DEVICE_STALE_TIMEOUT) -> int:
|
||||||
|
"""Prune stale devices."""
|
||||||
|
return self._aggregator.prune_stale_devices(max_age_seconds)
|
||||||
|
|
||||||
|
def get_capabilities(self) -> SystemCapabilities:
|
||||||
|
"""Get system capabilities."""
|
||||||
|
if not self._capabilities:
|
||||||
|
self._capabilities = check_capabilities()
|
||||||
|
return self._capabilities
|
||||||
|
|
||||||
|
def set_on_device_updated(self, callback: Callable[[BTDeviceAggregate], None]) -> None:
|
||||||
|
"""Set callback for device updates."""
|
||||||
|
self._on_device_updated = callback
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanning(self) -> bool:
|
||||||
|
"""Check if scanning is active."""
|
||||||
|
return self._status.is_scanning
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_count(self) -> int:
|
||||||
|
"""Number of tracked devices."""
|
||||||
|
return self._aggregator.device_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_baseline(self) -> bool:
|
||||||
|
"""Whether baseline is set."""
|
||||||
|
return self._aggregator.has_baseline
|
||||||
|
|
||||||
|
|
||||||
|
def get_bluetooth_scanner(adapter_id: Optional[str] = None) -> BluetoothScanner:
|
||||||
|
"""
|
||||||
|
Get or create the global Bluetooth scanner instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
adapter_id: Adapter path/name (only used on first call).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BluetoothScanner instance.
|
||||||
|
"""
|
||||||
|
global _scanner_instance
|
||||||
|
|
||||||
|
with _scanner_lock:
|
||||||
|
if _scanner_instance is None:
|
||||||
|
_scanner_instance = BluetoothScanner(adapter_id)
|
||||||
|
return _scanner_instance
|
||||||
|
|
||||||
|
|
||||||
|
def reset_bluetooth_scanner() -> None:
|
||||||
|
"""Reset the global scanner instance (for testing)."""
|
||||||
|
global _scanner_instance
|
||||||
|
|
||||||
|
with _scanner_lock:
|
||||||
|
if _scanner_instance:
|
||||||
|
_scanner_instance.stop_scan()
|
||||||
|
_scanner_instance = None
|
||||||
Reference in New Issue
Block a user