mirror of
https://github.com/smittix/intercept.git
synced 2026-07-24 16:58:12 -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 .wifi import wifi_bp
|
||||
from .bluetooth import bluetooth_bp
|
||||
from .bluetooth_v2 import bluetooth_v2_bp
|
||||
from .adsb import adsb_bp
|
||||
from .acars import acars_bp
|
||||
from .aprs import aprs_bp
|
||||
@@ -22,6 +23,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(rtlamr_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(bluetooth_bp)
|
||||
app.register_blueprint(bluetooth_v2_bp) # New unified Bluetooth API
|
||||
app.register_blueprint(adsb_bp)
|
||||
app.register_blueprint(acars_bp)
|
||||
app.register_blueprint(aprs_bp)
|
||||
|
||||
@@ -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'
|
||||
+104
-93
@@ -54,6 +54,13 @@ from utils.tscm.device_identity import (
|
||||
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')
|
||||
|
||||
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'])
|
||||
def start_sweep():
|
||||
"""Start a TSCM sweep."""
|
||||
global _sweep_running, _sweep_thread, _current_sweep_id
|
||||
|
||||
if _sweep_running:
|
||||
def start_sweep():
|
||||
"""Start a TSCM sweep."""
|
||||
global _sweep_running, _sweep_thread, _current_sweep_id
|
||||
|
||||
if _sweep_running:
|
||||
return jsonify({'status': 'error', 'message': 'Sweep already running'})
|
||||
|
||||
data = request.get_json() or {}
|
||||
sweep_type = data.get('sweep_type', 'standard')
|
||||
baseline_id = data.get('baseline_id')
|
||||
wifi_enabled = data.get('wifi', True)
|
||||
bt_enabled = data.get('bluetooth', True)
|
||||
rf_enabled = data.get('rf', True)
|
||||
verbose_results = bool(data.get('verbose_results', False))
|
||||
baseline_id = data.get('baseline_id')
|
||||
wifi_enabled = data.get('wifi', True)
|
||||
bt_enabled = data.get('bluetooth', True)
|
||||
rf_enabled = data.get('rf', True)
|
||||
verbose_results = bool(data.get('verbose_results', False))
|
||||
|
||||
# Get interface selections
|
||||
wifi_interface = data.get('wifi_interface', '')
|
||||
@@ -349,12 +356,12 @@ def start_sweep():
|
||||
_sweep_running = True
|
||||
|
||||
# Start sweep thread
|
||||
_sweep_thread = threading.Thread(
|
||||
target=_run_sweep,
|
||||
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results),
|
||||
daemon=True
|
||||
)
|
||||
_sweep_thread = threading.Thread(
|
||||
target=_run_sweep,
|
||||
args=(sweep_type, baseline_id, wifi_enabled, bt_enabled, rf_enabled,
|
||||
wifi_interface, bt_interface, sdr_device, verbose_results),
|
||||
daemon=True
|
||||
)
|
||||
_sweep_thread.start()
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _run_sweep(
|
||||
sweep_type: str,
|
||||
baseline_id: int | None,
|
||||
wifi_enabled: bool,
|
||||
bt_enabled: bool,
|
||||
rf_enabled: bool,
|
||||
wifi_interface: str = '',
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False
|
||||
) -> None:
|
||||
def _run_sweep(
|
||||
sweep_type: str,
|
||||
baseline_id: int | None,
|
||||
wifi_enabled: bool,
|
||||
bt_enabled: bool,
|
||||
rf_enabled: bool,
|
||||
wifi_interface: str = '',
|
||||
bt_interface: str = '',
|
||||
sdr_device: int | None = None,
|
||||
verbose_results: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Run the TSCM sweep in a background thread.
|
||||
|
||||
@@ -1322,7 +1329,11 @@ def _run_sweep(
|
||||
# Perform Bluetooth scan
|
||||
if bt_enabled and (current_time - last_bt_scan) >= bt_scan_interval:
|
||||
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:
|
||||
mac = device.get('mac', '')
|
||||
if mac and mac not in all_bt:
|
||||
@@ -1472,61 +1483,61 @@ def _run_sweep(
|
||||
identity_summary = identity_engine.get_summary()
|
||||
identity_clusters = [c.to_dict() for c in identity_engine.get_clusters()]
|
||||
|
||||
if verbose_results:
|
||||
wifi_payload = list(all_wifi.values())
|
||||
bt_payload = list(all_bt.values())
|
||||
rf_payload = list(all_rf)
|
||||
else:
|
||||
wifi_payload = [
|
||||
{
|
||||
'bssid': d.get('bssid') or d.get('mac'),
|
||||
'essid': d.get('essid') or d.get('ssid'),
|
||||
'ssid': d.get('ssid') or d.get('essid'),
|
||||
'channel': d.get('channel'),
|
||||
'power': d.get('power', d.get('signal')),
|
||||
'privacy': d.get('privacy', d.get('encryption')),
|
||||
'encryption': d.get('encryption', d.get('privacy')),
|
||||
}
|
||||
for d in all_wifi.values()
|
||||
]
|
||||
bt_payload = [
|
||||
{
|
||||
'mac': d.get('mac') or d.get('address'),
|
||||
'name': d.get('name'),
|
||||
'rssi': d.get('rssi'),
|
||||
'manufacturer': d.get('manufacturer', d.get('manufacturer_name')),
|
||||
}
|
||||
for d in all_bt.values()
|
||||
]
|
||||
rf_payload = [
|
||||
{
|
||||
'frequency': s.get('frequency'),
|
||||
'power': s.get('power', s.get('level')),
|
||||
'modulation': s.get('modulation'),
|
||||
'band': s.get('band'),
|
||||
}
|
||||
for s in all_rf
|
||||
]
|
||||
|
||||
update_tscm_sweep(
|
||||
_current_sweep_id,
|
||||
status='completed',
|
||||
results={
|
||||
'wifi_devices': wifi_payload,
|
||||
'bt_devices': bt_payload,
|
||||
'rf_signals': rf_payload,
|
||||
'wifi_count': len(all_wifi),
|
||||
'bt_count': len(all_bt),
|
||||
'rf_count': len(all_rf),
|
||||
'severity_counts': severity_counts,
|
||||
'correlation_summary': findings.get('summary', {}),
|
||||
'identity_summary': identity_summary.get('statistics', {}),
|
||||
'baseline_comparison': baseline_comparison,
|
||||
'results_detail_level': 'full' if verbose_results else 'compact',
|
||||
},
|
||||
threats_found=threats_found,
|
||||
completed=True
|
||||
)
|
||||
if verbose_results:
|
||||
wifi_payload = list(all_wifi.values())
|
||||
bt_payload = list(all_bt.values())
|
||||
rf_payload = list(all_rf)
|
||||
else:
|
||||
wifi_payload = [
|
||||
{
|
||||
'bssid': d.get('bssid') or d.get('mac'),
|
||||
'essid': d.get('essid') or d.get('ssid'),
|
||||
'ssid': d.get('ssid') or d.get('essid'),
|
||||
'channel': d.get('channel'),
|
||||
'power': d.get('power', d.get('signal')),
|
||||
'privacy': d.get('privacy', d.get('encryption')),
|
||||
'encryption': d.get('encryption', d.get('privacy')),
|
||||
}
|
||||
for d in all_wifi.values()
|
||||
]
|
||||
bt_payload = [
|
||||
{
|
||||
'mac': d.get('mac') or d.get('address'),
|
||||
'name': d.get('name'),
|
||||
'rssi': d.get('rssi'),
|
||||
'manufacturer': d.get('manufacturer', d.get('manufacturer_name')),
|
||||
}
|
||||
for d in all_bt.values()
|
||||
]
|
||||
rf_payload = [
|
||||
{
|
||||
'frequency': s.get('frequency'),
|
||||
'power': s.get('power', s.get('level')),
|
||||
'modulation': s.get('modulation'),
|
||||
'band': s.get('band'),
|
||||
}
|
||||
for s in all_rf
|
||||
]
|
||||
|
||||
update_tscm_sweep(
|
||||
_current_sweep_id,
|
||||
status='completed',
|
||||
results={
|
||||
'wifi_devices': wifi_payload,
|
||||
'bt_devices': bt_payload,
|
||||
'rf_signals': rf_payload,
|
||||
'wifi_count': len(all_wifi),
|
||||
'bt_count': len(all_bt),
|
||||
'rf_count': len(all_rf),
|
||||
'severity_counts': severity_counts,
|
||||
'correlation_summary': findings.get('summary', {}),
|
||||
'identity_summary': identity_summary.get('statistics', {}),
|
||||
'baseline_comparison': baseline_comparison,
|
||||
'results_detail_level': 'full' if verbose_results else 'compact',
|
||||
},
|
||||
threats_found=threats_found,
|
||||
completed=True
|
||||
)
|
||||
|
||||
# Emit correlation findings
|
||||
_emit_event('correlation_findings', {
|
||||
@@ -1548,13 +1559,13 @@ def _run_sweep(
|
||||
})
|
||||
|
||||
# Emit device identity cluster findings (MAC-randomization resistant)
|
||||
_emit_event('identity_clusters', {
|
||||
'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 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),
|
||||
'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0),
|
||||
'clusters': identity_clusters,
|
||||
})
|
||||
_emit_event('identity_clusters', {
|
||||
'total_clusters': identity_summary.get('statistics', {}).get('total_clusters', 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),
|
||||
'unique_fingerprints': identity_summary.get('statistics', {}).get('unique_fingerprints', 0),
|
||||
'clusters': identity_clusters,
|
||||
})
|
||||
|
||||
_emit_event('sweep_completed', {
|
||||
'sweep_id': _current_sweep_id,
|
||||
@@ -2465,9 +2476,9 @@ def get_baseline_diff(baseline_id: int, sweep_id: int):
|
||||
import json
|
||||
results = json.loads(results)
|
||||
|
||||
current_wifi = results.get('wifi_devices', [])
|
||||
current_bt = results.get('bt_devices', [])
|
||||
current_rf = results.get('rf_signals', [])
|
||||
current_wifi = results.get('wifi_devices', [])
|
||||
current_bt = results.get('bt_devices', [])
|
||||
current_rf = results.get('rf_signals', [])
|
||||
|
||||
diff = calculate_baseline_diff(
|
||||
baseline=baseline,
|
||||
|
||||
Reference in New Issue
Block a user