From 9515f5fd7a9b5914763e2030ba10835ef411b627 Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 21 Jan 2026 22:06:16 +0000 Subject: [PATCH] Add unified WiFi scanning module with dual-mode architecture Backend: - New utils/wifi/ package with models, scanner, parsers, channel analyzer - Quick Scan mode using system tools (nmcli, iw, iwlist, airport) - Deep Scan mode using airodump-ng with monitor mode - Hidden SSID correlation engine - Channel utilization analysis with recommendations - v2 API endpoints at /wifi/v2/* with SSE streaming - TSCM integration updated to use new scanner (backwards compatible) Frontend: - WiFi mode controller (wifi.js) with dual-mode support - Channel utilization chart component (channel-chart.js) - Updated wifi.html template with scan mode tabs and export Co-Authored-By: Claude Opus 4.5 --- routes/__init__.py | 2 + routes/tscm.py | 179 +---- routes/wifi_v2.py | 516 ++++++++++++ static/js/components/channel-chart.js | 286 +++++++ static/js/modes/wifi.js | 1005 +++++++++++++++++++++++ templates/index.html | 3 + templates/partials/modes/wifi.html | 48 +- utils/wifi/__init__.py | 187 +++++ utils/wifi/channel_analyzer.py | 295 +++++++ utils/wifi/constants.py | 446 +++++++++++ utils/wifi/hidden_ssid.py | 327 ++++++++ utils/wifi/models.py | 653 +++++++++++++++ utils/wifi/parsers/__init__.py | 19 + utils/wifi/parsers/airodump.py | 392 +++++++++ utils/wifi/parsers/airport.py | 207 +++++ utils/wifi/parsers/iw.py | 233 ++++++ utils/wifi/parsers/iwlist.py | 209 +++++ utils/wifi/parsers/nmcli.py | 205 +++++ utils/wifi/scanner.py | 1049 +++++++++++++++++++++++++ 19 files changed, 6105 insertions(+), 156 deletions(-) create mode 100644 routes/wifi_v2.py create mode 100644 static/js/components/channel-chart.js create mode 100644 static/js/modes/wifi.js create mode 100644 utils/wifi/__init__.py create mode 100644 utils/wifi/channel_analyzer.py create mode 100644 utils/wifi/constants.py create mode 100644 utils/wifi/hidden_ssid.py create mode 100644 utils/wifi/models.py create mode 100644 utils/wifi/parsers/__init__.py create mode 100644 utils/wifi/parsers/airodump.py create mode 100644 utils/wifi/parsers/airport.py create mode 100644 utils/wifi/parsers/iw.py create mode 100644 utils/wifi/parsers/iwlist.py create mode 100644 utils/wifi/parsers/nmcli.py create mode 100644 utils/wifi/scanner.py diff --git a/routes/__init__.py b/routes/__init__.py index 910c29b..5960a95 100644 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -6,6 +6,7 @@ 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 @@ -22,6 +23,7 @@ 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) diff --git a/routes/tscm.py b/routes/tscm.py index 66870fb..56dce52 100644 --- a/routes/tscm.py +++ b/routes/tscm.py @@ -636,166 +636,41 @@ 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}") + Args: + interface: WiFi interface name (optional). - else: - # Linux: Try multiple scan methods - import shutil + Returns: + List of network dicts with: bssid, essid, power, channel, privacy + """ + try: + from utils.wifi import get_wifi_scanner - # 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' - else: - iface = interface + scanner = get_wifi_scanner() + 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]: diff --git a/routes/wifi_v2.py b/routes/wifi_v2.py new file mode 100644 index 0000000..07dc6fb --- /dev/null +++ b/routes/wifi_v2.py @@ -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/', 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/', 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 diff --git a/static/js/components/channel-chart.js b/static/js/components/channel-chart.js new file mode 100644 index 0000000..1d11769 --- /dev/null +++ b/static/js/components/channel-chart.js @@ -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: 150, + barWidth: 20, + barSpacing: 4, + padding: { top: 20, right: 20, bottom: 30, left: 40 }, + 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 + let svg = ` + + + + + + + + + + + + + + + + + + APs + + + ${renderYAxis(chartHeight, maxApCount)} + + + + ${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('')} + + + + + ${channels.map((ch, i) => { + const x = i * (CONFIG.barWidth + CONFIG.barSpacing) + CONFIG.barWidth / 2; + const isNonOverlapping = nonOverlapping.includes(ch); + return `${ch}`; + }).join('')} + + + `; + + // 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(` + + ${i} + `); + } + + 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 ? + ` + ${rec.rank}` : ''; + + // Non-overlapping channel marker + const channelMarker = isNonOverlapping ? + `` : ''; + + return ` + + + + + + + + + ${stats.ap_count > 0 ? ` + + ${stats.ap_count} + + ` : ''} + + ${channelMarker} + ${recIndicator} + + + + + `; + } + + function renderLegend() { + return ` +
+
+ + Low +
+
+ + Medium +
+
+ + High +
+
+ + Non-overlapping +
+
+ `; + } + + function renderRecommendations() { + const topRecs = recommendations.slice(0, 3); + if (topRecs.length === 0) return ''; + + return ` +
+
Recommended Channels:
+
+ ${topRecs.map((rec, i) => ` +
+ #${i + 1} + Ch ${rec.channel} + (${rec.band}) + ${rec.is_dfs ? 'DFS' : ''} +
+ `).join('')} +
+
+ `; + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + update, + setBand, + }; +})(); diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js new file mode 100644 index 0000000..4141e1b --- /dev/null +++ b/static/js/modes/wifi.js @@ -0,0 +1,1005 @@ +/** + * WiFi Mode Controller (v2) + * + * Unified WiFi scanning with dual-mode architecture: + * - Quick Scan: System tools without monitor mode + * - Deep Scan: airodump-ng with monitor mode + * + * Features: + * - Proximity radar visualization + * - Channel utilization analysis + * - Hidden SSID correlation + * - Real-time SSE streaming + */ + +const WiFiMode = (function() { + 'use strict'; + + // ========================================================================== + // Configuration + // ========================================================================== + + const CONFIG = { + apiBase: '/wifi/v2', + pollInterval: 5000, + keepaliveTimeout: 30000, + maxNetworks: 500, + maxClients: 500, + maxProbes: 1000, + }; + + // ========================================================================== + // State + // ========================================================================== + + let isScanning = false; + let scanMode = 'quick'; // 'quick' or 'deep' + let eventSource = null; + let pollTimer = null; + + // Data stores + let networks = new Map(); // bssid -> network + let clients = new Map(); // mac -> client + let probeRequests = []; + let channelStats = []; + let recommendations = []; + + // UI state + let selectedNetwork = null; + let currentFilter = 'all'; + let currentSort = { field: 'rssi', order: 'desc' }; + + // Capabilities + let capabilities = null; + + // Callbacks for external integration + let onNetworkUpdate = null; + let onClientUpdate = null; + let onProbeRequest = null; + + // ========================================================================== + // Initialization + // ========================================================================== + + function init() { + console.log('[WiFiMode] Initializing...'); + + // Cache DOM elements + cacheDOM(); + + // Check capabilities + checkCapabilities(); + + // Initialize components + initScanModeTabs(); + initNetworkFilters(); + initSortControls(); + initProximityRadar(); + initChannelChart(); + + // Check if already scanning + checkScanStatus(); + + console.log('[WiFiMode] Initialized'); + } + + // DOM element cache + let elements = {}; + + function cacheDOM() { + elements = { + // Scan controls + quickScanBtn: document.getElementById('wifiQuickScanBtn'), + deepScanBtn: document.getElementById('wifiDeepScanBtn'), + stopScanBtn: document.getElementById('wifiStopScanBtn'), + scanModeQuick: document.getElementById('wifiScanModeQuick'), + scanModeDeep: document.getElementById('wifiScanModeDeep'), + + // Status + scanStatus: document.getElementById('wifiScanStatus'), + networkCount: document.getElementById('wifiNetworkCount'), + clientCount: document.getElementById('wifiClientCount'), + hiddenCount: document.getElementById('wifiHiddenCount'), + + // Network list + networkTable: document.getElementById('wifiNetworkTable'), + networkTableBody: document.getElementById('wifiNetworkTableBody'), + networkFilters: document.getElementById('wifiNetworkFilters'), + + // Visualizations + proximityRadar: document.getElementById('wifiProximityRadar'), + channelChart: document.getElementById('wifiChannelChart'), + channelBandTabs: document.getElementById('wifiChannelBandTabs'), + + // Detail panel + detailPanel: document.getElementById('wifiDetailPanel'), + detailContent: document.getElementById('wifiDetailContent'), + + // Interface select + interfaceSelect: document.getElementById('wifiInterfaceSelect'), + + // Capability status + capabilityStatus: document.getElementById('wifiCapabilityStatus'), + + // Export buttons + exportCsvBtn: document.getElementById('wifiExportCsv'), + exportJsonBtn: document.getElementById('wifiExportJson'), + }; + } + + // ========================================================================== + // Capabilities + // ========================================================================== + + async function checkCapabilities() { + try { + const response = await fetch(`${CONFIG.apiBase}/capabilities`); + if (!response.ok) throw new Error('Failed to fetch capabilities'); + + capabilities = await response.json(); + console.log('[WiFiMode] Capabilities:', capabilities); + + updateCapabilityUI(); + populateInterfaceSelect(); + } catch (error) { + console.error('[WiFiMode] Capability check failed:', error); + showCapabilityError('Failed to check WiFi capabilities'); + } + } + + function updateCapabilityUI() { + if (!capabilities || !elements.capabilityStatus) return; + + let html = ''; + + if (!capabilities.can_quick_scan && !capabilities.can_deep_scan) { + html = ` +
+ WiFi scanning not available +
    + ${capabilities.issues.map(i => `
  • ${escapeHtml(i)}
  • `).join('')} +
+
+ `; + } else { + // Show available modes + const modes = []; + if (capabilities.can_quick_scan) modes.push('Quick Scan'); + if (capabilities.can_deep_scan) modes.push('Deep Scan'); + + html = ` +
+ Available modes: ${modes.join(', ')} + ${capabilities.preferred_quick_tool ? ` (using ${capabilities.preferred_quick_tool})` : ''} +
+ `; + + if (capabilities.issues.length > 0) { + html += ` +
+ ${capabilities.issues.join('. ')} +
+ `; + } + } + + elements.capabilityStatus.innerHTML = html; + elements.capabilityStatus.style.display = html ? 'block' : 'none'; + + // Enable/disable scan buttons based on capabilities + if (elements.quickScanBtn) { + elements.quickScanBtn.disabled = !capabilities.can_quick_scan; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.disabled = !capabilities.can_deep_scan; + } + } + + function showCapabilityError(message) { + if (!elements.capabilityStatus) return; + + elements.capabilityStatus.innerHTML = ` +
${escapeHtml(message)}
+ `; + elements.capabilityStatus.style.display = 'block'; + } + + function populateInterfaceSelect() { + if (!elements.interfaceSelect || !capabilities) return; + + elements.interfaceSelect.innerHTML = ''; + + if (capabilities.interfaces.length === 0) { + elements.interfaceSelect.innerHTML = ''; + return; + } + + capabilities.interfaces.forEach(iface => { + const option = document.createElement('option'); + option.value = iface.name; + option.textContent = `${iface.name}${iface.supports_monitor ? ' (monitor capable)' : ''}`; + elements.interfaceSelect.appendChild(option); + }); + + // Select default + if (capabilities.default_interface) { + elements.interfaceSelect.value = capabilities.default_interface; + } + } + + // ========================================================================== + // Scan Mode Tabs + // ========================================================================== + + function initScanModeTabs() { + if (elements.scanModeQuick) { + elements.scanModeQuick.addEventListener('click', () => setScanMode('quick')); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.addEventListener('click', () => setScanMode('deep')); + } + } + + function setScanMode(mode) { + scanMode = mode; + + // Update tab UI + if (elements.scanModeQuick) { + elements.scanModeQuick.classList.toggle('active', mode === 'quick'); + } + if (elements.scanModeDeep) { + elements.scanModeDeep.classList.toggle('active', mode === 'deep'); + } + + console.log('[WiFiMode] Scan mode set to:', mode); + } + + // ========================================================================== + // Scanning + // ========================================================================== + + async function startQuickScan() { + if (isScanning) return; + + console.log('[WiFiMode] Starting quick scan...'); + setScanning(true, 'quick'); + + try { + const iface = elements.interfaceSelect?.value || null; + + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Quick scan failed'); + } + + const result = await response.json(); + console.log('[WiFiMode] Quick scan complete:', result); + + // Process results + processQuickScanResult(result); + + // For quick scan, we're done after one scan + // But keep polling if user wants continuous updates + if (scanMode === 'quick') { + startQuickScanPolling(); + } + } catch (error) { + console.error('[WiFiMode] Quick scan error:', error); + showError(error.message); + setScanning(false); + } + } + + async function startDeepScan() { + if (isScanning) return; + + console.log('[WiFiMode] Starting deep scan...'); + setScanning(true, 'deep'); + + try { + const iface = elements.interfaceSelect?.value || null; + const band = document.getElementById('wifiBand')?.value || 'all'; + const channel = document.getElementById('wifiChannel')?.value || null; + + const response = await fetch(`${CONFIG.apiBase}/scan/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + interface: iface, + band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5', + channel: channel ? parseInt(channel) : null, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start deep scan'); + } + + // Start SSE stream for real-time updates + startEventStream(); + } catch (error) { + console.error('[WiFiMode] Deep scan error:', error); + showError(error.message); + setScanning(false); + } + } + + async function stopScan() { + console.log('[WiFiMode] Stopping scan...'); + + // Stop polling + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + + // Close event stream + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + // Stop deep scan on server + if (scanMode === 'deep') { + try { + await fetch(`${CONFIG.apiBase}/scan/stop`, { method: 'POST' }); + } catch (error) { + console.warn('[WiFiMode] Error stopping scan:', error); + } + } + + setScanning(false); + } + + function setScanning(scanning, mode = null) { + isScanning = scanning; + if (mode) scanMode = mode; + + // Update buttons + if (elements.quickScanBtn) { + elements.quickScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.deepScanBtn) { + elements.deepScanBtn.style.display = scanning ? 'none' : 'inline-block'; + } + if (elements.stopScanBtn) { + elements.stopScanBtn.style.display = scanning ? 'inline-block' : 'none'; + } + + // Update status + if (elements.scanStatus) { + elements.scanStatus.textContent = scanning + ? `Scanning (${scanMode === 'quick' ? 'Quick' : 'Deep'})...` + : 'Idle'; + elements.scanStatus.className = scanning ? 'status-scanning' : 'status-idle'; + } + } + + async function checkScanStatus() { + try { + const response = await fetch(`${CONFIG.apiBase}/scan/status`); + if (!response.ok) return; + + const status = await response.json(); + + if (status.is_scanning) { + setScanning(true, status.scan_mode); + if (status.scan_mode === 'deep') { + startEventStream(); + } else { + startQuickScanPolling(); + } + } + } catch (error) { + console.debug('[WiFiMode] Status check failed:', error); + } + } + + // ========================================================================== + // Quick Scan Polling + // ========================================================================== + + function startQuickScanPolling() { + if (pollTimer) return; + + pollTimer = setInterval(async () => { + if (!isScanning || scanMode !== 'quick') { + clearInterval(pollTimer); + pollTimer = null; + return; + } + + try { + const iface = elements.interfaceSelect?.value || null; + const response = await fetch(`${CONFIG.apiBase}/scan/quick`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interface: iface }), + }); + + if (response.ok) { + const result = await response.json(); + processQuickScanResult(result); + } + } catch (error) { + console.debug('[WiFiMode] Poll error:', error); + } + }, CONFIG.pollInterval); + } + + function processQuickScanResult(result) { + // Update networks + result.access_points.forEach(ap => { + networks.set(ap.bssid, ap); + }); + + // Update channel stats + channelStats = result.channel_stats || []; + recommendations = result.recommendations || []; + + // Update UI + updateNetworkTable(); + updateStats(); + updateProximityRadar(); + updateChannelChart(); + + // Callbacks + result.access_points.forEach(ap => { + if (onNetworkUpdate) onNetworkUpdate(ap); + }); + } + + // ========================================================================== + // SSE Event Stream + // ========================================================================== + + function startEventStream() { + if (eventSource) { + eventSource.close(); + } + + console.log('[WiFiMode] Starting event stream...'); + eventSource = new EventSource(`${CONFIG.apiBase}/stream`); + + eventSource.onopen = () => { + console.log('[WiFiMode] Event stream connected'); + }; + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleStreamEvent(data); + } catch (error) { + console.debug('[WiFiMode] Event parse error:', error); + } + }; + + eventSource.onerror = (error) => { + console.warn('[WiFiMode] Event stream error:', error); + if (isScanning) { + // Attempt to reconnect + setTimeout(() => { + if (isScanning && scanMode === 'deep') { + startEventStream(); + } + }, 3000); + } + }; + } + + function handleStreamEvent(event) { + switch (event.type) { + case 'network_update': + handleNetworkUpdate(event.network); + break; + + case 'client_update': + handleClientUpdate(event.client); + break; + + case 'probe_request': + handleProbeRequest(event.probe); + break; + + case 'hidden_revealed': + handleHiddenRevealed(event.bssid, event.revealed_essid); + break; + + case 'scan_started': + console.log('[WiFiMode] Scan started:', event); + break; + + case 'scan_stopped': + console.log('[WiFiMode] Scan stopped'); + setScanning(false); + break; + + case 'scan_error': + console.error('[WiFiMode] Scan error:', event.error); + showError(event.error); + setScanning(false); + break; + + case 'keepalive': + // Ignore keepalives + break; + + default: + console.debug('[WiFiMode] Unknown event type:', event.type); + } + } + + function handleNetworkUpdate(network) { + networks.set(network.bssid, network); + updateNetworkRow(network); + updateStats(); + updateProximityRadar(); + + if (onNetworkUpdate) onNetworkUpdate(network); + } + + function handleClientUpdate(client) { + clients.set(client.mac, client); + updateStats(); + + if (onClientUpdate) onClientUpdate(client); + } + + function handleProbeRequest(probe) { + probeRequests.push(probe); + if (probeRequests.length > CONFIG.maxProbes) { + probeRequests.shift(); + } + + if (onProbeRequest) onProbeRequest(probe); + } + + function handleHiddenRevealed(bssid, revealedSsid) { + const network = networks.get(bssid); + if (network) { + network.revealed_essid = revealedSsid; + network.display_name = `${revealedSsid} (revealed)`; + updateNetworkRow(network); + + // Show notification + showInfo(`Hidden SSID revealed: ${revealedSsid}`); + } + } + + // ========================================================================== + // Network Table + // ========================================================================== + + function initNetworkFilters() { + if (!elements.networkFilters) return; + + elements.networkFilters.addEventListener('click', (e) => { + if (e.target.matches('.wifi-filter-btn')) { + const filter = e.target.dataset.filter; + setNetworkFilter(filter); + } + }); + } + + function setNetworkFilter(filter) { + currentFilter = filter; + + // Update button states + if (elements.networkFilters) { + elements.networkFilters.querySelectorAll('.wifi-filter-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.filter === filter); + }); + } + + updateNetworkTable(); + } + + function initSortControls() { + if (!elements.networkTable) return; + + elements.networkTable.addEventListener('click', (e) => { + const th = e.target.closest('th[data-sort]'); + if (th) { + const field = th.dataset.sort; + if (currentSort.field === field) { + currentSort.order = currentSort.order === 'desc' ? 'asc' : 'desc'; + } else { + currentSort.field = field; + currentSort.order = 'desc'; + } + updateNetworkTable(); + } + }); + } + + function updateNetworkTable() { + if (!elements.networkTableBody) return; + + // Filter networks + let filtered = Array.from(networks.values()); + + switch (currentFilter) { + case 'hidden': + filtered = filtered.filter(n => n.is_hidden); + break; + case 'open': + filtered = filtered.filter(n => n.security === 'Open'); + break; + case 'strong': + filtered = filtered.filter(n => n.rssi_current && n.rssi_current >= -60); + break; + case '2.4': + filtered = filtered.filter(n => n.band === '2.4GHz'); + break; + case '5': + filtered = filtered.filter(n => n.band === '5GHz'); + break; + } + + // Sort networks + filtered.sort((a, b) => { + let aVal, bVal; + + switch (currentSort.field) { + case 'rssi': + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + break; + case 'channel': + aVal = a.channel || 0; + bVal = b.channel || 0; + break; + case 'essid': + aVal = (a.essid || '').toLowerCase(); + bVal = (b.essid || '').toLowerCase(); + break; + case 'clients': + aVal = a.client_count || 0; + bVal = b.client_count || 0; + break; + default: + aVal = a.rssi_current || -100; + bVal = b.rssi_current || -100; + } + + if (currentSort.order === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Render table + elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join(''); + } + + function createNetworkRow(network) { + const rssi = network.rssi_current; + const signalClass = rssi >= -50 ? 'signal-strong' : + rssi >= -70 ? 'signal-medium' : + rssi >= -85 ? 'signal-weak' : 'signal-very-weak'; + + const securityClass = network.security === 'Open' ? 'security-open' : + network.security === 'WEP' ? 'security-wep' : + network.security.includes('WPA3') ? 'security-wpa3' : 'security-wpa'; + + const hiddenBadge = network.is_hidden ? 'Hidden' : ''; + const newBadge = network.is_new ? 'New' : ''; + + return ` + + + ${escapeHtml(network.display_name || network.essid || '[Hidden]')} + ${hiddenBadge}${newBadge} + + ${escapeHtml(network.bssid)} + ${network.channel || '-'} + + ${rssi !== null ? rssi : '-'} + + + ${escapeHtml(network.security)} + + ${network.client_count || 0} + ${escapeHtml(network.vendor || '-')} + + `; + } + + function updateNetworkRow(network) { + const row = elements.networkTableBody?.querySelector(`tr[data-bssid="${network.bssid}"]`); + if (row) { + row.outerHTML = createNetworkRow(network); + } else { + // Add new row + updateNetworkTable(); + } + } + + function selectNetwork(bssid) { + selectedNetwork = bssid; + + // Update row selection + elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + row.classList.toggle('selected', row.dataset.bssid === bssid); + }); + + // Update detail panel + updateDetailPanel(bssid); + + // Highlight on radar + if (typeof WiFiProximityRadar !== 'undefined') { + WiFiProximityRadar.highlightNetwork(bssid); + } + } + + // ========================================================================== + // Detail Panel + // ========================================================================== + + function updateDetailPanel(bssid) { + if (!elements.detailPanel || !elements.detailContent) return; + + const network = networks.get(bssid); + if (!network) { + elements.detailPanel.style.display = 'none'; + return; + } + + elements.detailPanel.style.display = 'block'; + elements.detailContent.innerHTML = ` +
+

