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:
Smittix
2026-01-21 23:42:12 +00:00
57 changed files with 21435 additions and 720 deletions
+19 -6
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+516
View File
@@ -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
+879
View File
@@ -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;
}
}
+287
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+286
View File
@@ -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,
};
})();
+718
View File
@@ -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">&times;</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;
+326
View File
@@ -0,0 +1,326 @@
/**
* Message Card Component
* Status and alert messages for Bluetooth and TSCM modes
*/
const MessageCard = (function() {
'use strict';
// Message types and their styling
const MESSAGE_TYPES = {
info: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>`,
color: '#3b82f6',
bgColor: 'rgba(59, 130, 246, 0.1)'
},
success: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>`,
color: '#22c55e',
bgColor: 'rgba(34, 197, 94, 0.1)'
},
warning: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>`,
color: '#f59e0b',
bgColor: 'rgba(245, 158, 11, 0.1)'
},
error: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>`,
color: '#ef4444',
bgColor: 'rgba(239, 68, 68, 0.1)'
},
scanning: {
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>`,
color: '#06b6d4',
bgColor: 'rgba(6, 182, 212, 0.1)'
}
};
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Create a message card
*/
function createMessageCard(options) {
const {
type = 'info',
title,
message,
details,
actions,
dismissible = true,
autoHide = 0,
id
} = options;
const config = MESSAGE_TYPES[type] || MESSAGE_TYPES.info;
const card = document.createElement('div');
card.className = `message-card message-card-${type}`;
if (id) card.id = id;
card.style.setProperty('--message-color', config.color);
card.style.setProperty('--message-bg', config.bgColor);
card.innerHTML = `
<div class="message-card-icon">
${config.icon}
</div>
<div class="message-card-content">
${title ? `<div class="message-card-title">${escapeHtml(title)}</div>` : ''}
${message ? `<div class="message-card-text">${escapeHtml(message)}</div>` : ''}
${details ? `<div class="message-card-details">${escapeHtml(details)}</div>` : ''}
</div>
${dismissible ? `
<button class="message-card-dismiss" title="Dismiss">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
` : ''}
${actions && actions.length > 0 ? `
<div class="message-card-actions">
${actions.map(action => `
<button class="message-action-btn ${action.primary ? 'primary' : ''}"
${action.id ? `id="${escapeHtml(action.id)}"` : ''}>
${escapeHtml(action.label)}
</button>
`).join('')}
</div>
` : ''}
`;
// Dismiss handler
if (dismissible) {
card.querySelector('.message-card-dismiss').addEventListener('click', () => {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
});
}
// Action handlers
if (actions && actions.length > 0) {
actions.forEach(action => {
if (action.handler) {
const btn = action.id
? card.querySelector(`#${action.id}`)
: card.querySelector('.message-action-btn');
if (btn) {
btn.addEventListener('click', (e) => {
action.handler(e, card);
});
}
}
});
}
// Auto-hide
if (autoHide > 0) {
setTimeout(() => {
if (card.parentElement) {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
}
}, autoHide);
}
return card;
}
/**
* Create a scanning status card
*/
function createScanningCard(options = {}) {
const {
backend = 'auto',
adapter = 'hci0',
deviceCount = 0,
elapsed = 0,
remaining = null
} = options;
return createMessageCard({
type: 'scanning',
title: 'Scanning for Bluetooth devices...',
message: `Backend: ${backend} | Adapter: ${adapter}`,
details: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''}` +
(remaining !== null ? ` | ${Math.round(remaining)}s remaining` : ''),
dismissible: false,
id: 'btScanningStatus'
});
}
/**
* Create a capability warning card
*/
function createCapabilityWarning(issues) {
if (!issues || issues.length === 0) return null;
return createMessageCard({
type: 'warning',
title: 'Bluetooth Capability Issues',
message: issues.join('. '),
dismissible: true,
actions: [
{
label: 'Retry Check',
handler: (e, card) => {
card.remove();
if (typeof window.checkBtCapabilities === 'function') {
window.checkBtCapabilities();
}
}
}
]
});
}
/**
* Create a baseline status card
*/
function createBaselineCard(deviceCount, isSet = true) {
if (isSet) {
return createMessageCard({
type: 'success',
title: 'Baseline Set',
message: `${deviceCount} device${deviceCount !== 1 ? 's' : ''} saved as baseline`,
details: 'New devices will be highlighted',
dismissible: true,
autoHide: 5000
});
} else {
return createMessageCard({
type: 'info',
title: 'No Baseline',
message: 'Set a baseline to track new devices',
dismissible: true,
actions: [
{
label: 'Set Baseline',
primary: true,
handler: () => {
if (typeof window.setBtBaseline === 'function') {
window.setBtBaseline();
}
}
}
]
});
}
}
/**
* Create a scan complete card
*/
function createScanCompleteCard(deviceCount, duration) {
return createMessageCard({
type: 'success',
title: 'Scan Complete',
message: `Found ${deviceCount} device${deviceCount !== 1 ? 's' : ''} in ${Math.round(duration)}s`,
dismissible: true,
autoHide: 5000,
actions: [
{
label: 'Export Results',
handler: () => {
window.open('/api/bluetooth/export?format=csv', '_blank');
}
}
]
});
}
/**
* Create an error card
*/
function createErrorCard(error, retryHandler) {
return createMessageCard({
type: 'error',
title: 'Scan Error',
message: error,
dismissible: true,
actions: retryHandler ? [
{
label: 'Retry',
primary: true,
handler: retryHandler
}
] : []
});
}
/**
* Show a message in a container
*/
function showMessage(container, options) {
const card = createMessageCard(options);
container.insertBefore(card, container.firstChild);
return card;
}
/**
* Remove a message by ID
*/
function removeMessage(id) {
const card = document.getElementById(id);
if (card) {
card.classList.add('message-card-hiding');
setTimeout(() => card.remove(), 200);
}
}
/**
* Update scanning status
*/
function updateScanningStatus(options) {
const existing = document.getElementById('btScanningStatus');
if (existing) {
const details = existing.querySelector('.message-card-details');
if (details) {
details.textContent = `Found ${options.deviceCount} device${options.deviceCount !== 1 ? 's' : ''}` +
(options.remaining !== null ? ` | ${Math.round(options.remaining)}s remaining` : '');
}
}
}
// Public API
return {
createMessageCard,
createScanningCard,
createCapabilityWarning,
createBaselineCard,
createScanCompleteCard,
createErrorCard,
showMessage,
removeMessage,
updateScanningStatus,
MESSAGE_TYPES
};
})();
// Make globally available
window.MessageCard = MessageCard;
+395
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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;
+243
View File
@@ -0,0 +1,243 @@
/**
* RSSI Sparkline Component
* SVG-based real-time RSSI visualization
*/
const RSSISparkline = (function() {
'use strict';
// Default configuration
const DEFAULT_CONFIG = {
width: 80,
height: 24,
maxSamples: 30,
strokeWidth: 1.5,
minRssi: -100,
maxRssi: -30,
showCurrentValue: true,
showGradient: true,
animateUpdates: true
};
// Color thresholds based on RSSI
const RSSI_COLORS = {
excellent: { rssi: -50, color: '#22c55e' }, // Green
good: { rssi: -60, color: '#84cc16' }, // Lime
fair: { rssi: -70, color: '#eab308' }, // Yellow
weak: { rssi: -80, color: '#f97316' }, // Orange
poor: { rssi: -100, color: '#ef4444' } // Red
};
/**
* Get color for RSSI value
*/
function getRssiColor(rssi) {
if (rssi >= RSSI_COLORS.excellent.rssi) return RSSI_COLORS.excellent.color;
if (rssi >= RSSI_COLORS.good.rssi) return RSSI_COLORS.good.color;
if (rssi >= RSSI_COLORS.fair.rssi) return RSSI_COLORS.fair.color;
if (rssi >= RSSI_COLORS.weak.rssi) return RSSI_COLORS.weak.color;
return RSSI_COLORS.poor.color;
}
/**
* Normalize RSSI value to 0-1 range
*/
function normalizeRssi(rssi, min, max) {
return Math.max(0, Math.min(1, (rssi - min) / (max - min)));
}
/**
* Create sparkline SVG element
*/
function createSparklineSvg(samples, config = {}) {
const cfg = { ...DEFAULT_CONFIG, ...config };
const { width, height, minRssi, maxRssi, strokeWidth, showGradient } = cfg;
if (!samples || samples.length < 2) {
return createEmptySparkline(width, height);
}
// Normalize samples
const normalized = samples.map(s => {
const rssi = typeof s === 'object' ? s.rssi : s;
return {
value: normalizeRssi(rssi, minRssi, maxRssi),
rssi: rssi
};
});
// Calculate path
const stepX = width / (normalized.length - 1);
let pathD = '';
let areaD = '';
const points = [];
normalized.forEach((sample, i) => {
const x = i * stepX;
const y = height - (sample.value * (height - 2)) - 1; // 1px padding top/bottom
points.push({ x, y, rssi: sample.rssi });
if (i === 0) {
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
} else {
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
}
});
// Close area path
areaD += ` L${width},${height} Z`;
// Get current color based on latest value
const latestRssi = normalized[normalized.length - 1].rssi;
const strokeColor = getRssiColor(latestRssi);
// Create SVG
const gradientId = `sparkline-gradient-${Math.random().toString(36).substr(2, 9)}`;
let gradientDef = '';
if (showGradient) {
gradientDef = `
<defs>
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
</linearGradient>
</defs>
`;
}
return `
<svg class="rssi-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${gradientDef}
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
stroke-linecap="round" stroke-linejoin="round" />
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
r="2" fill="${strokeColor}" class="sparkline-dot" />
</svg>
`;
}
/**
* Create empty sparkline placeholder
*/
function createEmptySparkline(width, height) {
return `
<svg class="rssi-sparkline-svg rssi-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
stroke="#444" stroke-width="1" stroke-dasharray="2,2" />
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
fill="#666" font-size="8" font-family="monospace">No data</text>
</svg>
`;
}
/**
* Create a live sparkline component with update capability
*/
class LiveSparkline {
constructor(container, config = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container;
this.config = { ...DEFAULT_CONFIG, ...config };
this.samples = [];
this.animationFrame = null;
this.render();
}
addSample(rssi) {
this.samples.push({
rssi: rssi,
timestamp: Date.now()
});
// Limit samples
if (this.samples.length > this.config.maxSamples) {
this.samples.shift();
}
this.render();
}
setSamples(samples) {
this.samples = samples.slice(-this.config.maxSamples);
this.render();
}
render() {
if (!this.container) return;
const svg = createSparklineSvg(this.samples, this.config);
this.container.innerHTML = svg;
// Add current value display if enabled
if (this.config.showCurrentValue && this.samples.length > 0) {
const latest = this.samples[this.samples.length - 1];
const rssi = typeof latest === 'object' ? latest.rssi : latest;
const valueEl = document.createElement('span');
valueEl.className = 'rssi-current-value';
valueEl.textContent = `${rssi} dBm`;
valueEl.style.color = getRssiColor(rssi);
this.container.appendChild(valueEl);
}
}
clear() {
this.samples = [];
this.render();
}
destroy() {
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
if (this.container) {
this.container.innerHTML = '';
}
}
}
/**
* Create inline sparkline HTML (for use in templates)
*/
function createInlineSparkline(rssiHistory, options = {}) {
const samples = rssiHistory.map(h => typeof h === 'object' ? h.rssi : h);
return createSparklineSvg(samples, options);
}
/**
* Create sparkline with value display
*/
function createSparklineWithValue(rssiHistory, currentRssi, options = {}) {
const { width = 60, height = 20 } = options;
const svg = createInlineSparkline(rssiHistory, { ...options, width, height });
const color = getRssiColor(currentRssi);
return `
<div class="rssi-sparkline-wrapper">
${svg}
<span class="rssi-value" style="color: ${color}">${currentRssi !== null ? currentRssi : '--'} dBm</span>
</div>
`;
}
// Public API
return {
createSparklineSvg,
createInlineSparkline,
createSparklineWithValue,
createEmptySparkline,
LiveSparkline,
getRssiColor,
normalizeRssi,
DEFAULT_CONFIG,
RSSI_COLORS
};
})();
// Make globally available
window.RSSISparkline = RSSISparkline;
+409
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// 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;
-1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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()">&times;</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()">&times;</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) {
+47 -50
View File
@@ -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>
+1 -1
View File
@@ -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;">
+44 -4
View File
@@ -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>
+318
View File
@@ -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()
+554
View File
@@ -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]
+469
View File
@@ -0,0 +1,469 @@
"""API endpoint tests for Bluetooth v2 routes."""
import pytest
import json
from unittest.mock import MagicMock, patch, PropertyMock
from datetime import datetime
from flask import Flask
from routes.bluetooth_v2 import bluetooth_v2_bp
from utils.bluetooth.models import BTDeviceAggregate, ScanStatus, SystemCapabilities
@pytest.fixture
def app():
"""Create Flask application for testing."""
app = Flask(__name__)
app.register_blueprint(bluetooth_v2_bp)
app.config['TESTING'] = True
return app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
@pytest.fixture
def mock_scanner():
"""Create mock BluetoothScanner."""
with patch('routes.bluetooth_v2.get_bluetooth_scanner') as mock_get:
scanner = MagicMock()
scanner.is_scanning = False
scanner.scan_mode = None
scanner.scan_start_time = None
scanner.device_count = 0
mock_get.return_value = scanner
yield scanner
@pytest.fixture
def sample_device():
"""Create sample BTDeviceAggregate."""
return BTDeviceAggregate(
device_id="AA:BB:CC:DD:EE:FF:public",
address="AA:BB:CC:DD:EE:FF",
address_type="public",
protocol="ble",
first_seen=datetime.now(),
last_seen=datetime.now(),
seen_count=5,
seen_rate=1.0,
rssi_samples=[],
rssi_current=-55,
rssi_median=-57.0,
rssi_min=-60,
rssi_max=-50,
rssi_variance=4.0,
rssi_confidence=0.85,
range_band="close",
range_confidence=0.75,
name="Test Device",
manufacturer_id=76,
manufacturer_name="Apple, Inc.",
manufacturer_bytes=None,
service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"],
is_new=False,
is_persistent=True,
is_beacon_like=False,
is_strong_stable=True,
has_random_address=False,
)
class TestScanEndpoints:
"""Tests for scan control endpoints."""
def test_start_scan_success(self, client, mock_scanner):
"""Test starting a scan successfully."""
mock_scanner.start_scan.return_value = True
mock_scanner.scan_mode = "dbus"
response = client.post('/api/bluetooth/scan/start',
json={'mode': 'auto', 'duration_s': 30})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'started'
mock_scanner.start_scan.assert_called_once()
def test_start_scan_already_scanning(self, client, mock_scanner):
"""Test starting scan when already scanning."""
mock_scanner.is_scanning = True
response = client.post('/api/bluetooth/scan/start', json={})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'already_scanning'
def test_start_scan_failed(self, client, mock_scanner):
"""Test start scan failure."""
mock_scanner.start_scan.return_value = False
response = client.post('/api/bluetooth/scan/start', json={})
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'error'
def test_stop_scan_success(self, client, mock_scanner):
"""Test stopping a scan."""
mock_scanner.is_scanning = True
response = client.post('/api/bluetooth/scan/stop')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'stopped'
mock_scanner.stop_scan.assert_called_once()
def test_get_scan_status(self, client, mock_scanner):
"""Test getting scan status."""
mock_scanner.is_scanning = True
mock_scanner.scan_mode = "dbus"
mock_scanner.device_count = 10
mock_scanner.get_baseline_count.return_value = 5
response = client.get('/api/bluetooth/scan/status')
assert response.status_code == 200
data = response.get_json()
assert data['is_scanning'] is True
assert data['mode'] == 'dbus'
assert data['device_count'] == 10
class TestDeviceEndpoints:
"""Tests for device listing and detail endpoints."""
def test_list_devices(self, client, mock_scanner, sample_device):
"""Test listing all devices."""
mock_scanner.get_devices.return_value = [sample_device]
response = client.get('/api/bluetooth/devices')
assert response.status_code == 200
data = response.get_json()
assert len(data['devices']) == 1
assert data['devices'][0]['address'] == 'AA:BB:CC:DD:EE:FF'
def test_list_devices_with_filters(self, client, mock_scanner, sample_device):
"""Test listing devices with filters."""
mock_scanner.get_devices.return_value = [sample_device]
response = client.get('/api/bluetooth/devices?protocol=ble&min_rssi=-60&sort_by=rssi')
assert response.status_code == 200
mock_scanner.get_devices.assert_called_with(
sort_by='rssi',
protocol='ble',
min_rssi=-60,
new_only=False,
)
def test_list_devices_new_only(self, client, mock_scanner, sample_device):
"""Test listing only new devices."""
sample_device.is_new = True
mock_scanner.get_devices.return_value = [sample_device]
response = client.get('/api/bluetooth/devices?new_only=true')
assert response.status_code == 200
mock_scanner.get_devices.assert_called_with(
sort_by='last_seen',
protocol=None,
min_rssi=None,
new_only=True,
)
def test_get_device_detail(self, client, mock_scanner, sample_device):
"""Test getting device details."""
mock_scanner.get_device.return_value = sample_device
response = client.get('/api/bluetooth/devices/AA:BB:CC:DD:EE:FF:public')
assert response.status_code == 200
data = response.get_json()
assert data['address'] == 'AA:BB:CC:DD:EE:FF'
assert data['manufacturer_name'] == 'Apple, Inc.'
def test_get_device_not_found(self, client, mock_scanner):
"""Test getting non-existent device."""
mock_scanner.get_device.return_value = None
response = client.get('/api/bluetooth/devices/NONEXISTENT')
assert response.status_code == 404
data = response.get_json()
assert data['status'] == 'error'
class TestBaselineEndpoints:
"""Tests for baseline management endpoints."""
def test_set_baseline(self, client, mock_scanner):
"""Test setting baseline."""
mock_scanner.set_baseline.return_value = 15
response = client.post('/api/bluetooth/baseline/set')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'success'
assert data['device_count'] == 15
def test_clear_baseline(self, client, mock_scanner):
"""Test clearing baseline."""
response = client.post('/api/bluetooth/baseline/clear')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'success'
mock_scanner.clear_baseline.assert_called_once()
class TestCapabilitiesEndpoint:
"""Tests for capabilities check endpoint."""
def test_get_capabilities(self, client):
"""Test getting system capabilities."""
mock_caps = SystemCapabilities(
available=True,
dbus_available=True,
bluez_version="5.66",
adapters=[],
has_root=True,
rfkill_blocked=False,
fallback_tools=['bleak', 'hcitool'],
issues=[],
preferred_backend='dbus',
)
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
response = client.get('/api/bluetooth/capabilities')
assert response.status_code == 200
data = response.get_json()
assert data['available'] is True
assert data['dbus_available'] is True
def test_capabilities_not_available(self, client):
"""Test capabilities when Bluetooth not available."""
mock_caps = SystemCapabilities(
available=False,
dbus_available=False,
bluez_version=None,
adapters=[],
has_root=False,
rfkill_blocked=False,
fallback_tools=[],
issues=['No Bluetooth adapter found'],
preferred_backend=None,
)
with patch('routes.bluetooth_v2.check_bluetooth_capabilities', return_value=mock_caps):
response = client.get('/api/bluetooth/capabilities')
assert response.status_code == 200
data = response.get_json()
assert data['available'] is False
assert 'No Bluetooth adapter found' in data['issues']
class TestExportEndpoint:
"""Tests for data export endpoint."""
def test_export_json(self, client, mock_scanner, sample_device):
"""Test JSON export."""
mock_scanner.get_devices.return_value = [sample_device]
response = client.get('/api/bluetooth/export?format=json')
assert response.status_code == 200
assert response.content_type == 'application/json'
data = response.get_json()
assert 'devices' in data
assert 'timestamp' in data
def test_export_csv(self, client, mock_scanner, sample_device):
"""Test CSV export."""
mock_scanner.get_devices.return_value = [sample_device]
response = client.get('/api/bluetooth/export?format=csv')
assert response.status_code == 200
assert 'text/csv' in response.content_type
# Check CSV content
csv_content = response.data.decode('utf-8')
assert 'address' in csv_content.lower()
assert 'AA:BB:CC:DD:EE:FF' in csv_content
def test_export_empty_devices(self, client, mock_scanner):
"""Test export with no devices."""
mock_scanner.get_devices.return_value = []
response = client.get('/api/bluetooth/export?format=json')
assert response.status_code == 200
data = response.get_json()
assert data['devices'] == []
class TestStreamEndpoint:
"""Tests for SSE streaming endpoint."""
def test_stream_headers(self, client, mock_scanner):
"""Test SSE stream has correct headers."""
mock_scanner.stream_events.return_value = iter([])
response = client.get('/api/bluetooth/stream')
assert response.content_type == 'text/event-stream'
assert response.headers.get('Cache-Control') == 'no-cache'
def test_stream_returns_generator(self, client, mock_scanner):
"""Test stream endpoint returns a generator response."""
mock_scanner.stream_events.return_value = iter([
{'event': 'device_update', 'data': {'address': 'AA:BB:CC:DD:EE:FF'}}
])
response = client.get('/api/bluetooth/stream')
# Should be a streaming response
assert response.is_streamed is True
class TestTSCMIntegration:
"""Tests for TSCM integration helper."""
def test_get_tscm_bluetooth_snapshot(self, mock_scanner, sample_device):
"""Test TSCM snapshot function."""
from routes.bluetooth_v2 import get_tscm_bluetooth_snapshot
mock_scanner.get_devices.return_value = [sample_device]
with patch('routes.bluetooth_v2.get_bluetooth_scanner', return_value=mock_scanner):
devices = get_tscm_bluetooth_snapshot(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
+357
View File
@@ -0,0 +1,357 @@
"""Unit tests for Bluetooth heuristic detection."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import MagicMock
from utils.bluetooth.heuristics import HeuristicsEngine
from utils.bluetooth.models import BTDeviceAggregate
from utils.bluetooth.constants import (
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
+426
View File
@@ -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
+443
View File
@@ -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'])
+121
View File
@@ -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',
]
+611
View File
@@ -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()
+314
View File
@@ -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
+280
View File
@@ -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',
}
+415
View File
@@ -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
+120
View File
@@ -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'
+274
View File
@@ -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
+550
View File
@@ -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
+205
View File
@@ -0,0 +1,205 @@
"""
Heuristics engine for Bluetooth device analysis.
Provides factual, observable heuristics without making tracker detection claims.
"""
from __future__ import annotations
import statistics
from datetime import datetime, timedelta
from typing import Optional
from .constants import (
PERSISTENT_MIN_SEEN_COUNT,
PERSISTENT_WINDOW_SECONDS,
BEACON_INTERVAL_MAX_VARIANCE,
STRONG_RSSI_THRESHOLD,
STABLE_VARIANCE_THRESHOLD,
)
from .models import BTDeviceAggregate
class HeuristicsEngine:
"""
Evaluates observable device behaviors without making tracker detection claims.
Heuristics provided:
- is_new: Device not in baseline (appeared after baseline was set)
- is_persistent: Continuously present over time window
- is_beacon_like: Regular advertising pattern
- is_strong_stable: Very close with consistent signal
- has_random_address: Uses privacy-preserving random address
"""
def evaluate(self, device: BTDeviceAggregate) -> None:
"""
Evaluate all heuristics for a device and update its flags.
Args:
device: The BTDeviceAggregate to evaluate.
"""
# Note: is_new and has_random_address are set by the aggregator
# Here we evaluate the behavioral heuristics
device.is_persistent = self._check_persistent(device)
device.is_beacon_like = self._check_beacon_like(device)
device.is_strong_stable = self._check_strong_stable(device)
def _check_persistent(self, device: BTDeviceAggregate) -> bool:
"""
Check if device is persistently present.
A device is considered persistent if it has been seen frequently
over the analysis window.
"""
if device.seen_count < PERSISTENT_MIN_SEEN_COUNT:
return False
# Check if the observations span a reasonable time window
duration = device.duration_seconds
if duration < PERSISTENT_WINDOW_SECONDS * 0.5: # At least half the window
return False
# Check seen rate (should be reasonably consistent)
# Minimum 2 observations per minute for persistent
min_rate = 2.0
return device.seen_rate >= min_rate
def _check_beacon_like(self, device: BTDeviceAggregate) -> bool:
"""
Check if device has beacon-like advertising pattern.
Beacon-like devices advertise at regular intervals with low variance.
"""
if len(device.rssi_samples) < 10:
return False
# Calculate advertisement intervals
intervals = self._calculate_intervals(device)
if len(intervals) < 5:
return False
# Check interval consistency
mean_interval = statistics.mean(intervals)
if mean_interval <= 0:
return False
try:
stdev_interval = statistics.stdev(intervals)
# Coefficient of variation (CV) = stdev / mean
cv = stdev_interval / mean_interval
return cv < BEACON_INTERVAL_MAX_VARIANCE
except statistics.StatisticsError:
return False
def _check_strong_stable(self, device: BTDeviceAggregate) -> bool:
"""
Check if device has strong and stable signal.
Strong + stable indicates the device is very close and stationary.
"""
if device.rssi_median is None or device.rssi_variance is None:
return False
# Must be strong signal
if device.rssi_median < STRONG_RSSI_THRESHOLD:
return False
# Must have low variance (stable)
if device.rssi_variance > STABLE_VARIANCE_THRESHOLD:
return False
# Must have reasonable sample count for confidence
if len(device.rssi_samples) < 5:
return False
return True
def _calculate_intervals(self, device: BTDeviceAggregate) -> list[float]:
"""Calculate time intervals between observations."""
if len(device.rssi_samples) < 2:
return []
intervals = []
prev_time = device.rssi_samples[0][0]
for timestamp, _ in device.rssi_samples[1:]:
interval = (timestamp - prev_time).total_seconds()
# Filter out unreasonably long intervals (gaps in scanning)
if 0 < interval < 30: # Max 30 seconds between observations
intervals.append(interval)
prev_time = timestamp
return intervals
def get_heuristic_summary(self, device: BTDeviceAggregate) -> dict:
"""
Get a summary of heuristic analysis for a device.
Returns:
Dictionary with heuristic flags and explanations.
"""
summary = {
'flags': [],
'details': {}
}
if device.is_new:
summary['flags'].append('new')
summary['details']['new'] = 'Device appeared after baseline was set'
if device.is_persistent:
summary['flags'].append('persistent')
summary['details']['persistent'] = (
f'Seen {device.seen_count} times over '
f'{device.duration_seconds:.0f}s ({device.seen_rate:.1f}/min)'
)
if device.is_beacon_like:
summary['flags'].append('beacon_like')
intervals = self._calculate_intervals(device)
if intervals:
mean_int = statistics.mean(intervals)
summary['details']['beacon_like'] = (
f'Regular advertising interval (~{mean_int:.1f}s)'
)
else:
summary['details']['beacon_like'] = 'Regular advertising pattern'
if device.is_strong_stable:
summary['flags'].append('strong_stable')
summary['details']['strong_stable'] = (
f'Strong signal ({device.rssi_median:.0f} dBm) '
f'with low variance ({device.rssi_variance:.1f})'
)
if device.has_random_address:
summary['flags'].append('random_address')
summary['details']['random_address'] = (
f'Uses {device.address_type} address (privacy-preserving)'
)
return summary
def evaluate_device_heuristics(device: BTDeviceAggregate) -> None:
"""
Convenience function to evaluate heuristics for a single device.
Args:
device: The BTDeviceAggregate to evaluate.
"""
engine = HeuristicsEngine()
engine.evaluate(device)
def evaluate_all_devices(devices: list[BTDeviceAggregate]) -> None:
"""
Evaluate heuristics for multiple devices.
Args:
devices: List of BTDeviceAggregate instances to evaluate.
"""
engine = HeuristicsEngine()
for device in devices:
engine.evaluate(device)
+448
View File
@@ -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,
}
+335
View File
@@ -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
+415
View File
@@ -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
+804
View File
@@ -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,
)
+13 -1
View File
@@ -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
View File
@@ -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'],
+26 -2
View File
@@ -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'),
+187
View File
@@ -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',
]
+295
View File
@@ -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)
+446
View File
@@ -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
+327
View File
@@ -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
+655
View File
@@ -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,
}
+19
View File
@@ -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',
]
+392
View File
@@ -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
+207
View File
@@ -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
+233
View File
@@ -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
+209
View File
@@ -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
+205
View File
@@ -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