mirror of
https://github.com/smittix/intercept.git
synced 2026-06-08 14:11:54 -07:00
Merge bluetooth-overhaul: Fix Bluetooth/WiFi TSCM scanning issues
- Fix bytes conversion errors in multiple Bluetooth scanner modules - Add monitor mode detection for WiFi interfaces - Auto-use deep scan (airodump-ng) for monitor mode interfaces - Fix is_known_tracker to handle hex string manufacturer data - Add debug logging for TSCM Bluetooth scanning
This commit is contained in:
@@ -365,10 +365,14 @@ def get_all_sweep_presets() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None = None) -> dict | None:
|
||||
def is_known_tracker(device_name: str | None, manufacturer_data: bytes | str | None = None) -> dict | None:
|
||||
"""
|
||||
Check if a BLE device matches known tracker signatures.
|
||||
|
||||
Args:
|
||||
device_name: Device name to check against patterns
|
||||
manufacturer_data: Manufacturer data as bytes or hex string
|
||||
|
||||
Returns:
|
||||
Tracker info dict if match found, None otherwise
|
||||
"""
|
||||
@@ -379,11 +383,20 @@ def is_known_tracker(device_name: str | None, manufacturer_data: bytes | None =
|
||||
if pattern in name_lower:
|
||||
return tracker_info
|
||||
|
||||
if manufacturer_data and len(manufacturer_data) >= 2:
|
||||
company_id = int.from_bytes(manufacturer_data[:2], 'little')
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
if tracker_info.get('company_id') == company_id:
|
||||
return tracker_info
|
||||
if manufacturer_data:
|
||||
# Convert hex string to bytes if needed
|
||||
mfr_bytes = manufacturer_data
|
||||
if isinstance(manufacturer_data, str):
|
||||
try:
|
||||
mfr_bytes = bytes.fromhex(manufacturer_data)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if len(mfr_bytes) >= 2:
|
||||
company_id = int.from_bytes(mfr_bytes[:2], 'little')
|
||||
for tracker_id, tracker_info in BLE_TRACKER_SIGNATURES.items():
|
||||
if tracker_info.get('company_id') == company_id:
|
||||
return tracker_info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ def register_blueprints(app):
|
||||
from .sensor import sensor_bp
|
||||
from .rtlamr import rtlamr_bp
|
||||
from .wifi import wifi_bp
|
||||
from .wifi_v2 import wifi_v2_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
|
||||
@@ -21,7 +23,9 @@ def register_blueprints(app):
|
||||
app.register_blueprint(sensor_bp)
|
||||
app.register_blueprint(rtlamr_bp)
|
||||
app.register_blueprint(wifi_bp)
|
||||
app.register_blueprint(wifi_v2_bp) # New unified WiFi API
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+177
-249
@@ -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}")
|
||||
@@ -629,166 +636,77 @@ def get_tscm_devices():
|
||||
|
||||
|
||||
def _scan_wifi_networks(interface: str) -> list[dict]:
|
||||
"""Scan for WiFi networks using system tools."""
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
"""
|
||||
Scan for WiFi networks using the unified WiFi scanner.
|
||||
|
||||
networks = []
|
||||
This is a facade that maintains backwards compatibility with TSCM
|
||||
while using the new unified scanner module.
|
||||
|
||||
if platform.system() == 'Darwin':
|
||||
# macOS: Use airport utility
|
||||
airport_path = '/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport'
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[airport_path, '-s'],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
# Parse airport output
|
||||
# Format: SSID BSSID RSSI CHANNEL HT CC SECURITY
|
||||
lines = result.stdout.strip().split('\n')
|
||||
for line in lines[1:]: # Skip header
|
||||
if not line.strip():
|
||||
continue
|
||||
# Parse the line - format is space-separated but SSID can have spaces
|
||||
parts = line.split()
|
||||
if len(parts) >= 7:
|
||||
# BSSID is always XX:XX:XX:XX:XX:XX format
|
||||
bssid_idx = None
|
||||
for i, p in enumerate(parts):
|
||||
if re.match(r'^[0-9a-fA-F:]{17}$', p):
|
||||
bssid_idx = i
|
||||
break
|
||||
if bssid_idx is not None:
|
||||
ssid = ' '.join(parts[:bssid_idx]) if bssid_idx > 0 else '[Hidden]'
|
||||
bssid = parts[bssid_idx]
|
||||
rssi = parts[bssid_idx + 1] if len(parts) > bssid_idx + 1 else '-100'
|
||||
channel = parts[bssid_idx + 2] if len(parts) > bssid_idx + 2 else '0'
|
||||
security = ' '.join(parts[bssid_idx + 5:]) if len(parts) > bssid_idx + 5 else ''
|
||||
networks.append({
|
||||
'bssid': bssid.upper(),
|
||||
'essid': ssid or '[Hidden]',
|
||||
'power': rssi,
|
||||
'channel': channel,
|
||||
'privacy': security
|
||||
})
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
logger.warning(f"macOS WiFi scan failed: {e}")
|
||||
Automatically detects monitor mode interfaces and uses deep scan
|
||||
(airodump-ng) when appropriate.
|
||||
|
||||
else:
|
||||
# Linux: Try multiple scan methods
|
||||
import shutil
|
||||
Args:
|
||||
interface: WiFi interface name (optional).
|
||||
|
||||
# Detect wireless interface if not specified
|
||||
if not interface:
|
||||
try:
|
||||
import glob
|
||||
wireless_paths = glob.glob('/sys/class/net/*/wireless')
|
||||
if wireless_paths:
|
||||
iface = wireless_paths[0].split('/')[4]
|
||||
else:
|
||||
iface = 'wlan0'
|
||||
except Exception:
|
||||
iface = 'wlan0'
|
||||
Returns:
|
||||
List of network dicts with: bssid, essid, power, channel, privacy
|
||||
"""
|
||||
try:
|
||||
from utils.wifi import get_wifi_scanner
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
# Check if interface is in monitor mode
|
||||
is_monitor = False
|
||||
if interface:
|
||||
is_monitor = scanner._is_monitor_mode_interface(interface)
|
||||
|
||||
if is_monitor:
|
||||
# Use deep scan for monitor mode interfaces
|
||||
logger.info(f"Interface {interface} is in monitor mode, using deep scan")
|
||||
|
||||
# Check if airodump-ng is available
|
||||
caps = scanner.check_capabilities()
|
||||
if not caps.has_airodump_ng:
|
||||
logger.warning("airodump-ng not available for monitor mode scanning")
|
||||
return []
|
||||
|
||||
# Start a short deep scan
|
||||
if not scanner.is_scanning:
|
||||
scanner.start_deep_scan(interface=interface, band='all')
|
||||
|
||||
# Wait briefly for some results
|
||||
import time
|
||||
time.sleep(5)
|
||||
|
||||
# Get current access points
|
||||
networks = []
|
||||
for ap in scanner.access_points:
|
||||
networks.append(ap.to_legacy_dict())
|
||||
|
||||
logger.info(f"WiFi deep scan found {len(networks)} networks")
|
||||
return networks
|
||||
else:
|
||||
iface = interface
|
||||
# Use quick scan for managed mode interfaces
|
||||
result = scanner.quick_scan(interface=interface, timeout=15)
|
||||
|
||||
logger.info(f"WiFi scan using interface: {iface}")
|
||||
if result.error:
|
||||
logger.warning(f"WiFi scan error: {result.error}")
|
||||
|
||||
# Method 1: Try iw scan (sometimes works without root)
|
||||
if shutil.which('iw'):
|
||||
try:
|
||||
logger.info("Trying 'iw' scan...")
|
||||
result = subprocess.run(
|
||||
['iw', 'dev', iface, 'scan'],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode == 0 and 'BSS' in result.stdout:
|
||||
# Parse iw output
|
||||
current_bss = None
|
||||
for line in result.stdout.split('\n'):
|
||||
if line.startswith('BSS '):
|
||||
if current_bss and current_bss.get('bssid'):
|
||||
networks.append(current_bss)
|
||||
# Extract BSSID from "BSS xx:xx:xx:xx:xx:xx(on wlan0)"
|
||||
bssid_match = re.search(r'BSS ([0-9a-fA-F:]{17})', line)
|
||||
if bssid_match:
|
||||
current_bss = {'bssid': bssid_match.group(1).upper(), 'essid': '[Hidden]'}
|
||||
elif current_bss:
|
||||
line = line.strip()
|
||||
if line.startswith('SSID:'):
|
||||
ssid = line[5:].strip()
|
||||
current_bss['essid'] = ssid or '[Hidden]'
|
||||
elif line.startswith('signal:'):
|
||||
sig_match = re.search(r'(-?\d+)', line)
|
||||
if sig_match:
|
||||
current_bss['power'] = sig_match.group(1)
|
||||
elif line.startswith('freq:'):
|
||||
freq = line[5:].strip()
|
||||
# Convert frequency to channel
|
||||
try:
|
||||
freq_mhz = int(freq)
|
||||
if freq_mhz < 3000:
|
||||
channel = (freq_mhz - 2407) // 5
|
||||
else:
|
||||
channel = (freq_mhz - 5000) // 5
|
||||
current_bss['channel'] = str(channel)
|
||||
except ValueError:
|
||||
pass
|
||||
elif 'WPA' in line or 'RSN' in line:
|
||||
current_bss['privacy'] = 'WPA2' if 'RSN' in line else 'WPA'
|
||||
if current_bss and current_bss.get('bssid'):
|
||||
networks.append(current_bss)
|
||||
logger.info(f"iw scan found {len(networks)} networks")
|
||||
elif 'Operation not permitted' in result.stderr or result.returncode != 0:
|
||||
logger.warning(f"iw scan requires root: {result.stderr[:100]}")
|
||||
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
logger.warning(f"iw scan failed: {e}")
|
||||
# Convert to legacy format for TSCM
|
||||
networks = []
|
||||
for ap in result.access_points:
|
||||
networks.append(ap.to_legacy_dict())
|
||||
|
||||
# Method 2: Try iwlist scan if iw didn't work
|
||||
if not networks and shutil.which('iwlist'):
|
||||
try:
|
||||
logger.info("Trying 'iwlist' scan...")
|
||||
result = subprocess.run(
|
||||
['iwlist', iface, 'scan'],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if 'Operation not permitted' in result.stderr:
|
||||
logger.warning("iwlist scan requires root privileges")
|
||||
else:
|
||||
current_network = {}
|
||||
for line in result.stdout.split('\n'):
|
||||
line = line.strip()
|
||||
if 'Cell' in line and 'Address:' in line:
|
||||
if current_network.get('bssid'):
|
||||
networks.append(current_network)
|
||||
bssid = line.split('Address:')[1].strip()
|
||||
current_network = {'bssid': bssid.upper(), 'essid': '[Hidden]'}
|
||||
elif 'ESSID:' in line:
|
||||
essid = line.split('ESSID:')[1].strip().strip('"')
|
||||
current_network['essid'] = essid or '[Hidden]'
|
||||
elif 'Channel:' in line:
|
||||
channel = line.split('Channel:')[1].strip()
|
||||
current_network['channel'] = channel
|
||||
elif 'Signal level=' in line:
|
||||
match = re.search(r'Signal level[=:]?\s*(-?\d+)', line)
|
||||
if match:
|
||||
current_network['power'] = match.group(1)
|
||||
elif 'Encryption key:' in line:
|
||||
encrypted = 'on' in line.lower()
|
||||
current_network['encrypted'] = encrypted
|
||||
elif 'WPA' in line or 'WPA2' in line:
|
||||
current_network['privacy'] = 'WPA2' if 'WPA2' in line else 'WPA'
|
||||
if current_network.get('bssid'):
|
||||
networks.append(current_network)
|
||||
logger.info(f"iwlist scan found {len(networks)} networks")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
||||
logger.warning(f"iwlist scan failed: {e}")
|
||||
logger.info(f"WiFi scan found {len(networks)} networks")
|
||||
return networks
|
||||
|
||||
if not networks:
|
||||
logger.warning("WiFi scanning requires root privileges. Run with sudo for WiFi scanning.")
|
||||
|
||||
return networks
|
||||
except ImportError as e:
|
||||
logger.error(f"Failed to import wifi scanner: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.exception(f"WiFi scan failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _scan_bluetooth_devices(interface: str, duration: int = 10) -> list[dict]:
|
||||
@@ -1145,11 +1063,12 @@ def _scan_rf_signals(sdr_device: int | None, duration: int = 30) -> list[dict]:
|
||||
db_values = [float(x) for x in parts[6:] if x.strip()]
|
||||
|
||||
# Find peaks above noise floor
|
||||
# RTL-SDR dongles have higher noise figures, so use permissive thresholds
|
||||
noise_floor = sum(db_values) / len(db_values) if db_values else -100
|
||||
threshold = noise_floor + 10 # Signal must be 10dB above noise
|
||||
threshold = noise_floor + 6 # Signal must be 6dB above noise
|
||||
|
||||
for idx, db in enumerate(db_values):
|
||||
if db > threshold and db > -70: # Detect signals above -70dBm
|
||||
if db > threshold and db > -90: # Detect signals above -90dBm
|
||||
freq_hz = hz_low + (idx * hz_step)
|
||||
freq_mhz = freq_hz / 1000000
|
||||
|
||||
@@ -1194,17 +1113,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.
|
||||
|
||||
@@ -1255,7 +1174,7 @@ def _run_sweep(
|
||||
last_rf_scan = 0
|
||||
wifi_scan_interval = 15 # Scan WiFi every 15 seconds
|
||||
bt_scan_interval = 20 # Scan Bluetooth every 20 seconds
|
||||
rf_scan_interval = 60 # Scan RF every 60 seconds (it's slower)
|
||||
rf_scan_interval = 30 # Scan RF every 30 seconds
|
||||
|
||||
while _sweep_running and (time.time() - start_time) < duration:
|
||||
current_time = time.time()
|
||||
@@ -1322,7 +1241,15 @@ 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:
|
||||
logger.info("TSCM: Using unified BT scanner for snapshot")
|
||||
bt_devices = get_tscm_bluetooth_snapshot(duration=8)
|
||||
logger.info(f"TSCM: Unified scanner returned {len(bt_devices)} devices")
|
||||
else:
|
||||
logger.info(f"TSCM: Using legacy BT scanner on {bt_interface}")
|
||||
bt_devices = _scan_bluetooth_devices(bt_interface, duration=8)
|
||||
logger.info(f"TSCM: Legacy scanner returned {len(bt_devices)} devices")
|
||||
for device in bt_devices:
|
||||
mac = device.get('mac', '')
|
||||
if mac and mac not in all_bt:
|
||||
@@ -1373,7 +1300,8 @@ def _run_sweep(
|
||||
})
|
||||
last_bt_scan = current_time
|
||||
except Exception as e:
|
||||
logger.error(f"Bluetooth scan error: {e}")
|
||||
import traceback
|
||||
logger.error(f"Bluetooth scan error: {e}\n{traceback.format_exc()}")
|
||||
|
||||
# Perform RF scan using SDR
|
||||
if rf_enabled and (current_time - last_rf_scan) >= rf_scan_interval:
|
||||
@@ -1392,7 +1320,7 @@ def _run_sweep(
|
||||
if not rf_signals and last_rf_scan == 0:
|
||||
_emit_event('rf_status', {
|
||||
'status': 'no_signals',
|
||||
'message': 'RF scan completed but no signals detected. Check RTL-SDR connection.',
|
||||
'message': 'RF scan completed - no signals above threshold. This may be normal in a quiet RF environment.',
|
||||
})
|
||||
|
||||
for signal in rf_signals:
|
||||
@@ -1472,61 +1400,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 +1476,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 +2393,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,
|
||||
|
||||
+315
@@ -1098,3 +1098,318 @@ def stream_wifi():
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# V2 API Endpoints - Using unified WiFi scanner
|
||||
# =============================================================================
|
||||
|
||||
from utils.wifi.scanner import get_wifi_scanner, reset_wifi_scanner
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/capabilities')
|
||||
def get_v2_capabilities():
|
||||
"""Get WiFi scanning capabilities on this system."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
caps = scanner.check_capabilities()
|
||||
return jsonify({
|
||||
'platform': caps.platform,
|
||||
'is_root': caps.is_root,
|
||||
'can_quick_scan': caps.can_quick_scan,
|
||||
'can_deep_scan': caps.can_deep_scan,
|
||||
'preferred_quick_tool': caps.preferred_quick_tool,
|
||||
'interfaces': caps.interfaces,
|
||||
'default_interface': caps.default_interface,
|
||||
'has_monitor_capable_interface': caps.has_monitor_capable_interface,
|
||||
'monitor_interface': caps.monitor_interface,
|
||||
'issues': caps.issues,
|
||||
'tools': {
|
||||
'nmcli': caps.has_nmcli,
|
||||
'iw': caps.has_iw,
|
||||
'iwlist': caps.has_iwlist,
|
||||
'airport': caps.has_airport,
|
||||
'airmon_ng': caps.has_airmon_ng,
|
||||
'airodump_ng': caps.has_airodump_ng,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error checking capabilities")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/quick', methods=['POST'])
|
||||
def v2_quick_scan():
|
||||
"""Perform a quick one-shot WiFi scan using system tools."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
interface = data.get('interface')
|
||||
timeout = data.get('timeout', 10.0)
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||
|
||||
if result.error:
|
||||
return jsonify({
|
||||
'error': result.error,
|
||||
'access_points': [],
|
||||
'channel_stats': [],
|
||||
'recommendations': [],
|
||||
}), 200 # Return 200 with error in body for cleaner handling
|
||||
|
||||
return jsonify({
|
||||
'access_points': [ap.to_summary_dict() for ap in result.access_points],
|
||||
'channel_stats': [s.to_dict() for s in result.channel_stats],
|
||||
'recommendations': [r.to_dict() for r in result.recommendations],
|
||||
'duration_seconds': result.duration_seconds,
|
||||
'warnings': result.warnings,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error in quick scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/start', methods=['POST'])
|
||||
def v2_start_scan():
|
||||
"""Start continuous deep scan with airodump-ng."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(interface=interface, band=band, channel=channel)
|
||||
|
||||
if success:
|
||||
return jsonify({'status': 'started'})
|
||||
else:
|
||||
status = scanner.get_status()
|
||||
return jsonify({'error': status.error or 'Failed to start scan'}), 400
|
||||
except Exception as e:
|
||||
logger.exception("Error starting deep scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/stop', methods=['POST'])
|
||||
def v2_stop_scan():
|
||||
"""Stop the current scan."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.stop_deep_scan()
|
||||
return jsonify({'status': 'stopped'})
|
||||
except Exception as e:
|
||||
logger.exception("Error stopping scan")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/scan/status')
|
||||
def v2_scan_status():
|
||||
"""Get current scan status."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
status = scanner.get_status()
|
||||
return jsonify({
|
||||
'is_scanning': status.is_scanning,
|
||||
'scan_mode': status.scan_mode,
|
||||
'interface': status.interface,
|
||||
'started_at': status.started_at.isoformat() if status.started_at else None,
|
||||
'networks_found': status.networks_found,
|
||||
'clients_found': status.clients_found,
|
||||
'error': status.error,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting scan status")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/networks')
|
||||
def v2_get_networks():
|
||||
"""Get all discovered networks."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
networks = scanner.access_points
|
||||
return jsonify({
|
||||
'networks': [ap.to_summary_dict() for ap in networks],
|
||||
'total': len(networks),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting networks")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clients')
|
||||
def v2_get_clients():
|
||||
"""Get all discovered clients."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
return jsonify({
|
||||
'clients': [c.to_dict() for c in clients],
|
||||
'total': len(clients),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting clients")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/probes')
|
||||
def v2_get_probes():
|
||||
"""Get probe requests."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
probes = scanner.probe_requests
|
||||
return jsonify({
|
||||
'probes': [p.to_dict() for p in probes[-100:]], # Last 100
|
||||
'total': len(probes),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting probes")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/channels')
|
||||
def v2_get_channels():
|
||||
"""Get channel statistics and recommendations."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
stats = scanner._calculate_channel_stats()
|
||||
recommendations = scanner._generate_recommendations(stats)
|
||||
return jsonify({
|
||||
'channel_stats': [s.to_dict() for s in stats],
|
||||
'recommendations': [r.to_dict() for r in recommendations],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.exception("Error getting channel stats")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/stream')
|
||||
def v2_stream():
|
||||
"""SSE stream for real-time WiFi events."""
|
||||
def generate():
|
||||
scanner = get_wifi_scanner()
|
||||
for event in scanner.get_event_stream():
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.headers['Connection'] = 'keep-alive'
|
||||
return response
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/export')
|
||||
def v2_export():
|
||||
"""Export scan data as CSV or JSON."""
|
||||
try:
|
||||
format_type = request.args.get('format', 'json')
|
||||
data_type = request.args.get('type', 'all')
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
if format_type == 'json':
|
||||
data = {}
|
||||
if data_type in ('all', 'networks'):
|
||||
data['networks'] = [ap.to_summary_dict() for ap in scanner.access_points]
|
||||
if data_type in ('all', 'clients'):
|
||||
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||
if data_type in ('all', 'probes'):
|
||||
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||
|
||||
response = Response(
|
||||
json.dumps(data, indent=2, default=str),
|
||||
mimetype='application/json',
|
||||
)
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.json'
|
||||
return response
|
||||
|
||||
elif format_type == 'csv':
|
||||
import csv
|
||||
import io
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write networks
|
||||
writer.writerow(['Networks'])
|
||||
writer.writerow(['BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security', 'Vendor', 'Clients', 'First Seen', 'Last Seen'])
|
||||
for ap in scanner.access_points:
|
||||
writer.writerow([
|
||||
ap.bssid,
|
||||
ap.essid or '[Hidden]',
|
||||
ap.channel,
|
||||
ap.band,
|
||||
ap.rssi_current,
|
||||
ap.security,
|
||||
ap.vendor,
|
||||
ap.client_count,
|
||||
ap.first_seen.isoformat() if ap.first_seen else '',
|
||||
ap.last_seen.isoformat() if ap.last_seen else '',
|
||||
])
|
||||
|
||||
writer.writerow([])
|
||||
|
||||
# Write clients
|
||||
writer.writerow(['Clients'])
|
||||
writer.writerow(['MAC', 'BSSID', 'Vendor', 'RSSI', 'Probed SSIDs', 'First Seen', 'Last Seen'])
|
||||
for c in scanner.clients:
|
||||
writer.writerow([
|
||||
c.mac,
|
||||
c.associated_bssid or '',
|
||||
c.vendor,
|
||||
c.rssi_current,
|
||||
', '.join(c.probed_ssids),
|
||||
c.first_seen.isoformat() if c.first_seen else '',
|
||||
c.last_seen.isoformat() if c.last_seen else '',
|
||||
])
|
||||
|
||||
response = Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
)
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=wifi_scan.csv'
|
||||
return response
|
||||
|
||||
else:
|
||||
return jsonify({'error': f'Unknown format: {format_type}'}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Error exporting data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/set', methods=['POST'])
|
||||
def v2_set_baseline():
|
||||
"""Set current networks as baseline."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.set_baseline()
|
||||
return jsonify({'status': 'baseline_set', 'count': len(scanner._baseline_networks)})
|
||||
except Exception as e:
|
||||
logger.exception("Error setting baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/baseline/clear', methods=['POST'])
|
||||
def v2_clear_baseline():
|
||||
"""Clear the baseline."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_baseline()
|
||||
return jsonify({'status': 'baseline_cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing baseline")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@wifi_bp.route('/v2/clear', methods=['POST'])
|
||||
def v2_clear_data():
|
||||
"""Clear all discovered data."""
|
||||
try:
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_data()
|
||||
return jsonify({'status': 'cleared'})
|
||||
except Exception as e:
|
||||
logger.exception("Error clearing data")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
WiFi v2 API routes.
|
||||
|
||||
New unified WiFi scanning API with Quick Scan and Deep Scan modes,
|
||||
channel analysis, hidden SSID correlation, and SSE streaming.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Generator
|
||||
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from utils.wifi import (
|
||||
get_wifi_scanner,
|
||||
analyze_channels,
|
||||
get_hidden_correlator,
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
)
|
||||
from utils.sse import format_sse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
wifi_v2_bp = Blueprint('wifi_v2', __name__, url_prefix='/wifi/v2')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Capabilities
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/capabilities', methods=['GET'])
|
||||
def get_capabilities():
|
||||
"""
|
||||
Get WiFi scanning capabilities.
|
||||
|
||||
Returns available tools, interfaces, and scan mode support.
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
caps = scanner.check_capabilities()
|
||||
return jsonify(caps.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Quick Scan
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/quick', methods=['POST'])
|
||||
def quick_scan():
|
||||
"""
|
||||
Perform a quick one-shot WiFi scan.
|
||||
|
||||
Uses system tools (nmcli, iw, iwlist, airport) without monitor mode.
|
||||
|
||||
Request body:
|
||||
interface: Optional interface name
|
||||
timeout: Optional scan timeout in seconds (default 15)
|
||||
|
||||
Returns:
|
||||
WiFiScanResult with discovered networks and channel analysis.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
timeout = float(data.get('timeout', 15))
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
result = scanner.quick_scan(interface=interface, timeout=timeout)
|
||||
|
||||
return jsonify(result.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Deep Scan (Monitor Mode)
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/scan/start', methods=['POST'])
|
||||
def start_deep_scan():
|
||||
"""
|
||||
Start a deep scan using airodump-ng.
|
||||
|
||||
Requires monitor mode interface and root privileges.
|
||||
|
||||
Request body:
|
||||
interface: Monitor mode interface (e.g., 'wlan0mon')
|
||||
band: Band to scan ('2.4', '5', 'all')
|
||||
channel: Optional specific channel to monitor
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
interface = data.get('interface')
|
||||
band = data.get('band', 'all')
|
||||
channel = data.get('channel')
|
||||
|
||||
if channel:
|
||||
try:
|
||||
channel = int(channel)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Invalid channel'}), 400
|
||||
|
||||
scanner = get_wifi_scanner()
|
||||
success = scanner.start_deep_scan(
|
||||
interface=interface,
|
||||
band=band,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'status': 'started',
|
||||
'mode': SCAN_MODE_DEEP,
|
||||
'interface': interface or scanner._capabilities.monitor_interface,
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'error': scanner._status.error,
|
||||
}), 400
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/stop', methods=['POST'])
|
||||
def stop_deep_scan():
|
||||
"""Stop the deep scan."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.stop_deep_scan()
|
||||
|
||||
return jsonify({
|
||||
'status': 'stopped',
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/scan/status', methods=['GET'])
|
||||
def get_scan_status():
|
||||
"""Get current scan status."""
|
||||
scanner = get_wifi_scanner()
|
||||
status = scanner.get_status()
|
||||
return jsonify(status.to_dict())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/networks', methods=['GET'])
|
||||
def get_networks():
|
||||
"""
|
||||
Get all discovered networks.
|
||||
|
||||
Query params:
|
||||
band: Filter by band ('2.4GHz', '5GHz', '6GHz')
|
||||
security: Filter by security type ('Open', 'WEP', 'WPA', 'WPA2', 'WPA3')
|
||||
hidden: Filter hidden networks only (true/false)
|
||||
min_rssi: Minimum RSSI threshold
|
||||
sort: Sort field ('rssi', 'channel', 'essid', 'last_seen')
|
||||
order: Sort order ('asc', 'desc')
|
||||
format: Response format ('full', 'summary')
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
networks = scanner.access_points
|
||||
|
||||
# Apply filters
|
||||
band = request.args.get('band')
|
||||
if band:
|
||||
networks = [n for n in networks if n.band == band]
|
||||
|
||||
security = request.args.get('security')
|
||||
if security:
|
||||
networks = [n for n in networks if n.security == security]
|
||||
|
||||
hidden = request.args.get('hidden')
|
||||
if hidden == 'true':
|
||||
networks = [n for n in networks if n.is_hidden]
|
||||
elif hidden == 'false':
|
||||
networks = [n for n in networks if not n.is_hidden]
|
||||
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
networks = [n for n in networks if n.rssi_current and n.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Apply sorting
|
||||
sort_field = request.args.get('sort', 'rssi')
|
||||
order = request.args.get('order', 'desc')
|
||||
reverse = order == 'desc'
|
||||
|
||||
sort_key_map = {
|
||||
'rssi': lambda n: n.rssi_current or -100,
|
||||
'channel': lambda n: n.channel or 0,
|
||||
'essid': lambda n: (n.essid or '').lower(),
|
||||
'last_seen': lambda n: n.last_seen,
|
||||
'clients': lambda n: n.client_count,
|
||||
}
|
||||
|
||||
if sort_field in sort_key_map:
|
||||
networks.sort(key=sort_key_map[sort_field], reverse=reverse)
|
||||
|
||||
# Format output
|
||||
output_format = request.args.get('format', 'summary')
|
||||
if output_format == 'full':
|
||||
return jsonify([n.to_dict() for n in networks])
|
||||
else:
|
||||
return jsonify([n.to_summary_dict() for n in networks])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/networks/<bssid>', methods=['GET'])
|
||||
def get_network(bssid):
|
||||
"""Get a specific network by BSSID."""
|
||||
scanner = get_wifi_scanner()
|
||||
network = scanner.get_network(bssid)
|
||||
|
||||
if network:
|
||||
return jsonify(network.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Network not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients', methods=['GET'])
|
||||
def get_clients():
|
||||
"""
|
||||
Get all discovered clients.
|
||||
|
||||
Query params:
|
||||
associated: Filter by association status (true/false)
|
||||
bssid: Filter by associated BSSID
|
||||
min_rssi: Minimum RSSI threshold
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
clients = scanner.clients
|
||||
|
||||
# Apply filters
|
||||
associated = request.args.get('associated')
|
||||
if associated == 'true':
|
||||
clients = [c for c in clients if c.is_associated]
|
||||
elif associated == 'false':
|
||||
clients = [c for c in clients if not c.is_associated]
|
||||
|
||||
bssid = request.args.get('bssid')
|
||||
if bssid:
|
||||
clients = [c for c in clients if c.associated_bssid == bssid.upper()]
|
||||
|
||||
min_rssi = request.args.get('min_rssi')
|
||||
if min_rssi:
|
||||
try:
|
||||
min_rssi = int(min_rssi)
|
||||
clients = [c for c in clients if c.rssi_current and c.rssi_current >= min_rssi]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify([c.to_dict() for c in clients])
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/clients/<mac>', methods=['GET'])
|
||||
def get_client(mac):
|
||||
"""Get a specific client by MAC address."""
|
||||
scanner = get_wifi_scanner()
|
||||
client = scanner.get_client(mac)
|
||||
|
||||
if client:
|
||||
return jsonify(client.to_dict())
|
||||
else:
|
||||
return jsonify({'error': 'Client not found'}), 404
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/probes', methods=['GET'])
|
||||
def get_probes():
|
||||
"""
|
||||
Get captured probe requests.
|
||||
|
||||
Query params:
|
||||
client_mac: Filter by client MAC
|
||||
ssid: Filter by probed SSID
|
||||
limit: Maximum number of results
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
probes = scanner.probe_requests
|
||||
|
||||
# Apply filters
|
||||
client_mac = request.args.get('client_mac')
|
||||
if client_mac:
|
||||
probes = [p for p in probes if p.client_mac == client_mac.upper()]
|
||||
|
||||
ssid = request.args.get('ssid')
|
||||
if ssid:
|
||||
probes = [p for p in probes if p.probed_ssid == ssid]
|
||||
|
||||
# Apply limit
|
||||
limit = request.args.get('limit')
|
||||
if limit:
|
||||
try:
|
||||
limit = int(limit)
|
||||
probes = probes[-limit:] # Most recent
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return jsonify([p.to_dict() for p in probes])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Analysis
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/channels', methods=['GET'])
|
||||
def get_channel_stats():
|
||||
"""
|
||||
Get channel utilization statistics and recommendations.
|
||||
|
||||
Query params:
|
||||
include_dfs: Include DFS channels in recommendations (true/false)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
include_dfs = request.args.get('include_dfs', 'false') == 'true'
|
||||
|
||||
stats, recommendations = analyze_channels(
|
||||
scanner.access_points,
|
||||
include_dfs=include_dfs,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'stats': [s.to_dict() for s in stats],
|
||||
'recommendations': [r.to_dict() for r in recommendations],
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Hidden SSID Correlation
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/hidden', methods=['GET'])
|
||||
def get_hidden_correlations():
|
||||
"""
|
||||
Get revealed hidden SSIDs from correlation.
|
||||
|
||||
Returns mapping of BSSID -> revealed SSID.
|
||||
"""
|
||||
correlator = get_hidden_correlator()
|
||||
return jsonify(correlator.get_all_revealed())
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Baseline Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/baseline/set', methods=['POST'])
|
||||
def set_baseline():
|
||||
"""Mark current networks as baseline (known networks)."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.set_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_set',
|
||||
'network_count': len(scanner._baseline_networks),
|
||||
'set_at': datetime.now().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@wifi_v2_bp.route('/baseline/clear', methods=['POST'])
|
||||
def clear_baseline():
|
||||
"""Clear the baseline."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_baseline()
|
||||
|
||||
return jsonify({
|
||||
'status': 'baseline_cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE Streaming
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/stream', methods=['GET'])
|
||||
def event_stream():
|
||||
"""
|
||||
Server-Sent Events stream for real-time updates.
|
||||
|
||||
Events:
|
||||
- network_update: Network discovered/updated
|
||||
- client_update: Client discovered/updated
|
||||
- probe_request: Probe request detected
|
||||
- hidden_revealed: Hidden SSID revealed
|
||||
- scan_started, scan_stopped, scan_error
|
||||
- keepalive: Periodic keepalive
|
||||
"""
|
||||
def generate() -> Generator[str, None, None]:
|
||||
scanner = get_wifi_scanner()
|
||||
|
||||
for event in scanner.get_event_stream():
|
||||
yield format_sse(event)
|
||||
|
||||
response = Response(generate(), mimetype='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Data Management
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/clear', methods=['POST'])
|
||||
def clear_data():
|
||||
"""Clear all discovered data."""
|
||||
scanner = get_wifi_scanner()
|
||||
scanner.clear_data()
|
||||
|
||||
return jsonify({
|
||||
'status': 'cleared',
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Export
|
||||
# =============================================================================
|
||||
|
||||
@wifi_v2_bp.route('/export', methods=['GET'])
|
||||
def export_data():
|
||||
"""
|
||||
Export scan data.
|
||||
|
||||
Query params:
|
||||
format: 'json' or 'csv' (default: json)
|
||||
type: 'networks', 'clients', 'probes', 'all' (default: all)
|
||||
"""
|
||||
scanner = get_wifi_scanner()
|
||||
export_format = request.args.get('format', 'json')
|
||||
export_type = request.args.get('type', 'all')
|
||||
|
||||
if export_format == 'csv':
|
||||
return _export_csv(scanner, export_type)
|
||||
else:
|
||||
return _export_json(scanner, export_type)
|
||||
|
||||
|
||||
def _export_json(scanner, export_type: str) -> Response:
|
||||
"""Export data as JSON."""
|
||||
data = {}
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
data['networks'] = [n.to_dict() for n in scanner.access_points]
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
data['clients'] = [c.to_dict() for c in scanner.clients]
|
||||
|
||||
if export_type in ('probes', 'all'):
|
||||
data['probes'] = [p.to_dict() for p in scanner.probe_requests]
|
||||
|
||||
data['exported_at'] = datetime.now().isoformat()
|
||||
data['network_count'] = len(scanner.access_points)
|
||||
data['client_count'] = len(scanner.clients)
|
||||
|
||||
response = Response(
|
||||
json.dumps(data, indent=2),
|
||||
mimetype='application/json',
|
||||
)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
return response
|
||||
|
||||
|
||||
def _export_csv(scanner, export_type: str) -> Response:
|
||||
"""Export data as CSV."""
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type in ('networks', 'all'):
|
||||
writer = csv.writer(output)
|
||||
writer.writerow([
|
||||
'BSSID', 'ESSID', 'Channel', 'Band', 'RSSI', 'Security',
|
||||
'Cipher', 'Auth', 'Vendor', 'Clients', 'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for n in scanner.access_points:
|
||||
writer.writerow([
|
||||
n.bssid,
|
||||
n.essid or '[Hidden]',
|
||||
n.channel,
|
||||
n.band,
|
||||
n.rssi_current,
|
||||
n.security,
|
||||
n.cipher,
|
||||
n.auth,
|
||||
n.vendor or '',
|
||||
n.client_count,
|
||||
n.first_seen.isoformat(),
|
||||
n.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
if export_type == 'all':
|
||||
writer.writerow([]) # Blank line separator
|
||||
|
||||
if export_type in ('clients', 'all'):
|
||||
writer = csv.writer(output)
|
||||
if export_type == 'clients':
|
||||
writer.writerow([
|
||||
'MAC', 'Vendor', 'RSSI', 'Associated BSSID', 'Probed SSIDs',
|
||||
'First Seen', 'Last Seen'
|
||||
])
|
||||
|
||||
for c in scanner.clients:
|
||||
writer.writerow([
|
||||
c.mac,
|
||||
c.vendor or '',
|
||||
c.rssi_current,
|
||||
c.associated_bssid or '',
|
||||
', '.join(c.probed_ssids),
|
||||
c.first_seen.isoformat(),
|
||||
c.last_seen.isoformat(),
|
||||
])
|
||||
|
||||
response = Response(output.getvalue(), mimetype='text/csv')
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=wifi_scan_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
return response
|
||||
@@ -0,0 +1,879 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BLUETOOTH DEVICE LIST CONTAINER
|
||||
============================================ */
|
||||
#btDeviceListContent {
|
||||
display: block !important;
|
||||
padding: 10px !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Pure inline-styled cards - ensure no interference */
|
||||
#btDeviceListContent > div[data-bt-device-id] {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Legacy card support */
|
||||
#btDeviceListContent .device-card,
|
||||
#btDeviceListContent .signal-card {
|
||||
margin: 0 0 10px 0;
|
||||
height: auto !important;
|
||||
min-height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Ensure card body is visible */
|
||||
.device-card .signal-card-body,
|
||||
.signal-card .signal-card-body {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 8px !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-identity,
|
||||
.signal-card .device-identity {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-signal-row,
|
||||
.signal-card .device-signal-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.device-card .device-meta-row,
|
||||
.signal-card .device-meta-row {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ENHANCED MODAL STYLES
|
||||
============================================ */
|
||||
.signal-details-modal-header .modal-header-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.signal-details-modal-subtitle {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.signal-details-modal-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-secondary, #252525);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.signal-details-copy-addr-btn:hover {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Modal Header Section */
|
||||
.modal-device-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.modal-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Modal Sections */
|
||||
.modal-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Signal Display */
|
||||
.modal-signal-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-rssi-large {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-rssi-large .rssi-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dim, #666);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Signal Stats Grid */
|
||||
.modal-signal-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-label {
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-dim, #666);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-signal-stats .stat-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.modal-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.modal-info-grid .info-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.modal-info-grid .info-value.mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--accent-cyan, #00d4ff);
|
||||
}
|
||||
|
||||
/* UUID List */
|
||||
.modal-uuid-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal-uuid {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* Heuristics Grid */
|
||||
.modal-heuristics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heuristic-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #1a1a1a);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heuristic-check.active {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.heuristic-indicator {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.heuristic-check.active .heuristic-indicator {
|
||||
color: var(--accent-green, #22c55e);
|
||||
}
|
||||
|
||||
.heuristic-label {
|
||||
font-size: 11px;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE MODAL
|
||||
============================================ */
|
||||
@media (max-width: 600px) {
|
||||
.modal-signal-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.modal-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-signal-display {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modal-sparkline {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-device-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DARK MODE OVERRIDES (if needed)
|
||||
============================================ */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.device-card {
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #141414;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Proximity Visualization Components
|
||||
* Styles for radar and timeline heatmap
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
PROXIMITY RADAR
|
||||
============================================ */
|
||||
|
||||
.proximity-radar-svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.radar-device {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.radar-device:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
.radar-dot-pulse circle:first-child {
|
||||
animation: radar-pulse 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes radar-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.radar-sweep {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
/* Radar filter buttons */
|
||||
.bt-radar-filter-btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn:hover {
|
||||
background: var(--bg-hover, #333) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.bt-radar-filter-btn.active {
|
||||
background: #00d4ff !important;
|
||||
color: #000 !important;
|
||||
border-color: #00d4ff !important;
|
||||
}
|
||||
|
||||
#btRadarPauseBtn.active {
|
||||
background: #f97316 !important;
|
||||
color: #000 !important;
|
||||
border-color: #f97316 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TIMELINE HEATMAP
|
||||
============================================ */
|
||||
|
||||
.timeline-heatmap-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim, #888);
|
||||
}
|
||||
|
||||
.heatmap-select {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heatmap-select:hover {
|
||||
border-color: var(--accent-color, #00d4ff);
|
||||
}
|
||||
|
||||
.heatmap-btn {
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim, #888);
|
||||
font-size: 10px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-btn:hover {
|
||||
background: var(--bg-hover, #252525);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.heatmap-btn.active {
|
||||
background: #f97316;
|
||||
color: #000;
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
.timeline-heatmap-content {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.heatmap-loading,
|
||||
.heatmap-empty,
|
||||
.heatmap-error {
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.heatmap-error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.heatmap-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.heatmap-row:hover:not(.heatmap-header) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.heatmap-row.selected {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
outline: 1px solid rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.heatmap-header {
|
||||
cursor: default;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.heatmap-label {
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heatmap-label .device-name {
|
||||
font-size: 10px;
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.heatmap-label .device-rssi {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #666);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.heatmap-cells {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.heatmap-cell {
|
||||
border-radius: 2px;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.heatmap-cell:hover {
|
||||
transform: scale(1.5);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heatmap-time-label {
|
||||
font-size: 8px;
|
||||
color: var(--text-dim, #666);
|
||||
text-align: center;
|
||||
transform: rotate(-45deg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.heatmap-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim, #666);
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZONE SUMMARY
|
||||
============================================ */
|
||||
|
||||
#btZoneSummary {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
#btZoneSummary > div {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RESPONSIVE ADJUSTMENTS
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timeline-heatmap-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.heatmap-control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.proximity-radar-svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#btRadarControls {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#btZoneSummary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
+1066
-98
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* WiFi Channel Utilization Chart Component
|
||||
*
|
||||
* Displays channel utilization as a bar chart with recommendations.
|
||||
* Shows AP count, client count, and utilization score per channel.
|
||||
*/
|
||||
|
||||
const ChannelChart = (function() {
|
||||
'use strict';
|
||||
|
||||
// ==========================================================================
|
||||
// Configuration
|
||||
// ==========================================================================
|
||||
|
||||
const CONFIG = {
|
||||
height: 120,
|
||||
barWidth: 14,
|
||||
barSpacing: 2,
|
||||
padding: { top: 15, right: 10, bottom: 25, left: 30 },
|
||||
colors: {
|
||||
low: '#22c55e', // Green - low utilization
|
||||
medium: '#eab308', // Yellow - medium
|
||||
high: '#ef4444', // Red - high
|
||||
recommended: '#3b82f6', // Blue - recommended
|
||||
},
|
||||
thresholds: {
|
||||
low: 0.3,
|
||||
medium: 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
// 2.4 GHz non-overlapping channels
|
||||
const CHANNELS_2_4 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||
const NON_OVERLAPPING_2_4 = [1, 6, 11];
|
||||
|
||||
// 5 GHz channels (non-DFS)
|
||||
const CHANNELS_5 = [36, 40, 44, 48, 149, 153, 157, 161, 165];
|
||||
|
||||
// ==========================================================================
|
||||
// State
|
||||
// ==========================================================================
|
||||
|
||||
let container = null;
|
||||
let currentBand = '2.4';
|
||||
let channelStats = [];
|
||||
let recommendations = [];
|
||||
|
||||
// ==========================================================================
|
||||
// Initialization
|
||||
// ==========================================================================
|
||||
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.warn('[ChannelChart] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(CONFIG, options);
|
||||
render();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Update
|
||||
// ==========================================================================
|
||||
|
||||
function update(stats, recs) {
|
||||
channelStats = stats || [];
|
||||
recommendations = recs || [];
|
||||
render();
|
||||
}
|
||||
|
||||
function setBand(band) {
|
||||
currentBand = band;
|
||||
render();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Rendering
|
||||
// ==========================================================================
|
||||
|
||||
function render() {
|
||||
if (!container) return;
|
||||
|
||||
const channels = currentBand === '2.4' ? CHANNELS_2_4 : CHANNELS_5;
|
||||
const nonOverlapping = currentBand === '2.4' ? NON_OVERLAPPING_2_4 : CHANNELS_5;
|
||||
|
||||
// Build stats map
|
||||
const statsMap = {};
|
||||
channelStats.forEach(s => {
|
||||
statsMap[s.channel] = s;
|
||||
});
|
||||
|
||||
// Build recommendations map
|
||||
const recsMap = {};
|
||||
recommendations.forEach((r, i) => {
|
||||
recsMap[r.channel] = { rank: i + 1, ...r };
|
||||
});
|
||||
|
||||
// Calculate dimensions
|
||||
const width = channels.length * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.padding.left + CONFIG.padding.right;
|
||||
const height = CONFIG.height + CONFIG.padding.top + CONFIG.padding.bottom;
|
||||
const chartHeight = CONFIG.height;
|
||||
|
||||
// Find max values for scaling
|
||||
let maxApCount = 1;
|
||||
channelStats.forEach(s => {
|
||||
if (s.ap_count > maxApCount) maxApCount = s.ap_count;
|
||||
});
|
||||
|
||||
// Build SVG with viewBox for responsive scaling
|
||||
let svg = `
|
||||
<svg viewBox="0 0 ${width} ${height}" class="channel-chart-svg" style="width: 100%; height: auto; max-height: ${height}px;">
|
||||
<defs>
|
||||
<linearGradient id="utilGradientLow" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.low};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="utilGradientMed" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.medium};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="utilGradientHigh" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.9" />
|
||||
<stop offset="100%" style="stop-color:${CONFIG.colors.high};stop-opacity:0.5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Y-axis label -->
|
||||
<text x="10" y="${height / 2}" fill="#666" font-size="10" transform="rotate(-90, 10, ${height / 2})" text-anchor="middle">APs</text>
|
||||
|
||||
<!-- Y-axis ticks -->
|
||||
${renderYAxis(chartHeight, maxApCount)}
|
||||
|
||||
<!-- Bars -->
|
||||
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top})">
|
||||
${channels.map((ch, i) => {
|
||||
const stats = statsMap[ch] || { ap_count: 0, utilization_score: 0 };
|
||||
const rec = recsMap[ch];
|
||||
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||
return renderBar(i, ch, stats, rec, isNonOverlapping, chartHeight, maxApCount);
|
||||
}).join('')}
|
||||
</g>
|
||||
|
||||
<!-- X-axis labels -->
|
||||
<g transform="translate(${CONFIG.padding.left}, ${CONFIG.padding.top + chartHeight + 5})">
|
||||
${channels.map((ch, i) => {
|
||||
const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2;
|
||||
const isNonOverlapping = nonOverlapping.includes(ch);
|
||||
return `<text x="${x}" y="12" fill="${isNonOverlapping ? '#fff' : '#666'}" font-size="9" text-anchor="middle">${ch}</text>`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Add legend
|
||||
svg += renderLegend();
|
||||
|
||||
// Add recommendations
|
||||
if (recommendations.length > 0) {
|
||||
svg += renderRecommendations();
|
||||
}
|
||||
|
||||
container.innerHTML = svg;
|
||||
}
|
||||
|
||||
function renderYAxis(chartHeight, maxApCount) {
|
||||
const ticks = [];
|
||||
const tickCount = Math.min(5, maxApCount);
|
||||
const step = Math.ceil(maxApCount / tickCount);
|
||||
|
||||
for (let i = 0; i <= maxApCount; i += step) {
|
||||
const y = CONFIG.padding.top + chartHeight - (i / maxApCount * chartHeight);
|
||||
ticks.push(`
|
||||
<line x1="${CONFIG.padding.left - 5}" y1="${y}" x2="${CONFIG.padding.left}" y2="${y}" stroke="#444" />
|
||||
<text x="${CONFIG.padding.left - 8}" y="${y + 3}" fill="#666" font-size="9" text-anchor="end">${i}</text>
|
||||
`);
|
||||
}
|
||||
|
||||
return ticks.join('');
|
||||
}
|
||||
|
||||
function renderBar(index, channel, stats, rec, isNonOverlapping, chartHeight, maxApCount) {
|
||||
const x = index * (CONFIG.barWidth + CONFIG.barSpacing);
|
||||
const barHeight = (stats.ap_count / maxApCount) * chartHeight;
|
||||
const y = chartHeight - barHeight;
|
||||
|
||||
// Determine color based on utilization
|
||||
let gradient = 'utilGradientLow';
|
||||
if (stats.utilization_score >= CONFIG.thresholds.medium) {
|
||||
gradient = 'utilGradientHigh';
|
||||
} else if (stats.utilization_score >= CONFIG.thresholds.low) {
|
||||
gradient = 'utilGradientMed';
|
||||
}
|
||||
|
||||
// Recommended channel indicator
|
||||
const isRecommended = rec && rec.rank <= 3;
|
||||
const recIndicator = isRecommended ?
|
||||
`<circle cx="${x + CONFIG.barWidth / 2}" cy="${chartHeight + 20}" r="4" fill="${CONFIG.colors.recommended}" />
|
||||
<text x="${x + CONFIG.barWidth / 2}" y="${chartHeight + 23}" fill="#fff" font-size="7" text-anchor="middle">${rec.rank}</text>` : '';
|
||||
|
||||
// Non-overlapping channel marker
|
||||
const channelMarker = isNonOverlapping ?
|
||||
`<rect x="${x}" y="${chartHeight}" width="${CONFIG.barWidth}" height="2" fill="#3b82f6" />` : '';
|
||||
|
||||
return `
|
||||
<g class="channel-bar" data-channel="${channel}">
|
||||
<!-- Bar background -->
|
||||
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||
fill="#1a1a2e" rx="2" />
|
||||
|
||||
<!-- Utilization bar -->
|
||||
<rect x="${x}" y="${y}" width="${CONFIG.barWidth}" height="${barHeight}"
|
||||
fill="url(#${gradient})" rx="2" />
|
||||
|
||||
<!-- AP count label -->
|
||||
${stats.ap_count > 0 ? `
|
||||
<text x="${x + CONFIG.barWidth / 2}" y="${y - 4}" fill="#fff" font-size="9" text-anchor="middle">
|
||||
${stats.ap_count}
|
||||
</text>
|
||||
` : ''}
|
||||
|
||||
${channelMarker}
|
||||
${recIndicator}
|
||||
|
||||
<!-- Hover area -->
|
||||
<rect x="${x}" y="0" width="${CONFIG.barWidth}" height="${chartHeight}"
|
||||
fill="transparent" class="channel-hover" />
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLegend() {
|
||||
return `
|
||||
<div class="channel-chart-legend" style="display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.low}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">Low</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.medium}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">Medium</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 12px; background: ${CONFIG.colors.high}; border-radius: 2px;"></span>
|
||||
<span style="color: #888;">High</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span style="width: 12px; height: 3px; background: #3b82f6; border-radius: 1px;"></span>
|
||||
<span style="color: #888;">Non-overlapping</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecommendations() {
|
||||
const topRecs = recommendations.slice(0, 3);
|
||||
if (topRecs.length === 0) return '';
|
||||
|
||||
return `
|
||||
<div class="channel-chart-recommendations" style="margin-top: 12px; padding: 8px; background: #1a1a2e; border-radius: 4px;">
|
||||
<div style="font-size: 10px; color: #888; margin-bottom: 6px;">Recommended Channels:</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
${topRecs.map((rec, i) => `
|
||||
<div style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${i === 0 ? 'rgba(59, 130, 246, 0.2)' : '#0d0d1a'}; border-radius: 4px; border: 1px solid ${i === 0 ? '#3b82f6' : '#333'};">
|
||||
<span style="font-size: 11px; font-weight: bold; color: ${i === 0 ? '#3b82f6' : '#666'};">#${i + 1}</span>
|
||||
<span style="font-size: 12px; color: #fff;">Ch ${rec.channel}</span>
|
||||
<span style="font-size: 9px; color: #666;">(${rec.band})</span>
|
||||
${rec.is_dfs ? '<span style="font-size: 8px; color: #ff6b6b; margin-left: 4px;">DFS</span>' : ''}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API
|
||||
// ==========================================================================
|
||||
|
||||
return {
|
||||
init,
|
||||
update,
|
||||
setBand,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* 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 = {}) {
|
||||
// Debug: log received device data
|
||||
console.log('[DeviceCard] Creating card for:', device.address, device);
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'signal-card device-card';
|
||||
card.dataset.deviceId = device.device_id || '';
|
||||
card.dataset.protocol = device.protocol || 'ble';
|
||||
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
|
||||
try {
|
||||
card.dataset.deviceData = JSON.stringify(device);
|
||||
} catch (e) {
|
||||
card.dataset.deviceData = '{}';
|
||||
}
|
||||
|
||||
const relativeTime = formatRelativeTime(device.last_seen) || 'Unknown';
|
||||
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) || '';
|
||||
|
||||
// Build card with explicit defaults for all values
|
||||
const deviceName = device.name || device.device_id || 'Unknown Device';
|
||||
const deviceAddress = device.address || 'Unknown';
|
||||
const addressType = device.address_type || 'unknown';
|
||||
const rssiDisplay = (device.rssi_current !== null && device.rssi_current !== undefined)
|
||||
? device.rssi_current + ' dBm' : '--';
|
||||
const seenCount = device.seen_count || 0;
|
||||
const inBaseline = device.in_baseline || false;
|
||||
const mfrName = device.manufacturer_name || '';
|
||||
|
||||
// Build the HTML parts separately to avoid template issues
|
||||
const headerHtml = '<div class="signal-card-header">' +
|
||||
'<div class="signal-card-badges">' + protocolBadge + heuristicBadges + '</div>' +
|
||||
'<span class="signal-status-pill" data-status="' + (inBaseline ? 'baseline' : 'new') + '">' +
|
||||
'<span class="status-dot"></span>' + (inBaseline ? 'Known' : 'New') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
const identityHtml = '<div class="device-identity">' +
|
||||
'<div class="device-name">' + escapeHtml(deviceName) + '</div>' +
|
||||
'<div class="device-address">' +
|
||||
'<span class="address-value">' + escapeHtml(deviceAddress) + '</span>' +
|
||||
'<span class="address-type">(' + escapeHtml(addressType) + ')</span>' +
|
||||
'</div></div>';
|
||||
|
||||
const signalHtml = '<div class="device-signal-row">' +
|
||||
'<div class="rssi-display">' +
|
||||
'<span class="rssi-current" title="Current RSSI">' + rssiDisplay + '</span>' +
|
||||
sparkline + '</div>' + rangeBand + '</div>';
|
||||
|
||||
const mfrHtml = mfrName ?
|
||||
'<div class="device-manufacturer">' +
|
||||
'<span class="mfr-icon">🏭</span>' +
|
||||
'<span class="mfr-name">' + escapeHtml(mfrName) + '</span></div>' : '';
|
||||
|
||||
const metaHtml = '<div class="device-meta-row">' +
|
||||
'<span class="device-seen-count" title="Observation count">' +
|
||||
'<span class="seen-icon">👁</span>' + seenCount + '×</span>' +
|
||||
'<span class="device-timestamp" data-timestamp="' + escapeHtml(device.last_seen || '') + '">' +
|
||||
escapeHtml(relativeTime) + '</span></div>';
|
||||
|
||||
const bodyHtml = '<div class="signal-card-body">' +
|
||||
identityHtml + signalHtml + mfrHtml + metaHtml + '</div>';
|
||||
|
||||
card.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
// Make card clickable - opens modal with full details
|
||||
card.addEventListener('click', () => {
|
||||
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">
|
||||
<div class="modal-header-info">
|
||||
<span class="signal-details-modal-title"></span>
|
||||
<span class="signal-details-modal-subtitle"></span>
|
||||
</div>
|
||||
<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 JSON</button>
|
||||
<button class="signal-details-copy-addr-btn">Copy Address</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');
|
||||
});
|
||||
// Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('show')) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update copy button handlers with current device
|
||||
const copyBtn = modal.querySelector('.signal-details-copy-btn');
|
||||
const copyAddrBtn = modal.querySelector('.signal-details-copy-addr-btn');
|
||||
|
||||
copyBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(device, null, 2)).then(() => {
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy JSON'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
copyAddrBtn.onclick = () => {
|
||||
navigator.clipboard.writeText(device.address).then(() => {
|
||||
copyAddrBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyAddrBtn.textContent = 'Copy Address'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// Populate modal header
|
||||
modal.querySelector('.signal-details-modal-title').textContent = device.name || 'Unknown Device';
|
||||
modal.querySelector('.signal-details-modal-subtitle').textContent = device.address;
|
||||
|
||||
// Populate modal body with enhanced content
|
||||
modal.querySelector('.signal-details-modal-body').innerHTML = createModalContent(device);
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create enhanced modal content
|
||||
*/
|
||||
function createModalContent(device) {
|
||||
const protocolLabel = device.protocol === 'ble' ? 'Bluetooth Low Energy' : 'Classic Bluetooth';
|
||||
const sparkline = createSparkline(device.rssi_history, { width: 120, height: 30 });
|
||||
|
||||
return `
|
||||
<div class="modal-device-header">
|
||||
<div class="modal-badges">
|
||||
${createProtocolBadge(device.protocol)}
|
||||
${createHeuristicBadges(device.heuristic_flags)}
|
||||
</div>
|
||||
${createRangeBand(device.range_band, device.range_confidence)}
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Signal Strength</div>
|
||||
<div class="modal-signal-display">
|
||||
<div class="modal-rssi-large">${device.rssi_current !== null ? device.rssi_current : '--'}<span class="rssi-unit">dBm</span></div>
|
||||
<div class="modal-sparkline">${sparkline}</div>
|
||||
</div>
|
||||
<div class="modal-signal-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Median</span>
|
||||
<span class="stat-value">${device.rssi_median !== null ? device.rssi_median + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Min</span>
|
||||
<span class="stat-value">${device.rssi_min !== null ? device.rssi_min + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Max</span>
|
||||
<span class="stat-value">${device.rssi_max !== null ? device.rssi_max + ' dBm' : 'N/A'}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Confidence</span>
|
||||
<span class="stat-value">${Math.round((device.rssi_confidence || 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Device Information</div>
|
||||
<div class="modal-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Address</span>
|
||||
<span class="info-value mono">${escapeHtml(device.address)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Address Type</span>
|
||||
<span class="info-value">${escapeHtml(device.address_type)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Protocol</span>
|
||||
<span class="info-value">${protocolLabel}</span>
|
||||
</div>
|
||||
${device.manufacturer_name ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Manufacturer</span>
|
||||
<span class="info-value">${escapeHtml(device.manufacturer_name)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${device.manufacturer_id ? `
|
||||
<div class="info-item">
|
||||
<span class="info-label">Manufacturer ID</span>
|
||||
<span class="info-value mono">0x${device.manufacturer_id.toString(16).padStart(4, '0').toUpperCase()}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Observation Timeline</div>
|
||||
<div class="modal-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">First Seen</span>
|
||||
<span class="info-value">${formatRelativeTime(device.first_seen)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Last Seen</span>
|
||||
<span class="info-value">${formatRelativeTime(device.last_seen)}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Observations</span>
|
||||
<span class="info-value">${device.seen_count}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Rate</span>
|
||||
<span class="info-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="modal-section">
|
||||
<div class="modal-section-title">Service UUIDs</div>
|
||||
<div class="modal-uuid-list">
|
||||
${device.service_uuids.map(uuid => `<span class="modal-uuid">${escapeHtml(uuid)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${device.heuristics ? `
|
||||
<div class="modal-section">
|
||||
<div class="modal-section-title">Behavioral Analysis</div>
|
||||
<div class="modal-heuristics-grid">
|
||||
${Object.entries(device.heuristics).map(([key, value]) => `
|
||||
<div class="heuristic-check ${value ? 'active' : ''}">
|
||||
<span class="heuristic-indicator">${value ? '✓' : '−'}</span>
|
||||
<span class="heuristic-label">${escapeHtml(key.replace(/_/g, ' '))}</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Proximity Radar Component
|
||||
*
|
||||
* SVG-based circular radar visualization for Bluetooth device proximity.
|
||||
* Displays devices positioned by estimated distance with concentric rings
|
||||
* for proximity bands.
|
||||
*/
|
||||
|
||||
const ProximityRadar = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
size: 280,
|
||||
padding: 20,
|
||||
centerRadius: 8,
|
||||
rings: [
|
||||
{ band: 'immediate', radius: 0.25, color: '#22c55e', label: '< 1m' },
|
||||
{ band: 'near', radius: 0.5, color: '#eab308', label: '1-3m' },
|
||||
{ band: 'far', radius: 0.85, color: '#ef4444', label: '3-10m' },
|
||||
],
|
||||
dotMinSize: 4,
|
||||
dotMaxSize: 12,
|
||||
pulseAnimationDuration: 2000,
|
||||
newDeviceThreshold: 30, // seconds
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let svg = null;
|
||||
let devices = new Map();
|
||||
let isPaused = false;
|
||||
let activeFilter = null;
|
||||
let onDeviceClick = null;
|
||||
let selectedDeviceKey = null;
|
||||
|
||||
/**
|
||||
* Initialize the radar component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[ProximityRadar] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceClick) {
|
||||
onDeviceClick = options.onDeviceClick;
|
||||
}
|
||||
|
||||
createSVG();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the SVG radar structure
|
||||
*/
|
||||
function createSVG() {
|
||||
const size = CONFIG.size;
|
||||
const center = size / 2;
|
||||
|
||||
container.innerHTML = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="proximity-radar-svg">
|
||||
<defs>
|
||||
<radialGradient id="radarGradient" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(0, 212, 255, 0.1)" />
|
||||
<stop offset="100%" stop-color="rgba(0, 212, 255, 0)" />
|
||||
</radialGradient>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background gradient -->
|
||||
<circle cx="${center}" cy="${center}" r="${center - CONFIG.padding}"
|
||||
fill="url(#radarGradient)" />
|
||||
|
||||
<!-- Proximity rings -->
|
||||
<g class="radar-rings">
|
||||
${CONFIG.rings.map((ring, i) => {
|
||||
const r = ring.radius * (center - CONFIG.padding);
|
||||
return `
|
||||
<circle cx="${center}" cy="${center}" r="${r}"
|
||||
fill="none" stroke="${ring.color}" stroke-opacity="0.3"
|
||||
stroke-width="1" stroke-dasharray="4,4" />
|
||||
<text x="${center}" y="${center - r + 12}"
|
||||
text-anchor="middle" fill="${ring.color}" fill-opacity="0.6"
|
||||
font-size="9" font-family="monospace">${ring.label}</text>
|
||||
`;
|
||||
}).join('')}
|
||||
</g>
|
||||
|
||||
<!-- Sweep line (animated) -->
|
||||
<line class="radar-sweep" x1="${center}" y1="${center}"
|
||||
x2="${center}" y2="${CONFIG.padding}"
|
||||
stroke="rgba(0, 212, 255, 0.5)" stroke-width="1" />
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="${center}" cy="${center}" r="${CONFIG.centerRadius}"
|
||||
fill="#00d4ff" filter="url(#glow)" />
|
||||
|
||||
<!-- Device dots container -->
|
||||
<g class="radar-devices"></g>
|
||||
|
||||
<!-- Legend -->
|
||||
<g class="radar-legend" transform="translate(${size - 70}, ${size - 55})">
|
||||
<text x="0" y="0" fill="#666" font-size="8">PROXIMITY</text>
|
||||
<text x="0" y="0" fill="#666" font-size="7" font-style="italic"
|
||||
transform="translate(0, 10)">(signal strength)</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
svg = container.querySelector('svg');
|
||||
|
||||
// Add sweep animation
|
||||
animateSweep();
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate the radar sweep line
|
||||
*/
|
||||
function animateSweep() {
|
||||
const sweepLine = svg.querySelector('.radar-sweep');
|
||||
if (!sweepLine) return;
|
||||
|
||||
let angle = 0;
|
||||
const center = CONFIG.size / 2;
|
||||
|
||||
function rotate() {
|
||||
if (isPaused) {
|
||||
requestAnimationFrame(rotate);
|
||||
return;
|
||||
}
|
||||
|
||||
angle = (angle + 1) % 360;
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const radius = center - CONFIG.padding;
|
||||
const x2 = center + Math.sin(rad) * radius;
|
||||
const y2 = center - Math.cos(rad) * radius;
|
||||
|
||||
sweepLine.setAttribute('x2', x2);
|
||||
sweepLine.setAttribute('y2', y2);
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
requestAnimationFrame(rotate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update devices on the radar
|
||||
*/
|
||||
function updateDevices(deviceList) {
|
||||
if (isPaused) return;
|
||||
|
||||
// Update device map
|
||||
deviceList.forEach(device => {
|
||||
devices.set(device.device_key, device);
|
||||
});
|
||||
|
||||
// Apply filter and render
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render device dots on the radar
|
||||
*/
|
||||
function renderDevices() {
|
||||
const devicesGroup = svg.querySelector('.radar-devices');
|
||||
if (!devicesGroup) return;
|
||||
|
||||
const center = CONFIG.size / 2;
|
||||
const maxRadius = center - CONFIG.padding;
|
||||
|
||||
// Filter devices
|
||||
let visibleDevices = Array.from(devices.values());
|
||||
|
||||
if (activeFilter === 'newOnly') {
|
||||
visibleDevices = visibleDevices.filter(d => d.is_new || d.age_seconds < CONFIG.newDeviceThreshold);
|
||||
} else if (activeFilter === 'strongest') {
|
||||
visibleDevices = visibleDevices
|
||||
.filter(d => d.rssi_current != null)
|
||||
.sort((a, b) => (b.rssi_current || -100) - (a.rssi_current || -100))
|
||||
.slice(0, 10);
|
||||
} else if (activeFilter === 'unapproved') {
|
||||
visibleDevices = visibleDevices.filter(d => !d.in_baseline);
|
||||
}
|
||||
|
||||
// Build SVG for each device
|
||||
const dots = visibleDevices.map(device => {
|
||||
// Calculate position
|
||||
const { x, y, radius } = calculateDevicePosition(device, center, maxRadius);
|
||||
|
||||
// Calculate dot size based on confidence
|
||||
const confidence = device.distance_confidence || 0.5;
|
||||
const dotSize = CONFIG.dotMinSize + (CONFIG.dotMaxSize - CONFIG.dotMinSize) * confidence;
|
||||
|
||||
// Get color based on proximity band
|
||||
const color = getBandColor(device.proximity_band);
|
||||
|
||||
// Check if newly seen (pulse animation)
|
||||
const isNew = device.age_seconds < 5;
|
||||
const pulseClass = isNew ? 'radar-dot-pulse' : '';
|
||||
const isSelected = selectedDeviceKey && device.device_key === selectedDeviceKey;
|
||||
|
||||
return `
|
||||
<g class="radar-device ${pulseClass}${isSelected ? ' selected' : ''}" data-device-key="${escapeAttr(device.device_key)}"
|
||||
transform="translate(${x}, ${y})" style="cursor: pointer;">
|
||||
${isSelected ? `<circle r="${dotSize + 8}" fill="none" stroke="#00d4ff" stroke-width="2" stroke-opacity="0.8">
|
||||
<animate attributeName="r" values="${dotSize + 6};${dotSize + 10};${dotSize + 6}" dur="1.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="stroke-opacity" values="0.8;0.4;0.8" dur="1.5s" repeatCount="indefinite"/>
|
||||
</circle>` : ''}
|
||||
<circle r="${dotSize}" fill="${color}"
|
||||
fill-opacity="${isSelected ? 1 : 0.4 + confidence * 0.5}"
|
||||
stroke="${isSelected ? '#00d4ff' : color}" stroke-width="${isSelected ? 2 : 1}" />
|
||||
${device.is_new && !isSelected ? `<circle r="${dotSize + 3}" fill="none" stroke="#3b82f6" stroke-width="1" stroke-dasharray="2,2" />` : ''}
|
||||
<title>${escapeHtml(device.name || device.address)} (${device.rssi_current || '--'} dBm)</title>
|
||||
</g>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
devicesGroup.innerHTML = dots;
|
||||
|
||||
// Attach click handlers
|
||||
devicesGroup.querySelectorAll('.radar-device').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const deviceKey = el.getAttribute('data-device-key');
|
||||
if (onDeviceClick && deviceKey) {
|
||||
onDeviceClick(deviceKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate device position on radar
|
||||
*/
|
||||
function calculateDevicePosition(device, center, maxRadius) {
|
||||
// Calculate radius based on proximity band/distance
|
||||
let radiusRatio;
|
||||
const band = device.proximity_band || 'unknown';
|
||||
|
||||
if (device.estimated_distance_m != null) {
|
||||
// Use actual distance (log scale)
|
||||
const maxDistance = 15;
|
||||
radiusRatio = Math.min(1, Math.log10(device.estimated_distance_m + 1) / Math.log10(maxDistance + 1));
|
||||
} else {
|
||||
// Use band-based positioning
|
||||
switch (band) {
|
||||
case 'immediate': radiusRatio = 0.15; break;
|
||||
case 'near': radiusRatio = 0.4; break;
|
||||
case 'far': radiusRatio = 0.7; break;
|
||||
default: radiusRatio = 0.9; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate angle based on device key hash (stable positioning)
|
||||
const angle = hashToAngle(device.device_key || device.device_id);
|
||||
const radius = radiusRatio * maxRadius;
|
||||
|
||||
const x = center + Math.sin(angle) * radius;
|
||||
const y = center - Math.cos(angle) * radius;
|
||||
|
||||
return { x, y, radius };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash string to angle for stable positioning
|
||||
*/
|
||||
function hashToAngle(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
return (Math.abs(hash) % 360) * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for proximity band
|
||||
*/
|
||||
function getBandColor(band) {
|
||||
switch (band) {
|
||||
case 'immediate': return '#22c55e';
|
||||
case 'near': return '#eab308';
|
||||
case 'far': return '#ef4444';
|
||||
default: return '#6b7280';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set filter mode
|
||||
*/
|
||||
function setFilter(filter) {
|
||||
activeFilter = filter === activeFilter ? null : filter;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle pause state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all devices
|
||||
*/
|
||||
function clear() {
|
||||
devices.clear();
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a specific device on the radar
|
||||
*/
|
||||
function highlightDevice(deviceKey) {
|
||||
selectedDeviceKey = deviceKey;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear device highlighting
|
||||
*/
|
||||
function clearHighlight() {
|
||||
selectedDeviceKey = null;
|
||||
renderDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get zone counts
|
||||
*/
|
||||
function getZoneCounts() {
|
||||
const counts = { immediate: 0, near: 0, far: 0, unknown: 0 };
|
||||
devices.forEach(device => {
|
||||
const band = device.proximity_band || 'unknown';
|
||||
if (counts.hasOwnProperty(band)) {
|
||||
counts[band]++;
|
||||
} else {
|
||||
counts.unknown++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
updateDevices,
|
||||
setFilter,
|
||||
setPaused,
|
||||
clear,
|
||||
getZoneCounts,
|
||||
highlightDevice,
|
||||
clearHighlight,
|
||||
isPaused: () => isPaused,
|
||||
getFilter: () => activeFilter,
|
||||
getSelectedDevice: () => selectedDeviceKey,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ProximityRadar;
|
||||
}
|
||||
|
||||
window.ProximityRadar = ProximityRadar;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Timeline Heatmap Component
|
||||
*
|
||||
* Displays RSSI signal history as a heatmap grid.
|
||||
* Y-axis: devices, X-axis: time buckets, Cell color: RSSI strength
|
||||
*/
|
||||
|
||||
const TimelineHeatmap = (function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
cellWidth: 8,
|
||||
cellHeight: 20,
|
||||
labelWidth: 120,
|
||||
maxDevices: 20,
|
||||
refreshInterval: 5000,
|
||||
// RSSI color scale (green = strong, red = weak)
|
||||
colorScale: [
|
||||
{ rssi: -40, color: '#22c55e' }, // Strong - green
|
||||
{ rssi: -55, color: '#84cc16' }, // Good - lime
|
||||
{ rssi: -65, color: '#eab308' }, // Medium - yellow
|
||||
{ rssi: -75, color: '#f97316' }, // Weak - orange
|
||||
{ rssi: -90, color: '#ef4444' }, // Very weak - red
|
||||
],
|
||||
noDataColor: '#2a2a3e',
|
||||
};
|
||||
|
||||
// State
|
||||
let container = null;
|
||||
let contentEl = null;
|
||||
let controlsEl = null;
|
||||
let data = null;
|
||||
let isPaused = false;
|
||||
let refreshTimer = null;
|
||||
let selectedDeviceKey = null;
|
||||
let onDeviceSelect = null;
|
||||
|
||||
// Settings
|
||||
let settings = {
|
||||
windowMinutes: 10,
|
||||
bucketSeconds: 10,
|
||||
sortBy: 'recency',
|
||||
topN: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the heatmap component
|
||||
*/
|
||||
function init(containerId, options = {}) {
|
||||
container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
console.error('[TimelineHeatmap] Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.onDeviceSelect) {
|
||||
onDeviceSelect = options.onDeviceSelect;
|
||||
}
|
||||
|
||||
// Merge options into settings
|
||||
Object.assign(settings, options);
|
||||
|
||||
createStructure();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the heatmap DOM structure
|
||||
*/
|
||||
function createStructure() {
|
||||
container.innerHTML = `
|
||||
<div class="timeline-heatmap-controls">
|
||||
<div class="heatmap-control-group">
|
||||
<label>Window:</label>
|
||||
<select id="heatmapWindow" class="heatmap-select">
|
||||
<option value="10" ${settings.windowMinutes === 10 ? 'selected' : ''}>10 min</option>
|
||||
<option value="30" ${settings.windowMinutes === 30 ? 'selected' : ''}>30 min</option>
|
||||
<option value="60" ${settings.windowMinutes === 60 ? 'selected' : ''}>60 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Bucket:</label>
|
||||
<select id="heatmapBucket" class="heatmap-select">
|
||||
<option value="10" ${settings.bucketSeconds === 10 ? 'selected' : ''}>10s</option>
|
||||
<option value="30" ${settings.bucketSeconds === 30 ? 'selected' : ''}>30s</option>
|
||||
<option value="60" ${settings.bucketSeconds === 60 ? 'selected' : ''}>60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="heatmap-control-group">
|
||||
<label>Sort:</label>
|
||||
<select id="heatmapSort" class="heatmap-select">
|
||||
<option value="recency" ${settings.sortBy === 'recency' ? 'selected' : ''}>Recent</option>
|
||||
<option value="strength" ${settings.sortBy === 'strength' ? 'selected' : ''}>Strength</option>
|
||||
<option value="activity" ${settings.sortBy === 'activity' ? 'selected' : ''}>Activity</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="heatmapPauseBtn" class="heatmap-btn ${isPaused ? 'active' : ''}">
|
||||
${isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="timeline-heatmap-content">
|
||||
<div class="heatmap-loading">Loading signal history...</div>
|
||||
</div>
|
||||
<div class="heatmap-legend">
|
||||
<span class="legend-label">Signal:</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #22c55e;"></span>Strong</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #eab308;"></span>Medium</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: #ef4444;"></span>Weak</span>
|
||||
<span class="legend-item"><span class="legend-color" style="background: ${CONFIG.noDataColor};"></span>No data</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentEl = container.querySelector('.timeline-heatmap-content');
|
||||
controlsEl = container.querySelector('.timeline-heatmap-controls');
|
||||
|
||||
// Attach event listeners
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to controls
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
const windowSelect = container.querySelector('#heatmapWindow');
|
||||
const bucketSelect = container.querySelector('#heatmapBucket');
|
||||
const sortSelect = container.querySelector('#heatmapSort');
|
||||
const pauseBtn = container.querySelector('#heatmapPauseBtn');
|
||||
|
||||
windowSelect?.addEventListener('change', (e) => {
|
||||
settings.windowMinutes = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
bucketSelect?.addEventListener('change', (e) => {
|
||||
settings.bucketSeconds = parseInt(e.target.value, 10);
|
||||
refresh();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener('change', (e) => {
|
||||
settings.sortBy = e.target.value;
|
||||
refresh();
|
||||
});
|
||||
|
||||
pauseBtn?.addEventListener('click', () => {
|
||||
isPaused = !isPaused;
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-refresh timer
|
||||
*/
|
||||
function startAutoRefresh() {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!isPaused) {
|
||||
refresh();
|
||||
}
|
||||
}, CONFIG.refreshInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render heatmap data
|
||||
*/
|
||||
async function refresh() {
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
top_n: settings.topN,
|
||||
window_minutes: settings.windowMinutes,
|
||||
bucket_seconds: settings.bucketSeconds,
|
||||
sort_by: settings.sortBy,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/bluetooth/heatmap/data?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch heatmap data');
|
||||
|
||||
data = await response.json();
|
||||
render();
|
||||
} catch (err) {
|
||||
console.error('[TimelineHeatmap] Refresh error:', err);
|
||||
contentEl.innerHTML = '<div class="heatmap-error">Failed to load data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the heatmap grid
|
||||
*/
|
||||
function render() {
|
||||
if (!data || !data.devices || data.devices.length === 0) {
|
||||
contentEl.innerHTML = '<div class="heatmap-empty">No signal history available yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate time buckets
|
||||
const windowMs = settings.windowMinutes * 60 * 1000;
|
||||
const bucketMs = settings.bucketSeconds * 1000;
|
||||
const numBuckets = Math.ceil(windowMs / bucketMs);
|
||||
const now = new Date();
|
||||
|
||||
// Generate time labels
|
||||
const timeLabels = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const time = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
if (i % Math.ceil(numBuckets / 6) === 0) {
|
||||
timeLabels.push(time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
} else {
|
||||
timeLabels.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Build heatmap HTML
|
||||
let html = '<div class="heatmap-grid">';
|
||||
|
||||
// Time axis header
|
||||
html += `<div class="heatmap-row heatmap-header">
|
||||
<div class="heatmap-label"></div>
|
||||
<div class="heatmap-cells">
|
||||
${timeLabels.map(label =>
|
||||
`<div class="heatmap-time-label" style="width: ${CONFIG.cellWidth}px;">${label}</div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Device rows
|
||||
data.devices.forEach(device => {
|
||||
const isSelected = device.device_key === selectedDeviceKey;
|
||||
const rowClass = isSelected ? 'heatmap-row selected' : 'heatmap-row';
|
||||
|
||||
// Create lookup for timeseries data
|
||||
const tsLookup = new Map();
|
||||
device.timeseries.forEach(point => {
|
||||
const ts = new Date(point.timestamp).getTime();
|
||||
tsLookup.set(ts, point.rssi);
|
||||
});
|
||||
|
||||
// Generate cells for each time bucket
|
||||
const cells = [];
|
||||
for (let i = 0; i < numBuckets; i++) {
|
||||
const bucketTime = new Date(now.getTime() - (numBuckets - 1 - i) * bucketMs);
|
||||
const bucketKey = Math.floor(bucketTime.getTime() / bucketMs) * bucketMs;
|
||||
|
||||
// Find closest timestamp in data
|
||||
let rssi = null;
|
||||
const tolerance = bucketMs;
|
||||
tsLookup.forEach((val, ts) => {
|
||||
if (Math.abs(ts - bucketKey) < tolerance) {
|
||||
rssi = val;
|
||||
}
|
||||
});
|
||||
|
||||
const color = rssi !== null ? getRssiColor(rssi) : CONFIG.noDataColor;
|
||||
const title = rssi !== null ? `${rssi} dBm` : 'No data';
|
||||
|
||||
cells.push(`<div class="heatmap-cell" style="width: ${CONFIG.cellWidth}px; height: ${CONFIG.cellHeight}px; background: ${color};" title="${title}"></div>`);
|
||||
}
|
||||
|
||||
const displayName = device.name || formatAddress(device.address) || device.device_key.substring(0, 12);
|
||||
const rssiDisplay = device.rssi_ema != null ? `${Math.round(device.rssi_ema)} dBm` : '--';
|
||||
|
||||
html += `
|
||||
<div class="${rowClass}" data-device-key="${escapeAttr(device.device_key)}">
|
||||
<div class="heatmap-label" title="${escapeHtml(device.name || device.address || '')}">
|
||||
<span class="device-name">${escapeHtml(displayName)}</span>
|
||||
<span class="device-rssi">${rssiDisplay}</span>
|
||||
</div>
|
||||
<div class="heatmap-cells">${cells.join('')}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
contentEl.innerHTML = html;
|
||||
|
||||
// Attach row click handlers
|
||||
contentEl.querySelectorAll('.heatmap-row:not(.heatmap-header)').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const deviceKey = row.getAttribute('data-device-key');
|
||||
selectDevice(deviceKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for RSSI value
|
||||
*/
|
||||
function getRssiColor(rssi) {
|
||||
const scale = CONFIG.colorScale;
|
||||
|
||||
// Find the appropriate color from scale
|
||||
for (let i = 0; i < scale.length; i++) {
|
||||
if (rssi >= scale[i].rssi) {
|
||||
return scale[i].color;
|
||||
}
|
||||
}
|
||||
return scale[scale.length - 1].color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format MAC address for display
|
||||
*/
|
||||
function formatAddress(address) {
|
||||
if (!address) return null;
|
||||
const parts = address.split(':');
|
||||
if (parts.length === 6) {
|
||||
return `${parts[0]}:${parts[1]}:..${parts[5]}`;
|
||||
}
|
||||
return address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a device row
|
||||
*/
|
||||
function selectDevice(deviceKey) {
|
||||
selectedDeviceKey = deviceKey === selectedDeviceKey ? null : deviceKey;
|
||||
|
||||
// Update row highlighting
|
||||
contentEl.querySelectorAll('.heatmap-row').forEach(row => {
|
||||
const isSelected = row.getAttribute('data-device-key') === selectedDeviceKey;
|
||||
row.classList.toggle('selected', isSelected);
|
||||
});
|
||||
|
||||
// Callback
|
||||
if (onDeviceSelect && selectedDeviceKey) {
|
||||
const device = data?.devices?.find(d => d.device_key === selectedDeviceKey);
|
||||
onDeviceSelect(selectedDeviceKey, device);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update with new data directly (for SSE integration)
|
||||
*/
|
||||
function updateData(newData) {
|
||||
if (isPaused) return;
|
||||
data = newData;
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set paused state
|
||||
*/
|
||||
function setPaused(paused) {
|
||||
isPaused = paused;
|
||||
const pauseBtn = container?.querySelector('#heatmapPauseBtn');
|
||||
if (pauseBtn) {
|
||||
pauseBtn.textContent = isPaused ? 'Resume' : 'Pause';
|
||||
pauseBtn.classList.toggle('active', isPaused);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the component
|
||||
*/
|
||||
function destroy() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML for safe rendering
|
||||
*/
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape attribute value
|
||||
*/
|
||||
function escapeAttr(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
refresh,
|
||||
updateData,
|
||||
setPaused,
|
||||
destroy,
|
||||
selectDevice,
|
||||
getSelectedDevice: () => selectedDeviceKey,
|
||||
isPaused: () => isPaused,
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for module systems
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TimelineHeatmap;
|
||||
}
|
||||
|
||||
window.TimelineHeatmap = TimelineHeatmap;
|
||||
@@ -119,7 +119,6 @@ function switchMode(mode) {
|
||||
document.getElementById('aircraftStats').style.display = mode === 'aircraft' ? 'flex' : 'none';
|
||||
document.getElementById('satelliteStats').style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
document.getElementById('wifiStats').style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
document.getElementById('btStats').style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Hide signal meter - individual panels show signal strength where needed
|
||||
document.getElementById('signalMeter').style.display = 'none';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -230,7 +230,7 @@
|
||||
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
|
||||
<span class="remote-dump1090-controls" style="display: none;">
|
||||
<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>
|
||||
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
|
||||
<option value="0">SDR 0</option>
|
||||
|
||||
+408
-301
@@ -19,6 +19,8 @@
|
||||
<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/activity-timeline.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -472,230 +474,176 @@
|
||||
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()"
|
||||
title="Click: Rogue AP details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span> <span id="rogueApCount">0</span></div>
|
||||
</div>
|
||||
<div class="stats" id="btStats" style="display: none;">
|
||||
<div title="Bluetooth Devices"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> <span id="btDeviceCount">0</span></div>
|
||||
<div title="BLE Beacons"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span> <span id="btBeaconCount">0</span></div>
|
||||
</div>
|
||||
<div class="stats" id="satelliteStats" style="display: none;">
|
||||
<div title="Upcoming Passes"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> <span id="passCount">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Layout Container (visualizations left, device cards right) -->
|
||||
<!-- WiFi Layout Container -->
|
||||
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
|
||||
<!-- Left: WiFi Visualizations -->
|
||||
<div class="wifi-visuals" id="wifiVisuals">
|
||||
<!-- Selected WiFi Device Info - at top for visibility -->
|
||||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||||
<h5>Selected Device</h5>
|
||||
<div id="wifiSelectedDevice" style="font-size: 11px; min-height: 100px;">
|
||||
<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a network
|
||||
or client to view details</div>
|
||||
</div>
|
||||
<!-- Status Bar -->
|
||||
<div class="wifi-status-bar">
|
||||
<div class="wifi-status-item">
|
||||
<span class="wifi-status-label">Networks:</span>
|
||||
<span class="wifi-status-value" id="wifiNetworkCount">0</span>
|
||||
</div>
|
||||
<!-- Row 1: Network Radar + Security Overview -->
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Network Radar</h5>
|
||||
<div class="radar-container">
|
||||
<canvas id="radarCanvas" width="150" height="150"></canvas>
|
||||
</div>
|
||||
<div class="wifi-status-item">
|
||||
<span class="wifi-status-label">Clients:</span>
|
||||
<span class="wifi-status-value" id="wifiClientCount">0</span>
|
||||
</div>
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Security Overview</h5>
|
||||
<div class="security-container">
|
||||
<div class="security-donut">
|
||||
<canvas id="securityCanvas" width="80" height="80"></canvas>
|
||||
</div>
|
||||
<div class="security-legend">
|
||||
<div class="security-legend-item">
|
||||
<div class="security-legend-dot wpa3"></div>WPA3: <span id="wpa3Count">0</span>
|
||||
</div>
|
||||
<div class="security-legend-item">
|
||||
<div class="security-legend-dot wpa2"></div>WPA2: <span id="wpa2Count">0</span>
|
||||
</div>
|
||||
<div class="security-legend-item">
|
||||
<div class="security-legend-dot wep"></div>WEP: <span id="wepCount">0</span>
|
||||
</div>
|
||||
<div class="security-legend-item">
|
||||
<div class="security-legend-dot open"></div>Open: <span id="openCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-status-item">
|
||||
<span class="wifi-status-label">Hidden:</span>
|
||||
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
|
||||
</div>
|
||||
<!-- Row 2: Channel Utilization (2.4 GHz + 5 GHz side by side) -->
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Channel Utilization (2.4 GHz)</h5>
|
||||
<div class="channel-graph" id="channelGraph">
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">1</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">2</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">3</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">4</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">5</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">6</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">7</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">8</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">9</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">10</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">11</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">12</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">13</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Channel Utilization (5 GHz)</h5>
|
||||
<div class="channel-graph" id="channelGraph5g" style="font-size: 7px;">
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">36</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">40</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">44</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">48</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">52</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">56</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">60</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">64</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">100</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">149</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">153</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">157</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">161</span>
|
||||
</div>
|
||||
<div class="channel-bar-wrapper">
|
||||
<div class="channel-bar" style="height: 2px;"></div><span
|
||||
class="channel-label">165</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: Channel Recommendation -->
|
||||
<div class="wifi-visual-panel channel-recommendation" id="channelRecommendation">
|
||||
<h4>Channel Recommendation</h4>
|
||||
<div class="rec-text">
|
||||
<strong>2.4 GHz:</strong> Use channel <span class="rec-channel"
|
||||
id="rec24Channel">--</span>
|
||||
<span id="rec24Reason" style="font-size: 10px; color: var(--text-dim);"></span>
|
||||
</div>
|
||||
<div class="rec-text" style="margin-top: 5px;">
|
||||
<strong>5 GHz:</strong> Use channel <span class="rec-channel" id="rec5Channel">--</span>
|
||||
<span id="rec5Reason" style="font-size: 10px; color: var(--text-dim);"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Correlation -->
|
||||
<div class="wifi-visual-panel" id="correlationPanel">
|
||||
<h5>Device Correlation</h5>
|
||||
<div id="correlationList" style="font-size: 11px; max-height: 100px; overflow-y: auto;">
|
||||
<div style="color: var(--text-dim);">Analyzing WiFi/BT device patterns...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden SSID Revealer -->
|
||||
<div class="wifi-visual-panel" id="hiddenSsidPanel">
|
||||
<h5>Hidden SSIDs Revealed</h5>
|
||||
<div id="hiddenSsidList" style="font-size: 11px; max-height: 100px; overflow-y: auto;">
|
||||
<div style="color: var(--text-dim);">Monitoring probe requests...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Client Probe Analysis -->
|
||||
<div class="wifi-visual-panel" id="probeAnalysisPanel" style="grid-column: span 2;">
|
||||
<h5>Client Probe Analysis</h5>
|
||||
<div style="display: flex; gap: 10px; margin-bottom: 8px; font-size: 10px;">
|
||||
<span>Clients: <strong id="probeClientCount">0</strong></span>
|
||||
<span>Unique SSIDs: <strong id="probeSSIDCount">0</strong></span>
|
||||
<span>Privacy Leaks: <strong id="probePrivacyCount"
|
||||
style="color: var(--accent-orange);">0</strong></span>
|
||||
</div>
|
||||
<div id="probeAnalysisList" style="font-size: 11px; max-height: 200px; overflow-y: auto;">
|
||||
<div style="color: var(--text-dim);">Waiting for client probe requests...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Network Activity Timeline -->
|
||||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||||
<div id="wifiTimelineContainer"></div>
|
||||
<div class="wifi-status-item" id="wifiScanStatus">
|
||||
<span class="wifi-status-indicator idle"></span>
|
||||
<span>Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: WiFi Device Cards -->
|
||||
<div class="wifi-device-list" id="wifiDeviceList">
|
||||
<div class="wifi-device-list-header">
|
||||
<h5>Discovered Networks</h5>
|
||||
<span class="device-count">(<span id="wifiDeviceListCount">0</span>)</span>
|
||||
|
||||
<!-- Main Content: 3-column layout -->
|
||||
<div class="wifi-main-content">
|
||||
<!-- LEFT: Networks Table -->
|
||||
<div class="wifi-networks-panel">
|
||||
<div class="wifi-networks-header">
|
||||
<h5>Discovered Networks</h5>
|
||||
<div class="wifi-network-filters" id="wifiNetworkFilters">
|
||||
<button class="wifi-filter-btn active" data-filter="all">All</button>
|
||||
<button class="wifi-filter-btn" data-filter="2.4">2.4G</button>
|
||||
<button class="wifi-filter-btn" data-filter="5">5G</button>
|
||||
<button class="wifi-filter-btn" data-filter="open">Open</button>
|
||||
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-networks-table-wrapper">
|
||||
<table class="wifi-networks-table" id="wifiNetworkTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" data-sort="essid">SSID</th>
|
||||
<th class="sortable" data-sort="bssid">BSSID</th>
|
||||
<th class="sortable" data-sort="channel">Ch</th>
|
||||
<th class="sortable" data-sort="rssi">Signal</th>
|
||||
<th class="sortable" data-sort="security">Security</th>
|
||||
<th class="sortable" data-sort="clients">Clients</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="wifiNetworkTableBody">
|
||||
<tr class="wifi-network-placeholder">
|
||||
<td colspan="6">
|
||||
<div class="placeholder-text">Start scanning to discover networks</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-device-list-content" id="wifiDeviceListContent">
|
||||
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
|
||||
Start scanning to discover WiFi networks
|
||||
|
||||
<!-- CENTER: Proximity Radar -->
|
||||
<div class="wifi-radar-panel">
|
||||
<h5>Proximity Radar</h5>
|
||||
<div id="wifiProximityRadar" class="wifi-radar-container"></div>
|
||||
<div class="wifi-zone-summary">
|
||||
<div class="wifi-zone near">
|
||||
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
|
||||
<span class="wifi-zone-label">Near</span>
|
||||
</div>
|
||||
<div class="wifi-zone mid">
|
||||
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
|
||||
<span class="wifi-zone-label">Mid</span>
|
||||
</div>
|
||||
<div class="wifi-zone far">
|
||||
<span class="wifi-zone-count" id="wifiZoneFar">0</span>
|
||||
<span class="wifi-zone-label">Far</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Channel Analysis + Security -->
|
||||
<div class="wifi-analysis-panel">
|
||||
<div class="wifi-channel-section">
|
||||
<h5>Channel Analysis</h5>
|
||||
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
|
||||
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
|
||||
<button class="channel-band-tab" data-band="5">5 GHz</button>
|
||||
</div>
|
||||
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
|
||||
</div>
|
||||
<div class="wifi-security-section">
|
||||
<h5>Security Overview</h5>
|
||||
<div class="wifi-security-stats">
|
||||
<div class="wifi-security-item wpa3">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA3</span>
|
||||
<span class="wifi-security-count" id="wpa3Count">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item wpa2">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WPA2</span>
|
||||
<span class="wifi-security-count" id="wpa2Count">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item wep">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>WEP</span>
|
||||
<span class="wifi-security-count" id="wepCount">0</span>
|
||||
</div>
|
||||
<div class="wifi-security-item open">
|
||||
<span class="wifi-security-dot"></span>
|
||||
<span>Open</span>
|
||||
<span class="wifi-security-count" id="openCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail Drawer (slides up on network selection) -->
|
||||
<div class="wifi-detail-drawer" id="wifiDetailDrawer">
|
||||
<div class="wifi-detail-header">
|
||||
<div class="wifi-detail-title">
|
||||
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
|
||||
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
|
||||
</div>
|
||||
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">×</button>
|
||||
</div>
|
||||
<div class="wifi-detail-content" id="wifiDetailContent">
|
||||
<div class="wifi-detail-grid">
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Signal</span>
|
||||
<span class="value" id="wifiDetailRssi">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Channel</span>
|
||||
<span class="value" id="wifiDetailChannel">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Band</span>
|
||||
<span class="value" id="wifiDetailBand">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Security</span>
|
||||
<span class="value" id="wifiDetailSecurity">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Cipher</span>
|
||||
<span class="value" id="wifiDetailCipher">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Vendor</span>
|
||||
<span class="value" id="wifiDetailVendor">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">Clients</span>
|
||||
<span class="value" id="wifiDetailClients">--</span>
|
||||
</div>
|
||||
<div class="wifi-detail-stat">
|
||||
<span class="label">First Seen</span>
|
||||
<span class="value" id="wifiDetailFirstSeen">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
|
||||
<h6>Connected Clients</h6>
|
||||
<div class="wifi-client-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,71 +652,129 @@
|
||||
<!-- Bluetooth Layout Container (visualizations left, device cards right) -->
|
||||
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
|
||||
<!-- Left: Bluetooth Visualizations -->
|
||||
<div class="wifi-visuals" id="btVisuals">
|
||||
<!-- Selected Bluetooth Device Info - at top for visibility -->
|
||||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||||
<h5>Selected Device</h5>
|
||||
<div id="btSelectedDevice" style="font-size: 11px; min-height: 100px;">
|
||||
<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a device
|
||||
to view details</div>
|
||||
<div class="bt-visuals-column" id="btVisuals">
|
||||
<!-- Device Detail Panel (always visible) -->
|
||||
<div class="bt-detail-panel" id="btDetailPanel">
|
||||
<div class="bt-detail-header">
|
||||
<h5>Device Details</h5>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 1: Bluetooth Radar + Device Types -->
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Proximity Radar</h5>
|
||||
<div class="radar-container">
|
||||
<canvas id="btRadarCanvas" width="150" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Device Types</h5>
|
||||
<div class="bt-type-overview" id="btTypeOverview">
|
||||
<div class="bt-type-item">Phones: <strong id="btPhoneCount">0</strong></div>
|
||||
<div class="bt-type-item">Computers: <strong id="btComputerCount">0</strong></div>
|
||||
<div class="bt-type-item">Audio: <strong id="btAudioCount">0</strong></div>
|
||||
<div class="bt-type-item">Wearables: <strong id="btWearableCount">0</strong></div>
|
||||
<div class="bt-type-item">Other: <strong id="btOtherCount">0</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Tracker Detection + Signal Analysis -->
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Tracker Detection</h5>
|
||||
<div id="btTrackerList" style="max-height: 120px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for
|
||||
AirTags, Tiles...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-visual-panel">
|
||||
<h5>Signal Distribution</h5>
|
||||
<div class="bt-signal-dist" id="btSignalDist">
|
||||
<div class="signal-range"><span>Strong (-50+)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
|
||||
</div><span id="btSignalStrongCount">0</span>
|
||||
<div class="bt-detail-body">
|
||||
<!-- Placeholder shown when no device selected -->
|
||||
<div class="bt-detail-placeholder" id="btDetailPlaceholder">
|
||||
<span>Select a device to view details</span>
|
||||
</div>
|
||||
<div class="signal-range"><span>Medium (-70)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
|
||||
</div><span id="btSignalMediumCount">0</span>
|
||||
</div>
|
||||
<div class="signal-range"><span>Weak (-90)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
|
||||
</div><span id="btSignalWeakCount">0</span>
|
||||
<!-- Content shown when device is selected -->
|
||||
<div class="bt-detail-content" id="btDetailContent" style="display: none;">
|
||||
<div class="bt-detail-top-row">
|
||||
<div class="bt-detail-identity">
|
||||
<div class="bt-detail-name" id="btDetailName">Device Name</div>
|
||||
<div class="bt-detail-address" id="btDetailAddress">00:00:00:00:00:00</div>
|
||||
</div>
|
||||
<div class="bt-detail-rssi-display">
|
||||
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
|
||||
<span class="bt-detail-rssi-unit">dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-detail-badges" id="btDetailBadges"></div>
|
||||
<div class="bt-detail-grid">
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Manufacturer</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailMfr">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Type</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailAddrType">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Seen</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailSeen">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Range</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailRange">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Min/Max</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailRssiRange">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">First Seen</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailFirstSeen">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Last Seen</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailLastSeen">--</span>
|
||||
</div>
|
||||
<div class="bt-detail-stat">
|
||||
<span class="bt-detail-stat-label">Mfr ID</span>
|
||||
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bt-detail-bottom-row">
|
||||
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
|
||||
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
|
||||
</div>
|
||||
<button class="bt-detail-btn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: FindMy Detection -->
|
||||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||||
<h5>Apple FindMy Network</h5>
|
||||
<div id="btFindMyList" style="max-height: 100px; overflow-y: auto; font-size: 11px;">
|
||||
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Scanning for
|
||||
FindMy-compatible devices...</div>
|
||||
<!-- Main area: Side panels + Radar -->
|
||||
<div class="bt-main-area">
|
||||
<!-- Left side panels -->
|
||||
<div class="bt-side-panels">
|
||||
<div class="wifi-visual-panel bt-side-panel">
|
||||
<h5>Tracker Detection</h5>
|
||||
<div id="btTrackerList" style="font-size: 11px; max-height: 200px; overflow-y: auto;">
|
||||
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for AirTags, Tiles...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wifi-visual-panel bt-side-panel">
|
||||
<h5>Signal Distribution</h5>
|
||||
<div class="bt-signal-dist" id="btSignalDist">
|
||||
<div class="signal-range"><span>Strong (-50+)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
|
||||
</div><span id="btSignalStrongCount">0</span>
|
||||
</div>
|
||||
<div class="signal-range"><span>Medium (-70)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
|
||||
</div><span id="btSignalMediumCount">0</span>
|
||||
</div>
|
||||
<div class="signal-range"><span>Weak (-90)</span>
|
||||
<div class="signal-bar-bg">
|
||||
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
|
||||
</div><span id="btSignalWeakCount">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Proximity Radar -->
|
||||
<div class="wifi-visual-panel bt-radar-panel">
|
||||
<h5>Proximity Radar</h5>
|
||||
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
|
||||
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
|
||||
<button data-filter="newOnly" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">New Only</button>
|
||||
<button data-filter="strongest" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Strongest</button>
|
||||
<button data-filter="unapproved" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Unapproved</button>
|
||||
<button id="btRadarPauseBtn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Pause</button>
|
||||
</div>
|
||||
<div id="btZoneSummary" style="display: flex; justify-content: center; gap: 24px; margin-top: 12px; font-size: 11px;">
|
||||
<div style="text-align: center;">
|
||||
<span id="btZoneImmediate" style="font-size: 20px; font-weight: 600; color: #22c55e;">0</span>
|
||||
<div style="color: #666;">Immediate</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<span id="btZoneNear" style="font-size: 20px; font-weight: 600; color: #eab308;">0</span>
|
||||
<div style="color: #666;">Near</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<span id="btZoneFar" style="font-size: 20px; font-weight: 600; color: #ef4444;">0</span>
|
||||
<div style="color: #666;">Far</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Activity Timeline -->
|
||||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||||
<div id="bluetoothTimelineContainer"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Bluetooth Device Cards -->
|
||||
@@ -777,6 +783,12 @@
|
||||
<h5>Bluetooth Devices</h5>
|
||||
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
|
||||
</div>
|
||||
<div class="bt-device-filters" id="btDeviceFilters">
|
||||
<button class="bt-filter-btn active" data-filter="all">All</button>
|
||||
<button class="bt-filter-btn" data-filter="new">New</button>
|
||||
<button class="bt-filter-btn" data-filter="named">Named</button>
|
||||
<button class="bt-filter-btn" data-filter="strong">Strong</button>
|
||||
</div>
|
||||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||||
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
|
||||
Start scanning to discover Bluetooth devices
|
||||
@@ -785,6 +797,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bluetooth Device Detail Modal -->
|
||||
<div id="btDeviceModal" class="bt-modal-overlay" style="display: none;">
|
||||
<div class="bt-modal">
|
||||
<div class="bt-modal-header">
|
||||
<h4 id="btModalTitle">Device Details</h4>
|
||||
<button class="bt-modal-close" onclick="BluetoothMode.closeModal()">×</button>
|
||||
</div>
|
||||
<div class="bt-modal-body" id="btModalBody">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APRS Visualizations -->
|
||||
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
|
||||
<!-- APRS Function Bar -->
|
||||
@@ -1483,6 +1508,16 @@
|
||||
<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/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/components/proximity-radar.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}"></script>
|
||||
<!-- WiFi v2 components -->
|
||||
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
|
||||
|
||||
<script>
|
||||
@@ -1566,7 +1601,12 @@
|
||||
if (!container) return;
|
||||
|
||||
// Create timeline using new ActivityTimeline
|
||||
if (typeof ActivityTimeline !== 'undefined') {
|
||||
// For TSCM mode, use SignalTimeline.create() to ensure backward compatibility
|
||||
// with SignalTimeline.addEvent() calls used in TSCM event handlers
|
||||
if (mode === 'tscm' && typeof SignalTimeline !== 'undefined') {
|
||||
SignalTimeline.create(modeConfig.container, modeConfig.config);
|
||||
modeTimelines[mode] = { addEvent: (e) => SignalTimeline.addEvent(e.id, e.strength, e.duration, e.label) };
|
||||
} else if (typeof ActivityTimeline !== 'undefined') {
|
||||
modeTimelines[mode] = ActivityTimeline.create(modeConfig.container, modeConfig.config);
|
||||
}
|
||||
}
|
||||
@@ -1731,12 +1771,6 @@
|
||||
if (headerHandshakeCount) headerHandshakeCount.textContent = document.getElementById('handshakeCount')?.textContent || '0';
|
||||
if (headerDroneCount) headerDroneCount.textContent = document.getElementById('droneCount')?.textContent || '0';
|
||||
|
||||
// Bluetooth stats
|
||||
const headerBtDeviceCount = document.getElementById('headerBtDeviceCount');
|
||||
const headerBtBeaconCount = document.getElementById('headerBtBeaconCount');
|
||||
if (headerBtDeviceCount) headerBtDeviceCount.textContent = document.getElementById('btDeviceCount')?.textContent || '0';
|
||||
if (headerBtBeaconCount) headerBtBeaconCount.textContent = document.getElementById('btBeaconCount')?.textContent || '0';
|
||||
|
||||
// Satellite stats
|
||||
const headerPassCount = document.getElementById('headerPassCount');
|
||||
if (headerPassCount) headerPassCount.textContent = document.getElementById('passCount')?.textContent || '0';
|
||||
@@ -2016,19 +2050,16 @@
|
||||
const sensorStats = document.getElementById('sensorStats');
|
||||
const satelliteStats = document.getElementById('satelliteStats');
|
||||
const wifiStats = document.getElementById('wifiStats');
|
||||
const btStats = document.getElementById('btStats');
|
||||
if (pagerStats) pagerStats.style.display = mode === 'pager' ? 'flex' : 'none';
|
||||
if (sensorStats) sensorStats.style.display = mode === 'sensor' ? 'flex' : 'none';
|
||||
if (satelliteStats) satelliteStats.style.display = mode === 'satellite' ? 'flex' : 'none';
|
||||
if (wifiStats) wifiStats.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btStats) btStats.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
|
||||
// Update header stats groups
|
||||
document.getElementById('headerPagerStats')?.classList.toggle('active', mode === 'pager');
|
||||
document.getElementById('headerSensorStats')?.classList.toggle('active', mode === 'sensor');
|
||||
document.getElementById('headerSatelliteStats')?.classList.toggle('active', mode === 'satellite');
|
||||
document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi');
|
||||
document.getElementById('headerBtStats')?.classList.toggle('active', mode === 'bluetooth');
|
||||
|
||||
// Show/hide dashboard buttons in nav bar
|
||||
const satelliteDashboardBtn = document.getElementById('satelliteDashboardBtn');
|
||||
@@ -2129,6 +2160,10 @@
|
||||
refreshWifiInterfaces();
|
||||
initRadar();
|
||||
initWatchList();
|
||||
// Initialize v2 WiFi components
|
||||
if (typeof WiFiMode !== 'undefined') {
|
||||
WiFiMode.init();
|
||||
}
|
||||
} else if (mode === 'bluetooth') {
|
||||
refreshBtInterfaces();
|
||||
initBtRadar();
|
||||
@@ -4215,21 +4250,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI with more context
|
||||
document.getElementById('rec24Channel').textContent = best24;
|
||||
// Update UI with more context (with null checks for v2 layout)
|
||||
const rec24El = document.getElementById('rec24Channel');
|
||||
const rec24ReasonEl = document.getElementById('rec24Reason');
|
||||
const rec5El = document.getElementById('rec5Channel');
|
||||
const rec5ReasonEl = document.getElementById('rec5Reason');
|
||||
|
||||
if (rec24El) rec24El.textContent = best24;
|
||||
if (totalNetworks === 0) {
|
||||
document.getElementById('rec24Reason').textContent = '(no networks detected)';
|
||||
if (rec24ReasonEl) rec24ReasonEl.textContent = '(no networks detected)';
|
||||
} else {
|
||||
const usage = channelUsage24.map(c => `CH${c.channel}:${Math.round(c.count)}`).join(', ');
|
||||
document.getElementById('rec24Reason').textContent =
|
||||
if (rec24ReasonEl) rec24ReasonEl.textContent =
|
||||
minCount24 === 0 ? '(clear)' : `(${Math.round(minCount24)} interference) [${usage}]`;
|
||||
}
|
||||
|
||||
document.getElementById('rec5Channel').textContent = best5;
|
||||
if (rec5El) rec5El.textContent = best5;
|
||||
if (totalNetworks === 0) {
|
||||
document.getElementById('rec5Reason').textContent = '(no networks detected)';
|
||||
if (rec5ReasonEl) rec5ReasonEl.textContent = '(no networks detected)';
|
||||
} else {
|
||||
document.getElementById('rec5Reason').textContent =
|
||||
if (rec5ReasonEl) rec5ReasonEl.textContent =
|
||||
minCount5 === 0 ? `(clear, ${channels5g.length - used5g} unused)` : `(${minCount5} networks)`;
|
||||
}
|
||||
}
|
||||
@@ -4361,6 +4401,48 @@
|
||||
|
||||
// NOTE: Browser Notifications code moved to static/js/core/audio.js
|
||||
|
||||
// Sync legacy WiFi data to v2 channel chart
|
||||
function syncLegacyToChannelChart() {
|
||||
if (typeof ChannelChart === 'undefined') return;
|
||||
|
||||
const networksList = Object.values(wifiNetworks);
|
||||
if (networksList.length === 0) return;
|
||||
|
||||
// Calculate channel stats from legacy networks
|
||||
const stats = {};
|
||||
|
||||
// Initialize 2.4 GHz channels
|
||||
for (let ch = 1; ch <= 11; ch++) {
|
||||
stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, utilization_score: 0 };
|
||||
}
|
||||
// Initialize 5 GHz channels
|
||||
[36, 40, 44, 48, 149, 153, 157, 161, 165].forEach(ch => {
|
||||
stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, utilization_score: 0 };
|
||||
});
|
||||
|
||||
// Count APs per channel
|
||||
networksList.forEach(net => {
|
||||
const ch = parseInt(net.channel);
|
||||
if (stats[ch]) {
|
||||
stats[ch].ap_count++;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate utilization (0-1)
|
||||
const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count));
|
||||
Object.values(stats).forEach(s => {
|
||||
s.utilization_score = s.ap_count / maxAPs;
|
||||
});
|
||||
|
||||
// Get active band from tab
|
||||
const activeTab = document.querySelector('.channel-band-tab.active');
|
||||
const band = activeTab ? activeTab.dataset.band : '2.4';
|
||||
const bandFilter = band === '2.4' ? '2.4GHz' : '5GHz';
|
||||
|
||||
const filteredStats = Object.values(stats).filter(s => s.band === bandFilter);
|
||||
ChannelChart.update(filteredStats, []);
|
||||
}
|
||||
|
||||
// Update visualizations periodically
|
||||
setInterval(() => {
|
||||
if (currentMode === 'wifi') {
|
||||
@@ -4368,6 +4450,7 @@
|
||||
correlateDevices();
|
||||
updateHiddenSsidDisplay();
|
||||
updateProbeAnalysis();
|
||||
syncLegacyToChannelChart();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
@@ -4829,10 +4912,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Update counters
|
||||
document.getElementById('probeClientCount').textContent = clientsWithProbes.length;
|
||||
document.getElementById('probeSSIDCount').textContent = allProbes.size;
|
||||
document.getElementById('probePrivacyCount').textContent = privacyLeaks;
|
||||
// Update counters (with null checks for v2 layout)
|
||||
const probeClientEl = document.getElementById('probeClientCount');
|
||||
const probeSSIDEl = document.getElementById('probeSSIDCount');
|
||||
const probePrivacyEl = document.getElementById('probePrivacyCount');
|
||||
if (probeClientEl) probeClientEl.textContent = clientsWithProbes.length;
|
||||
if (probeSSIDEl) probeSSIDEl.textContent = allProbes.size;
|
||||
if (probePrivacyEl) probePrivacyEl.textContent = privacyLeaks;
|
||||
|
||||
if (clientsWithProbes.length === 0) {
|
||||
list.innerHTML = '<div style="color: var(--text-dim);">Waiting for client probe requests...</div>';
|
||||
@@ -5790,13 +5876,22 @@
|
||||
let btRadarAnimFrame = null;
|
||||
let btRadarDevices = [];
|
||||
|
||||
// Refresh Bluetooth interfaces
|
||||
// Refresh Bluetooth interfaces (legacy - now handled by BluetoothMode.init())
|
||||
function refreshBtInterfaces() {
|
||||
// New Bluetooth mode uses /api/bluetooth/capabilities instead
|
||||
// This function is kept for backwards compatibility but uses new API
|
||||
if (typeof BluetoothMode !== 'undefined') {
|
||||
BluetoothMode.checkCapabilities();
|
||||
return;
|
||||
}
|
||||
// Legacy fallback (shouldn't be needed)
|
||||
const select = document.getElementById('btInterfaceSelect') || document.getElementById('btAdapterSelect');
|
||||
if (!select) return;
|
||||
|
||||
fetch('/bt/interfaces')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const select = document.getElementById('btInterfaceSelect');
|
||||
if (data.interfaces.length === 0) {
|
||||
if (!data.interfaces || data.interfaces.length === 0) {
|
||||
select.innerHTML = '<option value="">No BT interfaces found</option>';
|
||||
} else {
|
||||
select.innerHTML = data.interfaces.map(i =>
|
||||
@@ -5804,13 +5899,16 @@
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Update tool status
|
||||
// Update tool status (if element exists)
|
||||
const statusDiv = document.getElementById('btToolStatus');
|
||||
statusDiv.innerHTML = `
|
||||
<span>hcitool:</span><span class="tool-status ${data.tools.hcitool ? 'ok' : 'missing'}">${data.tools.hcitool ? 'OK' : 'Missing'}</span>
|
||||
<span>bluetoothctl:</span><span class="tool-status ${data.tools.bluetoothctl ? 'ok' : 'missing'}">${data.tools.bluetoothctl ? 'OK' : 'Missing'}</span>
|
||||
`;
|
||||
});
|
||||
if (statusDiv) {
|
||||
statusDiv.innerHTML = `
|
||||
<span>hcitool:</span><span class="tool-status ${data.tools.hcitool ? 'ok' : 'missing'}">${data.tools.hcitool ? 'OK' : 'Missing'}</span>
|
||||
<span>bluetoothctl:</span><span class="tool-status ${data.tools.bluetoothctl ? 'ok' : 'missing'}">${data.tools.bluetoothctl ? 'OK' : 'Missing'}</span>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Legacy BT interface check failed:', err));
|
||||
}
|
||||
|
||||
// Start Bluetooth scan
|
||||
@@ -6072,6 +6170,11 @@
|
||||
|
||||
// Handle discovered Bluetooth device (called from batched update)
|
||||
function handleBtDeviceImmediate(device) {
|
||||
// Skip if new BluetoothMode is handling devices
|
||||
if (typeof BluetoothMode !== 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNew = !btDevices[device.mac];
|
||||
|
||||
// Check for Find My network
|
||||
@@ -6090,7 +6193,6 @@
|
||||
|
||||
if (isNew) {
|
||||
btDeviceCount++;
|
||||
document.getElementById('btDeviceCount').textContent = btDeviceCount;
|
||||
playAlert();
|
||||
pulseSignal();
|
||||
}
|
||||
@@ -6297,6 +6399,11 @@
|
||||
|
||||
// Add Bluetooth device card to device list panel
|
||||
function addBtDeviceCard(device, isNew) {
|
||||
// Skip if new BluetoothMode is handling rendering
|
||||
if (typeof BluetoothMode !== 'undefined' && BluetoothMode.isScanning()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to new device list panel
|
||||
const deviceList = document.getElementById('btDeviceListContent');
|
||||
if (deviceList) {
|
||||
|
||||
@@ -1,70 +1,67 @@
|
||||
<!-- BLUETOOTH MODE -->
|
||||
<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">
|
||||
<h3>Bluetooth Interface</h3>
|
||||
<h3>Scanner Configuration</h3>
|
||||
<div class="form-group">
|
||||
<select id="btInterfaceSelect">
|
||||
<option value="">Detecting interfaces...</option>
|
||||
<label>Adapter</label>
|
||||
<select id="btAdapterSelect">
|
||||
<option value="">Detecting adapters...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="preset-btn" onclick="refreshBtInterfaces()" style="width: 100%;">
|
||||
Refresh Interfaces
|
||||
</button>
|
||||
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="btToolStatus">
|
||||
<span>hcitool:</span><span class="tool-status missing">Checking...</span>
|
||||
<span>bluetoothctl:</span><span class="tool-status missing">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 class="form-group">
|
||||
<label>Scan Mode</label>
|
||||
<select id="btScanMode">
|
||||
<option value="auto">Auto (Recommended)</option>
|
||||
<option value="bleak">Bleak Library</option>
|
||||
<option value="hcitool">hcitool (Linux)</option>
|
||||
<option value="bluetoothctl">bluetoothctl (Linux)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Duration (sec)</label>
|
||||
<input type="text" id="btScanDuration" value="30" placeholder="30">
|
||||
<label>Transport</label>
|
||||
<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 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">
|
||||
<label>Target MAC</label>
|
||||
<input type="text" id="btTargetMac" placeholder="AA:BB:CC:DD:EE:FF">
|
||||
<label>Duration (seconds, 0 = continuous)</label>
|
||||
<input type="number" id="btScanDuration" value="0" min="0" max="300" placeholder="0">
|
||||
</div>
|
||||
<button class="preset-btn" onclick="btEnumServices()" style="width: 100%;">
|
||||
Enumerate Services
|
||||
<div class="form-group">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Tracker Following Alert -->
|
||||
<div id="trackerFollowingAlert" class="tracker-following-alert" style="display: none;">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<!-- Message Container for status cards -->
|
||||
<div id="btMessageContainer"></div>
|
||||
|
||||
<button class="run-btn" id="startBtBtn" onclick="startBtScan()">
|
||||
<button class="run-btn" id="startBtBtn" onclick="btStartScan()">
|
||||
Start Scanning
|
||||
</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
|
||||
</button>
|
||||
<button class="preset-btn" onclick="resetBtAdapter()" style="margin-top: 5px; width: 100%;">
|
||||
Reset Adapter
|
||||
</button>
|
||||
|
||||
<div class="section" style="margin-top: 10px;">
|
||||
<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>
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<label class="inline-checkbox">
|
||||
<input type="checkbox" id="tscmRfEnabled">
|
||||
<input type="checkbox" id="tscmRfEnabled" checked>
|
||||
RF/SDR
|
||||
</label>
|
||||
<select id="tscmSdrDevice" style="margin-top: 4px;">
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
<!-- WiFi MODE -->
|
||||
<div id="wifiMode" class="mode-content">
|
||||
<!-- Scan Mode Tabs -->
|
||||
<div class="section" style="padding: 8px;">
|
||||
<div class="wifi-scan-mode-tabs" style="display: flex; gap: 4px;">
|
||||
<button id="wifiScanModeQuick" class="wifi-mode-tab active" style="flex: 1; padding: 8px; font-size: 11px; background: var(--accent-green); color: #000; border: none; border-radius: 4px; cursor: pointer;">
|
||||
Quick Scan
|
||||
</button>
|
||||
<button id="wifiScanModeDeep" class="wifi-mode-tab" style="flex: 1; padding: 8px; font-size: 11px; background: var(--bg-tertiary); color: #888; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer;">
|
||||
Deep Scan
|
||||
</button>
|
||||
</div>
|
||||
<div id="wifiCapabilityStatus" class="info-text" style="margin-top: 8px; font-size: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>WiFi Adapter</h3>
|
||||
<div class="form-group">
|
||||
@@ -133,10 +146,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()">
|
||||
Start Scanning
|
||||
</button>
|
||||
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
|
||||
<!-- v2 Scan Buttons -->
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||
<button class="run-btn" id="wifiQuickScanBtn" onclick="WiFiMode.startQuickScan()" style="flex: 1;">
|
||||
Quick Scan
|
||||
</button>
|
||||
<button class="run-btn" id="wifiDeepScanBtn" onclick="WiFiMode.startDeepScan()" style="flex: 1; background: var(--accent-orange);">
|
||||
Deep Scan
|
||||
</button>
|
||||
</div>
|
||||
<button class="stop-btn" id="wifiStopScanBtn" onclick="WiFiMode.stopScan()" style="display: none; width: 100%;">
|
||||
Stop Scanning
|
||||
</button>
|
||||
|
||||
<!-- Legacy Scan Buttons (hidden, for backwards compatibility) -->
|
||||
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()" style="display: none;">
|
||||
Start Scanning (Legacy)
|
||||
</button>
|
||||
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
|
||||
Stop Scanning (Legacy)
|
||||
</button>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="section" style="margin-top: 10px;">
|
||||
<h3>Export</h3>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="preset-btn" onclick="WiFiMode.exportData('csv')" style="flex: 1;">
|
||||
Export CSV
|
||||
</button>
|
||||
<button class="preset-btn" onclick="WiFiMode.exportData('json')" style="flex: 1;">
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke Test for Bluetooth API Backwards Compatibility
|
||||
|
||||
Run this script against a running INTERCEPT server to verify:
|
||||
1. Existing v1/v2 endpoints still work
|
||||
2. New tracker endpoints work
|
||||
3. TSCM integration is not broken
|
||||
4. JSON schemas are compatible
|
||||
|
||||
Usage:
|
||||
python tests/smoke_test_bluetooth.py [--host HOST] [--port PORT]
|
||||
|
||||
Requirements:
|
||||
- INTERCEPT server must be running
|
||||
- requests library: pip install requests
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("Error: requests library required. Install with: pip install requests")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 5000
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SCHEMA VALIDATORS
|
||||
# =============================================================================
|
||||
|
||||
def validate_device_schema(device: dict, context: str = "") -> list[str]:
|
||||
"""Validate that a device dict has expected fields (backwards compatible)."""
|
||||
errors = []
|
||||
required_fields = [
|
||||
'device_id', 'address', 'rssi_current', 'last_seen', 'seen_count'
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
if field not in device:
|
||||
errors.append(f"{context}Missing required field: {field}")
|
||||
|
||||
# New tracker fields should be present (v2) but are optional
|
||||
tracker_fields = ['is_tracker', 'tracker_type', 'tracker_confidence']
|
||||
for field in tracker_fields:
|
||||
if field in device:
|
||||
# Field exists, check type
|
||||
if field == 'is_tracker' and not isinstance(device[field], bool):
|
||||
errors.append(f"{context}is_tracker should be bool, got {type(device[field])}")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_tracker_schema(tracker: dict, context: str = "") -> list[str]:
|
||||
"""Validate tracker endpoint response schema."""
|
||||
errors = []
|
||||
|
||||
required_fields = [
|
||||
'device_id', 'address', 'tracker'
|
||||
]
|
||||
for field in required_fields:
|
||||
if field not in tracker:
|
||||
errors.append(f"{context}Missing required field: {field}")
|
||||
|
||||
# Tracker sub-object
|
||||
if 'tracker' in tracker:
|
||||
tracker_obj = tracker['tracker']
|
||||
tracker_required = ['type', 'confidence', 'evidence']
|
||||
for field in tracker_required:
|
||||
if field not in tracker_obj:
|
||||
errors.append(f"{context}tracker.{field} missing")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_diagnostics_schema(diagnostics: dict) -> list[str]:
|
||||
"""Validate diagnostics endpoint response schema."""
|
||||
errors = []
|
||||
|
||||
required_sections = ['system', 'bluez', 'adapters', 'permissions', 'backends']
|
||||
for section in required_sections:
|
||||
if section not in diagnostics:
|
||||
errors.append(f"Missing diagnostics section: {section}")
|
||||
|
||||
if 'can_scan' not in diagnostics:
|
||||
errors.append("Missing can_scan field")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
class SmokeTests:
|
||||
"""Smoke test runner."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.passed = 0
|
||||
self.failed = 0
|
||||
self.errors = []
|
||||
|
||||
def _check(self, name: str, condition: bool, error_msg: str = ""):
|
||||
"""Record a test result."""
|
||||
if condition:
|
||||
print(f" [PASS] {name}")
|
||||
self.passed += 1
|
||||
else:
|
||||
print(f" [FAIL] {name}: {error_msg}")
|
||||
self.failed += 1
|
||||
self.errors.append(f"{name}: {error_msg}")
|
||||
|
||||
def test_capabilities_endpoint(self):
|
||||
"""Test GET /api/bluetooth/capabilities"""
|
||||
print("\n=== Test: Capabilities Endpoint ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/capabilities", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'available' field", 'available' in data or 'can_scan' in data)
|
||||
self._check("Has 'adapters' field", 'adapters' in data)
|
||||
self._check("Has 'recommended_backend' field", 'recommended_backend' in data or 'preferred_backend' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_devices_endpoint(self):
|
||||
"""Test GET /api/bluetooth/devices (backwards compatibility)"""
|
||||
print("\n=== Test: Devices Endpoint (v2) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/devices", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'count' field", 'count' in data)
|
||||
self._check("Has 'devices' array", 'devices' in data and isinstance(data['devices'], list))
|
||||
|
||||
# If devices exist, validate schema
|
||||
if data.get('devices'):
|
||||
device = data['devices'][0]
|
||||
errors = validate_device_schema(device, "First device: ")
|
||||
self._check("Device schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
# Check for new tracker fields (should exist even if empty)
|
||||
self._check("Has tracker fields", 'is_tracker' in device,
|
||||
"New tracker field missing (backwards compat issue)")
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_trackers_endpoint(self):
|
||||
"""Test GET /api/bluetooth/trackers (new v2 endpoint)"""
|
||||
print("\n=== Test: Trackers Endpoint (NEW) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/trackers", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'count' field", 'count' in data)
|
||||
self._check("Has 'trackers' array", 'trackers' in data and isinstance(data['trackers'], list))
|
||||
self._check("Has 'summary' field", 'summary' in data)
|
||||
|
||||
# If trackers exist, validate schema
|
||||
if data.get('trackers'):
|
||||
tracker = data['trackers'][0]
|
||||
errors = validate_tracker_schema(tracker, "First tracker: ")
|
||||
self._check("Tracker schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_diagnostics_endpoint(self):
|
||||
"""Test GET /api/bluetooth/diagnostics (new endpoint)"""
|
||||
print("\n=== Test: Diagnostics Endpoint (NEW) ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/diagnostics", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
errors = validate_diagnostics_schema(data)
|
||||
self._check("Diagnostics schema valid", len(errors) == 0, "; ".join(errors))
|
||||
|
||||
self._check("Has recommendations", 'recommendations' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_scan_status_endpoint(self):
|
||||
"""Test GET /api/bluetooth/scan/status"""
|
||||
print("\n=== Test: Scan Status Endpoint ===")
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/scan/status", timeout=5)
|
||||
self._check("Status code 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'is_scanning' field", 'is_scanning' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_baseline_endpoints(self):
|
||||
"""Test baseline management endpoints"""
|
||||
print("\n=== Test: Baseline Endpoints ===")
|
||||
try:
|
||||
# List baselines
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/baseline/list", timeout=5)
|
||||
self._check("List baselines: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
|
||||
data = resp.json()
|
||||
self._check("Has 'baselines' array", 'baselines' in data)
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_tscm_integration(self):
|
||||
"""Test that TSCM still works with Bluetooth"""
|
||||
print("\n=== Test: TSCM Integration ===")
|
||||
try:
|
||||
# Get TSCM sweep presets
|
||||
resp = requests.get(f"{self.base_url}/tscm/devices", timeout=5)
|
||||
# This might 404 if no devices, which is ok
|
||||
self._check("TSCM devices endpoint accessible", resp.status_code in (200, 404))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def test_export_endpoint(self):
|
||||
"""Test GET /api/bluetooth/export"""
|
||||
print("\n=== Test: Export Endpoint ===")
|
||||
try:
|
||||
# JSON export
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=json", timeout=5)
|
||||
self._check("JSON export: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
self._check("JSON export: Content-Type", 'application/json' in resp.headers.get('Content-Type', ''))
|
||||
|
||||
# CSV export
|
||||
resp = requests.get(f"{self.base_url}/api/bluetooth/export?format=csv", timeout=5)
|
||||
self._check("CSV export: Status 200", resp.status_code == 200, f"Got {resp.status_code}")
|
||||
self._check("CSV export: Content-Type", 'text/csv' in resp.headers.get('Content-Type', ''))
|
||||
|
||||
except requests.RequestException as e:
|
||||
self._check("Request succeeded", False, str(e))
|
||||
|
||||
def run_all(self):
|
||||
"""Run all smoke tests."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"BLUETOOTH API SMOKE TESTS")
|
||||
print(f"Target: {self.base_url}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
self.test_capabilities_endpoint()
|
||||
self.test_devices_endpoint()
|
||||
self.test_trackers_endpoint()
|
||||
self.test_diagnostics_endpoint()
|
||||
self.test_scan_status_endpoint()
|
||||
self.test_baseline_endpoints()
|
||||
self.test_export_endpoint()
|
||||
self.test_tscm_integration()
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"RESULTS: {self.passed} passed, {self.failed} failed")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if self.errors:
|
||||
print("\nFailed tests:")
|
||||
for error in self.errors:
|
||||
print(f" - {error}")
|
||||
|
||||
return self.failed == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bluetooth API smoke tests")
|
||||
parser.add_argument("--host", default=DEFAULT_HOST, help="Server host")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Server port")
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = f"http://{args.host}:{args.port}"
|
||||
|
||||
# Check server is reachable
|
||||
print(f"Checking server at {base_url}...")
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/api/bluetooth/capabilities", timeout=5)
|
||||
print(f"Server responded: {resp.status_code}")
|
||||
except requests.RequestException as e:
|
||||
print(f"ERROR: Cannot reach server at {base_url}")
|
||||
print(f"Details: {e}")
|
||||
print("\nMake sure INTERCEPT is running:")
|
||||
print(" cd /path/to/intercept && python app.py")
|
||||
sys.exit(1)
|
||||
|
||||
# Run tests
|
||||
tests = SmokeTests(base_url)
|
||||
success = tests.run_all()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,554 @@
|
||||
"""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 (
|
||||
MAX_RSSI_SAMPLES,
|
||||
DEVICE_STALE_TIMEOUT as 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]
|
||||
@@ -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(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()
|
||||
|
||||
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
|
||||
@@ -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 (
|
||||
PERSISTENT_MIN_SEEN_COUNT as HEURISTIC_PERSISTENT_MIN_SEEN,
|
||||
PERSISTENT_WINDOW_SECONDS as HEURISTIC_PERSISTENT_WINDOW_SECONDS,
|
||||
BEACON_INTERVAL_MAX_VARIANCE as HEURISTIC_BEACON_VARIANCE_THRESHOLD,
|
||||
STRONG_RSSI_THRESHOLD as HEURISTIC_STRONG_STABLE_RSSI,
|
||||
STABLE_VARIANCE_THRESHOLD as 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
|
||||
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Unit tests for Bluetooth proximity visualization features.
|
||||
|
||||
Tests device key stability, EMA smoothing, distance estimation,
|
||||
band classification, and ring buffer functionality.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils.bluetooth.device_key import (
|
||||
generate_device_key,
|
||||
is_randomized_mac,
|
||||
extract_key_type,
|
||||
)
|
||||
from utils.bluetooth.distance import (
|
||||
DistanceEstimator,
|
||||
ProximityBand,
|
||||
RSSI_THRESHOLD_IMMEDIATE,
|
||||
RSSI_THRESHOLD_NEAR,
|
||||
RSSI_THRESHOLD_FAR,
|
||||
)
|
||||
from utils.bluetooth.ring_buffer import RingBuffer
|
||||
|
||||
|
||||
class TestDeviceKey:
|
||||
"""Tests for stable device key generation."""
|
||||
|
||||
def test_identity_address_takes_priority(self):
|
||||
"""Identity address should always be used when available."""
|
||||
key = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
identity_address='11:22:33:44:55:66',
|
||||
name='Test Device',
|
||||
manufacturer_id=76,
|
||||
)
|
||||
assert key == 'id:11:22:33:44:55:66'
|
||||
|
||||
def test_public_mac_used_directly(self):
|
||||
"""Public MAC addresses should be used directly."""
|
||||
key = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='public',
|
||||
)
|
||||
assert key == 'mac:AA:BB:CC:DD:EE:FF'
|
||||
|
||||
def test_static_random_mac_used_directly(self):
|
||||
"""Random static addresses should be used directly."""
|
||||
key = generate_device_key(
|
||||
address='CA:BB:CC:DD:EE:FF',
|
||||
address_type='random_static',
|
||||
)
|
||||
assert key == 'mac:CA:BB:CC:DD:EE:FF'
|
||||
|
||||
def test_random_address_fingerprint_with_name(self):
|
||||
"""Random addresses should generate fingerprint from name."""
|
||||
key = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
name='AirPods Pro',
|
||||
)
|
||||
assert key.startswith('fp:')
|
||||
assert len(key) == 19 # 'fp:' + 16 hex chars
|
||||
|
||||
def test_random_address_fingerprint_stability(self):
|
||||
"""Same name/mfr/services should produce same fingerprint key."""
|
||||
key1 = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
name='AirPods Pro',
|
||||
manufacturer_id=76,
|
||||
)
|
||||
key2 = generate_device_key(
|
||||
address='11:22:33:44:55:66', # Different address
|
||||
address_type='nrpa',
|
||||
name='AirPods Pro',
|
||||
manufacturer_id=76,
|
||||
)
|
||||
assert key1 == key2
|
||||
|
||||
def test_different_names_produce_different_keys(self):
|
||||
"""Different names should produce different fingerprint keys."""
|
||||
key1 = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
name='AirPods Pro',
|
||||
)
|
||||
key2 = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
name='AirPods Max',
|
||||
)
|
||||
assert key1 != key2
|
||||
|
||||
def test_random_address_fallback_to_mac(self):
|
||||
"""Random addresses without fingerprint data fall back to MAC."""
|
||||
key = generate_device_key(
|
||||
address='AA:BB:CC:DD:EE:FF',
|
||||
address_type='rpa',
|
||||
# No name, manufacturer, or services
|
||||
)
|
||||
assert key == 'mac:AA:BB:CC:DD:EE:FF'
|
||||
|
||||
def test_is_randomized_mac_public(self):
|
||||
"""Public addresses are not randomized."""
|
||||
assert is_randomized_mac('public') is False
|
||||
|
||||
def test_is_randomized_mac_random_static(self):
|
||||
"""Random static addresses are not randomized."""
|
||||
assert is_randomized_mac('random_static') is False
|
||||
|
||||
def test_is_randomized_mac_rpa(self):
|
||||
"""RPA addresses are randomized."""
|
||||
assert is_randomized_mac('rpa') is True
|
||||
|
||||
def test_is_randomized_mac_nrpa(self):
|
||||
"""NRPA addresses are randomized."""
|
||||
assert is_randomized_mac('nrpa') is True
|
||||
|
||||
def test_extract_key_type_id(self):
|
||||
"""Extract type from identity key."""
|
||||
assert extract_key_type('id:11:22:33:44:55:66') == 'id'
|
||||
|
||||
def test_extract_key_type_mac(self):
|
||||
"""Extract type from MAC key."""
|
||||
assert extract_key_type('mac:AA:BB:CC:DD:EE:FF') == 'mac'
|
||||
|
||||
def test_extract_key_type_fingerprint(self):
|
||||
"""Extract type from fingerprint key."""
|
||||
assert extract_key_type('fp:abcd1234efgh5678') == 'fp'
|
||||
|
||||
|
||||
class TestDistanceEstimator:
|
||||
"""Tests for distance estimation and EMA smoothing."""
|
||||
|
||||
@pytest.fixture
|
||||
def estimator(self):
|
||||
"""Create a distance estimator instance."""
|
||||
return DistanceEstimator()
|
||||
|
||||
def test_ema_first_value_initializes(self, estimator):
|
||||
"""First EMA value should equal the input."""
|
||||
ema = estimator.apply_ema_smoothing(current=-50, prev_ema=None)
|
||||
assert ema == -50.0
|
||||
|
||||
def test_ema_subsequent_values_weighted(self, estimator):
|
||||
"""Subsequent EMA values should be weighted correctly."""
|
||||
# Default alpha is 0.3
|
||||
# new_ema = 0.3 * current + 0.7 * prev_ema
|
||||
ema = estimator.apply_ema_smoothing(current=-60, prev_ema=-50.0)
|
||||
expected = 0.3 * (-60) + 0.7 * (-50) # -18 + -35 = -53
|
||||
assert ema == expected
|
||||
|
||||
def test_ema_custom_alpha(self, estimator):
|
||||
"""Custom alpha should be applied correctly."""
|
||||
ema = estimator.apply_ema_smoothing(current=-60, prev_ema=-50.0, alpha=0.5)
|
||||
expected = 0.5 * (-60) + 0.5 * (-50) # -30 + -25 = -55
|
||||
assert ema == expected
|
||||
|
||||
def test_distance_with_tx_power_path_loss(self, estimator):
|
||||
"""Distance should be calculated using path-loss formula with TX power."""
|
||||
# Formula: d = 10^((tx_power - rssi) / (10 * n)), n=2.5
|
||||
distance, confidence = estimator.estimate_distance(rssi=-69, tx_power=-59)
|
||||
# ((-59) - (-69)) / 25 = 10/25 = 0.4
|
||||
# 10^0.4 = ~2.51 meters
|
||||
assert 2.0 < distance < 3.0
|
||||
assert confidence >= 0.5 # Higher confidence with TX power
|
||||
|
||||
def test_distance_without_tx_power_band_based(self, estimator):
|
||||
"""Distance should use band estimation without TX power."""
|
||||
distance, confidence = estimator.estimate_distance(rssi=-50, tx_power=None)
|
||||
assert distance is not None
|
||||
assert confidence < 0.5 # Lower confidence without TX power
|
||||
|
||||
def test_distance_null_rssi(self, estimator):
|
||||
"""Null RSSI should return None distance."""
|
||||
distance, confidence = estimator.estimate_distance(rssi=None)
|
||||
assert distance is None
|
||||
assert confidence == 0.0
|
||||
|
||||
def test_band_classification_immediate(self, estimator):
|
||||
"""Strong RSSI should classify as immediate."""
|
||||
band = estimator.classify_proximity_band(rssi_ema=-35)
|
||||
assert band == ProximityBand.IMMEDIATE
|
||||
|
||||
def test_band_classification_near(self, estimator):
|
||||
"""Medium RSSI should classify as near."""
|
||||
band = estimator.classify_proximity_band(rssi_ema=-50)
|
||||
assert band == ProximityBand.NEAR
|
||||
|
||||
def test_band_classification_far(self, estimator):
|
||||
"""Weak RSSI should classify as far."""
|
||||
band = estimator.classify_proximity_band(rssi_ema=-70)
|
||||
assert band == ProximityBand.FAR
|
||||
|
||||
def test_band_classification_unknown(self, estimator):
|
||||
"""Very weak or null RSSI should classify as unknown."""
|
||||
band = estimator.classify_proximity_band(rssi_ema=-80)
|
||||
assert band == ProximityBand.UNKNOWN
|
||||
|
||||
band = estimator.classify_proximity_band(rssi_ema=None)
|
||||
assert band == ProximityBand.UNKNOWN
|
||||
|
||||
def test_band_classification_by_distance(self, estimator):
|
||||
"""Distance-based classification should work."""
|
||||
assert estimator.classify_proximity_band(distance_m=0.5) == ProximityBand.IMMEDIATE
|
||||
assert estimator.classify_proximity_band(distance_m=2.0) == ProximityBand.NEAR
|
||||
assert estimator.classify_proximity_band(distance_m=5.0) == ProximityBand.FAR
|
||||
assert estimator.classify_proximity_band(distance_m=15.0) == ProximityBand.UNKNOWN
|
||||
|
||||
def test_confidence_higher_with_tx_power(self, estimator):
|
||||
"""Confidence should be higher with TX power than without."""
|
||||
_, conf_with_tx = estimator.estimate_distance(rssi=-60, tx_power=-59)
|
||||
_, conf_without_tx = estimator.estimate_distance(rssi=-60, tx_power=None)
|
||||
assert conf_with_tx > conf_without_tx
|
||||
|
||||
def test_confidence_lower_with_high_variance(self, estimator):
|
||||
"""High variance should reduce confidence."""
|
||||
_, conf_low_var = estimator.estimate_distance(rssi=-60, tx_power=-59, variance=10)
|
||||
_, conf_high_var = estimator.estimate_distance(rssi=-60, tx_power=-59, variance=150)
|
||||
assert conf_low_var > conf_high_var
|
||||
|
||||
def test_get_rssi_60s_window(self, estimator):
|
||||
"""60-second window should return correct min/max."""
|
||||
now = datetime.now()
|
||||
samples = [
|
||||
(now - timedelta(seconds=30), -50),
|
||||
(now - timedelta(seconds=20), -60),
|
||||
(now - timedelta(seconds=10), -55),
|
||||
(now - timedelta(seconds=90), -40), # Outside window
|
||||
]
|
||||
min_rssi, max_rssi = estimator.get_rssi_60s_window(samples, window_seconds=60)
|
||||
assert min_rssi == -60
|
||||
assert max_rssi == -50
|
||||
|
||||
def test_get_rssi_60s_window_empty(self, estimator):
|
||||
"""Empty samples should return None."""
|
||||
min_rssi, max_rssi = estimator.get_rssi_60s_window([])
|
||||
assert min_rssi is None
|
||||
assert max_rssi is None
|
||||
|
||||
|
||||
class TestRingBuffer:
|
||||
"""Tests for ring buffer time-windowed storage."""
|
||||
|
||||
@pytest.fixture
|
||||
def buffer(self):
|
||||
"""Create a ring buffer instance."""
|
||||
return RingBuffer(
|
||||
retention_minutes=30,
|
||||
min_interval_seconds=2.0,
|
||||
max_observations_per_device=100,
|
||||
)
|
||||
|
||||
def test_ingest_new_device(self, buffer):
|
||||
"""Ingesting a new device should succeed."""
|
||||
now = datetime.now()
|
||||
result = buffer.ingest('device:1', rssi=-50, timestamp=now)
|
||||
assert result is True
|
||||
assert buffer.get_device_count() == 1
|
||||
assert buffer.get_observation_count('device:1') == 1
|
||||
|
||||
def test_ingest_rate_limited(self, buffer):
|
||||
"""Ingestion should be rate-limited to min_interval."""
|
||||
now = datetime.now()
|
||||
buffer.ingest('device:1', rssi=-50, timestamp=now)
|
||||
|
||||
# Try to ingest again within rate limit (1 second later)
|
||||
result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=1))
|
||||
assert result is False
|
||||
assert buffer.get_observation_count('device:1') == 1
|
||||
|
||||
def test_ingest_after_interval(self, buffer):
|
||||
"""Ingestion should succeed after min_interval."""
|
||||
now = datetime.now()
|
||||
buffer.ingest('device:1', rssi=-50, timestamp=now)
|
||||
|
||||
# Ingest after rate limit passes (3 seconds later)
|
||||
result = buffer.ingest('device:1', rssi=-55, timestamp=now + timedelta(seconds=3))
|
||||
assert result is True
|
||||
assert buffer.get_observation_count('device:1') == 2
|
||||
|
||||
def test_prune_old_observations(self, buffer):
|
||||
"""Old observations should be pruned."""
|
||||
now = datetime.now()
|
||||
old_time = now - timedelta(minutes=45) # Older than retention
|
||||
|
||||
buffer.ingest('device:1', rssi=-50, timestamp=old_time)
|
||||
buffer.ingest('device:2', rssi=-60, timestamp=now)
|
||||
|
||||
removed = buffer.prune_old()
|
||||
assert removed == 1
|
||||
assert buffer.get_device_count() == 1
|
||||
|
||||
def test_get_timeseries(self, buffer):
|
||||
"""Timeseries should return downsampled data."""
|
||||
now = datetime.now()
|
||||
|
||||
# Add observations
|
||||
for i in range(10):
|
||||
ts = now - timedelta(seconds=i * 5)
|
||||
buffer.ingest('device:1', rssi=-50 - i, timestamp=ts)
|
||||
|
||||
timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10)
|
||||
assert isinstance(timeseries, list)
|
||||
assert len(timeseries) > 0
|
||||
|
||||
for point in timeseries:
|
||||
assert 'timestamp' in point
|
||||
assert 'rssi' in point
|
||||
|
||||
def test_get_timeseries_empty_device(self, buffer):
|
||||
"""Unknown device should return empty timeseries."""
|
||||
timeseries = buffer.get_timeseries('unknown:device')
|
||||
assert timeseries == []
|
||||
|
||||
def test_get_all_timeseries_sorted_by_recency(self, buffer):
|
||||
"""All timeseries should be sorted by recency."""
|
||||
now = datetime.now()
|
||||
buffer.ingest('device:old', rssi=-50, timestamp=now - timedelta(minutes=5))
|
||||
buffer.ingest('device:new', rssi=-60, timestamp=now)
|
||||
|
||||
all_ts = buffer.get_all_timeseries(sort_by='recency')
|
||||
keys = list(all_ts.keys())
|
||||
assert keys[0] == 'device:new' # Most recent first
|
||||
|
||||
def test_get_all_timeseries_sorted_by_strength(self, buffer):
|
||||
"""All timeseries should be sortable by signal strength."""
|
||||
now = datetime.now()
|
||||
buffer.ingest('device:weak', rssi=-80, timestamp=now)
|
||||
buffer.ingest('device:strong', rssi=-40, timestamp=now + timedelta(seconds=3))
|
||||
|
||||
all_ts = buffer.get_all_timeseries(sort_by='strength')
|
||||
keys = list(all_ts.keys())
|
||||
assert keys[0] == 'device:strong' # Strongest first
|
||||
|
||||
def test_get_all_timeseries_top_n_limit(self, buffer):
|
||||
"""Top N should limit returned devices."""
|
||||
now = datetime.now()
|
||||
for i in range(10):
|
||||
buffer.ingest(f'device:{i}', rssi=-50, timestamp=now + timedelta(seconds=i * 3))
|
||||
|
||||
all_ts = buffer.get_all_timeseries(top_n=5)
|
||||
assert len(all_ts) == 5
|
||||
|
||||
def test_clear(self, buffer):
|
||||
"""Clear should remove all observations."""
|
||||
now = datetime.now()
|
||||
buffer.ingest('device:1', rssi=-50, timestamp=now)
|
||||
buffer.ingest('device:2', rssi=-60, timestamp=now)
|
||||
|
||||
buffer.clear()
|
||||
assert buffer.get_device_count() == 0
|
||||
|
||||
def test_downsampling_bucket_average(self, buffer):
|
||||
"""Downsampling should average RSSI in each bucket."""
|
||||
now = datetime.now()
|
||||
|
||||
# Add multiple observations in same 10s bucket
|
||||
buffer._observations['device:1'] = [
|
||||
(now, -50),
|
||||
(now + timedelta(seconds=1), -60),
|
||||
(now + timedelta(seconds=2), -55),
|
||||
]
|
||||
buffer._last_ingested['device:1'] = now + timedelta(seconds=2)
|
||||
|
||||
timeseries = buffer.get_timeseries('device:1', window_minutes=5, downsample_seconds=10)
|
||||
assert len(timeseries) == 1
|
||||
# Average of -50, -60, -55 = -55
|
||||
assert timeseries[0]['rssi'] == -55.0
|
||||
|
||||
def test_get_device_stats(self, buffer):
|
||||
"""Device stats should return correct values."""
|
||||
now = datetime.now()
|
||||
buffer._observations['device:1'] = [
|
||||
(now - timedelta(seconds=10), -50),
|
||||
(now - timedelta(seconds=5), -60),
|
||||
(now, -55),
|
||||
]
|
||||
|
||||
stats = buffer.get_device_stats('device:1')
|
||||
assert stats is not None
|
||||
assert stats['observation_count'] == 3
|
||||
assert stats['rssi_min'] == -60
|
||||
assert stats['rssi_max'] == -50
|
||||
assert stats['rssi_avg'] == -55.0
|
||||
|
||||
def test_get_device_stats_unknown_device(self, buffer):
|
||||
"""Unknown device should return None."""
|
||||
stats = buffer.get_device_stats('unknown:device')
|
||||
assert stats is None
|
||||
|
||||
|
||||
class TestProximityBand:
|
||||
"""Tests for ProximityBand enum."""
|
||||
|
||||
def test_proximity_band_str(self):
|
||||
"""ProximityBand should convert to string correctly."""
|
||||
assert str(ProximityBand.IMMEDIATE) == 'immediate'
|
||||
assert str(ProximityBand.NEAR) == 'near'
|
||||
assert str(ProximityBand.FAR) == 'far'
|
||||
assert str(ProximityBand.UNKNOWN) == 'unknown'
|
||||
|
||||
def test_proximity_band_values(self):
|
||||
"""ProximityBand values should match expected strings."""
|
||||
assert ProximityBand.IMMEDIATE.value == 'immediate'
|
||||
assert ProximityBand.NEAR.value == 'near'
|
||||
assert ProximityBand.FAR.value == 'far'
|
||||
assert ProximityBand.UNKNOWN.value == 'unknown'
|
||||
|
||||
|
||||
class TestRssiThresholds:
|
||||
"""Tests for RSSI threshold constants."""
|
||||
|
||||
def test_threshold_order(self):
|
||||
"""Thresholds should be in descending order."""
|
||||
assert RSSI_THRESHOLD_IMMEDIATE > RSSI_THRESHOLD_NEAR
|
||||
assert RSSI_THRESHOLD_NEAR > RSSI_THRESHOLD_FAR
|
||||
|
||||
def test_threshold_values(self):
|
||||
"""Threshold values should match expected dBm levels."""
|
||||
assert RSSI_THRESHOLD_IMMEDIATE == -40
|
||||
assert RSSI_THRESHOLD_NEAR == -55
|
||||
assert RSSI_THRESHOLD_FAR == -75
|
||||
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Test suite for the Tracker Signature Engine.
|
||||
|
||||
Contains sample payloads from real BLE tracker devices and verifies
|
||||
the signature engine correctly identifies them with appropriate confidence.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from utils.bluetooth.tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
detect_tracker,
|
||||
get_tracker_engine,
|
||||
APPLE_COMPANY_ID,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SAMPLE PAYLOADS FROM REAL DEVICES
|
||||
# =============================================================================
|
||||
|
||||
# Apple AirTag advertisement payload samples
|
||||
AIRTAG_SAMPLES = [
|
||||
{
|
||||
'name': 'AirTag sample 1 - Find My advertisement',
|
||||
'address': 'AA:BB:CC:DD:EE:FF',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('121910deadbeef0123456789abcdef0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'AirTag sample 2 - Shorter payload',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'rpa',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('1219abcdef1234567890'),
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.AIRTAG,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
},
|
||||
]
|
||||
|
||||
# Apple Find My accessory (non-AirTag)
|
||||
FINDMY_ACCESSORY_SAMPLES = [
|
||||
{
|
||||
'name': 'Chipolo ONE Spot (Find My network)',
|
||||
'address': 'CC:DD:EE:FF:00:11',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('12cafe0123456789'),
|
||||
'service_uuids': ['fd6f'],
|
||||
'expected_type': TrackerType.AIRTAG, # Using Find My, detected as AirTag-like
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
]
|
||||
|
||||
# Tile tracker samples
|
||||
TILE_SAMPLES = [
|
||||
{
|
||||
'name': 'Tile Mate - by company ID',
|
||||
'address': 'C4:E7:00:11:22:33',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x00ED, # Tile Inc
|
||||
'manufacturer_data': bytes.fromhex('ed00aabbccdd'),
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Tile Pro - by MAC prefix',
|
||||
'address': 'DC:54:AA:BB:CC:DD',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['feed'],
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.MEDIUM,
|
||||
},
|
||||
{
|
||||
'name': 'Tile - by name only',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'name': 'Tile Slim',
|
||||
'expected_type': TrackerType.TILE,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Samsung SmartTag samples
|
||||
SAMSUNG_SAMPLES = [
|
||||
{
|
||||
'name': 'Samsung SmartTag - by company ID and service',
|
||||
'address': '58:4D:AA:BB:CC:DD',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x0075, # Samsung
|
||||
'manufacturer_data': bytes.fromhex('75001234567890'),
|
||||
'service_uuids': ['fd5a'],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.HIGH,
|
||||
},
|
||||
{
|
||||
'name': 'Samsung SmartTag - by MAC prefix only',
|
||||
'address': 'A0:75:BB:CC:DD:EE',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': None,
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': [],
|
||||
'expected_type': TrackerType.SAMSUNG_SMARTTAG,
|
||||
'expected_confidence': TrackerConfidence.LOW,
|
||||
},
|
||||
]
|
||||
|
||||
# Non-tracker devices (should NOT be detected as trackers)
|
||||
NON_TRACKER_SAMPLES = [
|
||||
{
|
||||
'name': 'Apple AirPods - should not be tracker',
|
||||
'address': 'AA:BB:CC:DD:EE:00',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': APPLE_COMPANY_ID,
|
||||
'manufacturer_data': bytes.fromhex('100000'), # NOT Find My pattern
|
||||
'service_uuids': [],
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Generic BLE device',
|
||||
'address': '00:11:22:33:44:55',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0006, # Microsoft
|
||||
'manufacturer_data': bytes.fromhex('0600aabbccdd'),
|
||||
'service_uuids': ['180f', '180a'], # Battery and Device Info services
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Fitbit fitness tracker - not a location tracker',
|
||||
'address': 'FF:EE:DD:CC:BB:AA',
|
||||
'address_type': 'random',
|
||||
'manufacturer_id': 0x00D2, # Fitbit
|
||||
'manufacturer_data': bytes.fromhex('d2001234'),
|
||||
'service_uuids': ['adab'], # Fitbit service
|
||||
'expected_tracker': False,
|
||||
},
|
||||
{
|
||||
'name': 'Bluetooth speaker',
|
||||
'address': '11:22:33:44:55:66',
|
||||
'address_type': 'public',
|
||||
'manufacturer_id': 0x0310, # Bose
|
||||
'manufacturer_data': None,
|
||||
'service_uuids': ['111e'], # Handsfree
|
||||
'name': 'Bose Speaker',
|
||||
'expected_tracker': False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST CASES
|
||||
# =============================================================================
|
||||
|
||||
class TestTrackerDetection:
|
||||
"""Test tracker detection with sample payloads."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
"""Create a fresh engine for each test."""
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
# --- AirTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', AIRTAG_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_airtag_detection(self, engine, sample):
|
||||
"""Test AirTag detection with various payload samples."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
# Allow medium when expecting high (degraded confidence is acceptable)
|
||||
if sample['expected_confidence'] == TrackerConfidence.HIGH:
|
||||
assert result.confidence in (TrackerConfidence.HIGH, TrackerConfidence.MEDIUM), \
|
||||
f"Expected HIGH or MEDIUM confidence for {sample['name']}"
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Tile tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', TILE_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_tile_detection(self, engine, sample):
|
||||
"""Test Tile tracker detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
assert len(result.evidence) > 0, "Should provide evidence"
|
||||
|
||||
# --- Samsung SmartTag tests ---
|
||||
|
||||
@pytest.mark.parametrize('sample', SAMSUNG_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_samsung_smarttag_detection(self, engine, sample):
|
||||
"""Test Samsung SmartTag detection."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert result.is_tracker, f"Should detect {sample['name']} as tracker"
|
||||
assert result.tracker_type == sample['expected_type'], \
|
||||
f"Expected {sample['expected_type']}, got {result.tracker_type}"
|
||||
|
||||
# --- Non-tracker tests (negative cases) ---
|
||||
|
||||
@pytest.mark.parametrize('sample', NON_TRACKER_SAMPLES, ids=lambda s: s['name'])
|
||||
def test_non_tracker_not_detected(self, engine, sample):
|
||||
"""Test that non-tracker devices are NOT falsely detected."""
|
||||
result = engine.detect_tracker(
|
||||
address=sample['address'],
|
||||
address_type=sample['address_type'],
|
||||
name=sample.get('name'),
|
||||
manufacturer_id=sample['manufacturer_id'],
|
||||
manufacturer_data=sample['manufacturer_data'],
|
||||
service_uuids=sample['service_uuids'],
|
||||
)
|
||||
|
||||
assert not result.is_tracker, \
|
||||
f"{sample['name']} should NOT be detected as tracker (got: {result.tracker_type})"
|
||||
|
||||
|
||||
class TestFingerprinting:
|
||||
"""Test device fingerprinting for MAC randomization tracking."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
def test_fingerprint_consistency(self, engine):
|
||||
"""Test that same payload produces same fingerprint."""
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
)
|
||||
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219deadbeef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=-10,
|
||||
name='TestDevice',
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id, \
|
||||
"Same payload should produce same fingerprint"
|
||||
|
||||
def test_fingerprint_different_mac(self, engine):
|
||||
"""Test that fingerprint ignores MAC address (for tracking across rotations)."""
|
||||
# Fingerprinting doesn't take MAC as input, so this tests the concept
|
||||
fp1 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
# Same payload characteristics should produce same fingerprint
|
||||
fp2 = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219abcdef'),
|
||||
service_uuids=['fd6f'],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp1.fingerprint_id == fp2.fingerprint_id
|
||||
|
||||
def test_fingerprint_stability_score(self, engine):
|
||||
"""Test that fingerprints have appropriate stability scores."""
|
||||
# Rich payload = high stability
|
||||
fp_rich = engine.generate_device_fingerprint(
|
||||
manufacturer_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data=bytes.fromhex('1219aabbccdd'),
|
||||
service_uuids=['fd6f', '180f'],
|
||||
service_data={'fd6f': bytes.fromhex('01')},
|
||||
tx_power=-5,
|
||||
name='AirTag',
|
||||
)
|
||||
|
||||
# Minimal payload = low stability
|
||||
fp_minimal = engine.generate_device_fingerprint(
|
||||
manufacturer_id=None,
|
||||
manufacturer_data=None,
|
||||
service_uuids=[],
|
||||
service_data={},
|
||||
tx_power=None,
|
||||
name=None,
|
||||
)
|
||||
|
||||
assert fp_rich.stability_confidence > fp_minimal.stability_confidence, \
|
||||
"Rich payload should have higher stability confidence"
|
||||
|
||||
|
||||
class TestSuspiciousPresence:
|
||||
"""Test suspicious presence / following heuristics."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self):
|
||||
return TrackerSignatureEngine()
|
||||
|
||||
def test_risk_score_for_tracker(self, engine):
|
||||
"""Test that trackers get base risk score."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test123',
|
||||
is_tracker=True,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
seen_rate=2.0,
|
||||
rssi_variance=15.0,
|
||||
is_new=False,
|
||||
)
|
||||
|
||||
assert risk_score >= 0.3, "Tracker should have base risk score"
|
||||
assert any('tracker' in f.lower() for f in risk_factors)
|
||||
|
||||
def test_risk_score_for_persistent_tracker(self, engine):
|
||||
"""Test that persistent tracker presence increases risk."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test456',
|
||||
is_tracker=True,
|
||||
seen_count=50,
|
||||
duration_seconds=900, # 15 minutes
|
||||
seen_rate=3.5,
|
||||
rssi_variance=8.0, # Stable signal
|
||||
is_new=True,
|
||||
)
|
||||
|
||||
assert risk_score >= 0.5, "Persistent tracker should have high risk"
|
||||
assert len(risk_factors) >= 3, "Should have multiple risk factors"
|
||||
|
||||
def test_non_tracker_low_risk(self, engine):
|
||||
"""Test that non-trackers have low risk scores."""
|
||||
risk_score, risk_factors = engine.evaluate_suspicious_presence(
|
||||
fingerprint_id='test789',
|
||||
is_tracker=False,
|
||||
seen_count=5,
|
||||
duration_seconds=60,
|
||||
seen_rate=1.0,
|
||||
rssi_variance=20.0,
|
||||
is_new=False,
|
||||
)
|
||||
|
||||
assert risk_score < 0.3, "Non-tracker should have low risk"
|
||||
|
||||
|
||||
class TestConvenienceFunction:
|
||||
"""Test the module-level convenience function."""
|
||||
|
||||
def test_detect_tracker_function(self):
|
||||
"""Test the detect_tracker() convenience function."""
|
||||
result = detect_tracker(
|
||||
address='C4:E7:11:22:33:44',
|
||||
address_type='public',
|
||||
name='Tile Mate',
|
||||
manufacturer_id=0x00ED,
|
||||
service_uuids=['feed'],
|
||||
)
|
||||
|
||||
assert result.is_tracker
|
||||
assert result.tracker_type == TrackerType.TILE
|
||||
|
||||
def test_get_engine_singleton(self):
|
||||
"""Test that get_tracker_engine returns singleton."""
|
||||
engine1 = get_tracker_engine()
|
||||
engine2 = get_tracker_engine()
|
||||
assert engine1 is engine2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SMOKE TEST FOR API ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
def test_api_backwards_compatibility():
|
||||
"""
|
||||
Smoke test checklist for API backwards compatibility.
|
||||
|
||||
This is a documentation test - run manually to verify:
|
||||
|
||||
1. GET /api/bluetooth/devices - Should still return devices in same format
|
||||
- Check: device_id, address, name, rssi_current all present
|
||||
- New: tracker fields should be present but optional
|
||||
|
||||
2. POST /api/bluetooth/scan/start - Should work with same parameters
|
||||
- Check: mode, duration_s, transport, rssi_threshold
|
||||
|
||||
3. GET /api/bluetooth/stream - SSE should still emit device_update events
|
||||
- Check: Event format unchanged
|
||||
|
||||
4. GET /tscm/sweep/stream - TSCM should still work
|
||||
- Check: Bluetooth devices included in sweep results
|
||||
|
||||
5. New endpoints (v2):
|
||||
- GET /api/bluetooth/trackers - Returns only detected trackers
|
||||
- GET /api/bluetooth/trackers/<id> - Returns tracker detail
|
||||
- GET /api/bluetooth/diagnostics - Returns system diagnostics
|
||||
|
||||
Run with: pytest tests/test_tracker_signatures.py -v
|
||||
"""
|
||||
# This is just a documentation placeholder
|
||||
# Actual API tests would require a running Flask app
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
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 (legacy)
|
||||
RANGE_VERY_CLOSE,
|
||||
RANGE_CLOSE,
|
||||
RANGE_NEARBY,
|
||||
RANGE_FAR,
|
||||
RANGE_UNKNOWN,
|
||||
# Proximity bands (new)
|
||||
PROXIMITY_IMMEDIATE,
|
||||
PROXIMITY_NEAR,
|
||||
PROXIMITY_FAR,
|
||||
PROXIMITY_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 .device_key import generate_device_key, is_randomized_mac, extract_key_type
|
||||
from .distance import DistanceEstimator, ProximityBand, get_distance_estimator
|
||||
from .heuristics import HeuristicsEngine, evaluate_device_heuristics, evaluate_all_devices
|
||||
from .models import BTDeviceAggregate, BTObservation, ScanStatus, SystemCapabilities
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer, reset_ring_buffer
|
||||
from .scanner import BluetoothScanner, get_bluetooth_scanner, reset_bluetooth_scanner
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
TrackerDetectionResult,
|
||||
TrackerType,
|
||||
TrackerConfidence,
|
||||
DeviceFingerprint,
|
||||
detect_tracker,
|
||||
get_tracker_engine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main scanner
|
||||
'BluetoothScanner',
|
||||
'get_bluetooth_scanner',
|
||||
'reset_bluetooth_scanner',
|
||||
|
||||
# Models
|
||||
'BTObservation',
|
||||
'BTDeviceAggregate',
|
||||
'ScanStatus',
|
||||
'SystemCapabilities',
|
||||
|
||||
# Aggregator
|
||||
'DeviceAggregator',
|
||||
|
||||
# Device key generation
|
||||
'generate_device_key',
|
||||
'is_randomized_mac',
|
||||
'extract_key_type',
|
||||
|
||||
# Distance estimation
|
||||
'DistanceEstimator',
|
||||
'ProximityBand',
|
||||
'get_distance_estimator',
|
||||
|
||||
# Ring buffer
|
||||
'RingBuffer',
|
||||
'get_ring_buffer',
|
||||
'reset_ring_buffer',
|
||||
|
||||
# Heuristics
|
||||
'HeuristicsEngine',
|
||||
'evaluate_device_heuristics',
|
||||
'evaluate_all_devices',
|
||||
|
||||
# Capability checks
|
||||
'check_capabilities',
|
||||
'quick_adapter_check',
|
||||
|
||||
# Constants - Range bands (legacy)
|
||||
'RANGE_VERY_CLOSE',
|
||||
'RANGE_CLOSE',
|
||||
'RANGE_NEARBY',
|
||||
'RANGE_FAR',
|
||||
'RANGE_UNKNOWN',
|
||||
|
||||
# Constants - Proximity bands (new)
|
||||
'PROXIMITY_IMMEDIATE',
|
||||
'PROXIMITY_NEAR',
|
||||
'PROXIMITY_FAR',
|
||||
'PROXIMITY_UNKNOWN',
|
||||
|
||||
# Constants - Protocols
|
||||
'PROTOCOL_BLE',
|
||||
'PROTOCOL_CLASSIC',
|
||||
'PROTOCOL_AUTO',
|
||||
|
||||
# Constants - Address types
|
||||
'ADDRESS_TYPE_PUBLIC',
|
||||
'ADDRESS_TYPE_RANDOM',
|
||||
'ADDRESS_TYPE_RANDOM_STATIC',
|
||||
'ADDRESS_TYPE_RPA',
|
||||
'ADDRESS_TYPE_NRPA',
|
||||
|
||||
# Tracker detection
|
||||
'TrackerSignatureEngine',
|
||||
'TrackerDetectionResult',
|
||||
'TrackerType',
|
||||
'TrackerConfidence',
|
||||
'DeviceFingerprint',
|
||||
'detect_tracker',
|
||||
'get_tracker_engine',
|
||||
]
|
||||
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
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
|
||||
from .device_key import generate_device_key, is_randomized_mac
|
||||
from .distance import DistanceEstimator, get_distance_estimator
|
||||
from .ring_buffer import RingBuffer, get_ring_buffer
|
||||
from .tracker_signatures import (
|
||||
TrackerSignatureEngine,
|
||||
get_tracker_engine,
|
||||
TrackerDetectionResult,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Proximity estimation components
|
||||
self._distance_estimator = get_distance_estimator()
|
||||
self._ring_buffer = get_ring_buffer()
|
||||
|
||||
# Tracker detection engine
|
||||
self._tracker_engine = get_tracker_engine()
|
||||
|
||||
# Device key mapping (device_id -> device_key)
|
||||
self._device_keys: dict[str, str] = {}
|
||||
|
||||
# Fingerprint mapping for cross-MAC tracking
|
||||
self._fingerprint_to_devices: dict[str, set[str]] = {}
|
||||
|
||||
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
|
||||
|
||||
# Generate stable device key
|
||||
device_key = generate_device_key(
|
||||
address=observation.address,
|
||||
address_type=observation.address_type,
|
||||
name=device.name,
|
||||
manufacturer_id=device.manufacturer_id,
|
||||
service_uuids=device.service_uuids if device.service_uuids else None,
|
||||
)
|
||||
device.device_key = device_key
|
||||
self._device_keys[device_id] = device_key
|
||||
|
||||
# Check if randomized MAC
|
||||
device.is_randomized_mac = is_randomized_mac(observation.address_type)
|
||||
|
||||
# Apply EMA smoothing to RSSI
|
||||
if observation.rssi is not None:
|
||||
device.rssi_ema = self._distance_estimator.apply_ema_smoothing(
|
||||
current=observation.rssi,
|
||||
prev_ema=device.rssi_ema,
|
||||
)
|
||||
|
||||
# Get 60-second min/max
|
||||
device.rssi_60s_min, device.rssi_60s_max = self._distance_estimator.get_rssi_60s_window(
|
||||
device.rssi_samples,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
# Store in ring buffer for heatmap
|
||||
self._ring_buffer.ingest(
|
||||
device_key=device_key,
|
||||
rssi=observation.rssi,
|
||||
timestamp=observation.timestamp,
|
||||
)
|
||||
|
||||
# Estimate distance and proximity band
|
||||
self._update_proximity(device)
|
||||
|
||||
# Run tracker detection
|
||||
self._update_tracker_detection(device, observation)
|
||||
|
||||
# Evaluate suspicious presence heuristics
|
||||
self._update_risk_analysis(device)
|
||||
|
||||
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 _update_proximity(self, device: BTDeviceAggregate) -> None:
|
||||
"""Update proximity estimation for a device."""
|
||||
if device.rssi_ema is None:
|
||||
device.proximity_band = 'unknown'
|
||||
device.estimated_distance_m = None
|
||||
device.distance_confidence = 0.0
|
||||
return
|
||||
|
||||
# Estimate distance
|
||||
distance, confidence = self._distance_estimator.estimate_distance(
|
||||
rssi=device.rssi_ema,
|
||||
tx_power=device.tx_power,
|
||||
variance=device.rssi_variance,
|
||||
)
|
||||
|
||||
device.estimated_distance_m = distance
|
||||
device.distance_confidence = confidence
|
||||
|
||||
# Classify proximity band
|
||||
band = self._distance_estimator.classify_proximity_band(
|
||||
distance_m=distance,
|
||||
rssi_ema=device.rssi_ema,
|
||||
)
|
||||
device.proximity_band = str(band)
|
||||
|
||||
def _update_tracker_detection(
|
||||
self,
|
||||
device: BTDeviceAggregate,
|
||||
observation: BTObservation,
|
||||
) -> None:
|
||||
"""Run tracker signature detection on a device."""
|
||||
# Prepare service data from observation if available
|
||||
service_data = observation.service_data if observation.service_data else {}
|
||||
|
||||
# Store service data on device for investigation
|
||||
for uuid, data in service_data.items():
|
||||
device.service_data[uuid] = data
|
||||
|
||||
# Run tracker detection
|
||||
result = self._tracker_engine.detect_tracker(
|
||||
address=device.address,
|
||||
address_type=device.address_type,
|
||||
name=device.name,
|
||||
manufacturer_id=device.manufacturer_id,
|
||||
manufacturer_data=device.manufacturer_bytes,
|
||||
service_uuids=device.service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=device.tx_power,
|
||||
)
|
||||
|
||||
# Update device with detection results
|
||||
device.is_tracker = result.is_tracker
|
||||
device.tracker_type = result.tracker_type.value if result.tracker_type else None
|
||||
device.tracker_name = result.tracker_name
|
||||
device.tracker_confidence = result.confidence.value if result.confidence else None
|
||||
device.tracker_confidence_score = result.confidence_score
|
||||
device.tracker_evidence = result.evidence
|
||||
|
||||
# Generate and store payload fingerprint
|
||||
fingerprint = self._tracker_engine.generate_device_fingerprint(
|
||||
manufacturer_id=device.manufacturer_id,
|
||||
manufacturer_data=device.manufacturer_bytes,
|
||||
service_uuids=device.service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=device.tx_power,
|
||||
name=device.name,
|
||||
)
|
||||
device.payload_fingerprint_id = fingerprint.fingerprint_id
|
||||
device.payload_fingerprint_stability = fingerprint.stability_confidence
|
||||
|
||||
# Track fingerprint to device mapping
|
||||
if fingerprint.fingerprint_id not in self._fingerprint_to_devices:
|
||||
self._fingerprint_to_devices[fingerprint.fingerprint_id] = set()
|
||||
self._fingerprint_to_devices[fingerprint.fingerprint_id].add(device.device_id)
|
||||
|
||||
# Record sighting for persistence tracking
|
||||
self._tracker_engine.record_sighting(fingerprint.fingerprint_id)
|
||||
|
||||
def _update_risk_analysis(self, device: BTDeviceAggregate) -> None:
|
||||
"""Evaluate suspicious presence heuristics for a device."""
|
||||
if not device.payload_fingerprint_id:
|
||||
return
|
||||
|
||||
risk_score, risk_factors = self._tracker_engine.evaluate_suspicious_presence(
|
||||
fingerprint_id=device.payload_fingerprint_id,
|
||||
is_tracker=device.is_tracker,
|
||||
seen_count=device.seen_count,
|
||||
duration_seconds=device.duration_seconds,
|
||||
seen_rate=device.seen_rate,
|
||||
rssi_variance=device.rssi_variance,
|
||||
is_new=device.is_new,
|
||||
)
|
||||
|
||||
device.risk_score = risk_score
|
||||
device.risk_factors = risk_factors
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
def ring_buffer(self) -> RingBuffer:
|
||||
"""Access the ring buffer for timeseries data."""
|
||||
return self._ring_buffer
|
||||
|
||||
def get_device_by_key(self, device_key: str) -> Optional[BTDeviceAggregate]:
|
||||
"""Get a device by its stable device key."""
|
||||
with self._lock:
|
||||
# Find device_id from device_key
|
||||
for device_id, key in self._device_keys.items():
|
||||
if key == device_key:
|
||||
return self._devices.get(device_id)
|
||||
return None
|
||||
|
||||
def get_timeseries(
|
||||
self,
|
||||
device_key: str,
|
||||
window_minutes: int = 30,
|
||||
downsample_seconds: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get timeseries data for a device.
|
||||
|
||||
Args:
|
||||
device_key: Stable device identifier.
|
||||
window_minutes: Time window in minutes.
|
||||
downsample_seconds: Bucket size for downsampling.
|
||||
|
||||
Returns:
|
||||
List of {timestamp, rssi} dicts.
|
||||
"""
|
||||
return self._ring_buffer.get_timeseries(
|
||||
device_key=device_key,
|
||||
window_minutes=window_minutes,
|
||||
downsample_seconds=downsample_seconds,
|
||||
)
|
||||
|
||||
def get_heatmap_data(
|
||||
self,
|
||||
top_n: int = 20,
|
||||
window_minutes: int = 10,
|
||||
bucket_seconds: int = 10,
|
||||
sort_by: str = 'recency',
|
||||
) -> dict:
|
||||
"""
|
||||
Get heatmap data for visualization.
|
||||
|
||||
Args:
|
||||
top_n: Number of devices to include.
|
||||
window_minutes: Time window.
|
||||
bucket_seconds: Bucket size for downsampling.
|
||||
sort_by: Sort method ('recency', 'strength', 'activity').
|
||||
|
||||
Returns:
|
||||
Dict with device timeseries and metadata.
|
||||
"""
|
||||
# Get timeseries data from ring buffer
|
||||
timeseries = self._ring_buffer.get_all_timeseries(
|
||||
window_minutes=window_minutes,
|
||||
downsample_seconds=bucket_seconds,
|
||||
top_n=top_n,
|
||||
sort_by=sort_by,
|
||||
)
|
||||
|
||||
# Enrich with device metadata
|
||||
result = {
|
||||
'window_minutes': window_minutes,
|
||||
'bucket_seconds': bucket_seconds,
|
||||
'devices': [],
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
for device_key, ts_data in timeseries.items():
|
||||
device = self.get_device_by_key(device_key)
|
||||
device_info = {
|
||||
'device_key': device_key,
|
||||
'timeseries': ts_data,
|
||||
}
|
||||
|
||||
if device:
|
||||
device_info.update({
|
||||
'name': device.name,
|
||||
'address': device.address,
|
||||
'rssi_current': device.rssi_current,
|
||||
'rssi_ema': round(device.rssi_ema, 1) if device.rssi_ema else None,
|
||||
'proximity_band': device.proximity_band,
|
||||
})
|
||||
else:
|
||||
device_info.update({
|
||||
'name': None,
|
||||
'address': None,
|
||||
'rssi_current': None,
|
||||
'rssi_ema': None,
|
||||
'proximity_band': 'unknown',
|
||||
})
|
||||
|
||||
result['devices'].append(device_info)
|
||||
|
||||
return result
|
||||
|
||||
def prune_ring_buffer(self) -> int:
|
||||
"""Prune old observations from ring buffer."""
|
||||
return self._ring_buffer.prune_old()
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
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 = {
|
||||
'id': str(path), # Alias for frontend
|
||||
'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:
|
||||
adapter_name = adapter_match.group(1)
|
||||
current_adapter = {
|
||||
'id': adapter_name, # Alias for frontend
|
||||
'path': f'/org/bluez/{adapter_name}',
|
||||
'name': adapter_name,
|
||||
'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."""
|
||||
# NOTE: DBus/BlueZ requires a GLib main loop which Flask doesn't have.
|
||||
# For Flask applications, we prefer bleak or subprocess-based tools.
|
||||
|
||||
# Prefer bleak (cross-platform, works in Flask)
|
||||
if caps.has_bleak:
|
||||
caps.recommended_backend = 'bleak'
|
||||
return
|
||||
|
||||
# Fallback to hcitool (requires root on Linux)
|
||||
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
|
||||
|
||||
# DBus is last resort - won't work properly with Flask but keep as option
|
||||
# for potential future use with a separate scanning daemon
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# Generic subprocess timeout (short operations)
|
||||
SUBPROCESS_TIMEOUT_SHORT = 5.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'
|
||||
|
||||
# =============================================================================
|
||||
# PROXIMITY BANDS (new visualization system)
|
||||
# =============================================================================
|
||||
|
||||
PROXIMITY_IMMEDIATE = 'immediate' # < 1m
|
||||
PROXIMITY_NEAR = 'near' # 1-3m
|
||||
PROXIMITY_FAR = 'far' # 3-10m
|
||||
PROXIMITY_UNKNOWN = 'unknown'
|
||||
|
||||
# RSSI thresholds for proximity band classification (dBm)
|
||||
PROXIMITY_RSSI_IMMEDIATE = -40 # >= -40 dBm -> immediate
|
||||
PROXIMITY_RSSI_NEAR = -55 # >= -55 dBm -> near
|
||||
PROXIMITY_RSSI_FAR = -75 # >= -75 dBm -> far
|
||||
|
||||
# =============================================================================
|
||||
# DISTANCE ESTIMATION SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Path-loss exponent for indoor environments (typical range: 2-4)
|
||||
DISTANCE_PATH_LOSS_EXPONENT = 2.5
|
||||
|
||||
# Reference RSSI at 1 meter (typical BLE value)
|
||||
DISTANCE_RSSI_AT_1M = -59
|
||||
|
||||
# EMA smoothing alpha (higher = more responsive, lower = smoother)
|
||||
DISTANCE_EMA_ALPHA = 0.3
|
||||
|
||||
# Variance thresholds for confidence scoring (dBm^2)
|
||||
DISTANCE_LOW_VARIANCE = 25.0 # High confidence
|
||||
DISTANCE_HIGH_VARIANCE = 100.0 # Low confidence
|
||||
|
||||
# =============================================================================
|
||||
# RING BUFFER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Observation retention period (minutes)
|
||||
RING_BUFFER_RETENTION_MINUTES = 30
|
||||
|
||||
# Minimum interval between observations per device (seconds)
|
||||
RING_BUFFER_MIN_INTERVAL_SECONDS = 2.0
|
||||
|
||||
# Maximum observations stored per device
|
||||
RING_BUFFER_MAX_OBSERVATIONS = 1000
|
||||
|
||||
# =============================================================================
|
||||
# HEATMAP SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Default time window for heatmap (minutes)
|
||||
HEATMAP_DEFAULT_WINDOW_MINUTES = 10
|
||||
|
||||
# Default bucket size for downsampling (seconds)
|
||||
HEATMAP_DEFAULT_BUCKET_SECONDS = 10
|
||||
|
||||
# Maximum devices to show in heatmap
|
||||
HEATMAP_MAX_DEVICES = 50
|
||||
|
||||
# =============================================================================
|
||||
# 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',
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
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)
|
||||
# Handle various DBus data types safely
|
||||
try:
|
||||
if isinstance(mdata, (bytes, bytearray)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, dbus.Array):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, (list, tuple)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, str):
|
||||
manufacturer_data = bytes.fromhex(mdata)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.debug(f"Could not convert manufacturer data: {e}")
|
||||
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():
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, dbus.Array):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, str):
|
||||
service_data[str(uuid)] = bytes.fromhex(data)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.debug(f"Could not convert service data for {uuid}: {e}")
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Stable device key generation for Bluetooth devices.
|
||||
|
||||
Generates consistent identifiers for devices even when MAC addresses rotate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
ADDRESS_TYPE_PUBLIC,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
)
|
||||
|
||||
|
||||
def generate_device_key(
|
||||
address: str,
|
||||
address_type: str,
|
||||
identity_address: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a stable device key for identifying a Bluetooth device.
|
||||
|
||||
Priority order:
|
||||
1. identity_address -> "id:{address}" (resolved from RPA via IRK)
|
||||
2. public/static MAC -> "mac:{address}" (stable addresses)
|
||||
3. Random address -> "fp:{hash}" (fingerprint from device characteristics)
|
||||
|
||||
Args:
|
||||
address: The Bluetooth address (MAC).
|
||||
address_type: Type of address (public, random, random_static, rpa, nrpa).
|
||||
identity_address: Resolved identity address if available.
|
||||
name: Device name if available.
|
||||
manufacturer_id: Manufacturer ID if available.
|
||||
service_uuids: List of service UUIDs if available.
|
||||
|
||||
Returns:
|
||||
A stable device key string.
|
||||
"""
|
||||
# Priority 1: Use identity address if available (resolved RPA)
|
||||
if identity_address:
|
||||
return f"id:{identity_address.upper()}"
|
||||
|
||||
# Priority 2: Use public or random_static addresses directly
|
||||
if address_type in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC):
|
||||
return f"mac:{address.upper()}"
|
||||
|
||||
# Priority 3: Generate fingerprint hash for random addresses
|
||||
return _generate_fingerprint_key(address, name, manufacturer_id, service_uuids)
|
||||
|
||||
|
||||
def _generate_fingerprint_key(
|
||||
address: str,
|
||||
name: Optional[str],
|
||||
manufacturer_id: Optional[int],
|
||||
service_uuids: Optional[list[str]],
|
||||
) -> str:
|
||||
"""
|
||||
Generate a fingerprint-based key for devices with random addresses.
|
||||
|
||||
Uses device characteristics to create a stable identifier when the
|
||||
MAC address rotates.
|
||||
"""
|
||||
# Build fingerprint components
|
||||
components = []
|
||||
|
||||
# Include name if available (most stable identifier for random MACs)
|
||||
if name:
|
||||
components.append(f"name:{name}")
|
||||
|
||||
# Include manufacturer ID
|
||||
if manufacturer_id is not None:
|
||||
components.append(f"mfr:{manufacturer_id}")
|
||||
|
||||
# Include sorted service UUIDs
|
||||
if service_uuids:
|
||||
sorted_uuids = sorted(set(service_uuids))
|
||||
components.append(f"svc:{','.join(sorted_uuids)}")
|
||||
|
||||
# If we have enough characteristics, generate a hash
|
||||
if components:
|
||||
fingerprint_str = "|".join(components)
|
||||
hash_digest = hashlib.sha256(fingerprint_str.encode()).hexdigest()[:16]
|
||||
return f"fp:{hash_digest}"
|
||||
|
||||
# Fallback: use address directly (least stable for random MACs)
|
||||
return f"mac:{address.upper()}"
|
||||
|
||||
|
||||
def is_randomized_mac(address_type: str) -> bool:
|
||||
"""
|
||||
Check if an address type indicates a randomized MAC.
|
||||
|
||||
Args:
|
||||
address_type: The address type string.
|
||||
|
||||
Returns:
|
||||
True if the address is randomized, False otherwise.
|
||||
"""
|
||||
return address_type not in (ADDRESS_TYPE_PUBLIC, ADDRESS_TYPE_RANDOM_STATIC)
|
||||
|
||||
|
||||
def extract_key_type(device_key: str) -> str:
|
||||
"""
|
||||
Extract the key type prefix from a device key.
|
||||
|
||||
Args:
|
||||
device_key: The device key string.
|
||||
|
||||
Returns:
|
||||
The key type ('id', 'mac', or 'fp').
|
||||
"""
|
||||
if ':' in device_key:
|
||||
return device_key.split(':', 1)[0]
|
||||
return 'unknown'
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Distance estimation for Bluetooth devices.
|
||||
|
||||
Provides path-loss based distance calculation, band classification,
|
||||
and EMA smoothing for RSSI values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ProximityBand(str, Enum):
|
||||
"""Proximity band classifications."""
|
||||
IMMEDIATE = 'immediate' # < 1m
|
||||
NEAR = 'near' # 1-3m
|
||||
FAR = 'far' # 3-10m
|
||||
UNKNOWN = 'unknown' # Cannot determine
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
# Default path-loss exponent for indoor environments
|
||||
DEFAULT_PATH_LOSS_EXPONENT = 2.5
|
||||
|
||||
# RSSI thresholds for band classification (dBm)
|
||||
RSSI_THRESHOLD_IMMEDIATE = -40 # >= -40 dBm
|
||||
RSSI_THRESHOLD_NEAR = -55 # >= -55 dBm
|
||||
RSSI_THRESHOLD_FAR = -75 # >= -75 dBm
|
||||
|
||||
# Default reference RSSI at 1 meter (typical BLE)
|
||||
DEFAULT_RSSI_AT_1M = -59
|
||||
|
||||
# Default EMA alpha
|
||||
DEFAULT_EMA_ALPHA = 0.3
|
||||
|
||||
# Variance thresholds for confidence scoring
|
||||
LOW_VARIANCE_THRESHOLD = 25.0 # dBm^2
|
||||
HIGH_VARIANCE_THRESHOLD = 100.0 # dBm^2
|
||||
|
||||
|
||||
class DistanceEstimator:
|
||||
"""
|
||||
Estimates distance to Bluetooth devices based on RSSI.
|
||||
|
||||
Uses path-loss formula when TX power is available, falls back to
|
||||
band-based estimation otherwise.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path_loss_exponent: float = DEFAULT_PATH_LOSS_EXPONENT,
|
||||
rssi_at_1m: int = DEFAULT_RSSI_AT_1M,
|
||||
ema_alpha: float = DEFAULT_EMA_ALPHA,
|
||||
):
|
||||
"""
|
||||
Initialize the distance estimator.
|
||||
|
||||
Args:
|
||||
path_loss_exponent: Path-loss exponent (n), typically 2-4.
|
||||
rssi_at_1m: Reference RSSI at 1 meter.
|
||||
ema_alpha: Smoothing factor for EMA (0-1).
|
||||
"""
|
||||
self.path_loss_exponent = path_loss_exponent
|
||||
self.rssi_at_1m = rssi_at_1m
|
||||
self.ema_alpha = ema_alpha
|
||||
|
||||
def estimate_distance(
|
||||
self,
|
||||
rssi: float,
|
||||
tx_power: Optional[int] = None,
|
||||
variance: Optional[float] = None,
|
||||
) -> tuple[Optional[float], float]:
|
||||
"""
|
||||
Estimate distance to a device based on RSSI.
|
||||
|
||||
Args:
|
||||
rssi: Current RSSI value (dBm).
|
||||
tx_power: Transmitted power at 1m (dBm), if advertised.
|
||||
variance: RSSI variance for confidence scoring.
|
||||
|
||||
Returns:
|
||||
Tuple of (distance_m, confidence) where distance_m may be None
|
||||
if estimation fails, and confidence is 0.0-1.0.
|
||||
"""
|
||||
if rssi is None or rssi > 0:
|
||||
return None, 0.0
|
||||
|
||||
# Calculate base confidence from variance
|
||||
base_confidence = self._calculate_variance_confidence(variance)
|
||||
|
||||
if tx_power is not None:
|
||||
# Use path-loss formula: d = 10^((tx_power - rssi) / (10 * n))
|
||||
distance = self._path_loss_distance(rssi, tx_power)
|
||||
# Higher confidence with TX power
|
||||
confidence = min(1.0, base_confidence * 1.2) if base_confidence > 0 else 0.6
|
||||
return distance, confidence
|
||||
else:
|
||||
# Fall back to band-based estimation
|
||||
distance = self._estimate_from_bands(rssi)
|
||||
# Lower confidence without TX power
|
||||
confidence = base_confidence * 0.6 if base_confidence > 0 else 0.3
|
||||
return distance, confidence
|
||||
|
||||
def _path_loss_distance(self, rssi: float, tx_power: int) -> float:
|
||||
"""
|
||||
Calculate distance using path-loss formula.
|
||||
|
||||
Formula: d = 10^((tx_power - rssi) / (10 * n))
|
||||
|
||||
Args:
|
||||
rssi: Current RSSI value.
|
||||
tx_power: Transmitted power at 1m.
|
||||
|
||||
Returns:
|
||||
Estimated distance in meters.
|
||||
"""
|
||||
exponent = (tx_power - rssi) / (10 * self.path_loss_exponent)
|
||||
distance = 10 ** exponent
|
||||
# Clamp to reasonable range
|
||||
return max(0.1, min(100.0, distance))
|
||||
|
||||
def _estimate_from_bands(self, rssi: float) -> float:
|
||||
"""
|
||||
Estimate distance based on RSSI bands when TX power unavailable.
|
||||
|
||||
Uses calibrated thresholds to provide rough distance estimate.
|
||||
|
||||
Args:
|
||||
rssi: Current RSSI value.
|
||||
|
||||
Returns:
|
||||
Estimated distance in meters (midpoint of band).
|
||||
"""
|
||||
if rssi >= RSSI_THRESHOLD_IMMEDIATE:
|
||||
return 0.5 # Immediate: ~0.5m
|
||||
elif rssi >= RSSI_THRESHOLD_NEAR:
|
||||
return 2.0 # Near: ~2m
|
||||
elif rssi >= RSSI_THRESHOLD_FAR:
|
||||
return 6.0 # Far: ~6m
|
||||
else:
|
||||
return 15.0 # Very far: ~15m
|
||||
|
||||
def _calculate_variance_confidence(self, variance: Optional[float]) -> float:
|
||||
"""
|
||||
Calculate confidence based on RSSI variance.
|
||||
|
||||
Lower variance = higher confidence.
|
||||
|
||||
Args:
|
||||
variance: RSSI variance value.
|
||||
|
||||
Returns:
|
||||
Confidence factor (0.0-1.0).
|
||||
"""
|
||||
if variance is None:
|
||||
return 0.5 # Unknown variance
|
||||
|
||||
if variance <= LOW_VARIANCE_THRESHOLD:
|
||||
return 0.9 # High confidence - stable signal
|
||||
elif variance <= HIGH_VARIANCE_THRESHOLD:
|
||||
# Linear interpolation between thresholds
|
||||
ratio = (variance - LOW_VARIANCE_THRESHOLD) / (HIGH_VARIANCE_THRESHOLD - LOW_VARIANCE_THRESHOLD)
|
||||
return 0.9 - (ratio * 0.5) # 0.9 to 0.4
|
||||
else:
|
||||
return 0.3 # Low confidence - unstable signal
|
||||
|
||||
def classify_proximity_band(
|
||||
self,
|
||||
distance_m: Optional[float] = None,
|
||||
rssi_ema: Optional[float] = None,
|
||||
) -> ProximityBand:
|
||||
"""
|
||||
Classify device into a proximity band.
|
||||
|
||||
Uses distance if available, falls back to RSSI-based classification.
|
||||
|
||||
Args:
|
||||
distance_m: Estimated distance in meters.
|
||||
rssi_ema: Smoothed RSSI value.
|
||||
|
||||
Returns:
|
||||
ProximityBand classification.
|
||||
"""
|
||||
# Prefer distance-based classification
|
||||
if distance_m is not None:
|
||||
if distance_m < 1.0:
|
||||
return ProximityBand.IMMEDIATE
|
||||
elif distance_m < 3.0:
|
||||
return ProximityBand.NEAR
|
||||
elif distance_m < 10.0:
|
||||
return ProximityBand.FAR
|
||||
else:
|
||||
return ProximityBand.UNKNOWN
|
||||
|
||||
# Fall back to RSSI-based classification
|
||||
if rssi_ema is not None:
|
||||
if rssi_ema >= RSSI_THRESHOLD_IMMEDIATE:
|
||||
return ProximityBand.IMMEDIATE
|
||||
elif rssi_ema >= RSSI_THRESHOLD_NEAR:
|
||||
return ProximityBand.NEAR
|
||||
elif rssi_ema >= RSSI_THRESHOLD_FAR:
|
||||
return ProximityBand.FAR
|
||||
|
||||
return ProximityBand.UNKNOWN
|
||||
|
||||
def apply_ema_smoothing(
|
||||
self,
|
||||
current: int,
|
||||
prev_ema: Optional[float] = None,
|
||||
alpha: Optional[float] = None,
|
||||
) -> float:
|
||||
"""
|
||||
Apply Exponential Moving Average smoothing to RSSI.
|
||||
|
||||
Formula: new_ema = alpha * current + (1-alpha) * prev_ema
|
||||
|
||||
Args:
|
||||
current: Current RSSI value.
|
||||
prev_ema: Previous EMA value (None for first value).
|
||||
alpha: Smoothing factor (0-1), uses instance default if None.
|
||||
|
||||
Returns:
|
||||
New EMA value.
|
||||
"""
|
||||
if alpha is None:
|
||||
alpha = self.ema_alpha
|
||||
|
||||
if prev_ema is None:
|
||||
return float(current)
|
||||
|
||||
return alpha * current + (1 - alpha) * prev_ema
|
||||
|
||||
def get_rssi_60s_window(
|
||||
self,
|
||||
rssi_samples: list[tuple],
|
||||
window_seconds: int = 60,
|
||||
) -> tuple[Optional[int], Optional[int]]:
|
||||
"""
|
||||
Get min/max RSSI from the last N seconds.
|
||||
|
||||
Args:
|
||||
rssi_samples: List of (timestamp, rssi) tuples.
|
||||
window_seconds: Window size in seconds.
|
||||
|
||||
Returns:
|
||||
Tuple of (min_rssi, max_rssi) or (None, None) if no samples.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
if not rssi_samples:
|
||||
return None, None
|
||||
|
||||
cutoff = datetime.now() - timedelta(seconds=window_seconds)
|
||||
recent_rssi = [rssi for ts, rssi in rssi_samples if ts >= cutoff]
|
||||
|
||||
if not recent_rssi:
|
||||
return None, None
|
||||
|
||||
return min(recent_rssi), max(recent_rssi)
|
||||
|
||||
|
||||
# Module-level instance for convenience
|
||||
_default_estimator: Optional[DistanceEstimator] = None
|
||||
|
||||
|
||||
def get_distance_estimator() -> DistanceEstimator:
|
||||
"""Get or create the default distance estimator instance."""
|
||||
global _default_estimator
|
||||
if _default_estimator is None:
|
||||
_default_estimator = DistanceEstimator()
|
||||
return _default_estimator
|
||||
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
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
|
||||
# Handle various data types safely
|
||||
try:
|
||||
if isinstance(mdata, (bytes, bytearray)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, (list, tuple)):
|
||||
manufacturer_data = bytes(mdata)
|
||||
elif isinstance(mdata, str):
|
||||
manufacturer_data = bytes.fromhex(mdata)
|
||||
else:
|
||||
manufacturer_data = bytes(mdata)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.debug(f"Could not convert manufacturer data: {e}")
|
||||
break
|
||||
|
||||
# Extract service data
|
||||
service_data = {}
|
||||
if adv_data.service_data:
|
||||
for uuid, data in adv_data.service_data.items():
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
elif isinstance(data, str):
|
||||
service_data[str(uuid)] = bytes.fromhex(data)
|
||||
else:
|
||||
service_data[str(uuid)] = bytes(data)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.debug(f"Could not convert service data for {uuid}: {e}")
|
||||
|
||||
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=getattr(adv_data, 'connectable', True) if adv_data 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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
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,
|
||||
ADDRESS_TYPE_RANDOM,
|
||||
ADDRESS_TYPE_RANDOM_STATIC,
|
||||
ADDRESS_TYPE_RPA,
|
||||
ADDRESS_TYPE_NRPA,
|
||||
RANGE_UNKNOWN,
|
||||
PROTOCOL_BLE,
|
||||
PROXIMITY_UNKNOWN,
|
||||
)
|
||||
|
||||
# Import tracker types (will be available after tracker_signatures module loads)
|
||||
# Use string type hints to avoid circular imports
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from .tracker_signatures import TrackerDetectionResult, DeviceFingerprint
|
||||
|
||||
|
||||
@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) - legacy
|
||||
range_band: str = RANGE_UNKNOWN
|
||||
range_confidence: float = 0.0
|
||||
|
||||
# Proximity band (new system: immediate/near/far/unknown)
|
||||
device_key: Optional[str] = None
|
||||
proximity_band: str = PROXIMITY_UNKNOWN
|
||||
estimated_distance_m: Optional[float] = None
|
||||
distance_confidence: float = 0.0
|
||||
rssi_ema: Optional[float] = None
|
||||
rssi_60s_min: Optional[int] = None
|
||||
rssi_60s_max: Optional[int] = None
|
||||
is_randomized_mac: bool = False
|
||||
threat_tags: list[str] = field(default_factory=list)
|
||||
|
||||
# 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
|
||||
|
||||
# Tracker detection fields
|
||||
is_tracker: bool = False
|
||||
tracker_type: Optional[str] = None # 'airtag', 'tile', 'samsung_smarttag', etc.
|
||||
tracker_name: Optional[str] = None
|
||||
tracker_confidence: Optional[str] = None # 'high', 'medium', 'low', 'none'
|
||||
tracker_confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
tracker_evidence: list[str] = field(default_factory=list)
|
||||
|
||||
# Suspicious presence / following heuristics
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
|
||||
# Payload fingerprint (survives MAC randomization)
|
||||
payload_fingerprint_id: Optional[str] = None
|
||||
payload_fingerprint_stability: float = 0.0
|
||||
|
||||
# Service data (for tracker analysis)
|
||||
service_data: dict[str, bytes] = field(default_factory=dict)
|
||||
|
||||
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
|
||||
"""Get RSSI history for sparkline visualization."""
|
||||
if not self.rssi_samples:
|
||||
return []
|
||||
|
||||
# Downsample if needed
|
||||
samples = self.rssi_samples[-max_points:]
|
||||
return [
|
||||
{'timestamp': ts.isoformat(), 'rssi': rssi}
|
||||
for ts, rssi in samples
|
||||
]
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float:
|
||||
"""Seconds since last seen."""
|
||||
return (datetime.now() - self.last_seen).total_seconds()
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
"""Total duration from first to last seen."""
|
||||
return (self.last_seen - self.first_seen).total_seconds()
|
||||
|
||||
@property
|
||||
def heuristic_flags(self) -> list[str]:
|
||||
"""List of active heuristic flags."""
|
||||
flags = []
|
||||
if self.is_new:
|
||||
flags.append('new')
|
||||
if self.is_persistent:
|
||||
flags.append('persistent')
|
||||
if self.is_beacon_like:
|
||||
flags.append('beacon_like')
|
||||
if self.is_strong_stable:
|
||||
flags.append('strong_stable')
|
||||
if self.has_random_address:
|
||||
flags.append('random_address')
|
||||
return flags
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'device_id': self.device_id,
|
||||
'address': self.address,
|
||||
'address_type': self.address_type,
|
||||
'protocol': self.protocol,
|
||||
|
||||
# Timestamps
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'duration_seconds': self.duration_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'seen_rate': round(self.seen_rate, 2),
|
||||
|
||||
# RSSI stats
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||
'rssi_min': self.rssi_min,
|
||||
'rssi_max': self.rssi_max,
|
||||
'rssi_variance': round(self.rssi_variance, 2) if self.rssi_variance else None,
|
||||
'rssi_confidence': round(self.rssi_confidence, 2),
|
||||
'rssi_history': self.get_rssi_history(),
|
||||
|
||||
# Range (legacy)
|
||||
'range_band': self.range_band,
|
||||
'range_confidence': round(self.range_confidence, 2),
|
||||
|
||||
# Proximity (new system)
|
||||
'device_key': self.device_key,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_60s_min': self.rssi_60s_min,
|
||||
'rssi_60s_max': self.rssi_60s_max,
|
||||
'is_randomized_mac': self.is_randomized_mac,
|
||||
'threat_tags': self.threat_tags,
|
||||
|
||||
# Device info
|
||||
'name': self.name,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'manufacturer_bytes': self.manufacturer_bytes.hex() if self.manufacturer_bytes else None,
|
||||
'service_uuids': self.service_uuids,
|
||||
'tx_power': self.tx_power,
|
||||
'appearance': self.appearance,
|
||||
'class_of_device': self.class_of_device,
|
||||
'major_class': self.major_class,
|
||||
'minor_class': self.minor_class,
|
||||
'is_connectable': self.is_connectable,
|
||||
'is_paired': self.is_paired,
|
||||
'is_connected': self.is_connected,
|
||||
|
||||
# Heuristics
|
||||
'heuristics': {
|
||||
'is_new': self.is_new,
|
||||
'is_persistent': self.is_persistent,
|
||||
'is_beacon_like': self.is_beacon_like,
|
||||
'is_strong_stable': self.is_strong_stable,
|
||||
'has_random_address': self.has_random_address,
|
||||
},
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
|
||||
# Tracker detection
|
||||
'tracker': {
|
||||
'is_tracker': self.is_tracker,
|
||||
'type': self.tracker_type,
|
||||
'name': self.tracker_name,
|
||||
'confidence': self.tracker_confidence,
|
||||
'confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'evidence': self.tracker_evidence,
|
||||
},
|
||||
|
||||
# Suspicious presence analysis
|
||||
'risk_analysis': {
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'risk_factors': self.risk_factors,
|
||||
},
|
||||
|
||||
# Fingerprint
|
||||
'fingerprint': {
|
||||
'id': self.payload_fingerprint_id,
|
||||
'stability': round(self.payload_fingerprint_stability, 2),
|
||||
},
|
||||
|
||||
# Raw service data for investigation
|
||||
'service_data': {k: v.hex() for k, v in self.service_data.items()},
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
"""Compact dictionary for list views."""
|
||||
return {
|
||||
'device_id': self.device_id,
|
||||
'device_key': self.device_key,
|
||||
'address': self.address,
|
||||
'address_type': self.address_type,
|
||||
'protocol': self.protocol,
|
||||
'name': self.name,
|
||||
'manufacturer_name': self.manufacturer_name,
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||
'rssi_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'range_band': self.range_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
'is_randomized_mac': self.is_randomized_mac,
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': self.age_seconds,
|
||||
'seen_count': self.seen_count,
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
# Tracker info for list view
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type,
|
||||
'tracker_name': self.tracker_name,
|
||||
'tracker_confidence': self.tracker_confidence,
|
||||
'tracker_confidence_score': round(self.tracker_confidence_score, 2),
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'fingerprint_id': self.payload_fingerprint_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanStatus:
|
||||
"""Current scanning status."""
|
||||
|
||||
is_scanning: bool = False
|
||||
mode: str = 'auto' # 'dbus', 'bleak', 'hcitool', 'bluetoothctl', 'auto'
|
||||
backend: 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 {
|
||||
'available': self.can_scan, # Alias for frontend compatibility
|
||||
'can_scan': self.can_scan,
|
||||
'has_dbus': self.has_dbus,
|
||||
'has_bluez': self.has_bluez,
|
||||
'bluez_version': self.bluez_version,
|
||||
'adapters': self.adapters,
|
||||
'default_adapter': self.default_adapter,
|
||||
'has_bluetooth_permission': self.has_bluetooth_permission,
|
||||
'is_root': self.is_root,
|
||||
'is_soft_blocked': self.is_soft_blocked,
|
||||
'is_hard_blocked': self.is_hard_blocked,
|
||||
'has_bleak': self.has_bleak,
|
||||
'has_hcitool': self.has_hcitool,
|
||||
'has_bluetoothctl': self.has_bluetoothctl,
|
||||
'has_btmgmt': self.has_btmgmt,
|
||||
'preferred_backend': self.recommended_backend, # Alias for frontend
|
||||
'recommended_backend': self.recommended_backend,
|
||||
'issues': self.issues,
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Ring buffer for time-windowed Bluetooth observation storage.
|
||||
|
||||
Provides efficient storage of RSSI observations with rate limiting,
|
||||
automatic pruning, and downsampling for visualization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Default configuration
|
||||
DEFAULT_RETENTION_MINUTES = 30
|
||||
DEFAULT_MIN_INTERVAL_SECONDS = 2.0
|
||||
DEFAULT_MAX_OBSERVATIONS_PER_DEVICE = 1000
|
||||
|
||||
|
||||
class RingBuffer:
|
||||
"""
|
||||
Time-windowed ring buffer for Bluetooth RSSI observations.
|
||||
|
||||
Features:
|
||||
- Rate-limited ingestion (max 1 observation per device per interval)
|
||||
- Automatic pruning of old observations
|
||||
- Downsampling for efficient visualization
|
||||
- Thread-safe operations
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
retention_minutes: int = DEFAULT_RETENTION_MINUTES,
|
||||
min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS,
|
||||
max_observations_per_device: int = DEFAULT_MAX_OBSERVATIONS_PER_DEVICE,
|
||||
):
|
||||
"""
|
||||
Initialize the ring buffer.
|
||||
|
||||
Args:
|
||||
retention_minutes: How long to keep observations (minutes).
|
||||
min_interval_seconds: Minimum time between observations per device.
|
||||
max_observations_per_device: Maximum observations stored per device.
|
||||
"""
|
||||
self.retention_minutes = retention_minutes
|
||||
self.min_interval_seconds = min_interval_seconds
|
||||
self.max_observations_per_device = max_observations_per_device
|
||||
|
||||
# device_key -> deque[(timestamp, rssi)]
|
||||
self._observations: dict[str, deque[tuple[datetime, int]]] = {}
|
||||
# device_key -> last_ingested_timestamp
|
||||
self._last_ingested: dict[str, datetime] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def ingest(
|
||||
self,
|
||||
device_key: str,
|
||||
rssi: int,
|
||||
timestamp: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Ingest an RSSI observation for a device.
|
||||
|
||||
Rate-limited to prevent flooding from high-frequency advertisers.
|
||||
|
||||
Args:
|
||||
device_key: Stable device identifier.
|
||||
rssi: RSSI value in dBm.
|
||||
timestamp: Observation timestamp (defaults to now).
|
||||
|
||||
Returns:
|
||||
True if observation was stored, False if rate-limited.
|
||||
"""
|
||||
if timestamp is None:
|
||||
timestamp = datetime.now()
|
||||
|
||||
with self._lock:
|
||||
# Check rate limit
|
||||
last_time = self._last_ingested.get(device_key)
|
||||
if last_time is not None:
|
||||
elapsed = (timestamp - last_time).total_seconds()
|
||||
if elapsed < self.min_interval_seconds:
|
||||
return False
|
||||
|
||||
# Initialize deque for new device
|
||||
if device_key not in self._observations:
|
||||
self._observations[device_key] = deque(
|
||||
maxlen=self.max_observations_per_device
|
||||
)
|
||||
|
||||
# Store observation
|
||||
self._observations[device_key].append((timestamp, rssi))
|
||||
self._last_ingested[device_key] = timestamp
|
||||
|
||||
return True
|
||||
|
||||
def get_timeseries(
|
||||
self,
|
||||
device_key: str,
|
||||
window_minutes: Optional[int] = None,
|
||||
downsample_seconds: int = 10,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get downsampled timeseries data for a device.
|
||||
|
||||
Args:
|
||||
device_key: Device identifier.
|
||||
window_minutes: Time window (defaults to retention period).
|
||||
downsample_seconds: Bucket size for downsampling.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'timestamp' and 'rssi' keys.
|
||||
"""
|
||||
if window_minutes is None:
|
||||
window_minutes = self.retention_minutes
|
||||
|
||||
cutoff = datetime.now() - timedelta(minutes=window_minutes)
|
||||
|
||||
with self._lock:
|
||||
obs = self._observations.get(device_key)
|
||||
if not obs:
|
||||
return []
|
||||
|
||||
# Filter to window and downsample
|
||||
return self._downsample(
|
||||
[(ts, rssi) for ts, rssi in obs if ts >= cutoff],
|
||||
downsample_seconds,
|
||||
)
|
||||
|
||||
def get_all_timeseries(
|
||||
self,
|
||||
window_minutes: Optional[int] = None,
|
||||
downsample_seconds: int = 10,
|
||||
top_n: Optional[int] = None,
|
||||
sort_by: str = 'recency',
|
||||
) -> dict[str, list[dict]]:
|
||||
"""
|
||||
Get downsampled timeseries for all devices.
|
||||
|
||||
Args:
|
||||
window_minutes: Time window.
|
||||
downsample_seconds: Bucket size for downsampling.
|
||||
top_n: Limit to top N devices.
|
||||
sort_by: Sort method ('recency', 'strength', 'activity').
|
||||
|
||||
Returns:
|
||||
Dict mapping device_key to timeseries data.
|
||||
"""
|
||||
if window_minutes is None:
|
||||
window_minutes = self.retention_minutes
|
||||
|
||||
cutoff = datetime.now() - timedelta(minutes=window_minutes)
|
||||
|
||||
with self._lock:
|
||||
# Build list of (device_key, last_seen, avg_rssi, count)
|
||||
device_info = []
|
||||
for device_key, obs in self._observations.items():
|
||||
recent = [(ts, rssi) for ts, rssi in obs if ts >= cutoff]
|
||||
if not recent:
|
||||
continue
|
||||
|
||||
last_seen = max(ts for ts, _ in recent)
|
||||
avg_rssi = sum(rssi for _, rssi in recent) / len(recent)
|
||||
device_info.append((device_key, last_seen, avg_rssi, len(recent)))
|
||||
|
||||
# Sort based on criteria
|
||||
if sort_by == 'strength':
|
||||
device_info.sort(key=lambda x: x[2], reverse=True) # Higher RSSI first
|
||||
elif sort_by == 'activity':
|
||||
device_info.sort(key=lambda x: x[3], reverse=True) # More observations first
|
||||
else: # recency
|
||||
device_info.sort(key=lambda x: x[1], reverse=True) # Most recent first
|
||||
|
||||
# Limit to top N
|
||||
if top_n is not None:
|
||||
device_info = device_info[:top_n]
|
||||
|
||||
# Build result
|
||||
result = {}
|
||||
for device_key, _, _, _ in device_info:
|
||||
obs = self._observations.get(device_key, [])
|
||||
recent = [(ts, rssi) for ts, rssi in obs if ts >= cutoff]
|
||||
result[device_key] = self._downsample(recent, downsample_seconds)
|
||||
|
||||
return result
|
||||
|
||||
def _downsample(
|
||||
self,
|
||||
observations: list[tuple[datetime, int]],
|
||||
bucket_seconds: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Downsample observations into time buckets.
|
||||
|
||||
Uses average RSSI for each bucket.
|
||||
|
||||
Args:
|
||||
observations: List of (timestamp, rssi) tuples.
|
||||
bucket_seconds: Size of each bucket in seconds.
|
||||
|
||||
Returns:
|
||||
List of dicts with 'timestamp' and 'rssi'.
|
||||
"""
|
||||
if not observations:
|
||||
return []
|
||||
|
||||
# Group into buckets
|
||||
buckets: dict[datetime, list[int]] = {}
|
||||
for ts, rssi in observations:
|
||||
# Round timestamp to bucket boundary
|
||||
bucket_ts = ts.replace(
|
||||
second=(ts.second // bucket_seconds) * bucket_seconds,
|
||||
microsecond=0,
|
||||
)
|
||||
if bucket_ts not in buckets:
|
||||
buckets[bucket_ts] = []
|
||||
buckets[bucket_ts].append(rssi)
|
||||
|
||||
# Calculate average for each bucket
|
||||
result = []
|
||||
for bucket_ts in sorted(buckets.keys()):
|
||||
rssi_values = buckets[bucket_ts]
|
||||
avg_rssi = sum(rssi_values) / len(rssi_values)
|
||||
result.append({
|
||||
'timestamp': bucket_ts.isoformat(),
|
||||
'rssi': round(avg_rssi, 1),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def prune_old(self) -> int:
|
||||
"""
|
||||
Remove observations older than retention period.
|
||||
|
||||
Returns:
|
||||
Number of observations removed.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(minutes=self.retention_minutes)
|
||||
removed = 0
|
||||
|
||||
with self._lock:
|
||||
empty_devices = []
|
||||
|
||||
for device_key, obs in self._observations.items():
|
||||
initial_len = len(obs)
|
||||
# Remove old observations from the left
|
||||
while obs and obs[0][0] < cutoff:
|
||||
obs.popleft()
|
||||
removed += initial_len - len(obs)
|
||||
|
||||
if not obs:
|
||||
empty_devices.append(device_key)
|
||||
|
||||
# Clean up empty device entries
|
||||
for device_key in empty_devices:
|
||||
del self._observations[device_key]
|
||||
self._last_ingested.pop(device_key, None)
|
||||
|
||||
return removed
|
||||
|
||||
def get_device_count(self) -> int:
|
||||
"""Get number of devices with stored observations."""
|
||||
with self._lock:
|
||||
return len(self._observations)
|
||||
|
||||
def get_observation_count(self, device_key: Optional[str] = None) -> int:
|
||||
"""
|
||||
Get total observation count.
|
||||
|
||||
Args:
|
||||
device_key: If specified, count only for this device.
|
||||
|
||||
Returns:
|
||||
Number of stored observations.
|
||||
"""
|
||||
with self._lock:
|
||||
if device_key:
|
||||
obs = self._observations.get(device_key)
|
||||
return len(obs) if obs else 0
|
||||
return sum(len(obs) for obs in self._observations.values())
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all stored observations."""
|
||||
with self._lock:
|
||||
self._observations.clear()
|
||||
self._last_ingested.clear()
|
||||
|
||||
def get_device_stats(self, device_key: str) -> Optional[dict]:
|
||||
"""
|
||||
Get statistics for a specific device.
|
||||
|
||||
Args:
|
||||
device_key: Device identifier.
|
||||
|
||||
Returns:
|
||||
Dict with stats or None if device not found.
|
||||
"""
|
||||
with self._lock:
|
||||
obs = self._observations.get(device_key)
|
||||
if not obs:
|
||||
return None
|
||||
|
||||
rssi_values = [rssi for _, rssi in obs]
|
||||
timestamps = [ts for ts, _ in obs]
|
||||
|
||||
return {
|
||||
'observation_count': len(obs),
|
||||
'first_observation': min(timestamps).isoformat(),
|
||||
'last_observation': max(timestamps).isoformat(),
|
||||
'rssi_min': min(rssi_values),
|
||||
'rssi_max': max(rssi_values),
|
||||
'rssi_avg': sum(rssi_values) / len(rssi_values),
|
||||
}
|
||||
|
||||
|
||||
# Module-level instance for shared access
|
||||
_ring_buffer: Optional[RingBuffer] = None
|
||||
|
||||
|
||||
def get_ring_buffer() -> RingBuffer:
|
||||
"""Get or create the shared ring buffer instance."""
|
||||
global _ring_buffer
|
||||
if _ring_buffer is None:
|
||||
_ring_buffer = RingBuffer()
|
||||
return _ring_buffer
|
||||
|
||||
|
||||
def reset_ring_buffer() -> None:
|
||||
"""Reset the shared ring buffer instance."""
|
||||
global _ring_buffer
|
||||
if _ring_buffer is not None:
|
||||
_ring_buffer.clear()
|
||||
_ring_buffer = None
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
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
|
||||
original_mode = mode
|
||||
|
||||
if mode == 'auto':
|
||||
mode = self._capabilities.recommended_backend or 'bleak'
|
||||
|
||||
if mode == 'dbus':
|
||||
started, backend_used = self._start_dbus(adapter, transport, rssi_threshold)
|
||||
|
||||
# Fallback: try non-DBus methods if DBus failed or wasn't requested
|
||||
if not started and (original_mode == 'auto' or mode in ('bleak', 'hcitool', 'bluetoothctl')):
|
||||
started, backend_used = self._start_fallback(adapter, original_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
|
||||
@@ -0,0 +1,804 @@
|
||||
"""
|
||||
Tracker Signature Engine for BLE device classification.
|
||||
|
||||
Detects Apple AirTag, Find My accessories, Tile trackers, Samsung SmartTag,
|
||||
and other known BLE trackers based on manufacturer data patterns, service UUIDs,
|
||||
and advertising payload analysis.
|
||||
|
||||
This module provides reliable tracker detection that:
|
||||
1. Works with MAC randomization (uses payload fingerprinting)
|
||||
2. Provides confidence scores and evidence for each match
|
||||
3. Does NOT claim certainty - provides "indicators" not proof
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger('intercept.bluetooth.tracker_signatures')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER TYPES
|
||||
# =============================================================================
|
||||
|
||||
class TrackerType(str, Enum):
|
||||
"""Known tracker device types."""
|
||||
AIRTAG = 'airtag'
|
||||
FINDMY_ACCESSORY = 'findmy_accessory'
|
||||
TILE = 'tile'
|
||||
SAMSUNG_SMARTTAG = 'samsung_smarttag'
|
||||
CHIPOLO = 'chipolo'
|
||||
PEBBLEBEE = 'pebblebee'
|
||||
NUTFIND = 'nutfind'
|
||||
ORBIT = 'orbit'
|
||||
EUFY = 'eufy'
|
||||
CUBE = 'cube'
|
||||
UNKNOWN_TRACKER = 'unknown_tracker'
|
||||
NOT_A_TRACKER = 'not_a_tracker'
|
||||
|
||||
|
||||
class TrackerConfidence(str, Enum):
|
||||
"""Confidence level for tracker detection."""
|
||||
HIGH = 'high' # Multiple strong indicators match
|
||||
MEDIUM = 'medium' # Some indicators match
|
||||
LOW = 'low' # Weak indicators, needs investigation
|
||||
NONE = 'none' # Not detected as tracker
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER SIGNATURES DATABASE
|
||||
# =============================================================================
|
||||
|
||||
# Apple Manufacturer ID
|
||||
APPLE_COMPANY_ID = 0x004C
|
||||
|
||||
# Apple Find My / AirTag advertisement types (first byte of manufacturer data after company ID)
|
||||
APPLE_FINDMY_ADV_TYPE = 0x12 # Find My network advertisement
|
||||
APPLE_NEARBY_ADV_TYPE = 0x10 # Nearby action
|
||||
APPLE_AIRTAG_ADV_PATTERN = bytes([0x12, 0x19]) # AirTag specific
|
||||
APPLE_FINDMY_PREFIX_SHORT = bytes([0x12]) # Find My prefix (short)
|
||||
APPLE_FINDMY_PREFIX_ALT = bytes([0x07, 0x19]) # Alternative Find My pattern
|
||||
|
||||
# Find My service UUID (Apple's offline finding service)
|
||||
APPLE_FINDMY_SERVICE_UUID = 'fd6f' # 16-bit UUID
|
||||
APPLE_CONTINUITY_SERVICE_UUID = 'd0611e78-bbb4-4591-a5f8-487910ae4366'
|
||||
|
||||
# Tile
|
||||
TILE_COMPANY_ID = 0x00ED # Tile Inc
|
||||
TILE_ALT_COMPANY_ID = 0x038F # Alternative Tile ID
|
||||
TILE_SERVICE_UUID = 'feed' # Tile service UUID (16-bit)
|
||||
TILE_MAC_PREFIXES = ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'E6:43', '90:32', 'D0:72']
|
||||
|
||||
# Samsung SmartTag
|
||||
SAMSUNG_COMPANY_ID = 0x0075
|
||||
SMARTTAG_SERVICE_UUID = 'fd5a' # SmartThings Find service
|
||||
SMARTTAG_MAC_PREFIXES = ['58:4D', 'A0:75', 'B8:D7', '50:32']
|
||||
|
||||
# Chipolo
|
||||
CHIPOLO_COMPANY_ID = 0x0A09
|
||||
CHIPOLO_SERVICE_UUID = 'feaa' # Eddystone beacon (used by some Chipolo)
|
||||
CHIPOLO_ALT_SERVICE = 'feb1'
|
||||
|
||||
# PebbleBee
|
||||
PEBBLEBEE_SERVICE_UUID = 'feab'
|
||||
PEBBLEBEE_MAC_PREFIXES = ['D4:3D', 'E0:E5']
|
||||
|
||||
# Other known trackers
|
||||
NUTFIND_COMPANY_ID = 0x0A09
|
||||
EUFY_COMPANY_ID = 0x0590
|
||||
|
||||
# Generic beacon patterns that may indicate a tracker
|
||||
BEACON_SERVICE_UUIDS = [
|
||||
'feaa', # Eddystone
|
||||
'feab', # Nokia beacon
|
||||
'feb1', # Dialog Semiconductor
|
||||
'febe', # Bose
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackerSignature:
|
||||
"""Defines a tracker signature pattern."""
|
||||
tracker_type: TrackerType
|
||||
name: str
|
||||
description: str
|
||||
company_id: Optional[int] = None
|
||||
company_ids: list[int] = field(default_factory=list)
|
||||
manufacturer_data_prefixes: list[bytes] = field(default_factory=list)
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data_prefixes: dict[str, bytes] = field(default_factory=dict)
|
||||
mac_prefixes: list[str] = field(default_factory=list)
|
||||
name_patterns: list[str] = field(default_factory=list)
|
||||
min_manufacturer_data_len: int = 0
|
||||
confidence_boost: float = 0.0 # Extra confidence for specific patterns
|
||||
|
||||
|
||||
# Tracker signatures database
|
||||
TRACKER_SIGNATURES: list[TrackerSignature] = [
|
||||
# Apple AirTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.AIRTAG,
|
||||
name='Apple AirTag',
|
||||
description='Apple AirTag tracking device using Find My network',
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_AIRTAG_ADV_PATTERN,
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['airtag'],
|
||||
min_manufacturer_data_len=22, # AirTags have 22+ byte payloads
|
||||
confidence_boost=0.2,
|
||||
),
|
||||
|
||||
# Apple Find My Accessory (non-AirTag)
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.FINDMY_ACCESSORY,
|
||||
name='Find My Accessory',
|
||||
description='Third-party Apple Find My network accessory',
|
||||
company_id=APPLE_COMPANY_ID,
|
||||
manufacturer_data_prefixes=[
|
||||
APPLE_FINDMY_PREFIX_SHORT,
|
||||
APPLE_FINDMY_PREFIX_ALT,
|
||||
],
|
||||
service_uuids=[APPLE_FINDMY_SERVICE_UUID],
|
||||
name_patterns=['findmy', 'find my', 'chipolo one spot', 'belkin'],
|
||||
),
|
||||
|
||||
# Tile
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.TILE,
|
||||
name='Tile Tracker',
|
||||
description='Tile Bluetooth tracker',
|
||||
company_ids=[TILE_COMPANY_ID, TILE_ALT_COMPANY_ID],
|
||||
service_uuids=[TILE_SERVICE_UUID],
|
||||
mac_prefixes=TILE_MAC_PREFIXES,
|
||||
name_patterns=['tile'],
|
||||
),
|
||||
|
||||
# Samsung SmartTag
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.SAMSUNG_SMARTTAG,
|
||||
name='Samsung SmartTag',
|
||||
description='Samsung SmartThings tracker',
|
||||
company_id=SAMSUNG_COMPANY_ID,
|
||||
service_uuids=[SMARTTAG_SERVICE_UUID],
|
||||
mac_prefixes=SMARTTAG_MAC_PREFIXES,
|
||||
name_patterns=['smarttag', 'smart tag', 'galaxy tag'],
|
||||
),
|
||||
|
||||
# Chipolo
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.CHIPOLO,
|
||||
name='Chipolo',
|
||||
description='Chipolo Bluetooth tracker',
|
||||
company_id=CHIPOLO_COMPANY_ID,
|
||||
service_uuids=[CHIPOLO_SERVICE_UUID, CHIPOLO_ALT_SERVICE],
|
||||
name_patterns=['chipolo'],
|
||||
),
|
||||
|
||||
# PebbleBee
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.PEBBLEBEE,
|
||||
name='PebbleBee',
|
||||
description='PebbleBee Bluetooth tracker',
|
||||
service_uuids=[PEBBLEBEE_SERVICE_UUID],
|
||||
mac_prefixes=PEBBLEBEE_MAC_PREFIXES,
|
||||
name_patterns=['pebblebee', 'pebble bee', 'honey'],
|
||||
),
|
||||
|
||||
# Eufy
|
||||
TrackerSignature(
|
||||
tracker_type=TrackerType.EUFY,
|
||||
name='Eufy SmartTrack',
|
||||
description='Eufy/Anker smart tracker',
|
||||
company_id=EUFY_COMPANY_ID,
|
||||
name_patterns=['eufy', 'smarttrack'],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER DETECTION RESULT
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class TrackerDetectionResult:
|
||||
"""Result of tracker detection analysis."""
|
||||
|
||||
is_tracker: bool = False
|
||||
tracker_type: TrackerType = TrackerType.NOT_A_TRACKER
|
||||
tracker_name: str = ''
|
||||
confidence: TrackerConfidence = TrackerConfidence.NONE
|
||||
confidence_score: float = 0.0 # 0.0 to 1.0
|
||||
evidence: list[str] = field(default_factory=list)
|
||||
matched_signature: Optional[str] = None
|
||||
|
||||
# For suspicious presence heuristics
|
||||
risk_factors: list[str] = field(default_factory=list)
|
||||
risk_score: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
# Raw data used for detection
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_hex: Optional[str] = None
|
||||
service_uuids_found: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'is_tracker': self.is_tracker,
|
||||
'tracker_type': self.tracker_type.value if self.tracker_type else None,
|
||||
'tracker_name': self.tracker_name,
|
||||
'confidence': self.confidence.value if self.confidence else None,
|
||||
'confidence_score': round(self.confidence_score, 2),
|
||||
'evidence': self.evidence,
|
||||
'matched_signature': self.matched_signature,
|
||||
'risk_factors': self.risk_factors,
|
||||
'risk_score': round(self.risk_score, 2),
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_hex': self.manufacturer_data_hex,
|
||||
'service_uuids_found': self.service_uuids_found,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DEVICE FINGERPRINT (survives MAC randomization)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class DeviceFingerprint:
|
||||
"""
|
||||
Stable fingerprint for a BLE device that can survive MAC randomization.
|
||||
|
||||
Uses stable parts of the advertising payload to create a probabilistic
|
||||
identity. This is NOT perfect - randomized devices may produce different
|
||||
fingerprints over time. Document this as a limitation.
|
||||
"""
|
||||
|
||||
fingerprint_id: str # SHA256 hash of stable features
|
||||
|
||||
# Features used for fingerprinting
|
||||
manufacturer_id: Optional[int] = None
|
||||
manufacturer_data_prefix: Optional[bytes] = None # First 4 bytes (stable across MACs)
|
||||
manufacturer_data_length: int = 0
|
||||
service_uuids: list[str] = field(default_factory=list)
|
||||
service_data_keys: list[str] = field(default_factory=list)
|
||||
tx_power_bucket: Optional[str] = None # "high"/"medium"/"low"
|
||||
name_hint: Optional[str] = None
|
||||
|
||||
# Confidence in this fingerprint's stability
|
||||
stability_confidence: float = 0.5 # 0.0-1.0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'fingerprint_id': self.fingerprint_id,
|
||||
'manufacturer_id': self.manufacturer_id,
|
||||
'manufacturer_data_prefix': self.manufacturer_data_prefix.hex() if self.manufacturer_data_prefix else None,
|
||||
'manufacturer_data_length': self.manufacturer_data_length,
|
||||
'service_uuids': self.service_uuids,
|
||||
'service_data_keys': self.service_data_keys,
|
||||
'tx_power_bucket': self.tx_power_bucket,
|
||||
'name_hint': self.name_hint,
|
||||
'stability_confidence': round(self.stability_confidence, 2),
|
||||
}
|
||||
|
||||
|
||||
def generate_fingerprint(
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
) -> DeviceFingerprint:
|
||||
"""
|
||||
Generate a stable fingerprint for a BLE device.
|
||||
|
||||
Fingerprint is based on stable parts of the advertising payload that
|
||||
typically persist across MAC address rotations.
|
||||
|
||||
Limitations:
|
||||
- Devices that fully randomize their payload will not be consistently tracked
|
||||
- Some devices change manufacturer data patterns periodically
|
||||
- Best for trackers which have consistent advertising patterns
|
||||
"""
|
||||
# Build fingerprint features
|
||||
features = []
|
||||
stability_score = 0.0
|
||||
|
||||
mfr_prefix = None
|
||||
mfr_length = 0
|
||||
|
||||
if manufacturer_id is not None:
|
||||
features.append(f'mfr:{manufacturer_id:04x}')
|
||||
stability_score += 0.2
|
||||
|
||||
if manufacturer_data:
|
||||
mfr_length = len(manufacturer_data)
|
||||
features.append(f'mfr_len:{mfr_length}')
|
||||
stability_score += 0.1
|
||||
|
||||
# First 4 bytes of manufacturer data are often stable
|
||||
mfr_prefix = manufacturer_data[:min(4, len(manufacturer_data))]
|
||||
features.append(f'mfr_pfx:{mfr_prefix.hex()}')
|
||||
stability_score += 0.2
|
||||
|
||||
sorted_uuids = sorted(service_uuids)
|
||||
if sorted_uuids:
|
||||
features.append(f'uuids:{",".join(sorted_uuids)}')
|
||||
stability_score += 0.2
|
||||
|
||||
sd_keys = sorted(service_data.keys())
|
||||
if sd_keys:
|
||||
features.append(f'sd_keys:{",".join(sd_keys)}')
|
||||
stability_score += 0.1
|
||||
|
||||
# TX power bucket
|
||||
tx_bucket = None
|
||||
if tx_power is not None:
|
||||
if tx_power >= 0:
|
||||
tx_bucket = 'high'
|
||||
elif tx_power >= -10:
|
||||
tx_bucket = 'medium'
|
||||
else:
|
||||
tx_bucket = 'low'
|
||||
features.append(f'tx:{tx_bucket}')
|
||||
stability_score += 0.05
|
||||
|
||||
# Name hint (for devices that advertise names)
|
||||
name_hint = None
|
||||
if name:
|
||||
# Only use first word of name (often stable)
|
||||
name_hint = name.split()[0].lower() if name else None
|
||||
if name_hint:
|
||||
features.append(f'name:{name_hint}')
|
||||
stability_score += 0.15
|
||||
|
||||
# Generate fingerprint ID
|
||||
feature_str = '|'.join(features)
|
||||
fingerprint_id = hashlib.sha256(feature_str.encode()).hexdigest()[:16]
|
||||
|
||||
return DeviceFingerprint(
|
||||
fingerprint_id=fingerprint_id,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data_prefix=mfr_prefix,
|
||||
manufacturer_data_length=mfr_length,
|
||||
service_uuids=sorted_uuids,
|
||||
service_data_keys=sd_keys,
|
||||
tx_power_bucket=tx_bucket,
|
||||
name_hint=name_hint,
|
||||
stability_confidence=min(1.0, stability_score),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TRACKER DETECTION ENGINE
|
||||
# =============================================================================
|
||||
|
||||
class TrackerSignatureEngine:
|
||||
"""
|
||||
Engine for detecting known BLE trackers from advertising data.
|
||||
|
||||
Detection is based on multiple indicators:
|
||||
1. Manufacturer ID matching known tracker companies
|
||||
2. Manufacturer data patterns specific to tracker types
|
||||
3. Service UUID matching known tracker services
|
||||
4. MAC address prefix matching known tracker OUIs
|
||||
5. Device name pattern matching
|
||||
|
||||
Confidence is cumulative - more matching indicators = higher confidence.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.signatures = TRACKER_SIGNATURES
|
||||
|
||||
# Tracking for suspicious presence detection
|
||||
self._sighting_history: dict[str, list[datetime]] = {}
|
||||
self._fingerprint_cache: dict[str, DeviceFingerprint] = {}
|
||||
|
||||
def detect_tracker(
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Analyze a BLE device for tracker indicators.
|
||||
|
||||
Returns a TrackerDetectionResult with:
|
||||
- is_tracker: True if any tracker indicators match
|
||||
- tracker_type: The most likely tracker type
|
||||
- confidence: HIGH/MEDIUM/LOW based on indicator strength
|
||||
- evidence: List of matching indicators for transparency
|
||||
|
||||
IMPORTANT: This is heuristic detection. A match indicates
|
||||
the device RESEMBLES a known tracker, not proof it IS one.
|
||||
"""
|
||||
result = TrackerDetectionResult()
|
||||
service_uuids = service_uuids or []
|
||||
service_data = service_data or {}
|
||||
|
||||
# Store raw data in result for transparency
|
||||
result.manufacturer_id = manufacturer_id
|
||||
if manufacturer_data:
|
||||
result.manufacturer_data_hex = manufacturer_data.hex()
|
||||
result.service_uuids_found = service_uuids
|
||||
|
||||
# Normalize service UUIDs to lowercase 16-bit format where possible
|
||||
normalized_uuids = self._normalize_service_uuids(service_uuids)
|
||||
|
||||
# Score each signature
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
best_evidence = []
|
||||
|
||||
for signature in self.signatures:
|
||||
score, evidence = self._score_signature(
|
||||
signature=signature,
|
||||
address=address,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
normalized_uuids=normalized_uuids,
|
||||
service_data=service_data,
|
||||
)
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = signature
|
||||
best_evidence = evidence
|
||||
|
||||
# Check for generic tracker indicators if no specific match
|
||||
if best_score < 0.3:
|
||||
generic_score, generic_evidence = self._check_generic_tracker_indicators(
|
||||
address=address,
|
||||
address_type=address_type,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
normalized_uuids=normalized_uuids,
|
||||
)
|
||||
if generic_score > best_score:
|
||||
best_score = generic_score
|
||||
best_match = None
|
||||
best_evidence = generic_evidence
|
||||
|
||||
# Build result
|
||||
if best_score >= 0.3: # Minimum threshold for tracker detection
|
||||
result.is_tracker = True
|
||||
result.confidence_score = min(1.0, best_score)
|
||||
result.evidence = best_evidence
|
||||
|
||||
if best_match:
|
||||
result.tracker_type = best_match.tracker_type
|
||||
result.tracker_name = best_match.name
|
||||
result.matched_signature = best_match.name
|
||||
else:
|
||||
result.tracker_type = TrackerType.UNKNOWN_TRACKER
|
||||
result.tracker_name = 'Unknown Tracker'
|
||||
|
||||
# Determine confidence level
|
||||
if best_score >= 0.7:
|
||||
result.confidence = TrackerConfidence.HIGH
|
||||
elif best_score >= 0.5:
|
||||
result.confidence = TrackerConfidence.MEDIUM
|
||||
else:
|
||||
result.confidence = TrackerConfidence.LOW
|
||||
|
||||
return result
|
||||
|
||||
def _score_signature(
|
||||
self,
|
||||
signature: TrackerSignature,
|
||||
address: str,
|
||||
name: Optional[str],
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
normalized_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Score how well a device matches a tracker signature."""
|
||||
score = 0.0
|
||||
evidence = []
|
||||
|
||||
# Check company ID
|
||||
# For Apple, company ID alone is NOT enough - require additional indicators
|
||||
# Many Apple devices (AirPods, Watch, etc.) share the same manufacturer ID
|
||||
company_id_matches = False
|
||||
if manufacturer_id is not None:
|
||||
if signature.company_id == manufacturer_id:
|
||||
company_id_matches = True
|
||||
elif manufacturer_id in signature.company_ids:
|
||||
company_id_matches = True
|
||||
|
||||
# For Apple devices, only add company ID score if we also have Find My indicators
|
||||
if company_id_matches:
|
||||
if manufacturer_id == APPLE_COMPANY_ID:
|
||||
# Apple devices need additional proof - just the company ID isn't enough
|
||||
# Only give full score if we have the manufacturer data pattern or service UUID
|
||||
has_findmy_pattern = False
|
||||
if manufacturer_data and len(manufacturer_data) >= 1:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE: # 0x12 = Find My
|
||||
has_findmy_pattern = True
|
||||
|
||||
has_findmy_service = APPLE_FINDMY_SERVICE_UUID in normalized_uuids
|
||||
|
||||
if has_findmy_pattern or has_findmy_service:
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
# Don't add score for Apple manufacturer ID without Find My indicators
|
||||
else:
|
||||
# Non-Apple trackers - company ID is strong evidence
|
||||
score += 0.35
|
||||
evidence.append(f'Manufacturer ID 0x{manufacturer_id:04X} matches {signature.name}')
|
||||
|
||||
# Check manufacturer data prefix (high weight for specific patterns)
|
||||
if manufacturer_data and signature.manufacturer_data_prefixes:
|
||||
for prefix in signature.manufacturer_data_prefixes:
|
||||
if manufacturer_data.startswith(prefix):
|
||||
score += 0.30
|
||||
evidence.append(f'Manufacturer data pattern matches {signature.name}')
|
||||
break
|
||||
|
||||
# Check manufacturer data length
|
||||
if manufacturer_data and signature.min_manufacturer_data_len > 0:
|
||||
if len(manufacturer_data) >= signature.min_manufacturer_data_len:
|
||||
score += 0.10
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) consistent with {signature.name}')
|
||||
|
||||
# Check service UUIDs (medium weight)
|
||||
for sig_uuid in signature.service_uuids:
|
||||
if sig_uuid.lower() in normalized_uuids:
|
||||
score += 0.25
|
||||
evidence.append(f'Service UUID {sig_uuid} matches {signature.name}')
|
||||
break
|
||||
|
||||
# Check MAC prefix (medium weight)
|
||||
if signature.mac_prefixes:
|
||||
mac_upper = address.upper()
|
||||
for prefix in signature.mac_prefixes:
|
||||
if mac_upper.startswith(prefix):
|
||||
score += 0.20
|
||||
evidence.append(f'MAC prefix {prefix} matches known {signature.name} range')
|
||||
break
|
||||
|
||||
# Check name patterns (lower weight - can be spoofed)
|
||||
if name and signature.name_patterns:
|
||||
name_lower = name.lower()
|
||||
for pattern in signature.name_patterns:
|
||||
if pattern.lower() in name_lower:
|
||||
score += 0.15
|
||||
evidence.append(f'Device name "{name}" contains pattern "{pattern}"')
|
||||
break
|
||||
|
||||
# Apply confidence boost for specific signatures
|
||||
score += signature.confidence_boost
|
||||
|
||||
return score, evidence
|
||||
|
||||
def _check_generic_tracker_indicators(
|
||||
self,
|
||||
address: str,
|
||||
address_type: str,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
normalized_uuids: list[str],
|
||||
) -> tuple[float, list[str]]:
|
||||
"""Check for generic tracker-like indicators."""
|
||||
score = 0.0
|
||||
evidence = []
|
||||
|
||||
# Apple Find My service UUID without specific AirTag pattern
|
||||
if APPLE_FINDMY_SERVICE_UUID in normalized_uuids:
|
||||
score += 0.4
|
||||
evidence.append('Uses Apple Find My network service (fd6f)')
|
||||
|
||||
# Apple manufacturer with Find My advertisement type
|
||||
if manufacturer_id == APPLE_COMPANY_ID and manufacturer_data:
|
||||
if len(manufacturer_data) >= 2:
|
||||
adv_type = manufacturer_data[0]
|
||||
if adv_type == APPLE_FINDMY_ADV_TYPE:
|
||||
score += 0.35
|
||||
evidence.append('Apple Find My network advertisement detected')
|
||||
|
||||
# Check for beacon-like service UUIDs
|
||||
for beacon_uuid in BEACON_SERVICE_UUIDS:
|
||||
if beacon_uuid in normalized_uuids:
|
||||
score += 0.15
|
||||
evidence.append(f'Uses beacon service UUID ({beacon_uuid})')
|
||||
break
|
||||
|
||||
# Random address (most trackers use random addresses)
|
||||
if address_type in ('random', 'rpa', 'nrpa'):
|
||||
# This is a weak indicator - many devices use random addresses
|
||||
if score > 0: # Only add if other indicators present
|
||||
score += 0.05
|
||||
evidence.append('Uses randomized MAC address')
|
||||
|
||||
# Small manufacturer data payload typical of beacons
|
||||
if manufacturer_data and 20 <= len(manufacturer_data) <= 30:
|
||||
if score > 0:
|
||||
score += 0.05
|
||||
evidence.append(f'Manufacturer data length ({len(manufacturer_data)} bytes) typical of beacon')
|
||||
|
||||
return score, evidence
|
||||
|
||||
def _normalize_service_uuids(self, uuids: list[str]) -> list[str]:
|
||||
"""Normalize service UUIDs to lowercase, extracting 16-bit UUIDs where possible."""
|
||||
normalized = []
|
||||
for uuid in uuids:
|
||||
uuid_lower = uuid.lower()
|
||||
# Extract 16-bit UUID from full 128-bit Bluetooth Base UUID
|
||||
# Format: 0000XXXX-0000-1000-8000-00805f9b34fb
|
||||
if len(uuid_lower) == 36 and uuid_lower.endswith('-0000-1000-8000-00805f9b34fb'):
|
||||
short_uuid = uuid_lower[4:8]
|
||||
normalized.append(short_uuid)
|
||||
else:
|
||||
normalized.append(uuid_lower)
|
||||
return normalized
|
||||
|
||||
def generate_device_fingerprint(
|
||||
self,
|
||||
manufacturer_id: Optional[int],
|
||||
manufacturer_data: Optional[bytes],
|
||||
service_uuids: list[str],
|
||||
service_data: dict[str, bytes],
|
||||
tx_power: Optional[int],
|
||||
name: Optional[str],
|
||||
) -> DeviceFingerprint:
|
||||
"""Generate a fingerprint for device tracking across MAC rotations."""
|
||||
return generate_fingerprint(
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=tx_power,
|
||||
name=name,
|
||||
)
|
||||
|
||||
def record_sighting(self, fingerprint_id: str, timestamp: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
Record a device sighting for persistence tracking.
|
||||
|
||||
Returns the number of times this fingerprint has been seen.
|
||||
"""
|
||||
ts = timestamp or datetime.now()
|
||||
|
||||
if fingerprint_id not in self._sighting_history:
|
||||
self._sighting_history[fingerprint_id] = []
|
||||
|
||||
# Keep only last 24 hours of sightings
|
||||
cutoff = ts - timedelta(hours=24)
|
||||
self._sighting_history[fingerprint_id] = [
|
||||
t for t in self._sighting_history[fingerprint_id]
|
||||
if t > cutoff
|
||||
]
|
||||
|
||||
self._sighting_history[fingerprint_id].append(ts)
|
||||
return len(self._sighting_history[fingerprint_id])
|
||||
|
||||
def get_sighting_count(self, fingerprint_id: str, window_hours: int = 24) -> int:
|
||||
"""Get the number of times a fingerprint has been seen in the time window."""
|
||||
if fingerprint_id not in self._sighting_history:
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now() - timedelta(hours=window_hours)
|
||||
return sum(1 for t in self._sighting_history[fingerprint_id] if t > cutoff)
|
||||
|
||||
def evaluate_suspicious_presence(
|
||||
self,
|
||||
fingerprint_id: str,
|
||||
is_tracker: bool,
|
||||
seen_count: int,
|
||||
duration_seconds: float,
|
||||
seen_rate: float,
|
||||
rssi_variance: Optional[float],
|
||||
is_new: bool,
|
||||
) -> tuple[float, list[str]]:
|
||||
"""
|
||||
Evaluate if a device shows suspicious "following" behavior.
|
||||
|
||||
Returns (risk_score, risk_factors) where:
|
||||
- risk_score: 0.0-1.0 indicating likelihood of suspicious presence
|
||||
- risk_factors: List of reasons contributing to the score
|
||||
|
||||
IMPORTANT: These are HEURISTICS only. They indicate patterns that
|
||||
MIGHT suggest a device is following/tracking, but cannot prove intent.
|
||||
Always present to users with appropriate caveats.
|
||||
"""
|
||||
risk_score = 0.0
|
||||
risk_factors = []
|
||||
|
||||
# Tracker baseline - if it's a tracker, start with some risk
|
||||
if is_tracker:
|
||||
risk_score += 0.3
|
||||
risk_factors.append('Device matches known tracker signature')
|
||||
|
||||
# Heuristic 1: Persistently near - seen many times over a long period
|
||||
if seen_count >= 20 and duration_seconds >= 600: # 10+ minutes
|
||||
points = min(0.25, (seen_count / 100) * 0.25)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Persistently present: seen {seen_count} times over {duration_seconds/60:.1f} min')
|
||||
elif seen_count >= 50:
|
||||
risk_score += 0.2
|
||||
risk_factors.append(f'High observation count: {seen_count} sightings')
|
||||
|
||||
# Heuristic 2: Consistent presence rate (beacon-like behavior)
|
||||
if seen_rate >= 3.0: # 3+ observations per minute
|
||||
points = min(0.15, (seen_rate / 10) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Beacon-like presence: {seen_rate:.1f} obs/min')
|
||||
|
||||
# Heuristic 3: Stable RSSI (moving with us, same relative distance)
|
||||
if rssi_variance is not None and rssi_variance < 10:
|
||||
risk_score += 0.1
|
||||
risk_factors.append(f'Stable signal strength (variance: {rssi_variance:.1f})')
|
||||
|
||||
# Heuristic 4: New device appearing (not in baseline)
|
||||
if is_new and is_tracker:
|
||||
risk_score += 0.15
|
||||
risk_factors.append('New tracker appeared after baseline was set')
|
||||
|
||||
# Cross-session persistence (from sighting history)
|
||||
historical_count = self.get_sighting_count(fingerprint_id, window_hours=24)
|
||||
if historical_count >= 10:
|
||||
points = min(0.15, (historical_count / 50) * 0.15)
|
||||
risk_score += points
|
||||
risk_factors.append(f'Seen across multiple sessions: {historical_count} total sightings in 24h')
|
||||
|
||||
return min(1.0, risk_score), risk_factors
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SINGLETON ENGINE INSTANCE
|
||||
# =============================================================================
|
||||
|
||||
_engine_instance: Optional[TrackerSignatureEngine] = None
|
||||
|
||||
|
||||
def get_tracker_engine() -> TrackerSignatureEngine:
|
||||
"""Get the singleton tracker signature engine instance."""
|
||||
global _engine_instance
|
||||
if _engine_instance is None:
|
||||
_engine_instance = TrackerSignatureEngine()
|
||||
return _engine_instance
|
||||
|
||||
|
||||
def detect_tracker(
|
||||
address: str,
|
||||
address_type: str = 'public',
|
||||
name: Optional[str] = None,
|
||||
manufacturer_id: Optional[int] = None,
|
||||
manufacturer_data: Optional[bytes] = None,
|
||||
service_uuids: Optional[list[str]] = None,
|
||||
service_data: Optional[dict[str, bytes]] = None,
|
||||
tx_power: Optional[int] = None,
|
||||
) -> TrackerDetectionResult:
|
||||
"""
|
||||
Convenience function to detect if a BLE device is a tracker.
|
||||
|
||||
See TrackerSignatureEngine.detect_tracker for full documentation.
|
||||
"""
|
||||
engine = get_tracker_engine()
|
||||
return engine.detect_tracker(
|
||||
address=address,
|
||||
address_type=address_type,
|
||||
name=name,
|
||||
manufacturer_id=manufacturer_id,
|
||||
manufacturer_data=manufacturer_data,
|
||||
service_uuids=service_uuids,
|
||||
service_data=service_data,
|
||||
tx_power=tx_power,
|
||||
)
|
||||
@@ -178,7 +178,19 @@ class BLEScanner:
|
||||
for company_id, data in adv_data.manufacturer_data.items():
|
||||
ble_device.manufacturer_id = company_id
|
||||
ble_device.manufacturer_name = COMPANY_IDS.get(company_id, f'Unknown ({hex(company_id)})')
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
# Handle various data types safely
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
elif isinstance(data, (list, tuple)):
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
elif isinstance(data, str):
|
||||
ble_device.manufacturer_data = bytes.fromhex(data)
|
||||
else:
|
||||
ble_device.manufacturer_data = bytes(data)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.debug(f"Could not convert manufacturer data: {e}")
|
||||
ble_device.manufacturer_data = None
|
||||
|
||||
# Check for known trackers
|
||||
self._identify_tracker(ble_device, company_id, data)
|
||||
|
||||
+48
-6
@@ -206,6 +206,8 @@ class ThreatDetector:
|
||||
"""
|
||||
Classify a Bluetooth device into informational/review/high_interest.
|
||||
|
||||
Now uses the v2 tracker detection data if available.
|
||||
|
||||
Returns:
|
||||
Dict with 'classification', 'reasons', and metadata
|
||||
"""
|
||||
@@ -217,7 +219,6 @@ class ThreatDetector:
|
||||
|
||||
reasons = []
|
||||
classification = 'informational'
|
||||
tracker_info = None
|
||||
|
||||
# Track repeat detections
|
||||
times_seen = _record_device_seen(f'bt:{mac}') if mac else 1
|
||||
@@ -225,8 +226,25 @@ class ThreatDetector:
|
||||
# Check if in baseline (known device)
|
||||
in_baseline = mac in self.baseline_bt_macs if self.baseline else False
|
||||
|
||||
# Check for trackers (do this early for all devices)
|
||||
tracker_info = is_known_tracker(name, manufacturer_data)
|
||||
# Use v2 tracker detection data if available (from get_tscm_bluetooth_snapshot)
|
||||
tracker_data = device.get('tracker', {})
|
||||
is_tracker_v2 = tracker_data.get('is_tracker', False)
|
||||
tracker_type_v2 = tracker_data.get('type')
|
||||
tracker_name_v2 = tracker_data.get('name')
|
||||
tracker_confidence_v2 = tracker_data.get('confidence')
|
||||
tracker_evidence_v2 = tracker_data.get('evidence', [])
|
||||
|
||||
# Use v2 risk analysis if available
|
||||
risk_data = device.get('risk_analysis', {})
|
||||
risk_score = risk_data.get('risk_score', 0)
|
||||
risk_factors = risk_data.get('risk_factors', [])
|
||||
|
||||
# Fall back to legacy detection if v2 not available
|
||||
tracker_info_legacy = None
|
||||
if not is_tracker_v2:
|
||||
tracker_info_legacy = is_known_tracker(name, manufacturer_data)
|
||||
|
||||
is_tracker = is_tracker_v2 or (tracker_info_legacy is not None)
|
||||
|
||||
if in_baseline:
|
||||
reasons.append('Known device in baseline')
|
||||
@@ -241,8 +259,24 @@ class ThreatDetector:
|
||||
classification = 'review'
|
||||
|
||||
# Check for trackers -> high interest
|
||||
if tracker_info:
|
||||
reasons.append(f"Known tracker: {tracker_info.get('name', 'Unknown')}")
|
||||
if is_tracker_v2:
|
||||
tracker_label = tracker_name_v2 or tracker_type_v2 or 'Unknown tracker'
|
||||
conf_label = f' ({tracker_confidence_v2})' if tracker_confidence_v2 else ''
|
||||
reasons.append(f"Tracker detected: {tracker_label}{conf_label}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Add evidence from v2 detection
|
||||
for evidence_item in tracker_evidence_v2[:2]: # First 2 items
|
||||
reasons.append(f"Evidence: {evidence_item}")
|
||||
|
||||
# Add risk factors if significant
|
||||
if risk_score >= 0.3:
|
||||
reasons.append(f"Risk score: {int(risk_score * 100)}%")
|
||||
for factor in risk_factors[:2]: # First 2 factors
|
||||
reasons.append(f"Risk: {factor}")
|
||||
|
||||
elif tracker_info_legacy:
|
||||
reasons.append(f"Known tracker: {tracker_info_legacy.get('name', 'Unknown')}")
|
||||
classification = 'high_interest'
|
||||
|
||||
# Check for audio-capable devices -> high interest
|
||||
@@ -268,6 +302,10 @@ class ThreatDetector:
|
||||
classification = 'high_interest'
|
||||
|
||||
# Include standardized signal classification
|
||||
try:
|
||||
rssi_val = int(rssi) if rssi else -100
|
||||
except (ValueError, TypeError):
|
||||
rssi_val = -100
|
||||
signal_info = get_signal_strength_info(rssi_val)
|
||||
|
||||
return {
|
||||
@@ -275,7 +313,11 @@ class ThreatDetector:
|
||||
'reasons': reasons,
|
||||
'in_baseline': in_baseline,
|
||||
'times_seen': times_seen,
|
||||
'is_tracker': tracker_info is not None,
|
||||
'is_tracker': is_tracker,
|
||||
'tracker_type': tracker_type_v2,
|
||||
'tracker_name': tracker_name_v2,
|
||||
'tracker_confidence': tracker_confidence_v2,
|
||||
'risk_score': risk_score,
|
||||
'is_audio_capable': _is_audio_capable_ble(name, device_type),
|
||||
'signal_strength': signal_info['strength'],
|
||||
'signal_label': signal_info['label'],
|
||||
|
||||
@@ -1157,6 +1157,30 @@ def reset_identity_engine() -> None:
|
||||
_identity_engine = DeviceIdentityEngine()
|
||||
|
||||
|
||||
def _convert_to_bytes(value) -> Optional[bytes]:
|
||||
"""Convert various data types to bytes safely."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
if isinstance(value, bytearray):
|
||||
return bytes(value)
|
||||
if isinstance(value, str):
|
||||
# Assume hex string
|
||||
try:
|
||||
return bytes.fromhex(value)
|
||||
except ValueError:
|
||||
# Not a valid hex string, encode as UTF-8
|
||||
return value.encode('utf-8')
|
||||
if isinstance(value, (list, tuple)):
|
||||
# Array of integers (like dbus.Array)
|
||||
try:
|
||||
return bytes(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def ingest_ble_dict(data: dict) -> DeviceSession:
|
||||
"""
|
||||
Ingest BLE observation from dictionary.
|
||||
@@ -1173,9 +1197,9 @@ def ingest_ble_dict(data: dict) -> DeviceSession:
|
||||
adv_type=data.get('adv_type', 'unknown'),
|
||||
adv_flags=data.get('adv_flags'),
|
||||
manufacturer_id=data.get('manufacturer_id'),
|
||||
manufacturer_data=bytes.fromhex(data['manufacturer_data']) if data.get('manufacturer_data') else None,
|
||||
manufacturer_data=_convert_to_bytes(data.get('manufacturer_data')),
|
||||
service_uuids=data.get('service_uuids', []),
|
||||
service_data=bytes.fromhex(data['service_data']) if data.get('service_data') else None,
|
||||
service_data=_convert_to_bytes(data.get('service_data')),
|
||||
local_name=data.get('local_name', data.get('name')),
|
||||
appearance=data.get('appearance'),
|
||||
packet_length=data.get('packet_length'),
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
WiFi scanning package for INTERCEPT.
|
||||
|
||||
Provides unified WiFi scanning with dual-mode architecture:
|
||||
- Quick Scan: Uses system tools (nmcli, iw, iwlist, airport) without monitor mode
|
||||
- Deep Scan: Uses airodump-ng with monitor mode for clients and probes
|
||||
|
||||
Also includes channel analysis, hidden SSID correlation, and network aggregation.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
WiFiObservation,
|
||||
WiFiAccessPoint,
|
||||
WiFiClient,
|
||||
WiFiProbeRequest,
|
||||
WiFiScanResult,
|
||||
WiFiScanStatus,
|
||||
WiFiCapabilities,
|
||||
ChannelStats,
|
||||
ChannelRecommendation,
|
||||
)
|
||||
|
||||
from .scanner import (
|
||||
UnifiedWiFiScanner,
|
||||
get_wifi_scanner,
|
||||
reset_wifi_scanner,
|
||||
)
|
||||
|
||||
from .constants import (
|
||||
# Bands
|
||||
BAND_2_4_GHZ,
|
||||
BAND_5_GHZ,
|
||||
BAND_6_GHZ,
|
||||
BAND_UNKNOWN,
|
||||
# Channels
|
||||
CHANNELS_2_4_GHZ,
|
||||
CHANNELS_5_GHZ,
|
||||
CHANNELS_6_GHZ,
|
||||
NON_OVERLAPPING_2_4_GHZ,
|
||||
NON_OVERLAPPING_5_GHZ,
|
||||
# Security
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA3,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_WPA2_WPA3,
|
||||
SECURITY_ENTERPRISE,
|
||||
SECURITY_UNKNOWN,
|
||||
# Cipher
|
||||
CIPHER_NONE,
|
||||
CIPHER_WEP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_GCMP,
|
||||
CIPHER_UNKNOWN,
|
||||
# Auth
|
||||
AUTH_OPEN,
|
||||
AUTH_PSK,
|
||||
AUTH_SAE,
|
||||
AUTH_EAP,
|
||||
AUTH_OWE,
|
||||
AUTH_UNKNOWN,
|
||||
# Signal bands
|
||||
SIGNAL_STRONG,
|
||||
SIGNAL_MEDIUM,
|
||||
SIGNAL_WEAK,
|
||||
SIGNAL_VERY_WEAK,
|
||||
SIGNAL_UNKNOWN,
|
||||
# Proximity bands (consistent with Bluetooth)
|
||||
PROXIMITY_IMMEDIATE,
|
||||
PROXIMITY_NEAR,
|
||||
PROXIMITY_FAR,
|
||||
PROXIMITY_UNKNOWN,
|
||||
# Scan modes
|
||||
SCAN_MODE_QUICK,
|
||||
SCAN_MODE_DEEP,
|
||||
# Helper functions
|
||||
get_band_from_channel,
|
||||
get_band_from_frequency,
|
||||
get_channel_from_frequency,
|
||||
get_signal_band,
|
||||
get_proximity_band,
|
||||
get_vendor_from_mac,
|
||||
)
|
||||
|
||||
from .channel_analyzer import (
|
||||
ChannelAnalyzer,
|
||||
analyze_channels,
|
||||
)
|
||||
|
||||
from .hidden_ssid import (
|
||||
HiddenSSIDCorrelator,
|
||||
get_hidden_correlator,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main scanner
|
||||
'UnifiedWiFiScanner',
|
||||
'get_wifi_scanner',
|
||||
'reset_wifi_scanner',
|
||||
|
||||
# Models
|
||||
'WiFiObservation',
|
||||
'WiFiAccessPoint',
|
||||
'WiFiClient',
|
||||
'WiFiProbeRequest',
|
||||
'WiFiScanResult',
|
||||
'WiFiScanStatus',
|
||||
'WiFiCapabilities',
|
||||
'ChannelStats',
|
||||
'ChannelRecommendation',
|
||||
|
||||
# Channel analysis
|
||||
'ChannelAnalyzer',
|
||||
'analyze_channels',
|
||||
|
||||
# Hidden SSID correlation
|
||||
'HiddenSSIDCorrelator',
|
||||
'get_hidden_correlator',
|
||||
|
||||
# Constants - Bands
|
||||
'BAND_2_4_GHZ',
|
||||
'BAND_5_GHZ',
|
||||
'BAND_6_GHZ',
|
||||
'BAND_UNKNOWN',
|
||||
|
||||
# Constants - Channels
|
||||
'CHANNELS_2_4_GHZ',
|
||||
'CHANNELS_5_GHZ',
|
||||
'CHANNELS_6_GHZ',
|
||||
'NON_OVERLAPPING_2_4_GHZ',
|
||||
'NON_OVERLAPPING_5_GHZ',
|
||||
|
||||
# Constants - Security
|
||||
'SECURITY_OPEN',
|
||||
'SECURITY_WEP',
|
||||
'SECURITY_WPA',
|
||||
'SECURITY_WPA2',
|
||||
'SECURITY_WPA3',
|
||||
'SECURITY_WPA_WPA2',
|
||||
'SECURITY_WPA2_WPA3',
|
||||
'SECURITY_ENTERPRISE',
|
||||
'SECURITY_UNKNOWN',
|
||||
|
||||
# Constants - Cipher
|
||||
'CIPHER_NONE',
|
||||
'CIPHER_WEP',
|
||||
'CIPHER_TKIP',
|
||||
'CIPHER_CCMP',
|
||||
'CIPHER_GCMP',
|
||||
'CIPHER_UNKNOWN',
|
||||
|
||||
# Constants - Auth
|
||||
'AUTH_OPEN',
|
||||
'AUTH_PSK',
|
||||
'AUTH_SAE',
|
||||
'AUTH_EAP',
|
||||
'AUTH_OWE',
|
||||
'AUTH_UNKNOWN',
|
||||
|
||||
# Constants - Signal bands
|
||||
'SIGNAL_STRONG',
|
||||
'SIGNAL_MEDIUM',
|
||||
'SIGNAL_WEAK',
|
||||
'SIGNAL_VERY_WEAK',
|
||||
'SIGNAL_UNKNOWN',
|
||||
|
||||
# Constants - Proximity bands
|
||||
'PROXIMITY_IMMEDIATE',
|
||||
'PROXIMITY_NEAR',
|
||||
'PROXIMITY_FAR',
|
||||
'PROXIMITY_UNKNOWN',
|
||||
|
||||
# Constants - Scan modes
|
||||
'SCAN_MODE_QUICK',
|
||||
'SCAN_MODE_DEEP',
|
||||
|
||||
# Helper functions
|
||||
'get_band_from_channel',
|
||||
'get_band_from_frequency',
|
||||
'get_channel_from_frequency',
|
||||
'get_signal_band',
|
||||
'get_proximity_band',
|
||||
'get_vendor_from_mac',
|
||||
]
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
WiFi channel utilization analysis and recommendations.
|
||||
|
||||
Analyzes channel congestion based on:
|
||||
- Number of access points per channel
|
||||
- Number of clients per channel
|
||||
- Signal strength (stronger = more interference)
|
||||
- Channel overlap effects
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
BAND_2_4_GHZ,
|
||||
BAND_5_GHZ,
|
||||
BAND_6_GHZ,
|
||||
CHANNELS_2_4_GHZ,
|
||||
CHANNELS_5_GHZ,
|
||||
CHANNELS_6_GHZ,
|
||||
NON_OVERLAPPING_2_4_GHZ,
|
||||
NON_OVERLAPPING_5_GHZ,
|
||||
CHANNEL_FREQUENCIES,
|
||||
CHANNEL_WEIGHT_AP_COUNT,
|
||||
CHANNEL_WEIGHT_CLIENT_COUNT,
|
||||
CHANNEL_RSSI_INTERFERENCE_FACTOR,
|
||||
get_band_from_channel,
|
||||
)
|
||||
from .models import WiFiAccessPoint, ChannelStats, ChannelRecommendation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# DFS channels (Dynamic Frequency Selection) - require radar detection
|
||||
DFS_CHANNELS_5_GHZ = list(range(52, 65)) + list(range(100, 145))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelScore:
|
||||
"""Internal scoring for a channel."""
|
||||
channel: int
|
||||
band: str
|
||||
ap_count: int = 0
|
||||
client_count: int = 0
|
||||
rssi_sum: float = 0.0
|
||||
rssi_count: int = 0
|
||||
overlap_penalty: float = 0.0
|
||||
|
||||
|
||||
class ChannelAnalyzer:
|
||||
"""
|
||||
Analyzes WiFi channel utilization and provides recommendations.
|
||||
|
||||
Uses a scoring algorithm that considers:
|
||||
1. AP density (60% weight by default)
|
||||
2. Client density (40% weight by default)
|
||||
3. Signal strength adjustment (stronger signals = more interference)
|
||||
4. Channel overlap effects for 2.4 GHz
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap_weight: float = CHANNEL_WEIGHT_AP_COUNT,
|
||||
client_weight: float = CHANNEL_WEIGHT_CLIENT_COUNT,
|
||||
rssi_factor: float = CHANNEL_RSSI_INTERFERENCE_FACTOR,
|
||||
):
|
||||
"""
|
||||
Initialize channel analyzer.
|
||||
|
||||
Args:
|
||||
ap_weight: Weight for AP count in utilization score (0-1).
|
||||
client_weight: Weight for client count in utilization score (0-1).
|
||||
rssi_factor: Factor for RSSI-based interference adjustment.
|
||||
"""
|
||||
self.ap_weight = ap_weight
|
||||
self.client_weight = client_weight
|
||||
self.rssi_factor = rssi_factor
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
access_points: list[WiFiAccessPoint],
|
||||
include_dfs: bool = False,
|
||||
) -> tuple[list[ChannelStats], list[ChannelRecommendation]]:
|
||||
"""
|
||||
Analyze channel utilization from access point data.
|
||||
|
||||
Args:
|
||||
access_points: List of discovered access points.
|
||||
include_dfs: Whether to include DFS channels in recommendations.
|
||||
|
||||
Returns:
|
||||
Tuple of (channel_stats, recommendations).
|
||||
"""
|
||||
# Build per-channel scores
|
||||
scores: dict[int, ChannelScore] = {}
|
||||
|
||||
for ap in access_points:
|
||||
if ap.channel is None:
|
||||
continue
|
||||
|
||||
channel = ap.channel
|
||||
if channel not in scores:
|
||||
scores[channel] = ChannelScore(
|
||||
channel=channel,
|
||||
band=get_band_from_channel(channel),
|
||||
)
|
||||
|
||||
score = scores[channel]
|
||||
score.ap_count += 1
|
||||
score.client_count += ap.client_count
|
||||
|
||||
if ap.rssi_current is not None:
|
||||
score.rssi_sum += ap.rssi_current
|
||||
score.rssi_count += 1
|
||||
|
||||
# Calculate overlap penalties for 2.4 GHz
|
||||
self._calculate_overlap_penalties(scores)
|
||||
|
||||
# Convert to ChannelStats
|
||||
channel_stats = self._build_channel_stats(scores)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = self._generate_recommendations(
|
||||
scores, access_points, include_dfs
|
||||
)
|
||||
|
||||
return channel_stats, recommendations
|
||||
|
||||
def _calculate_overlap_penalties(self, scores: dict[int, ChannelScore]):
|
||||
"""Calculate overlap penalties for 2.4 GHz channels."""
|
||||
# In 2.4 GHz, channels overlap: each channel is 22 MHz wide
|
||||
# but only 5 MHz apart. Channels 1, 6, 11 don't overlap.
|
||||
#
|
||||
# Channel overlap:
|
||||
# - Adjacent channel (+/- 1): 75% overlap
|
||||
# - 2 channels apart: 50% overlap
|
||||
# - 3 channels apart: 25% overlap
|
||||
# - 4 channels apart: ~12% overlap
|
||||
# - 5+ channels apart: no overlap
|
||||
|
||||
overlap_factors = {1: 0.75, 2: 0.50, 3: 0.25, 4: 0.12}
|
||||
|
||||
for channel, score in scores.items():
|
||||
if score.band != BAND_2_4_GHZ:
|
||||
continue
|
||||
|
||||
penalty = 0.0
|
||||
for other_channel, other_score in scores.items():
|
||||
if other_channel == channel or other_score.band != BAND_2_4_GHZ:
|
||||
continue
|
||||
|
||||
distance = abs(channel - other_channel)
|
||||
if distance in overlap_factors:
|
||||
# Penalty based on APs on overlapping channel
|
||||
overlap = overlap_factors[distance]
|
||||
penalty += other_score.ap_count * overlap * 0.5
|
||||
|
||||
score.overlap_penalty = penalty
|
||||
|
||||
def _build_channel_stats(self, scores: dict[int, ChannelScore]) -> list[ChannelStats]:
|
||||
"""Build ChannelStats from scores."""
|
||||
stats_list = []
|
||||
|
||||
for channel, score in sorted(scores.items()):
|
||||
rssi_avg = None
|
||||
if score.rssi_count > 0:
|
||||
rssi_avg = score.rssi_sum / score.rssi_count
|
||||
|
||||
# Calculate utilization score
|
||||
utilization = self._calculate_utilization(score)
|
||||
|
||||
stats = ChannelStats(
|
||||
channel=channel,
|
||||
band=score.band,
|
||||
frequency_mhz=CHANNEL_FREQUENCIES.get(channel),
|
||||
ap_count=score.ap_count,
|
||||
client_count=score.client_count,
|
||||
rssi_avg=rssi_avg,
|
||||
utilization_score=utilization,
|
||||
)
|
||||
stats_list.append(stats)
|
||||
|
||||
return stats_list
|
||||
|
||||
def _calculate_utilization(self, score: ChannelScore) -> float:
|
||||
"""Calculate channel utilization score (0.0-1.0, lower is better)."""
|
||||
# Base score from AP and client counts
|
||||
ap_score = score.ap_count * self.ap_weight
|
||||
client_score = score.client_count * self.client_weight
|
||||
|
||||
# RSSI adjustment: stronger signals = more interference
|
||||
rssi_adjustment = 0.0
|
||||
if score.rssi_count > 0:
|
||||
avg_rssi = score.rssi_sum / score.rssi_count
|
||||
# Normalize: -30 dBm (very strong) -> 1.0, -100 dBm (weak) -> 0.0
|
||||
rssi_normalized = (avg_rssi + 100) / 70
|
||||
rssi_adjustment = max(0, rssi_normalized) * self.rssi_factor * score.ap_count
|
||||
|
||||
# Overlap penalty (already scaled)
|
||||
overlap_score = score.overlap_penalty
|
||||
|
||||
# Total score
|
||||
total = ap_score + client_score + rssi_adjustment + overlap_score
|
||||
|
||||
# Normalize to 0.0-1.0 range (cap at reasonable maximum)
|
||||
normalized = min(1.0, total / 10.0)
|
||||
|
||||
return normalized
|
||||
|
||||
def _generate_recommendations(
|
||||
self,
|
||||
scores: dict[int, ChannelScore],
|
||||
access_points: list[WiFiAccessPoint],
|
||||
include_dfs: bool,
|
||||
) -> list[ChannelRecommendation]:
|
||||
"""Generate channel recommendations."""
|
||||
recommendations = []
|
||||
|
||||
# Score all non-overlapping channels
|
||||
candidate_channels = []
|
||||
|
||||
# 2.4 GHz non-overlapping
|
||||
for channel in NON_OVERLAPPING_2_4_GHZ:
|
||||
candidate_channels.append((channel, BAND_2_4_GHZ, False))
|
||||
|
||||
# 5 GHz non-DFS
|
||||
for channel in NON_OVERLAPPING_5_GHZ:
|
||||
is_dfs = channel in DFS_CHANNELS_5_GHZ
|
||||
if is_dfs and not include_dfs:
|
||||
continue
|
||||
candidate_channels.append((channel, BAND_5_GHZ, is_dfs))
|
||||
|
||||
# 5 GHz DFS channels (if requested)
|
||||
if include_dfs:
|
||||
for channel in DFS_CHANNELS_5_GHZ:
|
||||
if channel not in NON_OVERLAPPING_5_GHZ:
|
||||
candidate_channels.append((channel, BAND_5_GHZ, True))
|
||||
|
||||
# Score each candidate
|
||||
for channel, band, is_dfs in candidate_channels:
|
||||
score = scores.get(channel)
|
||||
|
||||
if score:
|
||||
utilization = self._calculate_utilization(score)
|
||||
ap_count = score.ap_count
|
||||
else:
|
||||
utilization = 0.0
|
||||
ap_count = 0
|
||||
|
||||
# Build reason string
|
||||
if ap_count == 0:
|
||||
reason = "No APs detected - clear channel"
|
||||
elif ap_count == 1:
|
||||
reason = f"1 AP on channel"
|
||||
else:
|
||||
reason = f"{ap_count} APs on channel"
|
||||
|
||||
if is_dfs:
|
||||
reason += " (DFS - radar detection required)"
|
||||
|
||||
recommendations.append(ChannelRecommendation(
|
||||
channel=channel,
|
||||
band=band,
|
||||
score=utilization,
|
||||
reason=reason,
|
||||
is_dfs=is_dfs,
|
||||
))
|
||||
|
||||
# Sort by score (lower is better), then prefer non-DFS
|
||||
recommendations.sort(key=lambda r: (r.score, r.is_dfs, r.channel))
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
# Module-level convenience function
|
||||
def analyze_channels(
|
||||
access_points: list[WiFiAccessPoint],
|
||||
include_dfs: bool = False,
|
||||
) -> tuple[list[ChannelStats], list[ChannelRecommendation]]:
|
||||
"""
|
||||
Analyze channel utilization and get recommendations.
|
||||
|
||||
Args:
|
||||
access_points: List of discovered access points.
|
||||
include_dfs: Whether to include DFS channels.
|
||||
|
||||
Returns:
|
||||
Tuple of (channel_stats, recommendations).
|
||||
"""
|
||||
analyzer = ChannelAnalyzer()
|
||||
return analyzer.analyze(access_points, include_dfs)
|
||||
@@ -0,0 +1,446 @@
|
||||
"""
|
||||
WiFi-specific constants for the unified scanner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# =============================================================================
|
||||
# SCANNER SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
# Default quick scan timeout in seconds
|
||||
DEFAULT_QUICK_SCAN_TIMEOUT = 15
|
||||
|
||||
# Default deep scan channel dwell time (seconds)
|
||||
DEFAULT_CHANNEL_DWELL_TIME = 2
|
||||
|
||||
# Maximum RSSI samples to keep per network
|
||||
MAX_RSSI_SAMPLES = 300
|
||||
|
||||
# Network expiration time (seconds since last seen)
|
||||
NETWORK_STALE_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
# Client expiration time (seconds since last seen)
|
||||
CLIENT_STALE_TIMEOUT = 180 # 3 minutes
|
||||
|
||||
# Probe request retention time (seconds)
|
||||
PROBE_REQUEST_RETENTION = 600 # 10 minutes
|
||||
|
||||
# =============================================================================
|
||||
# WIFI BANDS
|
||||
# =============================================================================
|
||||
|
||||
BAND_2_4_GHZ = '2.4GHz'
|
||||
BAND_5_GHZ = '5GHz'
|
||||
BAND_6_GHZ = '6GHz'
|
||||
BAND_UNKNOWN = 'unknown'
|
||||
|
||||
# =============================================================================
|
||||
# WIFI BAND CHANNEL MAPPINGS
|
||||
# =============================================================================
|
||||
|
||||
# 2.4 GHz channels (1-14)
|
||||
CHANNELS_2_4_GHZ = list(range(1, 15))
|
||||
|
||||
# 5 GHz channels (UNII-1, UNII-2A, UNII-2C, UNII-3)
|
||||
CHANNELS_5_GHZ = [
|
||||
# UNII-1 (5150-5250 MHz)
|
||||
36, 40, 44, 48,
|
||||
# UNII-2A (5250-5350 MHz) - DFS
|
||||
52, 56, 60, 64,
|
||||
# UNII-2C (5470-5725 MHz) - DFS
|
||||
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
|
||||
# UNII-3 (5725-5850 MHz)
|
||||
149, 153, 157, 161, 165,
|
||||
]
|
||||
|
||||
# 6 GHz channels (Wi-Fi 6E)
|
||||
CHANNELS_6_GHZ = [
|
||||
1, 5, 9, 13, 17, 21, 25, 29, 33, 37, 41, 45, 49, 53, 57, 61, 65, 69, 73,
|
||||
77, 81, 85, 89, 93, 97, 101, 105, 109, 113, 117, 121, 125, 129, 133, 137,
|
||||
141, 145, 149, 153, 157, 161, 165, 169, 173, 177, 181, 185, 189, 193, 197,
|
||||
201, 205, 209, 213, 217, 221, 225, 229, 233
|
||||
]
|
||||
|
||||
# Non-overlapping channels for recommendations
|
||||
NON_OVERLAPPING_2_4_GHZ = [1, 6, 11]
|
||||
NON_OVERLAPPING_5_GHZ = [36, 40, 44, 48, 149, 153, 157, 161, 165] # Non-DFS
|
||||
|
||||
# Channel to frequency mappings (MHz)
|
||||
CHANNEL_FREQUENCIES = {
|
||||
# 2.4 GHz
|
||||
1: 2412, 2: 2417, 3: 2422, 4: 2427, 5: 2432, 6: 2437, 7: 2442,
|
||||
8: 2447, 9: 2452, 10: 2457, 11: 2462, 12: 2467, 13: 2472, 14: 2484,
|
||||
# 5 GHz
|
||||
36: 5180, 40: 5200, 44: 5220, 48: 5240,
|
||||
52: 5260, 56: 5280, 60: 5300, 64: 5320,
|
||||
100: 5500, 104: 5520, 108: 5540, 112: 5560, 116: 5580,
|
||||
120: 5600, 124: 5620, 128: 5640, 132: 5660, 136: 5680, 140: 5700, 144: 5720,
|
||||
149: 5745, 153: 5765, 157: 5785, 161: 5805, 165: 5825,
|
||||
}
|
||||
|
||||
# Frequency to channel reverse mapping
|
||||
FREQUENCY_CHANNELS = {v: k for k, v in CHANNEL_FREQUENCIES.items()}
|
||||
|
||||
|
||||
def get_band_from_channel(channel: int) -> str:
|
||||
"""Get WiFi band from channel number."""
|
||||
if 1 <= channel <= 14:
|
||||
return BAND_2_4_GHZ
|
||||
elif channel in CHANNELS_5_GHZ:
|
||||
return BAND_5_GHZ
|
||||
elif channel in CHANNELS_6_GHZ:
|
||||
return BAND_6_GHZ
|
||||
return BAND_UNKNOWN
|
||||
|
||||
|
||||
def get_band_from_frequency(frequency_mhz: int) -> str:
|
||||
"""Get WiFi band from frequency in MHz."""
|
||||
if 2400 <= frequency_mhz <= 2500:
|
||||
return BAND_2_4_GHZ
|
||||
elif 5150 <= frequency_mhz <= 5850:
|
||||
return BAND_5_GHZ
|
||||
elif 5925 <= frequency_mhz <= 7125:
|
||||
return BAND_6_GHZ
|
||||
return BAND_UNKNOWN
|
||||
|
||||
|
||||
def get_channel_from_frequency(frequency_mhz: int) -> int | None:
|
||||
"""Get channel number from frequency in MHz."""
|
||||
return FREQUENCY_CHANNELS.get(frequency_mhz)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY TYPES
|
||||
# =============================================================================
|
||||
|
||||
SECURITY_OPEN = 'Open'
|
||||
SECURITY_WEP = 'WEP'
|
||||
SECURITY_WPA = 'WPA'
|
||||
SECURITY_WPA2 = 'WPA2'
|
||||
SECURITY_WPA3 = 'WPA3'
|
||||
SECURITY_WPA_WPA2 = 'WPA/WPA2'
|
||||
SECURITY_WPA2_WPA3 = 'WPA2/WPA3'
|
||||
SECURITY_ENTERPRISE = 'Enterprise'
|
||||
SECURITY_UNKNOWN = 'Unknown'
|
||||
|
||||
# Security type priority (higher = more secure)
|
||||
SECURITY_PRIORITY = {
|
||||
SECURITY_OPEN: 0,
|
||||
SECURITY_WEP: 1,
|
||||
SECURITY_WPA: 2,
|
||||
SECURITY_WPA_WPA2: 3,
|
||||
SECURITY_WPA2: 4,
|
||||
SECURITY_WPA2_WPA3: 5,
|
||||
SECURITY_WPA3: 6,
|
||||
SECURITY_ENTERPRISE: 7,
|
||||
SECURITY_UNKNOWN: -1,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# CIPHER TYPES
|
||||
# =============================================================================
|
||||
|
||||
CIPHER_NONE = 'None'
|
||||
CIPHER_WEP = 'WEP'
|
||||
CIPHER_TKIP = 'TKIP'
|
||||
CIPHER_CCMP = 'CCMP'
|
||||
CIPHER_GCMP = 'GCMP'
|
||||
CIPHER_UNKNOWN = 'Unknown'
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION TYPES
|
||||
# =============================================================================
|
||||
|
||||
AUTH_OPEN = 'Open'
|
||||
AUTH_PSK = 'PSK'
|
||||
AUTH_SAE = 'SAE'
|
||||
AUTH_EAP = 'EAP'
|
||||
AUTH_OWE = 'OWE'
|
||||
AUTH_UNKNOWN = 'Unknown'
|
||||
|
||||
# =============================================================================
|
||||
# CHANNEL WIDTH
|
||||
# =============================================================================
|
||||
|
||||
WIDTH_20_MHZ = '20MHz'
|
||||
WIDTH_40_MHZ = '40MHz'
|
||||
WIDTH_80_MHZ = '80MHz'
|
||||
WIDTH_160_MHZ = '160MHz'
|
||||
WIDTH_320_MHZ = '320MHz'
|
||||
WIDTH_UNKNOWN = 'Unknown'
|
||||
|
||||
# =============================================================================
|
||||
# SIGNAL STRENGTH BANDS (for proximity radar)
|
||||
# =============================================================================
|
||||
|
||||
SIGNAL_STRONG = 'strong' # >= -50 dBm
|
||||
SIGNAL_MEDIUM = 'medium' # -50 to -70 dBm
|
||||
SIGNAL_WEAK = 'weak' # -70 to -85 dBm
|
||||
SIGNAL_VERY_WEAK = 'very_weak' # < -85 dBm
|
||||
SIGNAL_UNKNOWN = 'unknown'
|
||||
|
||||
# RSSI thresholds for signal bands
|
||||
RSSI_STRONG = -50
|
||||
RSSI_MEDIUM = -70
|
||||
RSSI_WEAK = -85
|
||||
|
||||
|
||||
def get_signal_band(rssi: int | None) -> str:
|
||||
"""Get signal band from RSSI value."""
|
||||
if rssi is None:
|
||||
return SIGNAL_UNKNOWN
|
||||
if rssi >= RSSI_STRONG:
|
||||
return SIGNAL_STRONG
|
||||
elif rssi >= RSSI_MEDIUM:
|
||||
return SIGNAL_MEDIUM
|
||||
elif rssi >= RSSI_WEAK:
|
||||
return SIGNAL_WEAK
|
||||
return SIGNAL_VERY_WEAK
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROXIMITY BANDS (consistent with Bluetooth)
|
||||
# =============================================================================
|
||||
|
||||
PROXIMITY_IMMEDIATE = 'immediate' # < 3m
|
||||
PROXIMITY_NEAR = 'near' # 3-10m
|
||||
PROXIMITY_FAR = 'far' # > 10m
|
||||
PROXIMITY_UNKNOWN = 'unknown'
|
||||
|
||||
# RSSI thresholds for proximity band classification
|
||||
PROXIMITY_RSSI_IMMEDIATE = -55 # >= -55 dBm -> immediate
|
||||
PROXIMITY_RSSI_NEAR = -70 # >= -70 dBm -> near
|
||||
|
||||
|
||||
def get_proximity_band(rssi: int | None) -> str:
|
||||
"""Get proximity band from RSSI value."""
|
||||
if rssi is None:
|
||||
return PROXIMITY_UNKNOWN
|
||||
if rssi >= PROXIMITY_RSSI_IMMEDIATE:
|
||||
return PROXIMITY_IMMEDIATE
|
||||
elif rssi >= PROXIMITY_RSSI_NEAR:
|
||||
return PROXIMITY_NEAR
|
||||
return PROXIMITY_FAR
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DISTANCE ESTIMATION (WiFi-specific)
|
||||
# =============================================================================
|
||||
|
||||
# Path-loss exponent for indoor WiFi (typically 2.5-4.0)
|
||||
WIFI_PATH_LOSS_EXPONENT = 3.0
|
||||
|
||||
# Reference RSSI at 1 meter (typical WiFi AP)
|
||||
WIFI_RSSI_AT_1M = -40
|
||||
|
||||
# EMA smoothing alpha for RSSI
|
||||
WIFI_EMA_ALPHA = 0.3
|
||||
|
||||
# =============================================================================
|
||||
# SCAN MODES
|
||||
# =============================================================================
|
||||
|
||||
SCAN_MODE_QUICK = 'quick' # Uses system tools (no monitor mode)
|
||||
SCAN_MODE_DEEP = 'deep' # Uses airodump-ng (monitor mode required)
|
||||
|
||||
# =============================================================================
|
||||
# TOOL DETECTION
|
||||
# =============================================================================
|
||||
|
||||
# Quick scan tools (by platform priority)
|
||||
QUICK_SCAN_TOOLS_LINUX = ['nmcli', 'iw', 'iwlist']
|
||||
QUICK_SCAN_TOOLS_DARWIN = ['airport']
|
||||
|
||||
# Deep scan tools
|
||||
DEEP_SCAN_TOOLS = ['airodump-ng']
|
||||
|
||||
# Monitor mode tools
|
||||
MONITOR_MODE_TOOLS = ['airmon-ng', 'iw']
|
||||
|
||||
# Tool command timeouts (seconds)
|
||||
TOOL_TIMEOUT_QUICK = 30.0
|
||||
TOOL_TIMEOUT_DETECT = 5.0
|
||||
|
||||
# =============================================================================
|
||||
# AIRODUMP-NG SETTINGS
|
||||
# =============================================================================
|
||||
|
||||
AIRODUMP_OUTPUT_PREFIX = 'airodump_wifi'
|
||||
AIRODUMP_POLL_INTERVAL = 1.0 # seconds between CSV reads
|
||||
|
||||
# =============================================================================
|
||||
# HEURISTIC FLAGS
|
||||
# =============================================================================
|
||||
|
||||
HEURISTIC_HIDDEN = 'hidden'
|
||||
HEURISTIC_ROGUE_AP = 'rogue_ap'
|
||||
HEURISTIC_EVIL_TWIN = 'evil_twin'
|
||||
HEURISTIC_BEACON_FLOOD = 'beacon_flood'
|
||||
HEURISTIC_WEAK_SECURITY = 'weak_security'
|
||||
HEURISTIC_DEAUTH_DETECTED = 'deauth_detected'
|
||||
HEURISTIC_NEW = 'new'
|
||||
HEURISTIC_PERSISTENT = 'persistent'
|
||||
HEURISTIC_STRONG_STABLE = 'strong_stable'
|
||||
|
||||
# Thresholds
|
||||
BEACON_FLOOD_THRESHOLD = 50 # Same BSSID seen > 50 times/minute
|
||||
PERSISTENT_MIN_SEEN = 10
|
||||
PERSISTENT_WINDOW_SECONDS = 300
|
||||
STRONG_RSSI_THRESHOLD = -50
|
||||
STABLE_VARIANCE_THRESHOLD = 5.0
|
||||
|
||||
# =============================================================================
|
||||
# COMMON VENDOR OUI PREFIXES (first 3 bytes of MAC)
|
||||
# =============================================================================
|
||||
|
||||
VENDOR_OUIS = {
|
||||
'00:00:5E': 'IANA',
|
||||
'00:03:93': 'Apple',
|
||||
'00:0A:95': 'Apple',
|
||||
'00:0D:93': 'Apple',
|
||||
'00:11:24': 'Apple',
|
||||
'00:14:51': 'Apple',
|
||||
'00:16:CB': 'Apple',
|
||||
'00:17:F2': 'Apple',
|
||||
'00:19:E3': 'Apple',
|
||||
'00:1B:63': 'Apple',
|
||||
'00:1C:B3': 'Apple',
|
||||
'00:1D:4F': 'Apple',
|
||||
'00:1E:52': 'Apple',
|
||||
'00:1E:C2': 'Apple',
|
||||
'00:1F:5B': 'Apple',
|
||||
'00:1F:F3': 'Apple',
|
||||
'00:21:E9': 'Apple',
|
||||
'00:22:41': 'Apple',
|
||||
'00:23:12': 'Apple',
|
||||
'00:23:32': 'Apple',
|
||||
'00:23:6C': 'Apple',
|
||||
'00:23:DF': 'Apple',
|
||||
'00:24:36': 'Apple',
|
||||
'00:25:00': 'Apple',
|
||||
'00:25:4B': 'Apple',
|
||||
'00:25:BC': 'Apple',
|
||||
'00:26:08': 'Apple',
|
||||
'00:26:4A': 'Apple',
|
||||
'00:26:B0': 'Apple',
|
||||
'00:26:BB': 'Apple',
|
||||
'00:50:F2': 'Microsoft',
|
||||
'00:15:5D': 'Microsoft',
|
||||
'00:17:FA': 'Microsoft',
|
||||
'00:1D:D8': 'Microsoft',
|
||||
'00:50:56': 'VMware',
|
||||
'00:0C:29': 'VMware',
|
||||
'00:05:69': 'VMware',
|
||||
'08:00:27': 'VirtualBox',
|
||||
'00:1C:42': 'Parallels',
|
||||
'00:16:3E': 'Xen',
|
||||
'DC:A6:32': 'Raspberry Pi',
|
||||
'B8:27:EB': 'Raspberry Pi',
|
||||
'E4:5F:01': 'Raspberry Pi',
|
||||
'28:CD:C1': 'Raspberry Pi',
|
||||
'00:1A:11': 'Google',
|
||||
'00:1A:22': 'Google',
|
||||
'3C:5A:B4': 'Google',
|
||||
'54:60:09': 'Google',
|
||||
'94:EB:2C': 'Google',
|
||||
'F4:F5:D8': 'Google',
|
||||
'00:17:C4': 'Netgear',
|
||||
'00:1B:2F': 'Netgear',
|
||||
'00:1E:2A': 'Netgear',
|
||||
'00:22:3F': 'Netgear',
|
||||
'00:24:B2': 'Netgear',
|
||||
'00:26:F2': 'Netgear',
|
||||
'00:18:F8': 'Cisco',
|
||||
'00:1A:A1': 'Cisco',
|
||||
'00:1B:0C': 'Cisco',
|
||||
'00:1B:D4': 'Cisco',
|
||||
'00:1C:0E': 'Cisco',
|
||||
'00:1C:57': 'Cisco',
|
||||
'00:40:96': 'Cisco',
|
||||
'00:50:54': 'Cisco',
|
||||
'00:60:5C': 'Cisco',
|
||||
'E8:65:D4': 'Ubiquiti',
|
||||
'FC:EC:DA': 'Ubiquiti',
|
||||
'00:27:22': 'Ubiquiti',
|
||||
'04:18:D6': 'Ubiquiti',
|
||||
'18:E8:29': 'Ubiquiti',
|
||||
'24:A4:3C': 'Ubiquiti',
|
||||
'44:D9:E7': 'Ubiquiti',
|
||||
'68:72:51': 'Ubiquiti',
|
||||
'74:83:C2': 'Ubiquiti',
|
||||
'78:8A:20': 'Ubiquiti',
|
||||
'B4:FB:E4': 'Ubiquiti',
|
||||
'F0:9F:C2': 'Ubiquiti',
|
||||
'00:0C:F1': 'Intel',
|
||||
'00:13:02': 'Intel',
|
||||
'00:13:20': 'Intel',
|
||||
'00:13:CE': 'Intel',
|
||||
'00:13:E8': 'Intel',
|
||||
'00:15:00': 'Intel',
|
||||
'00:15:17': 'Intel',
|
||||
'00:16:6F': 'Intel',
|
||||
'00:16:76': 'Intel',
|
||||
'00:16:EA': 'Intel',
|
||||
'00:16:EB': 'Intel',
|
||||
'00:18:DE': 'Intel',
|
||||
'00:19:D1': 'Intel',
|
||||
'00:19:D2': 'Intel',
|
||||
'00:1B:21': 'Intel',
|
||||
'00:1B:77': 'Intel',
|
||||
'00:1C:BF': 'Intel',
|
||||
'00:1D:E0': 'Intel',
|
||||
'00:1D:E1': 'Intel',
|
||||
'00:1E:64': 'Intel',
|
||||
'00:1E:65': 'Intel',
|
||||
'00:1E:67': 'Intel',
|
||||
'00:1F:3B': 'Intel',
|
||||
'00:1F:3C': 'Intel',
|
||||
'00:20:E0': 'TP-Link',
|
||||
'00:23:CD': 'TP-Link',
|
||||
'00:25:86': 'TP-Link',
|
||||
'00:27:19': 'TP-Link',
|
||||
'14:CC:20': 'TP-Link',
|
||||
'14:CF:92': 'TP-Link',
|
||||
'18:A6:F7': 'TP-Link',
|
||||
'1C:3B:F3': 'TP-Link',
|
||||
'30:B5:C2': 'TP-Link',
|
||||
'50:C7:BF': 'TP-Link',
|
||||
'54:C8:0F': 'TP-Link',
|
||||
'60:E3:27': 'TP-Link',
|
||||
'64:56:01': 'TP-Link',
|
||||
'64:66:B3': 'TP-Link',
|
||||
'64:70:02': 'TP-Link',
|
||||
}
|
||||
|
||||
|
||||
def get_vendor_from_mac(mac: str) -> str | None:
|
||||
"""Get vendor name from MAC address OUI."""
|
||||
if not mac:
|
||||
return None
|
||||
# Normalize MAC format
|
||||
mac_upper = mac.upper().replace('-', ':')
|
||||
oui = mac_upper[:8]
|
||||
return VENDOR_OUIS.get(oui)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HIDDEN SSID CORRELATION
|
||||
# =============================================================================
|
||||
|
||||
# Time window for correlating probe requests with hidden AP associations
|
||||
HIDDEN_CORRELATION_WINDOW_SECONDS = 60
|
||||
|
||||
# Minimum confidence for hidden SSID revelation
|
||||
HIDDEN_MIN_CORRELATION_CONFIDENCE = 0.7
|
||||
|
||||
# =============================================================================
|
||||
# CHANNEL ANALYSIS
|
||||
# =============================================================================
|
||||
|
||||
# Weights for channel utilization scoring
|
||||
CHANNEL_WEIGHT_AP_COUNT = 0.6
|
||||
CHANNEL_WEIGHT_CLIENT_COUNT = 0.4
|
||||
|
||||
# RSSI adjustment factor (stronger signals = more interference)
|
||||
CHANNEL_RSSI_INTERFERENCE_FACTOR = 0.1
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Hidden SSID correlation engine.
|
||||
|
||||
Correlates probe requests from clients with hidden access points to reveal
|
||||
the actual SSID of hidden networks.
|
||||
|
||||
Strategy:
|
||||
1. Track probe requests with source MACs and probed SSIDs
|
||||
2. Track hidden networks (empty ESSID) with their BSSIDs
|
||||
3. When a client probes for an SSID and then associates with a hidden AP
|
||||
within a time window, correlate the SSID to the hidden AP
|
||||
4. Also correlate when the same client is seen both probing for an SSID
|
||||
and sending data to a hidden AP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .constants import (
|
||||
HIDDEN_CORRELATION_WINDOW_SECONDS,
|
||||
HIDDEN_MIN_CORRELATION_CONFIDENCE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global correlator instance
|
||||
_correlator_instance: Optional['HiddenSSIDCorrelator'] = None
|
||||
_correlator_lock = threading.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbeRecord:
|
||||
"""Record of a probe request."""
|
||||
timestamp: datetime
|
||||
client_mac: str
|
||||
probed_ssid: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssociationRecord:
|
||||
"""Record of a client association."""
|
||||
timestamp: datetime
|
||||
client_mac: str
|
||||
bssid: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CorrelationResult:
|
||||
"""Result of an SSID correlation."""
|
||||
bssid: str
|
||||
revealed_ssid: str
|
||||
client_mac: str
|
||||
confidence: float
|
||||
correlation_time: datetime
|
||||
method: str # 'probe_association', 'data_correlation'
|
||||
|
||||
|
||||
class HiddenSSIDCorrelator:
|
||||
"""
|
||||
Correlates probe requests with hidden APs to reveal their SSIDs.
|
||||
|
||||
Uses time-based correlation: when a client probes for an SSID and
|
||||
then is seen communicating with a hidden AP, the SSID is likely
|
||||
that of the hidden network.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
correlation_window: float = HIDDEN_CORRELATION_WINDOW_SECONDS,
|
||||
min_confidence: float = HIDDEN_MIN_CORRELATION_CONFIDENCE,
|
||||
):
|
||||
"""
|
||||
Initialize the correlator.
|
||||
|
||||
Args:
|
||||
correlation_window: Time window for correlation (seconds).
|
||||
min_confidence: Minimum confidence to report a correlation.
|
||||
"""
|
||||
self.correlation_window = correlation_window
|
||||
self.min_confidence = min_confidence
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Storage
|
||||
self._probe_records: list[ProbeRecord] = []
|
||||
self._association_records: list[AssociationRecord] = []
|
||||
self._hidden_aps: dict[str, datetime] = {} # BSSID -> last_seen
|
||||
self._revealed: dict[str, CorrelationResult] = {} # BSSID -> result
|
||||
|
||||
# Callbacks
|
||||
self._on_ssid_revealed: Optional[Callable[[CorrelationResult], None]] = None
|
||||
|
||||
def record_probe(self, client_mac: str, probed_ssid: str, timestamp: Optional[datetime] = None):
|
||||
"""
|
||||
Record a probe request.
|
||||
|
||||
Args:
|
||||
client_mac: MAC address of the probing client.
|
||||
probed_ssid: SSID being probed for.
|
||||
timestamp: Time of the probe (defaults to now).
|
||||
"""
|
||||
if not client_mac or not probed_ssid:
|
||||
return
|
||||
|
||||
timestamp = timestamp or datetime.now()
|
||||
client_mac = client_mac.upper()
|
||||
|
||||
with self._lock:
|
||||
self._probe_records.append(ProbeRecord(
|
||||
timestamp=timestamp,
|
||||
client_mac=client_mac,
|
||||
probed_ssid=probed_ssid,
|
||||
))
|
||||
|
||||
# Prune old records
|
||||
self._prune_records()
|
||||
|
||||
# Check for correlations with known hidden APs
|
||||
self._check_correlations()
|
||||
|
||||
def record_association(self, client_mac: str, bssid: str, timestamp: Optional[datetime] = None):
|
||||
"""
|
||||
Record a client association with an AP.
|
||||
|
||||
Args:
|
||||
client_mac: MAC address of the client.
|
||||
bssid: BSSID of the AP.
|
||||
timestamp: Time of the association (defaults to now).
|
||||
"""
|
||||
if not client_mac or not bssid:
|
||||
return
|
||||
|
||||
timestamp = timestamp or datetime.now()
|
||||
client_mac = client_mac.upper()
|
||||
bssid = bssid.upper()
|
||||
|
||||
with self._lock:
|
||||
self._association_records.append(AssociationRecord(
|
||||
timestamp=timestamp,
|
||||
client_mac=client_mac,
|
||||
bssid=bssid,
|
||||
))
|
||||
|
||||
# Prune old records
|
||||
self._prune_records()
|
||||
|
||||
# Check for correlations
|
||||
self._check_correlations()
|
||||
|
||||
def record_hidden_ap(self, bssid: str, timestamp: Optional[datetime] = None):
|
||||
"""
|
||||
Record a hidden access point (empty SSID).
|
||||
|
||||
Args:
|
||||
bssid: BSSID of the hidden AP.
|
||||
timestamp: Time when seen (defaults to now).
|
||||
"""
|
||||
if not bssid:
|
||||
return
|
||||
|
||||
timestamp = timestamp or datetime.now()
|
||||
bssid = bssid.upper()
|
||||
|
||||
with self._lock:
|
||||
self._hidden_aps[bssid] = timestamp
|
||||
|
||||
# Check for correlations
|
||||
self._check_correlations()
|
||||
|
||||
def get_revealed_ssid(self, bssid: str) -> Optional[str]:
|
||||
"""
|
||||
Get the revealed SSID for a hidden AP, if known.
|
||||
|
||||
Args:
|
||||
bssid: BSSID to look up.
|
||||
|
||||
Returns:
|
||||
Revealed SSID or None.
|
||||
"""
|
||||
with self._lock:
|
||||
result = self._revealed.get(bssid.upper())
|
||||
return result.revealed_ssid if result else None
|
||||
|
||||
def get_correlation(self, bssid: str) -> Optional[CorrelationResult]:
|
||||
"""
|
||||
Get the full correlation result for a hidden AP.
|
||||
|
||||
Args:
|
||||
bssid: BSSID to look up.
|
||||
|
||||
Returns:
|
||||
CorrelationResult or None.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._revealed.get(bssid.upper())
|
||||
|
||||
def get_all_revealed(self) -> dict[str, str]:
|
||||
"""
|
||||
Get all revealed SSID mappings.
|
||||
|
||||
Returns:
|
||||
Dict of BSSID -> revealed SSID.
|
||||
"""
|
||||
with self._lock:
|
||||
return {
|
||||
bssid: result.revealed_ssid
|
||||
for bssid, result in self._revealed.items()
|
||||
}
|
||||
|
||||
def set_callback(self, callback: Callable[[CorrelationResult], None]):
|
||||
"""Set callback for when an SSID is revealed."""
|
||||
self._on_ssid_revealed = callback
|
||||
|
||||
def _prune_records(self):
|
||||
"""Remove records older than the correlation window."""
|
||||
cutoff = datetime.now() - timedelta(seconds=self.correlation_window * 2)
|
||||
|
||||
self._probe_records = [
|
||||
r for r in self._probe_records
|
||||
if r.timestamp > cutoff
|
||||
]
|
||||
|
||||
self._association_records = [
|
||||
r for r in self._association_records
|
||||
if r.timestamp > cutoff
|
||||
]
|
||||
|
||||
def _check_correlations(self):
|
||||
"""Check for new SSID correlations."""
|
||||
now = datetime.now()
|
||||
window = timedelta(seconds=self.correlation_window)
|
||||
|
||||
for bssid in list(self._hidden_aps.keys()):
|
||||
# Skip if already revealed
|
||||
if bssid in self._revealed:
|
||||
continue
|
||||
|
||||
# Find associations with this hidden AP
|
||||
relevant_associations = [
|
||||
a for a in self._association_records
|
||||
if a.bssid == bssid and (now - a.timestamp) <= window
|
||||
]
|
||||
|
||||
if not relevant_associations:
|
||||
continue
|
||||
|
||||
# For each associated client, look for recent probes
|
||||
for assoc in relevant_associations:
|
||||
client_probes = [
|
||||
p for p in self._probe_records
|
||||
if p.client_mac == assoc.client_mac
|
||||
and abs((p.timestamp - assoc.timestamp).total_seconds()) <= self.correlation_window
|
||||
]
|
||||
|
||||
if not client_probes:
|
||||
continue
|
||||
|
||||
# Use the most recent probe from this client
|
||||
latest_probe = max(client_probes, key=lambda p: p.timestamp)
|
||||
|
||||
# Calculate confidence based on timing
|
||||
time_diff = abs((latest_probe.timestamp - assoc.timestamp).total_seconds())
|
||||
confidence = 1.0 - (time_diff / self.correlation_window)
|
||||
confidence = max(0.0, min(1.0, confidence))
|
||||
|
||||
if confidence >= self.min_confidence:
|
||||
result = CorrelationResult(
|
||||
bssid=bssid,
|
||||
revealed_ssid=latest_probe.probed_ssid,
|
||||
client_mac=assoc.client_mac,
|
||||
confidence=confidence,
|
||||
correlation_time=now,
|
||||
method='probe_association',
|
||||
)
|
||||
|
||||
self._revealed[bssid] = result
|
||||
|
||||
logger.info(
|
||||
f"Hidden SSID revealed: {bssid} -> '{latest_probe.probed_ssid}' "
|
||||
f"(confidence: {confidence:.2f})"
|
||||
)
|
||||
|
||||
# Callback
|
||||
if self._on_ssid_revealed:
|
||||
try:
|
||||
self._on_ssid_revealed(result)
|
||||
except Exception as e:
|
||||
logger.debug(f"SSID reveal callback error: {e}")
|
||||
|
||||
break # Found correlation, move to next AP
|
||||
|
||||
def clear(self):
|
||||
"""Clear all stored data."""
|
||||
with self._lock:
|
||||
self._probe_records.clear()
|
||||
self._association_records.clear()
|
||||
self._hidden_aps.clear()
|
||||
self._revealed.clear()
|
||||
|
||||
|
||||
def get_hidden_correlator(
|
||||
correlation_window: float = HIDDEN_CORRELATION_WINDOW_SECONDS,
|
||||
min_confidence: float = HIDDEN_MIN_CORRELATION_CONFIDENCE,
|
||||
) -> HiddenSSIDCorrelator:
|
||||
"""
|
||||
Get or create the global hidden SSID correlator instance.
|
||||
|
||||
Args:
|
||||
correlation_window: Time window for correlation.
|
||||
min_confidence: Minimum confidence threshold.
|
||||
|
||||
Returns:
|
||||
HiddenSSIDCorrelator instance.
|
||||
"""
|
||||
global _correlator_instance
|
||||
|
||||
with _correlator_lock:
|
||||
if _correlator_instance is None:
|
||||
_correlator_instance = HiddenSSIDCorrelator(
|
||||
correlation_window=correlation_window,
|
||||
min_confidence=min_confidence,
|
||||
)
|
||||
return _correlator_instance
|
||||
@@ -0,0 +1,655 @@
|
||||
"""
|
||||
WiFi 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 (
|
||||
BAND_UNKNOWN,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_UNKNOWN,
|
||||
WIDTH_UNKNOWN,
|
||||
SIGNAL_UNKNOWN,
|
||||
PROXIMITY_UNKNOWN,
|
||||
SCAN_MODE_QUICK,
|
||||
get_band_from_channel,
|
||||
get_signal_band,
|
||||
get_proximity_band,
|
||||
get_vendor_from_mac,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiObservation:
|
||||
"""Represents a single WiFi access point scan result."""
|
||||
|
||||
timestamp: datetime
|
||||
bssid: str
|
||||
essid: Optional[str] = None
|
||||
channel: Optional[int] = None
|
||||
frequency_mhz: Optional[int] = None
|
||||
rssi: Optional[int] = None
|
||||
|
||||
# Security
|
||||
security: str = SECURITY_UNKNOWN
|
||||
cipher: str = CIPHER_UNKNOWN
|
||||
auth: str = AUTH_UNKNOWN
|
||||
|
||||
# Additional info
|
||||
width: str = WIDTH_UNKNOWN
|
||||
beacon_count: int = 0
|
||||
data_count: int = 0
|
||||
|
||||
@property
|
||||
def is_hidden(self) -> bool:
|
||||
"""Check if this is a hidden network."""
|
||||
return not self.essid or self.essid.strip() == ''
|
||||
|
||||
@property
|
||||
def band(self) -> str:
|
||||
"""Get WiFi band from channel."""
|
||||
if self.channel:
|
||||
return get_band_from_channel(self.channel)
|
||||
return BAND_UNKNOWN
|
||||
|
||||
@property
|
||||
def vendor(self) -> Optional[str]:
|
||||
"""Get vendor name from BSSID."""
|
||||
return get_vendor_from_mac(self.bssid)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'bssid': self.bssid,
|
||||
'essid': self.essid,
|
||||
'is_hidden': self.is_hidden,
|
||||
'channel': self.channel,
|
||||
'frequency_mhz': self.frequency_mhz,
|
||||
'band': self.band,
|
||||
'rssi': self.rssi,
|
||||
'security': self.security,
|
||||
'cipher': self.cipher,
|
||||
'auth': self.auth,
|
||||
'width': self.width,
|
||||
'beacon_count': self.beacon_count,
|
||||
'data_count': self.data_count,
|
||||
'vendor': self.vendor,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiAccessPoint:
|
||||
"""Aggregated WiFi access point data over time."""
|
||||
|
||||
# Identity
|
||||
bssid: str
|
||||
essid: Optional[str] = None
|
||||
is_hidden: bool = False
|
||||
revealed_essid: Optional[str] = None # Revealed through correlation
|
||||
|
||||
# Radio info
|
||||
channel: Optional[int] = None
|
||||
frequency_mhz: Optional[int] = None
|
||||
band: str = BAND_UNKNOWN
|
||||
width: str = WIDTH_UNKNOWN
|
||||
|
||||
# Signal aggregation
|
||||
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_ema: Optional[float] = None
|
||||
|
||||
# Proximity/signal bands
|
||||
signal_band: str = SIGNAL_UNKNOWN
|
||||
proximity_band: str = PROXIMITY_UNKNOWN
|
||||
estimated_distance_m: Optional[float] = None
|
||||
distance_confidence: float = 0.0
|
||||
|
||||
# Security
|
||||
security: str = SECURITY_UNKNOWN
|
||||
cipher: str = CIPHER_UNKNOWN
|
||||
auth: str = AUTH_UNKNOWN
|
||||
|
||||
# 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
|
||||
|
||||
# Traffic stats
|
||||
beacon_count: int = 0
|
||||
data_count: int = 0
|
||||
client_count: int = 0
|
||||
|
||||
# Metadata
|
||||
vendor: Optional[str] = None
|
||||
|
||||
# Heuristic flags
|
||||
heuristic_flags: list[str] = field(default_factory=list)
|
||||
is_new: bool = False
|
||||
is_persistent: bool = False
|
||||
is_strong_stable: bool = False
|
||||
|
||||
# Baseline tracking
|
||||
in_baseline: bool = False
|
||||
baseline_id: Optional[int] = None
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Get display name (revealed SSID, ESSID, or BSSID)."""
|
||||
if self.revealed_essid:
|
||||
return f"{self.revealed_essid} (revealed)"
|
||||
if self.essid and not self.is_hidden:
|
||||
return self.essid
|
||||
return f"[Hidden] {self.bssid}"
|
||||
|
||||
@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()
|
||||
|
||||
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
|
||||
"""Get RSSI history for visualization."""
|
||||
if not self.rssi_samples:
|
||||
return []
|
||||
samples = self.rssi_samples[-max_points:]
|
||||
return [
|
||||
{'timestamp': ts.isoformat(), 'rssi': rssi}
|
||||
for ts, rssi in samples
|
||||
]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
# Identity
|
||||
'bssid': self.bssid,
|
||||
'essid': self.essid,
|
||||
'display_name': self.display_name,
|
||||
'is_hidden': self.is_hidden,
|
||||
'revealed_essid': self.revealed_essid,
|
||||
|
||||
# Radio
|
||||
'channel': self.channel,
|
||||
'frequency_mhz': self.frequency_mhz,
|
||||
'band': self.band,
|
||||
'width': self.width,
|
||||
|
||||
# Signal
|
||||
'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_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_history': self.get_rssi_history(),
|
||||
|
||||
# Proximity
|
||||
'signal_band': self.signal_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
'distance_confidence': round(self.distance_confidence, 2),
|
||||
|
||||
# Security
|
||||
'security': self.security,
|
||||
'cipher': self.cipher,
|
||||
'auth': self.auth,
|
||||
|
||||
# Timestamps
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': round(self.age_seconds, 1),
|
||||
'duration_seconds': round(self.duration_seconds, 1),
|
||||
'seen_count': self.seen_count,
|
||||
'seen_rate': round(self.seen_rate, 2),
|
||||
|
||||
# Traffic
|
||||
'beacon_count': self.beacon_count,
|
||||
'data_count': self.data_count,
|
||||
'client_count': self.client_count,
|
||||
|
||||
# Metadata
|
||||
'vendor': self.vendor,
|
||||
|
||||
# Heuristics
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'heuristics': {
|
||||
'is_new': self.is_new,
|
||||
'is_persistent': self.is_persistent,
|
||||
'is_strong_stable': self.is_strong_stable,
|
||||
},
|
||||
|
||||
# Baseline
|
||||
'in_baseline': self.in_baseline,
|
||||
'baseline_id': self.baseline_id,
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
"""Compact dictionary for list views."""
|
||||
return {
|
||||
'bssid': self.bssid,
|
||||
'essid': self.essid,
|
||||
'display_name': self.display_name,
|
||||
'is_hidden': self.is_hidden,
|
||||
'channel': self.channel,
|
||||
'band': self.band,
|
||||
'rssi_current': self.rssi_current,
|
||||
'rssi_median': round(self.rssi_median, 1) if self.rssi_median else None,
|
||||
'signal_band': self.signal_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'security': self.security,
|
||||
'vendor': self.vendor,
|
||||
'client_count': self.client_count,
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': round(self.age_seconds, 1),
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
'in_baseline': self.in_baseline,
|
||||
}
|
||||
|
||||
def to_legacy_dict(self) -> dict:
|
||||
"""Convert to legacy format for TSCM compatibility."""
|
||||
return {
|
||||
'bssid': self.bssid,
|
||||
'essid': self.essid or '',
|
||||
'power': str(self.rssi_current) if self.rssi_current else '-100',
|
||||
'channel': str(self.channel) if self.channel else '',
|
||||
'privacy': self.security,
|
||||
'first_seen': self.first_seen.isoformat() if self.first_seen else '',
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else '',
|
||||
'beacon_count': str(self.beacon_count),
|
||||
'lan_ip': '', # Not tracked in new system
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiClient:
|
||||
"""WiFi client (station) observed during scanning."""
|
||||
|
||||
# Identity
|
||||
mac: str
|
||||
vendor: Optional[str] = None
|
||||
|
||||
# Signal
|
||||
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_ema: Optional[float] = None
|
||||
|
||||
# Proximity
|
||||
signal_band: str = SIGNAL_UNKNOWN
|
||||
proximity_band: str = PROXIMITY_UNKNOWN
|
||||
estimated_distance_m: Optional[float] = None
|
||||
|
||||
# Association
|
||||
associated_bssid: Optional[str] = None
|
||||
is_associated: bool = False
|
||||
|
||||
# Probes
|
||||
probed_ssids: list[str] = field(default_factory=list)
|
||||
probe_timestamps: dict[str, datetime] = field(default_factory=dict)
|
||||
|
||||
# Timestamps
|
||||
first_seen: datetime = field(default_factory=datetime.now)
|
||||
last_seen: datetime = field(default_factory=datetime.now)
|
||||
seen_count: int = 0
|
||||
|
||||
# Traffic stats
|
||||
packets_sent: int = 0
|
||||
packets_received: int = 0
|
||||
|
||||
# Heuristics
|
||||
heuristic_flags: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float:
|
||||
"""Seconds since last seen."""
|
||||
return (datetime.now() - self.last_seen).total_seconds()
|
||||
|
||||
def get_rssi_history(self, max_points: int = 50) -> list[dict]:
|
||||
"""Get RSSI history for visualization."""
|
||||
if not self.rssi_samples:
|
||||
return []
|
||||
samples = self.rssi_samples[-max_points:]
|
||||
return [
|
||||
{'timestamp': ts.isoformat(), 'rssi': rssi}
|
||||
for ts, rssi in samples
|
||||
]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'mac': self.mac,
|
||||
'vendor': self.vendor,
|
||||
|
||||
# Signal
|
||||
'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_ema': round(self.rssi_ema, 1) if self.rssi_ema else None,
|
||||
'rssi_history': self.get_rssi_history(),
|
||||
|
||||
# Proximity
|
||||
'signal_band': self.signal_band,
|
||||
'proximity_band': self.proximity_band,
|
||||
'estimated_distance_m': round(self.estimated_distance_m, 2) if self.estimated_distance_m else None,
|
||||
|
||||
# Association
|
||||
'associated_bssid': self.associated_bssid,
|
||||
'is_associated': self.is_associated,
|
||||
|
||||
# Probes
|
||||
'probed_ssids': self.probed_ssids,
|
||||
'probe_count': len(self.probed_ssids),
|
||||
|
||||
# Timestamps
|
||||
'first_seen': self.first_seen.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat(),
|
||||
'age_seconds': round(self.age_seconds, 1),
|
||||
'seen_count': self.seen_count,
|
||||
|
||||
# Traffic
|
||||
'packets_sent': self.packets_sent,
|
||||
'packets_received': self.packets_received,
|
||||
|
||||
# Heuristics
|
||||
'heuristic_flags': self.heuristic_flags,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiProbeRequest:
|
||||
"""A single probe request captured during scanning."""
|
||||
|
||||
timestamp: datetime
|
||||
client_mac: str
|
||||
probed_ssid: str
|
||||
rssi: Optional[int] = None
|
||||
client_vendor: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'client_mac': self.client_mac,
|
||||
'probed_ssid': self.probed_ssid,
|
||||
'rssi': self.rssi,
|
||||
'client_vendor': self.client_vendor,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelStats:
|
||||
"""Statistics for a single WiFi channel."""
|
||||
|
||||
channel: int
|
||||
band: str = BAND_UNKNOWN
|
||||
frequency_mhz: Optional[int] = None
|
||||
|
||||
# Counts
|
||||
ap_count: int = 0
|
||||
client_count: int = 0
|
||||
|
||||
# Signal stats
|
||||
rssi_avg: Optional[float] = None
|
||||
rssi_min: Optional[int] = None
|
||||
rssi_max: Optional[int] = None
|
||||
|
||||
# Utilization score (0.0-1.0, lower is better)
|
||||
utilization_score: float = 0.0
|
||||
|
||||
# Recommendation rank (1 = best)
|
||||
recommendation_rank: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'channel': self.channel,
|
||||
'band': self.band,
|
||||
'frequency_mhz': self.frequency_mhz,
|
||||
'ap_count': self.ap_count,
|
||||
'client_count': self.client_count,
|
||||
'rssi_avg': round(self.rssi_avg, 1) if self.rssi_avg else None,
|
||||
'rssi_min': self.rssi_min,
|
||||
'rssi_max': self.rssi_max,
|
||||
'utilization_score': round(self.utilization_score, 3),
|
||||
'recommendation_rank': self.recommendation_rank,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelRecommendation:
|
||||
"""Channel recommendation with reasoning."""
|
||||
|
||||
channel: int
|
||||
band: str
|
||||
score: float # Lower is better
|
||||
reason: str
|
||||
is_dfs: bool = False
|
||||
recommendation_rank: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'channel': self.channel,
|
||||
'band': self.band,
|
||||
'score': round(self.score, 3),
|
||||
'reason': self.reason,
|
||||
'is_dfs': self.is_dfs,
|
||||
'rank': self.recommendation_rank,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiScanResult:
|
||||
"""Complete result from a WiFi scan operation."""
|
||||
|
||||
# Discovered entities
|
||||
access_points: list[WiFiAccessPoint] = field(default_factory=list)
|
||||
clients: list[WiFiClient] = field(default_factory=list)
|
||||
probe_requests: list[WiFiProbeRequest] = field(default_factory=list)
|
||||
|
||||
# Channel analysis
|
||||
channel_stats: list[ChannelStats] = field(default_factory=list)
|
||||
recommendations: list[ChannelRecommendation] = field(default_factory=list)
|
||||
|
||||
# Scan metadata
|
||||
scan_mode: str = SCAN_MODE_QUICK
|
||||
interface: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
duration_seconds: Optional[float] = None
|
||||
|
||||
# Status
|
||||
is_complete: bool = False
|
||||
error: Optional[str] = None
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def network_count(self) -> int:
|
||||
"""Total number of access points found."""
|
||||
return len(self.access_points)
|
||||
|
||||
@property
|
||||
def client_count(self) -> int:
|
||||
"""Total number of clients found."""
|
||||
return len(self.clients)
|
||||
|
||||
@property
|
||||
def hidden_count(self) -> int:
|
||||
"""Number of hidden networks."""
|
||||
return sum(1 for ap in self.access_points if ap.is_hidden)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
# Entities
|
||||
'access_points': [ap.to_dict() for ap in self.access_points],
|
||||
'clients': [c.to_dict() for c in self.clients],
|
||||
'probe_requests': [p.to_dict() for p in self.probe_requests],
|
||||
|
||||
# Channel analysis
|
||||
'channel_stats': [cs.to_dict() for cs in self.channel_stats],
|
||||
'recommendations': [r.to_dict() for r in self.recommendations],
|
||||
|
||||
# Metadata
|
||||
'scan_mode': self.scan_mode,
|
||||
'interface': self.interface,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
'duration_seconds': round(self.duration_seconds, 2) if self.duration_seconds else None,
|
||||
|
||||
# Stats
|
||||
'network_count': self.network_count,
|
||||
'client_count': self.client_count,
|
||||
'hidden_count': self.hidden_count,
|
||||
|
||||
# Status
|
||||
'is_complete': self.is_complete,
|
||||
'error': self.error,
|
||||
'warnings': self.warnings,
|
||||
}
|
||||
|
||||
def to_summary_dict(self) -> dict:
|
||||
"""Compact summary for status endpoints."""
|
||||
return {
|
||||
'scan_mode': self.scan_mode,
|
||||
'interface': self.interface,
|
||||
'network_count': self.network_count,
|
||||
'client_count': self.client_count,
|
||||
'hidden_count': self.hidden_count,
|
||||
'is_complete': self.is_complete,
|
||||
'error': self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiScanStatus:
|
||||
"""Current WiFi scanning status."""
|
||||
|
||||
is_scanning: bool = False
|
||||
scan_mode: str = SCAN_MODE_QUICK
|
||||
interface: Optional[str] = None
|
||||
started_at: Optional[datetime] = None
|
||||
networks_found: int = 0
|
||||
clients_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
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
'is_scanning': self.is_scanning,
|
||||
'scan_mode': self.scan_mode,
|
||||
'interface': self.interface,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'elapsed_seconds': round(self.elapsed_seconds, 1) if self.elapsed_seconds else None,
|
||||
'networks_found': self.networks_found,
|
||||
'clients_found': self.clients_found,
|
||||
'error': self.error,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WiFiCapabilities:
|
||||
"""WiFi system capabilities check result."""
|
||||
|
||||
# Platform
|
||||
platform: str = 'unknown' # 'linux', 'darwin', 'windows'
|
||||
is_root: bool = False
|
||||
|
||||
# Interfaces
|
||||
interfaces: list[dict] = field(default_factory=list)
|
||||
default_interface: Optional[str] = None
|
||||
|
||||
# Quick scan tools
|
||||
has_nmcli: bool = False
|
||||
has_iw: bool = False
|
||||
has_iwlist: bool = False
|
||||
has_airport: bool = False
|
||||
preferred_quick_tool: Optional[str] = None
|
||||
|
||||
# Deep scan tools
|
||||
has_airmon_ng: bool = False
|
||||
has_airodump_ng: bool = False
|
||||
has_monitor_capable_interface: bool = False
|
||||
monitor_interface: Optional[str] = None
|
||||
|
||||
# Issues
|
||||
issues: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def can_quick_scan(self) -> bool:
|
||||
"""Whether quick scanning is available."""
|
||||
return (
|
||||
self.has_nmcli or
|
||||
self.has_iw or
|
||||
self.has_iwlist or
|
||||
self.has_airport
|
||||
) and len(self.interfaces) > 0
|
||||
|
||||
@property
|
||||
def can_deep_scan(self) -> bool:
|
||||
"""Whether deep scanning is available."""
|
||||
return (
|
||||
self.has_airmon_ng and
|
||||
self.has_airodump_ng and
|
||||
self.has_monitor_capable_interface and
|
||||
self.is_root
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
# Status
|
||||
'available': self.can_quick_scan,
|
||||
'can_quick_scan': self.can_quick_scan,
|
||||
'can_deep_scan': self.can_deep_scan,
|
||||
|
||||
# Platform
|
||||
'platform': self.platform,
|
||||
'is_root': self.is_root,
|
||||
|
||||
# Interfaces
|
||||
'interfaces': self.interfaces,
|
||||
'default_interface': self.default_interface,
|
||||
|
||||
# Quick scan tools
|
||||
'tools': {
|
||||
'nmcli': self.has_nmcli,
|
||||
'iw': self.has_iw,
|
||||
'iwlist': self.has_iwlist,
|
||||
'airport': self.has_airport,
|
||||
'airmon_ng': self.has_airmon_ng,
|
||||
'airodump_ng': self.has_airodump_ng,
|
||||
},
|
||||
'preferred_quick_tool': self.preferred_quick_tool,
|
||||
|
||||
# Deep scan
|
||||
'has_monitor_capable_interface': self.has_monitor_capable_interface,
|
||||
'monitor_interface': self.monitor_interface,
|
||||
|
||||
# Issues
|
||||
'issues': self.issues,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
WiFi scan output parsers.
|
||||
|
||||
Each parser converts tool-specific output into WiFiObservation objects.
|
||||
"""
|
||||
|
||||
from .airport import parse_airport_scan
|
||||
from .nmcli import parse_nmcli_scan
|
||||
from .iw import parse_iw_scan
|
||||
from .iwlist import parse_iwlist_scan
|
||||
from .airodump import parse_airodump_csv
|
||||
|
||||
__all__ = [
|
||||
'parse_airport_scan',
|
||||
'parse_nmcli_scan',
|
||||
'parse_iw_scan',
|
||||
'parse_iwlist_scan',
|
||||
'parse_airodump_csv',
|
||||
]
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Parser for airodump-ng CSV output.
|
||||
|
||||
airodump-ng outputs two sections in its CSV:
|
||||
1. Access Points section
|
||||
2. Clients section (stations)
|
||||
|
||||
Example format:
|
||||
BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key
|
||||
|
||||
00:11:22:33:44:55, 2024-01-01 10:00:00, 2024-01-01 10:05:00, 6, 54, WPA2, CCMP, PSK, -65, 100, 10, 0.0.0.0, 6, MyWiFi,
|
||||
|
||||
Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probed ESSIDs
|
||||
|
||||
AA:BB:CC:DD:EE:FF, 2024-01-01 10:00:00, 2024-01-01 10:05:00, -70, 50, 00:11:22:33:44:55, NetworkA, NetworkB
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models import WiFiObservation
|
||||
from ..constants import (
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA3,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_WEP,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_PSK,
|
||||
AUTH_SAE,
|
||||
AUTH_EAP,
|
||||
AUTH_OWE,
|
||||
AUTH_OPEN,
|
||||
AUTH_UNKNOWN,
|
||||
CHANNEL_FREQUENCIES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_airodump_csv(filepath: str) -> tuple[list[WiFiObservation], list[dict]]:
|
||||
"""
|
||||
Parse airodump-ng CSV file.
|
||||
|
||||
Args:
|
||||
filepath: Path to the airodump CSV file.
|
||||
|
||||
Returns:
|
||||
Tuple of (network observations, client data dicts).
|
||||
"""
|
||||
networks = []
|
||||
clients = []
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
|
||||
# airodump-ng separates sections with blank lines
|
||||
# Split into AP section and Station section
|
||||
sections = content.split('\n\n')
|
||||
|
||||
for section in sections:
|
||||
section = section.strip()
|
||||
if not section:
|
||||
continue
|
||||
|
||||
lines = section.split('\n')
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
header = lines[0].strip()
|
||||
|
||||
if header.startswith('BSSID'):
|
||||
# Access Points section
|
||||
networks = _parse_ap_section(lines)
|
||||
elif header.startswith('Station MAC'):
|
||||
# Clients/Stations section
|
||||
clients = _parse_client_section(lines)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug(f"airodump CSV not found: {filepath}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing airodump CSV: {e}")
|
||||
|
||||
return networks, clients
|
||||
|
||||
|
||||
def _parse_ap_section(lines: list[str]) -> list[WiFiObservation]:
|
||||
"""Parse the access points section of airodump CSV."""
|
||||
networks = []
|
||||
|
||||
if len(lines) < 2:
|
||||
return networks
|
||||
|
||||
# Parse header to get column indices
|
||||
header = lines[0]
|
||||
header_parts = [h.strip().lower() for h in header.split(',')]
|
||||
|
||||
# Find column indices
|
||||
col_map = {}
|
||||
for i, col in enumerate(header_parts):
|
||||
if 'bssid' in col:
|
||||
col_map['bssid'] = i
|
||||
elif 'channel' in col and 'id-length' not in col:
|
||||
col_map['channel'] = i
|
||||
elif 'privacy' in col:
|
||||
col_map['privacy'] = i
|
||||
elif 'cipher' in col:
|
||||
col_map['cipher'] = i
|
||||
elif 'authentication' in col:
|
||||
col_map['auth'] = i
|
||||
elif 'power' in col:
|
||||
col_map['power'] = i
|
||||
elif 'beacons' in col or '# beacons' in col:
|
||||
col_map['beacons'] = i
|
||||
elif '# iv' in col or 'iv' in col:
|
||||
col_map['data'] = i
|
||||
elif 'essid' in col:
|
||||
col_map['essid'] = i
|
||||
elif 'first time seen' in col:
|
||||
col_map['first_seen'] = i
|
||||
elif 'last time seen' in col:
|
||||
col_map['last_seen'] = i
|
||||
|
||||
# Parse data rows
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Handle CSV properly (ESSID might contain commas)
|
||||
try:
|
||||
# Use CSV reader for proper parsing
|
||||
reader = csv.reader(io.StringIO(line))
|
||||
parts = next(reader)
|
||||
except Exception:
|
||||
parts = line.split(',')
|
||||
|
||||
parts = [p.strip() for p in parts]
|
||||
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get BSSID
|
||||
bssid_idx = col_map.get('bssid', 0)
|
||||
bssid = parts[bssid_idx].upper() if bssid_idx < len(parts) else None
|
||||
if not bssid or not re.match(r'^[0-9A-F:]{17}$', bssid):
|
||||
continue
|
||||
|
||||
# Get channel
|
||||
channel = None
|
||||
chan_idx = col_map.get('channel', 3)
|
||||
if chan_idx < len(parts):
|
||||
chan_str = parts[chan_idx].strip()
|
||||
if chan_str.lstrip('-').isdigit():
|
||||
channel = int(chan_str)
|
||||
if channel < 0:
|
||||
channel = abs(channel) # Negative indicates not currently on channel
|
||||
|
||||
# Get power/RSSI
|
||||
rssi = None
|
||||
power_idx = col_map.get('power', 8)
|
||||
if power_idx < len(parts):
|
||||
power_str = parts[power_idx].strip()
|
||||
if power_str.lstrip('-').isdigit():
|
||||
rssi = int(power_str)
|
||||
if rssi > 0:
|
||||
rssi = -rssi # Should be negative
|
||||
|
||||
# Get security
|
||||
privacy_idx = col_map.get('privacy', 5)
|
||||
privacy = parts[privacy_idx].strip() if privacy_idx < len(parts) else ''
|
||||
security = _parse_airodump_security(privacy)
|
||||
|
||||
# Get cipher
|
||||
cipher_idx = col_map.get('cipher', 6)
|
||||
cipher_str = parts[cipher_idx].strip() if cipher_idx < len(parts) else ''
|
||||
cipher = _parse_airodump_cipher(cipher_str)
|
||||
|
||||
# Get auth
|
||||
auth_idx = col_map.get('auth', 7)
|
||||
auth_str = parts[auth_idx].strip() if auth_idx < len(parts) else ''
|
||||
auth = _parse_airodump_auth(auth_str)
|
||||
|
||||
# Get ESSID (usually last column, might contain commas)
|
||||
essid = None
|
||||
essid_idx = col_map.get('essid', len(parts) - 1)
|
||||
if essid_idx < len(parts):
|
||||
essid = parts[essid_idx].strip()
|
||||
# Handle special markers
|
||||
if essid in ('', '<length: 0>', '<length: 0>'):
|
||||
essid = None
|
||||
|
||||
# Get beacon count
|
||||
beacon_count = 0
|
||||
beacon_idx = col_map.get('beacons', 9)
|
||||
if beacon_idx < len(parts):
|
||||
beacon_str = parts[beacon_idx].strip()
|
||||
if beacon_str.isdigit():
|
||||
beacon_count = int(beacon_str)
|
||||
|
||||
# Get data count (IVs)
|
||||
data_count = 0
|
||||
data_idx = col_map.get('data', 10)
|
||||
if data_idx < len(parts):
|
||||
data_str = parts[data_idx].strip()
|
||||
if data_str.isdigit():
|
||||
data_count = int(data_str)
|
||||
|
||||
# Get frequency from channel
|
||||
frequency_mhz = CHANNEL_FREQUENCIES.get(channel) if channel else None
|
||||
|
||||
obs = WiFiObservation(
|
||||
timestamp=datetime.now(),
|
||||
bssid=bssid,
|
||||
essid=essid,
|
||||
channel=channel,
|
||||
frequency_mhz=frequency_mhz,
|
||||
rssi=rssi,
|
||||
security=security,
|
||||
cipher=cipher,
|
||||
auth=auth,
|
||||
beacon_count=beacon_count,
|
||||
data_count=data_count,
|
||||
)
|
||||
networks.append(obs)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing AP line: {line!r} - {e}")
|
||||
|
||||
return networks
|
||||
|
||||
|
||||
def _parse_client_section(lines: list[str]) -> list[dict]:
|
||||
"""Parse the clients/stations section of airodump CSV."""
|
||||
clients = []
|
||||
|
||||
if len(lines) < 2:
|
||||
return clients
|
||||
|
||||
# Parse header
|
||||
header = lines[0]
|
||||
header_parts = [h.strip().lower() for h in header.split(',')]
|
||||
|
||||
# Find column indices
|
||||
col_map = {}
|
||||
for i, col in enumerate(header_parts):
|
||||
if 'station mac' in col:
|
||||
col_map['mac'] = i
|
||||
elif 'power' in col:
|
||||
col_map['power'] = i
|
||||
elif 'packets' in col or '# packets' in col:
|
||||
col_map['packets'] = i
|
||||
elif 'bssid' in col:
|
||||
col_map['bssid'] = i
|
||||
elif 'probed' in col:
|
||||
col_map['probed'] = i
|
||||
elif 'first time seen' in col:
|
||||
col_map['first_seen'] = i
|
||||
elif 'last time seen' in col:
|
||||
col_map['last_seen'] = i
|
||||
|
||||
# Parse data rows
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(',')
|
||||
parts = [p.strip() for p in parts]
|
||||
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get MAC
|
||||
mac_idx = col_map.get('mac', 0)
|
||||
mac = parts[mac_idx].upper() if mac_idx < len(parts) else None
|
||||
if not mac or not re.match(r'^[0-9A-F:]{17}$', mac):
|
||||
continue
|
||||
|
||||
# Get power/RSSI
|
||||
rssi = None
|
||||
power_idx = col_map.get('power', 3)
|
||||
if power_idx < len(parts):
|
||||
power_str = parts[power_idx].strip()
|
||||
if power_str.lstrip('-').isdigit():
|
||||
rssi = int(power_str)
|
||||
if rssi > 0:
|
||||
rssi = -rssi
|
||||
|
||||
# Get packets
|
||||
packets = 0
|
||||
packets_idx = col_map.get('packets', 4)
|
||||
if packets_idx < len(parts):
|
||||
packets_str = parts[packets_idx].strip()
|
||||
if packets_str.isdigit():
|
||||
packets = int(packets_str)
|
||||
|
||||
# Get associated BSSID
|
||||
bssid = None
|
||||
bssid_idx = col_map.get('bssid', 5)
|
||||
if bssid_idx < len(parts):
|
||||
bssid = parts[bssid_idx].strip().upper()
|
||||
if bssid == '(NOT ASSOCIATED)' or not re.match(r'^[0-9A-F:]{17}$', bssid):
|
||||
bssid = None
|
||||
|
||||
# Get probed ESSIDs (remaining columns)
|
||||
probed_idx = col_map.get('probed', 6)
|
||||
probed_essids = []
|
||||
if probed_idx < len(parts):
|
||||
for essid in parts[probed_idx:]:
|
||||
essid = essid.strip()
|
||||
if essid and essid not in probed_essids:
|
||||
probed_essids.append(essid)
|
||||
|
||||
clients.append({
|
||||
'mac': mac,
|
||||
'rssi': rssi,
|
||||
'packets': packets,
|
||||
'bssid': bssid,
|
||||
'probed_essids': probed_essids,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing client line: {line!r} - {e}")
|
||||
|
||||
return clients
|
||||
|
||||
|
||||
def _parse_airodump_security(privacy: str) -> str:
|
||||
"""Parse airodump privacy field to security type."""
|
||||
privacy = privacy.upper()
|
||||
|
||||
if not privacy or privacy in ('', 'OPN', 'OPEN'):
|
||||
return SECURITY_OPEN
|
||||
elif 'WPA3' in privacy:
|
||||
return SECURITY_WPA3
|
||||
elif 'WPA2' in privacy and 'WPA' in privacy:
|
||||
return SECURITY_WPA_WPA2
|
||||
elif 'WPA2' in privacy:
|
||||
return SECURITY_WPA2
|
||||
elif 'WPA' in privacy:
|
||||
return SECURITY_WPA
|
||||
elif 'WEP' in privacy:
|
||||
return SECURITY_WEP
|
||||
|
||||
return SECURITY_UNKNOWN
|
||||
|
||||
|
||||
def _parse_airodump_cipher(cipher: str) -> str:
|
||||
"""Parse airodump cipher field."""
|
||||
cipher = cipher.upper()
|
||||
|
||||
if 'CCMP' in cipher:
|
||||
return CIPHER_CCMP
|
||||
elif 'TKIP' in cipher:
|
||||
return CIPHER_TKIP
|
||||
elif 'WEP' in cipher:
|
||||
return CIPHER_WEP
|
||||
|
||||
return CIPHER_UNKNOWN
|
||||
|
||||
|
||||
def _parse_airodump_auth(auth: str) -> str:
|
||||
"""Parse airodump authentication field."""
|
||||
auth = auth.upper()
|
||||
|
||||
if 'SAE' in auth:
|
||||
return AUTH_SAE
|
||||
elif 'PSK' in auth:
|
||||
return AUTH_PSK
|
||||
elif 'MGT' in auth or 'EAP' in auth or '802.1X' in auth:
|
||||
return AUTH_EAP
|
||||
elif 'OWE' in auth:
|
||||
return AUTH_OWE
|
||||
elif 'OPN' in auth or 'OPEN' in auth:
|
||||
return AUTH_OPEN
|
||||
|
||||
return AUTH_UNKNOWN
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
Parser for macOS airport utility output.
|
||||
|
||||
Example output from 'airport -s':
|
||||
SSID BSSID RSSI CHANNEL HT CC SECURITY
|
||||
MyWiFi 00:11:22:33:44:55 -65 6 Y US WPA2(PSK/AES/AES)
|
||||
Hidden -- 00:11:22:33:44:66 -70 11 Y US WPA2(PSK/AES/AES)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models import WiFiObservation
|
||||
from ..constants import (
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA3,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_WPA2_WPA3,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_WEP,
|
||||
CIPHER_NONE,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_PSK,
|
||||
AUTH_SAE,
|
||||
AUTH_EAP,
|
||||
AUTH_OPEN,
|
||||
AUTH_UNKNOWN,
|
||||
WIDTH_20_MHZ,
|
||||
WIDTH_40_MHZ,
|
||||
get_band_from_channel,
|
||||
CHANNEL_FREQUENCIES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_airport_scan(output: str) -> list[WiFiObservation]:
|
||||
"""
|
||||
Parse macOS airport scan output.
|
||||
|
||||
Args:
|
||||
output: Raw output from 'airport -s' command.
|
||||
|
||||
Returns:
|
||||
List of WiFiObservation objects.
|
||||
"""
|
||||
observations = []
|
||||
lines = output.strip().split('\n')
|
||||
|
||||
if len(lines) < 2:
|
||||
return observations
|
||||
|
||||
# Skip header line
|
||||
for line in lines[1:]:
|
||||
obs = _parse_airport_line(line)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
|
||||
return observations
|
||||
|
||||
|
||||
def _parse_airport_line(line: str) -> Optional[WiFiObservation]:
|
||||
"""Parse a single line of airport output."""
|
||||
# airport output is space-aligned, need careful parsing
|
||||
# Format: SSID (variable width) BSSID RSSI CHANNEL HT CC SECURITY
|
||||
#
|
||||
# The tricky part is SSID can contain spaces and the columns are
|
||||
# aligned by whitespace. We parse from the right side.
|
||||
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Split into parts, but we need to handle SSID which may have spaces
|
||||
# BSSID is always 17 chars (xx:xx:xx:xx:xx:xx)
|
||||
# Find BSSID using regex
|
||||
bssid_match = re.search(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', line)
|
||||
if not bssid_match:
|
||||
return None
|
||||
|
||||
bssid = bssid_match.group(0).upper()
|
||||
bssid_pos = bssid_match.start()
|
||||
|
||||
# SSID is everything before BSSID (stripped)
|
||||
ssid = line[:bssid_pos].strip()
|
||||
|
||||
# Handle hidden network indicator
|
||||
is_hidden = False
|
||||
if ssid == '--' or not ssid:
|
||||
ssid = None
|
||||
is_hidden = True
|
||||
|
||||
# Parse remainder after BSSID
|
||||
remainder = line[bssid_match.end():].strip()
|
||||
parts = remainder.split()
|
||||
|
||||
if len(parts) < 4:
|
||||
# Minimal: RSSI CHANNEL HT SECURITY
|
||||
return None
|
||||
|
||||
# Parse RSSI (negative number)
|
||||
rssi_str = parts[0]
|
||||
rssi = int(rssi_str) if rssi_str.lstrip('-').isdigit() else None
|
||||
|
||||
# Parse channel - might include +1 or -1 for 40MHz
|
||||
channel_str = parts[1]
|
||||
channel_match = re.match(r'(\d+)', channel_str)
|
||||
channel = int(channel_match.group(1)) if channel_match else None
|
||||
|
||||
# Determine width from channel string
|
||||
width = WIDTH_20_MHZ
|
||||
if '+' in channel_str or '-' in channel_str:
|
||||
width = WIDTH_40_MHZ
|
||||
|
||||
# HT flag (Y/N) at parts[2]
|
||||
# CC (country code) at parts[3]
|
||||
|
||||
# Security is the rest (might have multiple parts like WPA2(PSK/AES/AES))
|
||||
security_str = ' '.join(parts[4:]) if len(parts) > 4 else ''
|
||||
security, cipher, auth = _parse_airport_security(security_str)
|
||||
|
||||
# Get frequency
|
||||
frequency_mhz = CHANNEL_FREQUENCIES.get(channel) if channel else None
|
||||
|
||||
return WiFiObservation(
|
||||
timestamp=datetime.now(),
|
||||
bssid=bssid,
|
||||
essid=ssid,
|
||||
channel=channel,
|
||||
frequency_mhz=frequency_mhz,
|
||||
rssi=rssi,
|
||||
security=security,
|
||||
cipher=cipher,
|
||||
auth=auth,
|
||||
width=width,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse airport line: {line!r} - {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_airport_security(security_str: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
Parse airport security string.
|
||||
|
||||
Examples:
|
||||
'WPA2(PSK/AES/AES)' -> (WPA2, CCMP, PSK)
|
||||
'WPA(PSK/TKIP/TKIP)' -> (WPA, TKIP, PSK)
|
||||
'WPA2(PSK,FT-PSK/AES/AES)' -> (WPA2, CCMP, PSK)
|
||||
'RSN(PSK/AES,TKIP/TKIP)' -> (WPA2, CCMP, PSK)
|
||||
'WEP' -> (WEP, WEP, OPEN)
|
||||
'NONE' or '' -> (Open, None, Open)
|
||||
"""
|
||||
if not security_str or security_str.upper() == 'NONE':
|
||||
return SECURITY_OPEN, CIPHER_NONE, AUTH_OPEN
|
||||
|
||||
security_upper = security_str.upper()
|
||||
|
||||
# Determine security type
|
||||
security = SECURITY_UNKNOWN
|
||||
if 'WPA3' in security_upper or 'SAE' in security_upper:
|
||||
security = SECURITY_WPA3
|
||||
elif 'RSN' in security_upper or 'WPA2' in security_upper:
|
||||
security = SECURITY_WPA2
|
||||
elif 'WPA' in security_upper:
|
||||
security = SECURITY_WPA
|
||||
elif 'WEP' in security_upper:
|
||||
security = SECURITY_WEP
|
||||
|
||||
# Handle mixed mode
|
||||
if 'WPA2' in security_upper and 'WPA3' in security_upper:
|
||||
security = SECURITY_WPA2_WPA3
|
||||
elif 'WPA' in security_upper and 'WPA2' in security_upper:
|
||||
security = SECURITY_WPA_WPA2
|
||||
|
||||
# Determine cipher
|
||||
cipher = CIPHER_UNKNOWN
|
||||
if 'AES' in security_upper or 'CCMP' in security_upper:
|
||||
cipher = CIPHER_CCMP
|
||||
elif 'TKIP' in security_upper:
|
||||
cipher = CIPHER_TKIP
|
||||
elif 'WEP' in security_upper:
|
||||
cipher = CIPHER_WEP
|
||||
|
||||
# Determine auth
|
||||
auth = AUTH_UNKNOWN
|
||||
if 'SAE' in security_upper:
|
||||
auth = AUTH_SAE
|
||||
elif 'PSK' in security_upper:
|
||||
auth = AUTH_PSK
|
||||
elif 'EAP' in security_upper or '802.1X' in security_upper:
|
||||
auth = AUTH_EAP
|
||||
elif security == SECURITY_OPEN:
|
||||
auth = AUTH_OPEN
|
||||
|
||||
return security, cipher, auth
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
Parser for Linux iw scan output.
|
||||
|
||||
Example output from 'iw dev wlan0 scan':
|
||||
BSS 00:11:22:33:44:55(on wlan0)
|
||||
TSF: 12345678901234 usec (0d, 03:25:45)
|
||||
freq: 2437
|
||||
beacon interval: 100 TUs
|
||||
capability: ESS Privacy ShortSlotTime (0x0411)
|
||||
signal: -65.00 dBm
|
||||
last seen: 100 ms ago
|
||||
SSID: MyWiFi
|
||||
Supported rates: 1.0* 2.0* 5.5* 11.0* 6.0 9.0 12.0 18.0
|
||||
DS Parameter set: channel 6
|
||||
RSN: * Version: 1
|
||||
* Group cipher: CCMP
|
||||
* Pairwise ciphers: CCMP
|
||||
* Authentication suites: PSK
|
||||
* Capabilities: 16-PTKSA-RC 1-GTKSA-RC (0x000c)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models import WiFiObservation
|
||||
from ..constants import (
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA3,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_WPA2_WPA3,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_GCMP,
|
||||
CIPHER_WEP,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_PSK,
|
||||
AUTH_SAE,
|
||||
AUTH_EAP,
|
||||
AUTH_OWE,
|
||||
AUTH_OPEN,
|
||||
AUTH_UNKNOWN,
|
||||
WIDTH_20_MHZ,
|
||||
WIDTH_40_MHZ,
|
||||
WIDTH_80_MHZ,
|
||||
WIDTH_160_MHZ,
|
||||
get_channel_from_frequency,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_iw_scan(output: str) -> list[WiFiObservation]:
|
||||
"""
|
||||
Parse iw scan output.
|
||||
|
||||
Args:
|
||||
output: Raw output from 'iw dev <interface> scan'.
|
||||
|
||||
Returns:
|
||||
List of WiFiObservation objects.
|
||||
"""
|
||||
observations = []
|
||||
current_block = []
|
||||
|
||||
for line in output.split('\n'):
|
||||
if line.startswith('BSS '):
|
||||
# Start of new BSS entry
|
||||
if current_block:
|
||||
obs = _parse_iw_block(current_block)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
current_block = [line]
|
||||
elif current_block:
|
||||
current_block.append(line)
|
||||
|
||||
# Parse last block
|
||||
if current_block:
|
||||
obs = _parse_iw_block(current_block)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
|
||||
return observations
|
||||
|
||||
|
||||
def _parse_iw_block(lines: list[str]) -> Optional[WiFiObservation]:
|
||||
"""Parse a single BSS block from iw output."""
|
||||
try:
|
||||
# First line: BSS 00:11:22:33:44:55(on wlan0) -- associated
|
||||
first_line = lines[0]
|
||||
bssid_match = re.match(r'BSS ([0-9a-fA-F:]{17})', first_line)
|
||||
if not bssid_match:
|
||||
return None
|
||||
|
||||
bssid = bssid_match.group(1).upper()
|
||||
|
||||
# Parse remaining fields
|
||||
ssid = None
|
||||
frequency_mhz = None
|
||||
channel = None
|
||||
rssi = None
|
||||
width = WIDTH_20_MHZ
|
||||
has_privacy = False
|
||||
has_rsn = False
|
||||
has_wpa = False
|
||||
cipher = CIPHER_UNKNOWN
|
||||
auth = AUTH_UNKNOWN
|
||||
|
||||
i = 1
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
|
||||
if line.startswith('freq:'):
|
||||
freq_match = re.search(r'freq:\s*(\d+)', line)
|
||||
if freq_match:
|
||||
frequency_mhz = int(freq_match.group(1))
|
||||
channel = get_channel_from_frequency(frequency_mhz)
|
||||
|
||||
elif line.startswith('signal:'):
|
||||
signal_match = re.search(r'signal:\s*(-?\d+\.?\d*)', line)
|
||||
if signal_match:
|
||||
rssi = int(float(signal_match.group(1)))
|
||||
|
||||
elif line.startswith('SSID:'):
|
||||
ssid_match = re.match(r'SSID:\s*(.*)', line)
|
||||
if ssid_match:
|
||||
ssid = ssid_match.group(1).strip()
|
||||
if not ssid or ssid == '\\x00' * len(ssid):
|
||||
ssid = None
|
||||
|
||||
elif line.startswith('DS Parameter set:'):
|
||||
chan_match = re.search(r'channel\s*(\d+)', line)
|
||||
if chan_match:
|
||||
channel = int(chan_match.group(1))
|
||||
|
||||
elif line.startswith('capability:'):
|
||||
if 'Privacy' in line:
|
||||
has_privacy = True
|
||||
|
||||
elif line.startswith('RSN:') or line.startswith('WPA:'):
|
||||
is_rsn = line.startswith('RSN:')
|
||||
if is_rsn:
|
||||
has_rsn = True
|
||||
else:
|
||||
has_wpa = True
|
||||
|
||||
# Parse the RSN/WPA block
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].startswith('\t\t'):
|
||||
subline = lines[i].strip()
|
||||
|
||||
if 'Group cipher:' in subline or 'Pairwise ciphers:' in subline:
|
||||
if 'CCMP' in subline:
|
||||
cipher = CIPHER_CCMP
|
||||
elif 'TKIP' in subline:
|
||||
cipher = CIPHER_TKIP
|
||||
elif 'GCMP' in subline:
|
||||
cipher = CIPHER_GCMP
|
||||
|
||||
elif 'Authentication suites:' in subline:
|
||||
if 'SAE' in subline:
|
||||
auth = AUTH_SAE
|
||||
elif 'PSK' in subline:
|
||||
auth = AUTH_PSK
|
||||
elif 'IEEE 802.1X' in subline or 'EAP' in subline:
|
||||
auth = AUTH_EAP
|
||||
elif 'OWE' in subline:
|
||||
auth = AUTH_OWE
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
elif 'HT operation:' in line or 'VHT operation:' in line or 'HE operation:' in line:
|
||||
# Parse width from subsequent lines
|
||||
i += 1
|
||||
while i < len(lines) and lines[i].startswith('\t\t'):
|
||||
subline = lines[i].strip()
|
||||
if 'channel width:' in subline.lower():
|
||||
if '160' in subline:
|
||||
width = WIDTH_160_MHZ
|
||||
elif '80' in subline:
|
||||
width = WIDTH_80_MHZ
|
||||
elif '40' in subline:
|
||||
width = WIDTH_40_MHZ
|
||||
i += 1
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
# Determine security type
|
||||
security = SECURITY_OPEN
|
||||
if has_rsn and has_wpa:
|
||||
security = SECURITY_WPA_WPA2
|
||||
elif has_rsn:
|
||||
if auth == AUTH_SAE:
|
||||
security = SECURITY_WPA3
|
||||
else:
|
||||
security = SECURITY_WPA2
|
||||
elif has_wpa:
|
||||
security = SECURITY_WPA
|
||||
elif has_privacy:
|
||||
security = SECURITY_WEP
|
||||
cipher = CIPHER_WEP
|
||||
|
||||
if auth == AUTH_UNKNOWN:
|
||||
if security == SECURITY_OPEN:
|
||||
auth = AUTH_OPEN
|
||||
elif security in (SECURITY_WPA, SECURITY_WPA2, SECURITY_WPA_WPA2):
|
||||
auth = AUTH_PSK
|
||||
|
||||
return WiFiObservation(
|
||||
timestamp=datetime.now(),
|
||||
bssid=bssid,
|
||||
essid=ssid,
|
||||
channel=channel,
|
||||
frequency_mhz=frequency_mhz,
|
||||
rssi=rssi,
|
||||
security=security,
|
||||
cipher=cipher,
|
||||
auth=auth,
|
||||
width=width,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse iw block: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Parser for Linux iwlist scan output.
|
||||
|
||||
Example output from 'iwlist wlan0 scan':
|
||||
wlan0 Scan completed :
|
||||
Cell 01 - Address: 00:11:22:33:44:55
|
||||
Channel:6
|
||||
Frequency:2.437 GHz (Channel 6)
|
||||
Quality=70/70 Signal level=-40 dBm
|
||||
Encryption key:on
|
||||
ESSID:"MyWiFi"
|
||||
Bit Rates:54 Mb/s
|
||||
Mode:Master
|
||||
Extra:tsf=0000000000000000
|
||||
Extra: Last beacon: 100ms ago
|
||||
IE: Unknown: 000A4D79576946695F4E6574
|
||||
IE: IEEE 802.11i/WPA2 Version 1
|
||||
Group Cipher : CCMP
|
||||
Pairwise Ciphers (1) : CCMP
|
||||
Authentication Suites (1) : PSK
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models import WiFiObservation
|
||||
from ..constants import (
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_WEP,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_PSK,
|
||||
AUTH_EAP,
|
||||
AUTH_OPEN,
|
||||
AUTH_UNKNOWN,
|
||||
get_channel_from_frequency,
|
||||
CHANNEL_FREQUENCIES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_iwlist_scan(output: str) -> list[WiFiObservation]:
|
||||
"""
|
||||
Parse iwlist scan output.
|
||||
|
||||
Args:
|
||||
output: Raw output from 'iwlist <interface> scan'.
|
||||
|
||||
Returns:
|
||||
List of WiFiObservation objects.
|
||||
"""
|
||||
observations = []
|
||||
current_block = []
|
||||
|
||||
for line in output.split('\n'):
|
||||
# New cell starts with "Cell XX - Address:"
|
||||
if re.match(r'\s*Cell \d+ - Address:', line):
|
||||
if current_block:
|
||||
obs = _parse_iwlist_block(current_block)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
current_block = [line]
|
||||
elif current_block:
|
||||
current_block.append(line)
|
||||
|
||||
# Parse last block
|
||||
if current_block:
|
||||
obs = _parse_iwlist_block(current_block)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
|
||||
return observations
|
||||
|
||||
|
||||
def _parse_iwlist_block(lines: list[str]) -> Optional[WiFiObservation]:
|
||||
"""Parse a single Cell block from iwlist output."""
|
||||
try:
|
||||
# Extract BSSID from first line
|
||||
first_line = lines[0]
|
||||
bssid_match = re.search(r'Address:\s*([0-9A-Fa-f:]{17})', first_line)
|
||||
if not bssid_match:
|
||||
return None
|
||||
|
||||
bssid = bssid_match.group(1).upper()
|
||||
|
||||
# Parse remaining fields
|
||||
ssid = None
|
||||
frequency_mhz = None
|
||||
channel = None
|
||||
rssi = None
|
||||
has_encryption = False
|
||||
has_wpa = False
|
||||
has_wpa2 = False
|
||||
cipher = CIPHER_UNKNOWN
|
||||
auth = AUTH_UNKNOWN
|
||||
|
||||
for line in lines[1:]:
|
||||
line = line.strip()
|
||||
|
||||
# Channel
|
||||
if line.startswith('Channel:'):
|
||||
chan_match = re.search(r'Channel:(\d+)', line)
|
||||
if chan_match:
|
||||
channel = int(chan_match.group(1))
|
||||
|
||||
# Frequency
|
||||
elif line.startswith('Frequency:'):
|
||||
# Format: "Frequency:2.437 GHz (Channel 6)"
|
||||
freq_match = re.search(r'Frequency:(\d+\.?\d*)\s*GHz', line)
|
||||
if freq_match:
|
||||
frequency_ghz = float(freq_match.group(1))
|
||||
frequency_mhz = int(frequency_ghz * 1000)
|
||||
|
||||
# Also try to get channel from this line
|
||||
chan_match = re.search(r'\(Channel (\d+)\)', line)
|
||||
if chan_match and not channel:
|
||||
channel = int(chan_match.group(1))
|
||||
|
||||
# Signal level
|
||||
elif 'Signal level' in line:
|
||||
# Format: "Quality=70/70 Signal level=-40 dBm"
|
||||
signal_match = re.search(r'Signal level[=:]?\s*(-?\d+)', line)
|
||||
if signal_match:
|
||||
rssi = int(signal_match.group(1))
|
||||
|
||||
# ESSID
|
||||
elif line.startswith('ESSID:'):
|
||||
ssid_match = re.search(r'ESSID:"([^"]*)"', line)
|
||||
if ssid_match:
|
||||
ssid = ssid_match.group(1)
|
||||
if not ssid:
|
||||
ssid = None
|
||||
|
||||
# Encryption
|
||||
elif line.startswith('Encryption key:'):
|
||||
has_encryption = 'on' in line.lower()
|
||||
|
||||
# WPA/WPA2 IE
|
||||
elif 'WPA2' in line or 'IEEE 802.11i' in line:
|
||||
has_wpa2 = True
|
||||
elif 'WPA Version' in line:
|
||||
has_wpa = True
|
||||
|
||||
# Cipher
|
||||
elif 'Group Cipher' in line or 'Pairwise Ciphers' in line:
|
||||
if 'CCMP' in line:
|
||||
cipher = CIPHER_CCMP
|
||||
elif 'TKIP' in line:
|
||||
cipher = CIPHER_TKIP
|
||||
|
||||
# Auth
|
||||
elif 'Authentication Suites' in line:
|
||||
if 'PSK' in line:
|
||||
auth = AUTH_PSK
|
||||
elif '802.1x' in line.lower() or 'EAP' in line:
|
||||
auth = AUTH_EAP
|
||||
|
||||
# Derive channel from frequency if needed
|
||||
if not channel and frequency_mhz:
|
||||
channel = get_channel_from_frequency(frequency_mhz)
|
||||
|
||||
# Get frequency from channel if needed
|
||||
if not frequency_mhz and channel:
|
||||
frequency_mhz = CHANNEL_FREQUENCIES.get(channel)
|
||||
|
||||
# Determine security type
|
||||
security = SECURITY_OPEN
|
||||
if has_wpa2 and has_wpa:
|
||||
security = SECURITY_WPA_WPA2
|
||||
elif has_wpa2:
|
||||
security = SECURITY_WPA2
|
||||
elif has_wpa:
|
||||
security = SECURITY_WPA
|
||||
elif has_encryption:
|
||||
security = SECURITY_WEP
|
||||
cipher = CIPHER_WEP
|
||||
|
||||
if auth == AUTH_UNKNOWN:
|
||||
if security == SECURITY_OPEN:
|
||||
auth = AUTH_OPEN
|
||||
elif security != SECURITY_WEP:
|
||||
auth = AUTH_PSK
|
||||
|
||||
return WiFiObservation(
|
||||
timestamp=datetime.now(),
|
||||
bssid=bssid,
|
||||
essid=ssid,
|
||||
channel=channel,
|
||||
frequency_mhz=frequency_mhz,
|
||||
rssi=rssi,
|
||||
security=security,
|
||||
cipher=cipher,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse iwlist block: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,205 @@
|
||||
r"""
|
||||
Parser for NetworkManager nmcli output.
|
||||
|
||||
Example output from 'nmcli -t -f BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY device wifi list':
|
||||
00\:11\:22\:33\:44\:55:MyWiFi:Infra:6:2437 MHz:130 Mbit/s:75:WPA2
|
||||
00\:11\:22\:33\:44\:66::Infra:11:2462 MHz:54 Mbit/s:60:WPA2
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ..models import WiFiObservation
|
||||
from ..constants import (
|
||||
SECURITY_OPEN,
|
||||
SECURITY_WEP,
|
||||
SECURITY_WPA,
|
||||
SECURITY_WPA2,
|
||||
SECURITY_WPA3,
|
||||
SECURITY_WPA_WPA2,
|
||||
SECURITY_WPA2_WPA3,
|
||||
SECURITY_ENTERPRISE,
|
||||
SECURITY_UNKNOWN,
|
||||
CIPHER_CCMP,
|
||||
CIPHER_TKIP,
|
||||
CIPHER_UNKNOWN,
|
||||
AUTH_PSK,
|
||||
AUTH_SAE,
|
||||
AUTH_EAP,
|
||||
AUTH_OPEN,
|
||||
AUTH_UNKNOWN,
|
||||
get_channel_from_frequency,
|
||||
get_band_from_frequency,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_nmcli_scan(output: str) -> list[WiFiObservation]:
|
||||
"""
|
||||
Parse nmcli terse output.
|
||||
|
||||
Args:
|
||||
output: Raw output from nmcli with -t flag.
|
||||
|
||||
Returns:
|
||||
List of WiFiObservation objects.
|
||||
"""
|
||||
observations = []
|
||||
|
||||
for line in output.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
obs = _parse_nmcli_line(line)
|
||||
if obs:
|
||||
observations.append(obs)
|
||||
|
||||
return observations
|
||||
|
||||
|
||||
def _parse_nmcli_line(line: str) -> Optional[WiFiObservation]:
|
||||
"""Parse a single line of nmcli terse output."""
|
||||
try:
|
||||
# nmcli terse format uses : as delimiter but escapes colons in values with \:
|
||||
# Need to carefully split
|
||||
parts = _split_nmcli_line(line)
|
||||
|
||||
if len(parts) < 8:
|
||||
return None
|
||||
|
||||
# BSSID,SSID,MODE,CHAN,FREQ,RATE,SIGNAL,SECURITY
|
||||
bssid = parts[0].upper()
|
||||
ssid = parts[1] if parts[1] else None
|
||||
# mode = parts[2] # 'Infra' or 'Ad-Hoc'
|
||||
channel_str = parts[3]
|
||||
freq_str = parts[4]
|
||||
# rate_str = parts[5] # e.g., '130 Mbit/s'
|
||||
signal_str = parts[6]
|
||||
security_str = parts[7] if len(parts) > 7 else ''
|
||||
|
||||
# Parse channel
|
||||
channel = int(channel_str) if channel_str.isdigit() else None
|
||||
|
||||
# Parse frequency (e.g., "2437 MHz")
|
||||
freq_match = re.match(r'(\d+)', freq_str)
|
||||
frequency_mhz = int(freq_match.group(1)) if freq_match else None
|
||||
|
||||
# If no channel, derive from frequency
|
||||
if not channel and frequency_mhz:
|
||||
channel = get_channel_from_frequency(frequency_mhz)
|
||||
|
||||
# Parse signal strength (nmcli gives percentage 0-100)
|
||||
# Convert to approximate dBm: -100 + (signal * 0.5)
|
||||
# More accurate: signal 100 = -30 dBm, signal 0 = -100 dBm
|
||||
rssi = None
|
||||
if signal_str.isdigit():
|
||||
signal_pct = int(signal_str)
|
||||
rssi = int(-100 + (signal_pct * 0.7)) # Rough conversion
|
||||
|
||||
# Parse security
|
||||
security, cipher, auth = _parse_nmcli_security(security_str)
|
||||
|
||||
return WiFiObservation(
|
||||
timestamp=datetime.now(),
|
||||
bssid=bssid,
|
||||
essid=ssid,
|
||||
channel=channel,
|
||||
frequency_mhz=frequency_mhz,
|
||||
rssi=rssi,
|
||||
security=security,
|
||||
cipher=cipher,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse nmcli line: {line!r} - {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _split_nmcli_line(line: str) -> list[str]:
|
||||
"""Split nmcli terse line handling escaped colons."""
|
||||
parts = []
|
||||
current = []
|
||||
i = 0
|
||||
|
||||
while i < len(line):
|
||||
if line[i] == '\\' and i + 1 < len(line) and line[i + 1] == ':':
|
||||
# Escaped colon - add literal colon
|
||||
current.append(':')
|
||||
i += 2
|
||||
elif line[i] == ':':
|
||||
# Field delimiter
|
||||
parts.append(''.join(current))
|
||||
current = []
|
||||
i += 1
|
||||
else:
|
||||
current.append(line[i])
|
||||
i += 1
|
||||
|
||||
# Add last field
|
||||
parts.append(''.join(current))
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def _parse_nmcli_security(security_str: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
Parse nmcli security string.
|
||||
|
||||
Examples:
|
||||
'WPA2' -> (WPA2, CCMP, PSK)
|
||||
'WPA1 WPA2' -> (WPA/WPA2, CCMP, PSK)
|
||||
'WPA3' -> (WPA3, CCMP, SAE)
|
||||
'802.1X' -> (Enterprise, CCMP, EAP)
|
||||
'WEP' -> (WEP, WEP, OPEN)
|
||||
'' or '--' -> (Open, None, Open)
|
||||
"""
|
||||
if not security_str or security_str == '--':
|
||||
return SECURITY_OPEN, CIPHER_UNKNOWN, AUTH_OPEN
|
||||
|
||||
security_upper = security_str.upper()
|
||||
|
||||
# Determine security type
|
||||
security = SECURITY_UNKNOWN
|
||||
|
||||
if '802.1X' in security_upper:
|
||||
security = SECURITY_ENTERPRISE
|
||||
elif 'WPA3' in security_upper:
|
||||
if 'WPA2' in security_upper:
|
||||
security = SECURITY_WPA2_WPA3
|
||||
else:
|
||||
security = SECURITY_WPA3
|
||||
elif 'WPA2' in security_upper:
|
||||
if 'WPA1' in security_upper or security_upper.count('WPA') > 1:
|
||||
security = SECURITY_WPA_WPA2
|
||||
else:
|
||||
security = SECURITY_WPA2
|
||||
elif 'WPA' in security_upper:
|
||||
security = SECURITY_WPA
|
||||
elif 'WEP' in security_upper:
|
||||
security = SECURITY_WEP
|
||||
|
||||
# Determine cipher (assume CCMP for WPA2+)
|
||||
cipher = CIPHER_UNKNOWN
|
||||
if security in (SECURITY_WPA2, SECURITY_WPA3, SECURITY_WPA2_WPA3, SECURITY_ENTERPRISE):
|
||||
cipher = CIPHER_CCMP
|
||||
elif security == SECURITY_WPA or security == SECURITY_WPA_WPA2:
|
||||
cipher = CIPHER_TKIP # Often TKIP for mixed mode
|
||||
|
||||
# Determine auth
|
||||
auth = AUTH_UNKNOWN
|
||||
if security == SECURITY_ENTERPRISE or '802.1X' in security_upper:
|
||||
auth = AUTH_EAP
|
||||
elif security == SECURITY_WPA3:
|
||||
auth = AUTH_SAE
|
||||
elif security in (SECURITY_WPA, SECURITY_WPA2, SECURITY_WPA_WPA2, SECURITY_WPA2_WPA3):
|
||||
auth = AUTH_PSK
|
||||
elif security == SECURITY_OPEN:
|
||||
auth = AUTH_OPEN
|
||||
|
||||
return security, cipher, auth
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user