${escapeHtml(network.display_name || network.essid || '[Hidden SSID]')}

+ +
+
+
+ BSSID: + ${escapeHtml(network.bssid)} +
+
+ Channel: + ${network.channel || '-'} (${network.band}) +
+
+ Security: + ${escapeHtml(network.security)} / ${escapeHtml(network.cipher || '-')} / ${escapeHtml(network.auth || '-')} +
+
+ Signal: + ${network.rssi_current || '-'} dBm (${network.signal_band}) +
+
+ Vendor: + ${escapeHtml(network.vendor || 'Unknown')} +
+
+ Clients: + ${network.client_count || 0} +
+
+ First Seen: + ${formatTime(network.first_seen)} +
+
+ Last Seen: + ${formatTime(network.last_seen)} +
+ ${network.rssi_history?.length > 0 ? ` +
+
Signal History
+
+
+ ` : ''} +
+ `; + + // Render RSSI sparkline if available + if (network.rssi_history?.length > 0 && typeof RSSISparkline !== 'undefined') { + RSSISparkline.render('wifiDetailRssiChart', network.rssi_history); + } + } + + function closeDetail() { + selectedNetwork = null; + if (elements.detailPanel) { + elements.detailPanel.style.display = 'none'; + } + elements.networkTableBody?.querySelectorAll('.wifi-network-row').forEach(row => { + row.classList.remove('selected'); + }); + } + + // ========================================================================== + // Statistics + // ========================================================================== + + function updateStats() { + if (elements.networkCount) { + elements.networkCount.textContent = networks.size; + } + if (elements.clientCount) { + elements.clientCount.textContent = clients.size; + } + if (elements.hiddenCount) { + const hidden = Array.from(networks.values()).filter(n => n.is_hidden).length; + elements.hiddenCount.textContent = hidden; + } + } + + // ========================================================================== + // Proximity Radar + // ========================================================================== + + function initProximityRadar() { + if (!elements.proximityRadar) return; + + // Initialize radar component + if (typeof ProximityRadar !== 'undefined') { + ProximityRadar.init('wifiProximityRadar', { + mode: 'wifi', + size: 280, + onDeviceClick: (bssid) => selectNetwork(bssid), + }); + } + } + + function updateProximityRadar() { + if (typeof ProximityRadar === 'undefined') return; + + // Convert networks to radar-compatible format + const devices = Array.from(networks.values()).map(n => ({ + device_key: n.bssid, + device_id: n.bssid, + name: n.essid || '[Hidden]', + rssi_current: n.rssi_current, + rssi_ema: n.rssi_ema, + proximity_band: n.proximity_band, + estimated_distance_m: n.estimated_distance_m, + is_new: n.is_new, + heuristic_flags: n.heuristic_flags || [], + })); + + ProximityRadar.updateDevices(devices); + } + + // ========================================================================== + // Channel Chart + // ========================================================================== + + function initChannelChart() { + if (!elements.channelChart) return; + + // Initialize channel chart component + if (typeof ChannelChart !== 'undefined') { + ChannelChart.init('wifiChannelChart'); + } + + // Band tabs + if (elements.channelBandTabs) { + elements.channelBandTabs.addEventListener('click', (e) => { + if (e.target.matches('.channel-band-tab')) { + const band = e.target.dataset.band; + elements.channelBandTabs.querySelectorAll('.channel-band-tab').forEach(t => { + t.classList.toggle('active', t.dataset.band === band); + }); + updateChannelChart(band); + } + }); + } + } + + function updateChannelChart(band = '2.4') { + if (typeof ChannelChart === 'undefined') return; + + // Filter stats by band + const bandFilter = band === '2.4' ? '2.4GHz' : band === '5' ? '5GHz' : '6GHz'; + const filteredStats = channelStats.filter(s => s.band === bandFilter); + const filteredRecs = recommendations.filter(r => r.band === bandFilter); + + ChannelChart.update(filteredStats, filteredRecs); + } + + // ========================================================================== + // Export + // ========================================================================== + + async function exportData(format) { + try { + const response = await fetch(`${CONFIG.apiBase}/export?format=${format}&type=all`); + if (!response.ok) throw new Error('Export failed'); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `wifi_scan_${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + console.error('[WiFiMode] Export error:', error); + showError('Export failed: ' + error.message); + } + } + + // ========================================================================== + // Utilities + // ========================================================================== + + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatTime(isoString) { + if (!isoString) return '-'; + const date = new Date(isoString); + return date.toLocaleTimeString(); + } + + function showError(message) { + // Use global notification if available + if (typeof showNotification === 'function') { + showNotification('WiFi Error', message, 'error'); + } else { + console.error('[WiFiMode]', message); + } + } + + function showInfo(message) { + if (typeof showNotification === 'function') { + showNotification('WiFi', message, 'info'); + } else { + console.log('[WiFiMode]', message); + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + return { + init, + startQuickScan, + startDeepScan, + stopScan, + selectNetwork, + closeDetail, + setFilter: setNetworkFilter, + exportData, + checkCapabilities, + + // Getters + getNetworks: () => Array.from(networks.values()), + getClients: () => Array.from(clients.values()), + getProbes: () => [...probeRequests], + isScanning: () => isScanning, + getScanMode: () => scanMode, + + // Callbacks + onNetworkUpdate: (cb) => { onNetworkUpdate = cb; }, + onClientUpdate: (cb) => { onClientUpdate = cb; }, + onProbeRequest: (cb) => { onProbeRequest = cb; }, + }; +})(); + +// Auto-initialize when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + // Only init if we're in WiFi mode + if (typeof currentMode !== 'undefined' && currentMode === 'wifi') { + WiFiMode.init(); + } +}); diff --git a/templates/index.html b/templates/index.html index 69a834b..4e97dbf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1565,6 +1565,9 @@ + + +