Files
intercept/templates/index.html
Smittix 007400d2a7 Release v2.9.0 - iNTERCEPT rebrand and UI overhaul
- Rebrand from INTERCEPT to iNTERCEPT
- New logo design with 'i' and signal wave brackets
- Add animated landing page with "See the Invisible" tagline
- Fix tuning dial audio issues with debouncing and restart prevention
- Fix Listening Post scanner with proper signal hit logging
- Update setup script for apt-based Python package installation
- Add Instagram promo video template
- Add full-size logo assets for external use

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 01:00:17 +00:00

8741 lines
437 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT // See the Invisible</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Leaflet.js for aircraft map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<!-- Leaflet MarkerCluster for aircraft clustering -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css"/>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<!-- Chart.js for signal strength graphs -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
</head>
<body>
<!-- Landing Page / Splash Screen -->
<div class="landing-overlay" id="landingPage">
<div class="landing-content">
<div class="landing-logo">
<svg width="200" height="200" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" class="signal-wave signal-wave-3"/>
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" class="signal-wave signal-wave-3"/>
<!-- The 'i' letter -->
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot"/>
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
</div>
<h1 class="landing-title">iNTERCEPT</h1>
<p class="landing-tagline">// See the Invisible</p>
<p class="landing-subtitle">Signal Intelligence & Counter Surveillance Platform</p>
<button class="landing-enter-btn" onclick="enterApp()">
<span class="btn-text">ENTER</span>
<span class="btn-icon"></span>
</button>
<p class="landing-version">v{{ version }}</p>
</div>
<div class="landing-scanline"></div>
</div>
<!-- Disclaimer Modal -->
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
<div class="disclaimer-modal">
<div class="warning-icon">⚠️</div>
<h2>DISCLAIMER</h2>
<p>
<strong>iNTERCEPT</strong> is a signal intelligence tool designed for <strong>educational purposes only</strong>.
</p>
<p>By using this software, you acknowledge and agree that:</p>
<ul>
<li>This tool is intended for use by <strong>cyber security professionals</strong> and researchers only</li>
<li>You will only use this software in a <strong>controlled environment</strong> with proper authorization</li>
<li>Intercepting communications without consent may be <strong>illegal</strong> in your jurisdiction</li>
<li>You are solely responsible for ensuring compliance with all applicable laws and regulations</li>
<li>The developers assume no liability for misuse of this software</li>
</ul>
<p style="color: var(--accent-red); font-weight: bold;">
Only proceed if you understand and accept these terms.
</p>
<div style="display: flex; gap: 15px; justify-content: center; margin-top: 20px;">
<button class="accept-btn" onclick="acceptDisclaimer()">I UNDERSTAND & ACCEPT</button>
<button class="accept-btn" onclick="declineDisclaimer()" style="background: transparent; border: 1px solid var(--accent-red); color: var(--accent-red);">DECLINE</button>
</div>
</div>
</div>
<!-- Rejection Page -->
<div class="disclaimer-overlay disclaimer-hidden" id="rejectionPage">
<div class="disclaimer-modal" style="max-width: 600px;">
<pre style="color: var(--accent-red); font-size: 9px; line-height: 1.1; margin-bottom: 20px; text-align: center;">
█████╗ ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
███████║██║ ██║ █████╗ ███████╗███████╗
██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
██║ ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
<p style="font-family: 'JetBrains Mono', monospace; font-size: 11px; color: #888; text-align: left; margin: 0;">
<span style="color: var(--accent-red);">root@intercepted:</span><span style="color: var(--accent-cyan);">~#</span> sudo access --grant-permission<br>
<span style="color: #666;">[sudo] password for user: ********</span><br>
<span style="color: var(--accent-red);">Error:</span> User is not in the sudoers file.<br>
<span style="color: var(--accent-orange);">This incident will be reported.</span>
</p>
</div>
<p style="color: #666; font-size: 11px; text-align: center;">
"In a world of locked doors, the man with the key is king.<br>
And you, my friend, just threw away the key."
</p>
<button class="accept-btn" onclick="location.reload()" style="margin-top: 20px; background: transparent; border: 1px solid var(--accent-cyan); color: var(--accent-cyan);">
TRY AGAIN
</button>
</div>
</div>
<header>
<!-- UTC Clock -->
<div class="header-clock">
<span class="utc-label">UTC</span>
<span class="utc-time" id="headerUtcTime">--:--:--</span>
</div>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
<span class="icon-moon">🌙</span>
<span class="icon-sun">☀️</span>
</button>
<button class="help-btn" onclick="showDependencies()" title="Check Tool Dependencies" id="depsBtn" style="margin-right: 5px;">🔧</button>
<button class="help-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<div class="logo">
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- The 'i' letter -->
<!-- dot of i -->
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
<!-- stem of i with styled terminals -->
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
<!-- top terminal bar -->
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
<!-- bottom terminal bar -->
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
</svg>
</div>
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span> <span class="version-badge">v{{ version }}</span></h1>
<p class="subtitle">Signal Intelligence & Counter Surveillance Platform <span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span></p>
<!-- Header Stats (mode-specific) -->
<div class="header-stats">
<!-- Pager Stats -->
<div class="header-stats-group active" id="headerPagerStats">
<div class="stat-badge">
<span class="badge-icon">📨</span>
<div>
<span class="badge-value" id="headerMsgCount">0</span>
<span class="badge-label">messages</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📟</span>
<div>
<span class="badge-value" id="headerPocsagCount">0</span>
<span class="badge-label">POCSAG</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📠</span>
<div>
<span class="badge-value" id="headerFlexCount">0</span>
<span class="badge-label">FLEX</span>
</div>
</div>
</div>
<!-- 433MHz Sensor Stats -->
<div class="header-stats-group" id="headerSensorStats">
<div class="stat-badge">
<span class="badge-icon">🌡️</span>
<div>
<span class="badge-value" id="headerSensorCount">0</span>
<span class="badge-label">sensors</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📊</span>
<div>
<span class="badge-value" id="headerDeviceTypeCount">0</span>
<span class="badge-label">types</span>
</div>
</div>
</div>
<!-- WiFi Stats -->
<div class="header-stats-group" id="headerWifiStats">
<div class="stat-badge">
<span class="badge-icon">📡</span>
<div>
<span class="badge-value" id="headerApCount">0</span>
<span class="badge-label">networks</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">👤</span>
<div>
<span class="badge-value" id="headerClientCount">0</span>
<span class="badge-label">clients</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">🤝</span>
<div>
<span class="badge-value highlight" id="headerHandshakeCount">0</span>
<span class="badge-label">handshakes</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">🚁</span>
<div>
<span class="badge-value warning" id="headerDroneCount">0</span>
<span class="badge-label">drones</span>
</div>
</div>
</div>
<!-- Bluetooth Stats -->
<div class="header-stats-group" id="headerBtStats">
<div class="stat-badge">
<span class="badge-icon">🔵</span>
<div>
<span class="badge-value" id="headerBtDeviceCount">0</span>
<span class="badge-label">devices</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📍</span>
<div>
<span class="badge-value" id="headerBtBeaconCount">0</span>
<span class="badge-label">beacons</span>
</div>
</div>
</div>
<!-- Aircraft Stats -->
<div class="header-stats-group" id="headerAircraftStats">
<div class="stat-badge">
<span class="badge-icon">✈️</span>
<div>
<span class="badge-value" id="headerAircraftCount">0</span>
<span class="badge-label">aircraft</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📨</span>
<div>
<span class="badge-value" id="headerAdsbMsgCount">0</span>
<span class="badge-label">messages</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">🔢</span>
<div>
<span class="badge-value" id="headerIcaoCount">0</span>
<span class="badge-label">ICAO</span>
</div>
</div>
</div>
<!-- Satellite Stats -->
<div class="header-stats-group" id="headerSatelliteStats">
<div class="stat-badge">
<span class="badge-icon">🛰️</span>
<div>
<span class="badge-value" id="headerPassCount">0</span>
<span class="badge-label">passes</span>
</div>
</div>
<div class="stat-badge">
<span class="badge-icon">📡</span>
<div>
<span class="badge-value" id="headerBurstCount">0</span>
<span class="badge-label">bursts</span>
</div>
</div>
</div>
</div>
</header>
<!-- Mode Navigation Bar -->
<nav class="mode-nav">
<div class="mode-nav-group">
<span class="mode-nav-label">SDR / RF</span>
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon">📟</span><span class="nav-label">Pager</span></button>
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon">📡</span><span class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('aircraft')"><span class="nav-icon">✈️</span><span class="nav-label">Aircraft</span></button>
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon">🛰️</span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon">📻</span><span class="nav-label">Listening Post</span></button>
</div>
<div class="mode-nav-divider"></div>
<div class="mode-nav-group">
<span class="mode-nav-label">Wireless</span>
<button class="mode-nav-btn" onclick="switchMode('wifi')"><span class="nav-icon">📶</span><span class="nav-label">WiFi</span></button>
<button class="mode-nav-btn" onclick="switchMode('bluetooth')"><span class="nav-icon">🔵</span><span class="nav-label">Bluetooth</span></button>
</div>
<div class="mode-nav-actions">
<a href="/adsb/dashboard" target="_blank" class="nav-action-btn" id="adsbDashboardBtn" style="display: none;">
<span class="nav-icon">🖥️</span><span class="nav-label">Full Dashboard</span>
</a>
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn" style="display: none;">
<span class="nav-icon">🖥️</span><span class="nav-label">Full Dashboard</span>
</a>
</div>
</nav>
<div class="container">
<div class="main-content">
<div class="sidebar">
<div class="section" id="rtlDeviceSection">
<h3>SDR Device</h3>
<div class="form-group">
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
<option value="rtlsdr">RTL-SDR</option>
<option value="limesdr">LimeSDR</option>
<option value="hackrf">HackRF</option>
<option value="airspy">Airspy</option>
</select>
</div>
<div class="form-group">
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Device</label>
<select id="deviceSelect">
{% if devices %}
{% for device in devices %}
<option value="{{ device.index }}" data-sdr-type="{{ device.sdr_type | default('rtlsdr') }}">{{ device.index }}: {{ device.name }}</option>
{% endfor %}
{% else %}
<option value="0">No devices found</option>
{% endif %}
</select>
</div>
<div id="deviceCapabilities" class="info-text" style="font-size: 11px; margin-bottom: 8px; padding: 6px; background: #0a0a1a; border-radius: 4px;">
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">Freq:</span><span id="capFreqRange">24-1766 MHz</span>
<span style="color: #888;">Gain:</span><span id="capGainRange">0-50 dB</span>
</div>
</div>
<!-- Bias-T Power Toggle - Prominent Location -->
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; margin-bottom: 8px; background: linear-gradient(90deg, rgba(255,100,0,0.2), rgba(255,100,0,0.05)); border: 1px solid var(--accent-orange); border-radius: 4px;">
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()" style="width: 18px; height: 18px; accent-color: var(--accent-orange);">
<div>
<div style="color: var(--accent-orange); font-weight: bold; font-size: 12px;">⚡ Bias-T Power</div>
<div style="color: #888; font-size: 9px;">Powers external LNA/preamp</div>
</div>
</div>
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
Refresh Devices
</button>
<!-- Remote SDR (rtl_tcp) -->
<div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox">
<input type="checkbox" id="useRemoteSDR" onchange="toggleRemoteSDR()">
Use Remote SDR (rtl_tcp)
</label>
</div>
<div id="remoteSDRConfig" style="display: none; margin-bottom: 10px;">
<div class="form-group">
<label style="font-size: 11px; color: #888;">Host</label>
<input type="text" id="rtlTcpHost" placeholder="192.168.1.100" style="width: 100%;">
</div>
<div class="form-group">
<label style="font-size: 11px; color: #888;">Port</label>
<input type="number" id="rtlTcpPort" value="1234" min="1" max="65535" style="width: 100%;">
</div>
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
Connect to rtl_tcp server on remote machine.<br>
Start server with: <code style="color: #00d4ff;">rtl_tcp -a 0.0.0.0</code>
</div>
</div>
<div id="toolStatusPager" class="info-text tool-status-section" style="display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK' if tools.rtl_fm else 'Missing' }}</span>
<span>multimon-ng:</span><span class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon else 'Missing' }}</span>
</div>
<div id="toolStatusSensor" class="info-text tool-status-section" style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>rtl_433:</span><span class="tool-status {{ 'ok' if tools.rtl_433 else 'missing' }}">{{ 'OK' if tools.rtl_433 else 'Missing' }}</span>
</div>
<div id="toolStatusAircraft" class="info-text tool-status-section" style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>dump1090:</span><span id="dump1090StatusSidebar" class="tool-status">Checking...</span>
<span>rtl_adsb:</span><span id="rtlAdsbStatusSidebar" class="tool-status">Checking...</span>
</div>
</div>
<!-- PAGER MODE -->
<div id="pagerMode" class="mode-content active">
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="text" id="frequency" value="153.350" placeholder="e.g., 153.350">
</div>
<div class="preset-buttons" id="presetButtons">
<!-- Populated by JavaScript -->
</div>
<div style="margin-top: 8px; display: flex; gap: 5px;">
<input type="text" id="newPresetFreq" placeholder="New freq (MHz)" style="flex: 1; padding: 6px; background: #0f3460; border: 1px solid #1a1a2e; color: #fff; border-radius: 4px; font-size: 12px;">
<button class="preset-btn" onclick="addPreset()" style="background: #2ecc71;">Add</button>
</div>
<div style="margin-top: 5px;">
<button class="preset-btn" onclick="resetPresets()" style="font-size: 11px;">Reset to Defaults</button>
</div>
</div>
<div class="section">
<h3>Protocols</h3>
<div class="checkbox-group">
<label><input type="checkbox" id="proto_pocsag512" checked> POCSAG-512</label>
<label><input type="checkbox" id="proto_pocsag1200" checked> POCSAG-1200</label>
<label><input type="checkbox" id="proto_pocsag2400" checked> POCSAG-2400</label>
<label><input type="checkbox" id="proto_flex" checked> FLEX</label>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="text" id="gain" value="0" placeholder="0-49 or 0 for auto">
</div>
<div class="form-group">
<label>Squelch Level</label>
<input type="text" id="squelch" value="0" placeholder="0 = off">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="ppm" value="0" placeholder="Frequency correction">
</div>
</div>
<div class="section">
<h3>Logging</h3>
<div class="checkbox-group" style="margin-bottom: 15px;">
<label>
<input type="checkbox" id="loggingEnabled" onchange="toggleLogging()">
Enable Logging
</label>
</div>
<div class="form-group">
<label>Log file path</label>
<input type="text" id="logFilePath" value="pager_messages.log" placeholder="pager_messages.log">
</div>
</div>
<div class="section">
<h3>Message Filters</h3>
<div class="checkbox-group" style="margin-bottom: 10px;">
<label>
<input type="checkbox" id="filterToneOnly" checked onchange="savePagerFilters()">
Hide "Tone Only" messages
</label>
</div>
<div class="form-group">
<label>Hide messages containing (comma-separated)</label>
<input type="text" id="filterKeywords" placeholder="e.g. test, spam, alert" onchange="savePagerFilters()">
</div>
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 5px;">
Messages matching these keywords will be hidden from display but still logged.
</div>
</div>
<button class="run-btn" id="startBtn" onclick="startDecoding()">
Start Decoding
</button>
<button class="stop-btn" id="stopBtn" onclick="stopDecoding()" style="display: none;">
Stop Decoding
</button>
</div>
<!-- 433MHz SENSOR MODE -->
<div id="sensorMode" class="mode-content">
<div class="section">
<h3>Frequency</h3>
<div class="form-group">
<label>Frequency (MHz)</label>
<input type="text" id="sensorFrequency" value="433.92" placeholder="e.g., 433.92">
</div>
<div class="preset-buttons">
<button class="preset-btn" onclick="setSensorFreq('433.92')">433.92</button>
<button class="preset-btn" onclick="setSensorFreq('315.00')">315.00</button>
<button class="preset-btn" onclick="setSensorFreq('868.00')">868.00</button>
<button class="preset-btn" onclick="setSensorFreq('915.00')">915.00</button>
</div>
</div>
<div class="section">
<h3>Settings</h3>
<div class="form-group">
<label>Gain (dB, 0 = auto)</label>
<input type="text" id="sensorGain" value="0" placeholder="0-49 or 0 for auto">
</div>
<div class="form-group">
<label>PPM Correction</label>
<input type="text" id="sensorPpm" value="0" placeholder="Frequency correction">
</div>
</div>
<div class="section">
<h3>Protocols</h3>
<div class="info-text" style="margin-bottom: 10px;">
rtl_433 auto-detects 200+ device protocols including weather stations, TPMS, doorbells, and more.
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" id="sensorLogging" onchange="toggleSensorLogging()">
Enable Logging
</label>
</div>
</div>
<button class="run-btn" id="startSensorBtn" onclick="startSensorDecoding()">
Start Listening
</button>
<button class="stop-btn" id="stopSensorBtn" onclick="stopSensorDecoding()" style="display: none;">
Stop Listening
</button>
</div>
<!-- WiFi MODE -->
<div id="wifiMode" class="mode-content">
<div class="section">
<h3>WiFi Adapter</h3>
<div class="form-group">
<label>Select Device</label>
<select id="wifiInterfaceSelect" style="font-size: 12px;">
<option value="">Detecting interfaces...</option>
</select>
</div>
<button class="preset-btn" onclick="refreshWifiInterfaces()" style="width: 100%;">
🔄 Refresh Devices
</button>
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="wifiToolStatus">
<span>airmon-ng:</span><span class="tool-status missing">Checking...</span>
<span>airodump-ng:</span><span class="tool-status missing">Checking...</span>
</div>
</div>
<div class="section">
<h3>Monitor Mode</h3>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" id="monitorStartBtn" onclick="enableMonitorMode()" style="flex: 1; background: var(--accent-green); color: #000;">
Enable Monitor
</button>
<button class="preset-btn" id="monitorStopBtn" onclick="disableMonitorMode()" style="flex: 1; display: none;">
Disable Monitor
</button>
</div>
<div class="checkbox-group" style="margin-top: 8px;">
<label style="font-size: 10px;">
<input type="checkbox" id="killProcesses">
Kill interfering processes (may drop other connections)
</label>
</div>
<div id="monitorStatus" class="info-text" style="margin-top: 8px;">
Monitor mode: <span style="color: var(--accent-red);">Inactive</span>
</div>
</div>
<div class="section">
<h3>Scan Settings</h3>
<div class="form-group">
<label>Band</label>
<select id="wifiBand">
<option value="abg">All (2.4 + 5 GHz)</option>
<option value="bg">2.4 GHz only</option>
<option value="a">5 GHz only</option>
</select>
</div>
<div class="form-group">
<label>Channel (empty = hop)</label>
<input type="text" id="wifiChannel" placeholder="e.g., 6 or 36">
</div>
</div>
<div class="section">
<h3>Proximity Alerts</h3>
<div class="info-text" style="margin-bottom: 8px;">
Alert when specific MAC addresses appear
</div>
<div class="form-group">
<input type="text" id="watchMacInput" placeholder="AA:BB:CC:DD:EE:FF">
</div>
<button class="preset-btn" onclick="addWatchMac()" style="width: 100%; margin-bottom: 8px;">
Add to Watch List
</button>
<div id="watchList" style="max-height: 80px; overflow-y: auto; font-size: 10px; color: var(--text-dim);"></div>
</div>
<div class="section">
<h3>Attack Options</h3>
<div class="info-text" style="color: var(--accent-red); margin-bottom: 10px;">
⚠ Only use on authorized networks
</div>
<div class="form-group">
<label>Target BSSID</label>
<input type="text" id="targetBssid" placeholder="AA:BB:CC:DD:EE:FF">
</div>
<div class="form-group">
<label>Target Client (optional)</label>
<input type="text" id="targetClient" placeholder="FF:FF:FF:FF:FF:FF (broadcast)">
</div>
<div class="form-group">
<label>Deauth Count</label>
<input type="text" id="deauthCount" value="5" placeholder="5">
</div>
<button class="preset-btn" onclick="sendDeauth()" style="width: 100%; border-color: var(--accent-red); color: var(--accent-red);">
Send Deauth
</button>
</div>
<!-- Handshake Capture Status Panel -->
<div class="section" id="captureStatusPanel" style="display: none; border: 1px solid var(--accent-orange); border-radius: 4px; padding: 10px; background: rgba(255, 165, 0, 0.1);">
<h3 style="color: var(--accent-orange); margin: 0 0 8px 0;">🎯 Handshake Capture</h3>
<div style="font-size: 11px;">
<div style="margin-bottom: 4px;">
<span style="color: var(--text-dim);">Target:</span>
<span id="captureTargetBssid" style="font-family: monospace;">--</span>
</div>
<div style="margin-bottom: 4px;">
<span style="color: var(--text-dim);">Channel:</span>
<span id="captureTargetChannel">--</span>
</div>
<div style="margin-bottom: 4px;">
<span style="color: var(--text-dim);">File:</span>
<span id="captureFilePath" style="font-size: 9px; word-break: break-all;">--</span>
</div>
<div style="margin-bottom: 8px;">
<span style="color: var(--text-dim);">Status:</span>
<span id="captureStatus" style="font-weight: bold;">--</span>
</div>
<div style="display: flex; gap: 8px;">
<button class="preset-btn" onclick="checkCaptureStatus()" style="flex: 1; font-size: 10px; padding: 4px;">
Check Status
</button>
<button class="preset-btn" onclick="stopHandshakeCapture()" style="flex: 1; font-size: 10px; padding: 4px; border-color: var(--accent-red); color: var(--accent-red);">
Stop Capture
</button>
</div>
</div>
</div>
<!-- Beacon Flood Alert Panel -->
<div id="beaconFloodAlert" class="beacon-flood-alert" style="display: none;">
<h4 style="color: #ff4444; margin: 0 0 8px 0;">⚠️ BEACON FLOOD DETECTED</h4>
<div style="font-size: 11px;">
<div id="beaconFloodDetails">Multiple beacon frames detected from same channel</div>
<div style="margin-top: 8px;">
<span style="color: var(--text-dim);">Networks/sec:</span>
<span id="beaconFloodRate" style="font-weight: bold; color: #ff4444;">--</span>
</div>
</div>
</div>
<button class="run-btn" id="startWifiBtn" onclick="startWifiScan()">
Start Scanning
</button>
<button class="stop-btn" id="stopWifiBtn" onclick="stopWifiScan()" style="display: none;">
Stop Scanning
</button>
</div>
<!-- BLUETOOTH MODE -->
<div id="bluetoothMode" class="mode-content">
<div class="section">
<h3>Bluetooth Interface</h3>
<div class="form-group">
<select id="btInterfaceSelect">
<option value="">Detecting interfaces...</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>
<div class="form-group">
<label>Scan Duration (sec)</label>
<input type="text" id="btScanDuration" value="30" placeholder="30">
</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">
</div>
<button class="preset-btn" onclick="btEnumServices()" style="width: 100%;">
Enumerate Services
</button>
</div>
<!-- Tracker Following Alert -->
<div id="trackerFollowingAlert" class="tracker-following-alert" style="display: none;">
<!-- Populated by JavaScript -->
</div>
<button class="run-btn" id="startBtBtn" onclick="startBtScan()">
Start Scanning
</button>
<button class="stop-btn" id="stopBtBtn" onclick="stopBtScan()" style="display: none;">
Stop Scanning
</button>
<button class="preset-btn" onclick="resetBtAdapter()" style="margin-top: 5px; width: 100%;">
Reset Adapter
</button>
</div>
<!-- AIRCRAFT MODE (ADS-B) -->
<div id="aircraftMode" class="mode-content">
<div class="section">
<h3>ADS-B Receiver</h3>
<div class="form-group">
<label>Frequency</label>
<input type="text" id="adsbFrequency" value="1090" readonly style="opacity: 0.7;">
<div class="info-text">Fixed at 1090 MHz</div>
</div>
<div class="form-group">
<label>Gain (dB)</label>
<input type="text" id="adsbGain" value="40" placeholder="40">
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" id="adsbEnableMap" checked onchange="toggleAircraftRadar()">
Show Radar Display
</label>
</div>
</div>
<div class="section">
<h3>Display Settings</h3>
<div class="form-group">
<label>Range (nm)</label>
<select id="adsbRange">
<option value="50">50 nm</option>
<option value="100" selected>100 nm</option>
<option value="200">200 nm</option>
<option value="500">500 nm</option>
</select>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" id="adsbShowLabels" checked>
Show Callsigns
</label>
<label>
<input type="checkbox" id="adsbShowAltitude" checked>
Show Altitude
</label>
<label>
<input type="checkbox" id="adsbShowTrails">
Show Flight Trails
</label>
<label>
<input type="checkbox" id="adsbEnableClustering" onchange="toggleAircraftClustering()">
Cluster Markers
</label>
<label>
<input type="checkbox" id="adsbShowRangeRings" checked onchange="drawRangeRings()">
Show Range Rings
</label>
</div>
<div class="form-group" style="margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 8px;">
Observer Location
<span id="adsbGpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd">
<span class="gps-dot"></span> GPS
</span>
</label>
<div style="display: flex; gap: 5px;">
<input type="text" id="adsbObsLat" value="51.5074" placeholder="Latitude" style="flex: 1;" onchange="updateObserverLocation()">
<input type="text" id="adsbObsLon" value="-0.1278" placeholder="Longitude" style="flex: 1;" onchange="updateObserverLocation()">
</div>
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 5px;">
📍 Use Browser Location
</button>
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Aircraft Filter</label>
<select id="adsbAircraftFilter" onchange="applyAircraftFilter()">
<option value="all">All Aircraft</option>
<option value="military">Military Only</option>
<option value="civil">Civil Only</option>
<option value="emergency">Emergency Only</option>
</select>
</div>
<div class="form-group" style="margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="adsbAlertToggle" checked onchange="adsbAlertsEnabled = this.checked">
🔔 Audio Alerts (Military/Emergency)
</label>
</div>
</div>
<div class="section">
<h3>Reception Statistics</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 11px;">
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Max Range</div>
<div id="adsbMaxRange" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0 nm</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Total Seen</div>
<div id="adsbTotalSeen" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Msg Rate</div>
<div id="adsbMsgRate" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0/s</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Busiest Hour</div>
<div id="adsbBusiestHour" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">--</div>
</div>
</div>
</div>
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="adsbToolStatus">
<span>dump1090:</span><span class="tool-status" id="dump1090Status">Checking...</span>
<span>rtl_adsb:</span><span class="tool-status" id="rtlAdsbStatus">Checking...</span>
</div>
<button class="run-btn" id="startAdsbBtn" onclick="startAdsbScan()">
Start Tracking
</button>
<button class="stop-btn" id="stopAdsbBtn" onclick="stopAdsbScan()" style="display: none;">
Stop Tracking
</button>
</div>
<!-- SATELLITE MODE -->
<div id="satelliteMode" class="mode-content">
<div class="section">
<h3>Satellite Command</h3>
<p style="color: var(--text-secondary); font-size: 11px; line-height: 1.5; margin-bottom: 15px;">
Full satellite tracking dashboard with polar plot, ground track map, pass predictions, and live telemetry.
</p>
<div style="background: rgba(0,212,255,0.1); border: 1px solid var(--accent-cyan); border-radius: 6px; padding: 12px; margin-bottom: 15px;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 18px;">🛰️</span>
<span style="color: var(--accent-cyan); font-weight: 600; font-size: 12px; text-transform: uppercase;">Dashboard Active</span>
</div>
<p style="color: var(--text-secondary); font-size: 10px; margin: 0;">
Use the embedded dashboard controls to select satellites, set observer location, and calculate passes.
</p>
</div>
<a href="/satellite/dashboard" target="_blank" class="preset-btn" style="display: block; text-align: center; text-decoration: none; width: 100%;">
Open in New Window
</a>
</div>
<div class="section">
<h3>Quick Info</h3>
<div style="font-size: 11px; color: var(--text-secondary); line-height: 1.6;">
<p><strong>Polar Plot:</strong> Shows satellite path across the sky. Center = overhead, edge = horizon.</p>
<p style="margin-top: 8px;"><strong>Ground Track:</strong> Real-time satellite position and orbital path on world map.</p>
<p style="margin-top: 8px;"><strong>Pass Quality:</strong> Excellent (60°+), Good (30°+), Fair (below 30°).</p>
</div>
</div>
<div class="section">
<h3>Supported Satellites</h3>
<div style="font-size: 10px; color: var(--text-secondary);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 4px;">
<span>ISS (ZARYA)</span>
<span>NOAA 15/18/19/20</span>
<span>METEOR-M2</span>
<span>METEOR-M2-3</span>
</div>
</div>
</div>
</div>
<!-- LISTENING POST MODE -->
<div id="listeningPostMode" class="mode-content">
<div class="section">
<h3>Status</h3>
<!-- Dependency Warning -->
<div id="scannerToolsWarning" style="display: none; background: rgba(255, 100, 100, 0.1); border: 1px solid var(--accent-red); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<p style="color: var(--accent-red); margin: 0; font-size: 0.85em;">
<strong>Missing:</strong><br>
<span id="scannerToolsWarningText"></span>
</p>
</div>
<!-- Quick Status -->
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Status</span>
<span id="lpQuickStatus" style="font-size: 11px; color: var(--accent-cyan);">IDLE</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Frequency</span>
<span id="lpQuickFreq" style="font-size: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-primary);">---.--- MHz</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Signals</span>
<span id="lpQuickSignals" style="font-size: 14px; font-weight: bold; color: var(--accent-green);">0</span>
</div>
</div>
</div>
<div class="section">
<h3>Bookmarks</h3>
<div style="display: flex; gap: 4px; margin-bottom: 8px;">
<input type="text" id="bookmarkFreqInput" placeholder="Freq (MHz)" style="flex: 1; padding: 6px; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 4px; font-size: 11px;">
<button class="preset-btn" onclick="addFrequencyBookmark()" style="background: var(--accent-green); color: #000; padding: 6px 10px;">+</button>
</div>
<div id="bookmarksList" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No bookmarks saved</div>
</div>
</div>
<div class="section">
<h3>Recent Signals</h3>
<div id="sidebarRecentSignals" style="max-height: 150px; overflow-y: auto; font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 10px;">No signals yet</div>
</div>
</div>
</div>
<button class="preset-btn" onclick="killAll()" style="width: 100%; margin-top: 10px; border-color: #ff3366; color: #ff3366;">
Kill All Processes
</button>
</div>
<div class="output-panel">
<div class="output-header">
<h3 id="outputTitle">Pager Decoder</h3>
<div class="header-controls">
<div id="signalMeter" class="signal-meter" title="Signal Activity">
<div class="signal-bar"></div>
<div class="signal-bar"></div>
<div class="signal-bar"></div>
<div class="signal-bar"></div>
<div class="signal-bar"></div>
</div>
<div class="stats" id="pagerStats">
<div title="Total Messages">📨 <span id="msgCount">0</span></div>
<div title="POCSAG Messages">📟 <span id="pocsagCount">0</span></div>
<div title="FLEX Messages">📠 <span id="flexCount">0</span></div>
</div>
<div class="stats" id="sensorStats" style="display: none;">
<div title="Unique Sensors">🌡️ <span id="sensorCount">0</span></div>
<div title="Device Types">📊 <span id="deviceCount">0</span></div>
</div>
<div class="stats" id="wifiStats" style="display: none;">
<div title="Access Points">📡 <span id="apCount">0</span></div>
<div title="Connected Clients">👤 <span id="clientCount">0</span></div>
<div title="Captured Handshakes" style="color: var(--accent-green);">🤝 <span id="handshakeCount">0</span></div>
<div style="color: var(--accent-orange); cursor: pointer;" onclick="showDroneDetails()" title="Click: Drone details">🚁 <span id="droneCount">0</span></div>
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()" title="Click: Rogue AP details">⚠️ <span id="rogueApCount">0</span></div>
</div>
<div class="stats" id="btStats" style="display: none;">
<div title="Bluetooth Devices">🔵 <span id="btDeviceCount">0</span></div>
<div title="BLE Beacons">📍 <span id="btBeaconCount">0</span></div>
</div>
<div class="stats" id="aircraftStats" style="display: none;">
<div title="Aircraft Tracked">✈️ <span id="aircraftCount">0</span></div>
<div title="Messages Received">📨 <span id="adsbMsgCount">0</span></div>
<div title="Unique ICAO Codes">🔢 <span id="icaoCount">0</span></div>
</div>
<div class="stats" id="satelliteStats" style="display: none;">
<div title="Upcoming Passes">🛰️ <span id="passCount">0</span></div>
</div>
</div>
</div>
<!-- WiFi Layout Container (visualizations left, device cards right) -->
<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>
</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>
<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>
<!-- 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>
</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>
</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
</div>
</div>
</div>
</div>
<!-- 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>
</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"><span class="bt-type-icon">📱</span> Phones: <strong id="btPhoneCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon">💻</span> Computers: <strong id="btComputerCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon">🎧</span> Audio: <strong id="btAudioCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon"></span> Wearables: <strong id="btWearableCount">0</strong></div>
<div class="bt-type-item"><span class="bt-type-icon">🔵</span> 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>
<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>
<!-- 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>
</div>
</div>
</div>
<!-- Right: Bluetooth Device Cards -->
<div class="wifi-device-list bt-device-list" id="btDeviceListPanel">
<div class="wifi-device-list-header">
<h5>🔵 Bluetooth Devices</h5>
<span class="device-count">(<span id="btDeviceListCount">0</span>)</span>
</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
</div>
</div>
</div>
</div>
<!-- Aircraft Visualizations - Leaflet Map -->
<div class="wifi-visuals" id="aircraftVisuals" style="display: none;">
<!-- Map Panel -->
<div class="wifi-visual-panel" style="grid-column: span 2;">
<h5 style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan);">ADS-B AIRCRAFT TRACKING</h5>
<div class="aircraft-map-container">
<div class="map-header">
<span id="radarTime">--:--:--</span>
<span id="radarStatus">TRACKING</span>
</div>
<div id="aircraftMap"></div>
<div class="map-footer">
<span>AIRCRAFT: <span id="aircraftCount">0</span></span>
<span>CENTER: <span id="mapCenter">--</span></span>
</div>
</div>
</div>
<!-- Aircraft List & Details Panel -->
<div class="wifi-visual-panel" style="grid-column: span 1; display: flex; flex-direction: column; gap: 10px;">
<!-- Selected Aircraft -->
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); padding: 10px;">
<h5 style="color: var(--accent-orange); margin-bottom: 10px;">SELECTED TARGET</h5>
<div id="selectedAircraftInfo" style="font-size: 11px;">
<div style="color: var(--text-muted); text-align: center; padding: 20px;">
Click an aircraft to view details
</div>
</div>
</div>
<!-- Aircraft List -->
<div style="background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); padding: 10px; flex: 1; overflow-y: auto;">
<h5 style="color: var(--accent-cyan); margin-bottom: 10px;">TRACKED AIRCRAFT</h5>
<div id="aircraftListPanel" style="font-size: 11px; max-height: 300px; overflow-y: auto;">
<div style="color: var(--text-muted); text-align: center; padding: 20px;">
No aircraft detected
</div>
</div>
</div>
<!-- Open Full Dashboard Button -->
<a href="/adsb/dashboard" target="_blank" class="preset-btn" style="display: block; text-align: center; text-decoration: none; background: var(--accent-cyan); color: #000; padding: 8px;">
Open Full Dashboard
</a>
</div>
<!-- Aircraft Image Panel -->
<div class="wifi-visual-panel" style="grid-column: span 1; display: flex; flex-direction: column;">
<h5 style="color: var(--accent-green); margin-bottom: 10px;">AIRCRAFT IMAGE</h5>
<div id="mainAircraftPhotoContainer" style="flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center;">
<div id="mainAircraftPhotoPlaceholder" style="color: var(--text-muted); text-align: center; padding: 40px 20px;">
<div style="font-size: 48px; opacity: 0.3; margin-bottom: 10px;">✈️</div>
<div>Select an aircraft to view photo</div>
</div>
<div id="mainAircraftPhotoWrapper" style="display: none; width: 100%;">
<a id="mainAircraftPhotoLink" href="#" target="_blank" rel="noopener">
<img id="mainAircraftPhoto" src="" alt="Aircraft photo" style="width: 100%; border-radius: 6px; border: 1px solid var(--border-color);">
</a>
<div id="mainAircraftPhotoCredit" style="font-size: 9px; color: var(--text-dim); margin-top: 4px; text-align: right;"></div>
<div id="mainAircraftPhotoInfo" style="margin-top: 8px; padding: 8px; background: rgba(0,0,0,0.3); border-radius: 4px; font-size: 10px;">
<div style="color: var(--text-dim);">REGISTRATION</div>
<div id="mainAircraftPhotoReg" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">--</div>
</div>
</div>
<div id="mainAircraftPhotoLoading" style="display: none; color: var(--text-muted); text-align: center; padding: 20px;">
<div style="font-size: 24px; animation: pulse 1s infinite;">📷</div>
<div style="margin-top: 8px;">Loading photo...</div>
</div>
<div id="mainAircraftPhotoNoPhoto" style="display: none; color: var(--text-muted); text-align: center; padding: 20px;">
<div style="font-size: 32px; opacity: 0.5; margin-bottom: 8px;">📷</div>
<div>No photo available</div>
<div style="font-size: 10px; margin-top: 4px; color: var(--text-dim);">Registration not found in database</div>
</div>
</div>
</div>
</div>
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
<!-- TOP: FREQUENCY DISPLAY PANEL -->
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
<div style="display: flex; gap: 15px; align-items: stretch;">
<!-- Main Frequency Display -->
<div style="flex: 1; text-align: center; padding: 15px 20px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
<div class="freq-status" id="mainScannerModeLabel" style="font-size: 10px; color: var(--text-muted); margin-bottom: 4px; letter-spacing: 3px;">STOPPED</div>
<div class="freq-digits" id="mainScannerFreq" style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: 'JetBrains Mono', monospace; letter-spacing: 3px;">118.000</div>
<div style="display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 6px;">
<span class="freq-unit" style="font-size: 16px; color: var(--text-secondary);">MHz</span>
<span class="freq-mode-badge" id="mainScannerMod" style="background: var(--accent-cyan); color: #000; padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: bold;">AM</span>
</div>
<!-- Progress bar -->
<div id="mainScannerProgress" style="display: none; margin-top: 12px;">
<div style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted); margin-bottom: 3px;">
<span id="mainRangeStart">--</span>
<span id="mainRangeEnd">--</span>
</div>
<div style="height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
<div class="scan-bar" id="mainProgressBar" style="height: 100%; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); width: 0%; transition: width 0.1s;"></div>
</div>
</div>
</div>
<!-- Synthesizer + Audio Output Panel -->
<div style="width: 320px; display: flex; flex-direction: column; gap: 8px;">
<!-- Synthesizer Display -->
<div style="flex: 1; padding: 10px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
<div style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">Synthesizer</div>
<canvas id="synthesizerCanvas" style="width: 100%; height: 60px; background: rgba(0,0,0,0.4); border-radius: 4px;"></canvas>
</div>
<!-- Audio Output -->
<div style="padding: 10px; background: rgba(0,0,0,0.4); border-radius: 6px; border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<div style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Audio Output</div>
</div>
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
<div style="flex: 1; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden;">
<div id="audioSignalBar" style="height: 100%; background: linear-gradient(90deg, var(--accent-green), var(--accent-cyan), var(--accent-orange), var(--accent-red)); width: 0%; transition: width 0.1s;"></div>
</div>
<span id="audioSignalDb" style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">-- dB</span>
</div>
<!-- Signal Alert inline -->
<div id="mainSignalAlert" style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
<span style="font-size: 9px; color: var(--accent-green); font-weight: bold;">● SIGNAL</span>
<button class="tune-btn" onclick="skipSignal()" style="margin-left: 8px; padding: 2px 8px; font-size: 8px;">Skip</button>
</div>
</div>
</div>
</div>
</div>
<!-- CONTROL PANEL: Tuning Section | Mode/Band | Action Buttons -->
<div class="radio-module-box" style="grid-column: span 4; padding: 12px;">
<div style="display: flex; gap: 15px; align-items: stretch;">
<!-- LEFT: Tuning Section -->
<div style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 15px; background: rgba(0,0,0,0.3); border-radius: 8px; border: 1px solid var(--border-color);">
<!-- Fine Tune Buttons (Left of dial) -->
<div style="display: flex; flex-direction: column; gap: 4px;">
<button class="tune-btn" onclick="tuneFreq(-1)" style="padding: 8px 12px; font-size: 11px;">-1</button>
<button class="tune-btn" onclick="tuneFreq(-0.1)" style="padding: 8px 12px; font-size: 11px;">-.1</button>
</div>
<!-- Main Tuning Dial -->
<div style="display: flex; flex-direction: column; align-items: center;">
<div class="tuning-dial" id="mainTuningDial" data-value="118" data-min="24" data-max="1800" data-step="0.1" style="width: 100px; height: 100px;"></div>
<div style="font-size: 9px; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 1px;">Tune</div>
</div>
<!-- Fine Tune Buttons (Right of dial) -->
<div style="display: flex; flex-direction: column; gap: 4px;">
<button class="tune-btn" onclick="tuneFreq(0.1)" style="padding: 8px 12px; font-size: 11px;">+.1</button>
<button class="tune-btn" onclick="tuneFreq(1)" style="padding: 8px 12px; font-size: 11px;">+1</button>
</div>
<!-- Divider -->
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
<!-- Settings: Step & Dwell -->
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 90px;">
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
<span style="color: var(--text-muted);">Step</span>
<select id="radioScanStep" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
<option value="12.5">12.5k</option>
<option value="25" selected>25k</option>
<option value="50">50k</option>
<option value="100">100k</option>
</select>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
<span style="color: var(--text-muted);">Dwell</span>
<select id="radioScanDwell" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
<option value="2">2s</option>
<option value="5">5s</option>
<option value="10" selected>10s</option>
<option value="30">30s</option>
</select>
</div>
</div>
<!-- Divider -->
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
<!-- SQL, Gain, Vol Knobs -->
<div style="display: flex; gap: 15px;">
<div class="knob-container">
<div class="radio-knob" id="radioSquelchKnob" data-value="30" data-min="0" data-max="100" data-step="1"></div>
<div class="knob-label">SQL</div>
<div class="knob-value" id="radioSquelchValue">30</div>
</div>
<div class="knob-container">
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0" data-max="50" data-step="1"></div>
<div class="knob-label">GAIN</div>
<div class="knob-value" id="radioGainValue">40</div>
</div>
<div class="knob-container">
<div class="radio-knob" id="radioVolumeKnob" data-value="80" data-min="0" data-max="100" data-step="1"></div>
<div class="knob-label">VOL</div>
<div class="knob-value" id="radioVolumeValue">80</div>
</div>
</div>
</div>
<!-- CENTER: Mode & Band (Stacked) -->
<div style="width: 130px; display: flex; flex-direction: column; gap: 10px; justify-content: center;">
<div>
<div style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">Modulation</div>
<div class="radio-button-bank compact" id="modBtnBank" style="flex-wrap: wrap; gap: 2px;">
<button class="radio-btn active" data-mod="am" onclick="setModulation('am')" style="padding: 6px 10px; font-size: 10px;">AM</button>
<button class="radio-btn" data-mod="fm" onclick="setModulation('fm')" style="padding: 6px 10px; font-size: 10px;">NFM</button>
<button class="radio-btn" data-mod="wfm" onclick="setModulation('wfm')" style="padding: 6px 10px; font-size: 10px;">WFM</button>
<button class="radio-btn" data-mod="usb" onclick="setModulation('usb')" style="padding: 6px 10px; font-size: 10px;">USB</button>
<button class="radio-btn" data-mod="lsb" onclick="setModulation('lsb')" style="padding: 6px 10px; font-size: 10px;">LSB</button>
</div>
</div>
<div>
<div style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">Band</div>
<div class="radio-button-bank compact" id="bandBtnBank" style="flex-wrap: wrap; gap: 2px;">
<button class="radio-btn" data-band="fm" onclick="setBand('fm')" style="padding: 6px 10px; font-size: 10px;">FM</button>
<button class="radio-btn active" data-band="air" onclick="setBand('air')" style="padding: 6px 10px; font-size: 10px;">AIR</button>
<button class="radio-btn" data-band="marine" onclick="setBand('marine')" style="padding: 6px 10px; font-size: 10px;">MAR</button>
<button class="radio-btn" data-band="amateur2m" onclick="setBand('amateur2m')" style="padding: 6px 10px; font-size: 10px;">2M</button>
<button class="radio-btn" data-band="amateur70cm" onclick="setBand('amateur70cm')" style="padding: 6px 10px; font-size: 10px;">70CM</button>
</div>
</div>
</div>
<!-- RIGHT: Scan Range + Action Buttons -->
<div style="width: 175px; display: flex; flex-direction: column; gap: 8px;">
<!-- Frequency Range - Prominent -->
<div style="background: rgba(0,0,0,0.4); border: 1px solid var(--border-color); border-radius: 6px; padding: 10px;">
<div style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; text-align: center;">Scan Range (MHz)</div>
<div style="display: flex; align-items: center; gap: 6px;">
<div style="flex: 1;">
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">START</div>
<input type="number" id="radioScanStart" value="118" step="0.1" class="radio-input" style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
</div>
<span style="color: var(--text-muted); font-size: 16px; padding-top: 12px;"></span>
<div style="flex: 1;">
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">END</div>
<input type="number" id="radioScanEnd" value="137" step="0.1" class="radio-input" style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
</div>
</div>
</div>
<!-- Action Buttons -->
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()" style="padding: 12px; font-size: 13px; width: 100%; font-weight: bold;">📡 SCAN</button>
<button class="radio-action-btn" id="radioListenBtn" onclick="toggleDirectListen()" style="padding: 10px; font-size: 12px; width: 100%; background: var(--accent-purple); border-color: var(--accent-purple); color: #fff;">🎧 LISTEN</button>
</div>
</div>
</div>
<!-- QUICK TUNE BAR -->
<div class="radio-module-box" style="grid-column: span 4; padding: 8px 12px;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Quick Tune:</span>
<button class="preset-freq-btn" onclick="quickTune(121.5, 'am')">121.5 GUARD</button>
<button class="preset-freq-btn" onclick="quickTune(156.8, 'fm')">156.8 CH16</button>
<button class="preset-freq-btn" onclick="quickTune(145.5, 'fm')">145.5 2M</button>
<button class="preset-freq-btn" onclick="quickTune(98.1, 'wfm')">98.1 FM</button>
<button class="preset-freq-btn" onclick="quickTune(462.5625, 'fm')">462.56 FRS</button>
<button class="preset-freq-btn" onclick="quickTune(446.0, 'fm')">446.0 PMR</button>
</div>
</div>
<!-- SIGNAL HITS -->
<div class="radio-module-box" style="grid-column: span 2; padding: 10px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
<span>SIGNAL HITS</span>
<span style="font-size: 10px; color: var(--accent-cyan); background: rgba(0,212,255,0.1); padding: 2px 8px; border-radius: 3px;" id="scannerHitCount">0 signals</span>
</div>
<div id="scannerHitsList" style="overflow-y: auto; max-height: 100px;">
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<thead>
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 4px;">Time</th>
<th style="text-align: left; padding: 4px;">Frequency</th>
<th style="text-align: left; padding: 4px;">Mod</th>
<th style="text-align: center; padding: 4px; width: 60px;">Action</th>
</tr>
</thead>
<tbody id="scannerHitsBody">
<tr style="color: var(--text-muted);">
<td colspan="4" style="padding: 15px; text-align: center; font-size: 10px;">No signals detected</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- STATS PANEL -->
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
<span>STATS</span>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
<span style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;" id="mainSignalCount">0</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
<span style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;" id="mainFreqsScanned">0</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
<span style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;" id="mainScanCycles">0</span>
</div>
</div>
</div>
<!-- ACTIVITY LOG -->
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
<span>LOG</span>
<div style="display: flex; gap: 4px;">
<button class="tune-btn" onclick="exportScannerLog()" style="padding: 2px 6px; font-size: 8px;">Export</button>
<button class="tune-btn" onclick="clearScannerLog()" style="padding: 2px 6px; font-size: 8px;">Clear</button>
</div>
</div>
<div class="log-content" id="scannerActivityLog" style="max-height: 100px; overflow-y: auto; font-size: 9px; background: rgba(0,0,0,0.2); border-radius: 3px; padding: 6px;">
<div class="scanner-log-entry" style="color: var(--text-muted);">Ready</div>
</div>
</div>
</div>
<style>
@keyframes pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1); }
}
</style>
<!-- Satellite Dashboard (Embedded) -->
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
<iframe
id="satelliteDashboardFrame"
src="/satellite/dashboard?embedded=true"
frameborder="0"
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
allowfullscreen>
</iframe>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel" id="reconPanel">
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
<h4><span id="reconCollapseIcon"></span> Device Intelligence</h4>
<div class="recon-stats">
<div>TRACKED: <span id="trackedCount">0</span></div>
<div>NEW: <span id="newDeviceCount">0</span></div>
<div>ANOMALIES: <span id="anomalyCount">0</span></div>
</div>
</div>
<div class="recon-content" id="reconContent">
<div style="color: #444; text-align: center; padding: 20px; font-size: 11px;">
Device intelligence data will appear here as signals are intercepted.
</div>
</div>
</div>
<div class="waterfall-container">
<canvas id="waterfallCanvas" width="800" height="60"></canvas>
</div>
<div class="output-content" id="output">
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
Configure settings and click "Start Decoding" to begin.
</div>
</div>
<div class="status-bar">
<div class="status-indicator">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Idle</span>
</div>
<div class="status-controls">
<div class="control-group">
<span class="control-group-label">Mode</span>
<button id="reconBtn" class="recon-toggle" onclick="toggleRecon()">RECON</button>
<button id="muteBtn" class="control-btn" onclick="toggleMute()">🔊</button>
<button id="autoScrollBtn" class="control-btn active" onclick="toggleAutoScroll()">⬇ AUTO</button>
</div>
<div class="control-group">
<span class="control-group-label">Export</span>
<button class="control-btn" onclick="exportCSV()">CSV</button>
<button class="control-btn" onclick="exportJSON()">JSON</button>
<button class="control-btn" onclick="exportDeviceDB()" title="Export Device Intelligence">INTEL</button>
</div>
<button class="clear-btn" onclick="clearMessages()">Clear</button>
</div>
</div>
</div>
</div>
</div>
<!-- Intercept JS Modules -->
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
<script>
// Landing page enter function
function enterApp() {
const landing = document.getElementById('landingPage');
landing.classList.add('fade-out');
// After fade out, hide landing and show disclaimer if needed
setTimeout(() => {
landing.style.display = 'none';
checkDisclaimer();
}, 500);
}
// Disclaimer handling
function checkDisclaimer() {
const accepted = localStorage.getItem('disclaimerAccepted');
if (accepted === 'true') {
document.getElementById('disclaimerModal').style.display = 'none';
} else {
document.getElementById('disclaimerModal').style.display = 'flex';
}
}
function acceptDisclaimer() {
localStorage.setItem('disclaimerAccepted', 'true');
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
}
function declineDisclaimer() {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
}
// Don't auto-check disclaimer - wait for landing page enter
let eventSource = null;
let isRunning = false;
let isSensorRunning = false;
let isAdsbRunning = false;
let isWifiRunning = false;
let isBtRunning = false;
let currentMode = 'pager';
let msgCount = 0;
let pocsagCount = 0;
let flexCount = 0;
let sensorCount = 0;
let filteredCount = 0; // Count of filtered messages
let deviceList = {{ devices | tojson | safe }};
// Pager message filter settings
let pagerFilters = {
hideToneOnly: true,
keywords: []
};
// Aircraft (ADS-B) state
let adsbAircraft = {};
let adsbMsgCount = 0;
let adsbEventSource = null;
let aircraftTrails = {}; // ICAO -> array of positions
let activeSquawkAlerts = {}; // Active emergency squawk alerts
let alertedAircraft = {}; // Track aircraft that have already triggered alerts
let adsbAlertsEnabled = true; // Toggle for audio alerts
let selectedMainAircraft = null; // Selected aircraft in main tab
// UTC Clock Update
function updateHeaderClock() {
const now = new Date();
const utc = now.toISOString().substring(11, 19);
document.getElementById('headerUtcTime').textContent = utc;
}
// Update clock every second
setInterval(updateHeaderClock, 1000);
updateHeaderClock(); // Initial call
// Pager message filter functions
function loadPagerFilters() {
const saved = localStorage.getItem('pagerFilters');
if (saved) {
try {
pagerFilters = JSON.parse(saved);
} catch (e) {
console.warn('Failed to load pager filters:', e);
}
}
// Update UI
document.getElementById('filterToneOnly').checked = pagerFilters.hideToneOnly;
document.getElementById('filterKeywords').value = pagerFilters.keywords.join(', ');
}
function savePagerFilters() {
pagerFilters.hideToneOnly = document.getElementById('filterToneOnly').checked;
const keywordsInput = document.getElementById('filterKeywords').value;
pagerFilters.keywords = keywordsInput
.split(',')
.map(k => k.trim().toLowerCase())
.filter(k => k.length > 0);
localStorage.setItem('pagerFilters', JSON.stringify(pagerFilters));
}
function shouldFilterMessage(msg) {
// Check for Tone Only filter
if (pagerFilters.hideToneOnly) {
if (msg.message === '[Tone Only]' || msg.msg_type === 'Tone') {
return true;
}
}
// Check keyword filters
if (pagerFilters.keywords.length > 0) {
const msgLower = (msg.message || '').toLowerCase();
for (const keyword of pagerFilters.keywords) {
if (msgLower.includes(keyword)) {
return true;
}
}
}
return false;
}
// Sync header stats with output panel stats
function syncHeaderStats() {
// Pager stats
document.getElementById('headerMsgCount').textContent = msgCount;
document.getElementById('headerPocsagCount').textContent = pocsagCount;
document.getElementById('headerFlexCount').textContent = flexCount;
// Sensor stats
document.getElementById('headerSensorCount').textContent = document.getElementById('sensorCount')?.textContent || '0';
document.getElementById('headerDeviceTypeCount').textContent = document.getElementById('deviceCount')?.textContent || '0';
// WiFi stats
document.getElementById('headerApCount').textContent = document.getElementById('apCount')?.textContent || '0';
document.getElementById('headerClientCount').textContent = document.getElementById('clientCount')?.textContent || '0';
document.getElementById('headerHandshakeCount').textContent = document.getElementById('handshakeCount')?.textContent || '0';
document.getElementById('headerDroneCount').textContent = document.getElementById('droneCount')?.textContent || '0';
// Bluetooth stats
document.getElementById('headerBtDeviceCount').textContent = document.getElementById('btDeviceCount')?.textContent || '0';
document.getElementById('headerBtBeaconCount').textContent = document.getElementById('btBeaconCount')?.textContent || '0';
// Aircraft stats
document.getElementById('headerAircraftCount').textContent = document.getElementById('aircraftCount')?.textContent || '0';
document.getElementById('headerAdsbMsgCount').textContent = document.getElementById('adsbMsgCount')?.textContent || '0';
document.getElementById('headerIcaoCount').textContent = document.getElementById('icaoCount')?.textContent || '0';
// Satellite stats
document.getElementById('headerPassCount').textContent = document.getElementById('passCount')?.textContent || '0';
}
// Sync stats periodically
setInterval(syncHeaderStats, 500);
// ADS-B Statistics tracking
let adsbStats = {
totalAircraftSeen: new Set(), // Unique ICAO codes seen
maxRange: 0, // Max distance in nm
maxRangeAircraft: null, // Aircraft that achieved max range
hourlyCount: {}, // Hour -> count of aircraft
messagesPerSecond: 0, // Current msg/sec rate
messageTimestamps: [], // Recent message timestamps for rate calc
sessionStart: null // When tracking started
};
// Observer location for distance calculations (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarkerAdsb = null;
// GPS Dongle state
let gpsConnected = false;
let gpsEventSource = null;
let gpsLastPosition = null;
// Audio alert system using Web Audio API (uses shared audioContext declared later)
function getAdsbAudioContext() {
if (!window.adsbAudioCtx) {
window.adsbAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
return window.adsbAudioCtx;
}
function playAlertSound(type) {
if (!adsbAlertsEnabled) return;
try {
const ctx = getAdsbAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'emergency') {
// Urgent two-tone alert for emergencies
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
} else if (type === 'military') {
// Single tone for military
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
function checkAndAlertAircraft(icao, aircraft) {
// Skip if already alerted for this aircraft
if (alertedAircraft[icao]) return;
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
const squawkInfo = checkSquawkCode(aircraft);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`⚠️ EMERGENCY: ${squawkInfo.name} - ${aircraft.callsign || icao}`, squawkInfo.color);
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
playAlertSound('military');
showAlertBanner(`🎖️ MILITARY: ${aircraft.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
}
}
function showAlertBanner(message, color) {
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: ${color};
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
animation: slideDown 0.3s ease-out;
`;
banner.textContent = message;
document.body.appendChild(banner);
// Auto-remove after 5 seconds
setTimeout(() => {
banner.style.animation = 'fadeOut 0.3s ease-out forwards';
setTimeout(() => banner.remove(), 300);
}, 5000);
}
// Emergency squawk codes
const SQUAWK_CODES = {
'7500': { type: 'hijack', name: 'HIJACK', color: '#ff0000', description: 'Aircraft being hijacked' },
'7600': { type: 'radio', name: 'RADIO FAILURE', color: '#ff6600', description: 'Radio communications failure' },
'7700': { type: 'mayday', name: 'EMERGENCY', color: '#ff0000', description: 'General emergency' }
};
// Military ICAO hex ranges (specific military-only sub-ranges)
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' }, // US Military
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' }, // US Military
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' }, // France Military (Armee de l'Air)
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' }, // UK Military (RAF)
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' }, // Germany Military (Luftwaffe)
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' }, // NATO
];
// Military callsign prefixes
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
function isMilitaryAircraft(icao, callsign) {
// Check ICAO hex range
const icaoInt = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (icaoInt >= range.start && icaoInt <= range.end) {
return { military: true, country: range.country };
}
}
// Check callsign prefix
if (callsign) {
const upper = callsign.toUpperCase();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) {
return { military: true, type: 'callsign' };
}
}
}
return { military: false };
}
function checkSquawkCode(aircraft) {
if (!aircraft.squawk) return null;
const squawkInfo = SQUAWK_CODES[aircraft.squawk];
if (squawkInfo) {
// Show alert if not already shown
if (!activeSquawkAlerts[aircraft.icao]) {
activeSquawkAlerts[aircraft.icao] = true;
showSquawkAlert(aircraft, squawkInfo);
}
return squawkInfo;
}
return null;
}
function showSquawkAlert(aircraft, squawkInfo) {
// Create banner alert
const banner = document.createElement('div');
banner.className = 'squawk-alert-banner';
banner.id = 'squawkBanner_' + aircraft.icao;
banner.innerHTML = `
⚠️ ${squawkInfo.name} - ${aircraft.callsign || aircraft.icao} (${aircraft.squawk})
<br><small>${squawkInfo.description}</small>
<button onclick="this.parentElement.remove()" style="margin-left: 20px; background: transparent; border: 1px solid white; color: white; padding: 2px 10px; cursor: pointer;">✕</button>
`;
document.body.appendChild(banner);
// Auto-remove after 30 seconds
setTimeout(() => {
const el = document.getElementById('squawkBanner_' + aircraft.icao);
if (el) el.remove();
}, 30000);
// Audio alert
if (!muted) {
for (let i = 0; i < 5; i++) {
setTimeout(() => playAlertSound(), i * 200);
}
}
showNotification(`⚠️ ${squawkInfo.name}`, `${aircraft.callsign || aircraft.icao} - Squawk ${aircraft.squawk}`);
}
function updateAircraftTrail(icao, lat, lon) {
if (!aircraftTrails[icao]) {
aircraftTrails[icao] = [];
}
const trail = aircraftTrails[icao];
const lastPos = trail[trail.length - 1];
// Only add if position changed significantly
if (!lastPos || Math.abs(lastPos.lat - lat) > 0.001 || Math.abs(lastPos.lon - lon) > 0.001) {
trail.push({ lat, lon, time: Date.now() });
// Keep only last 100 positions (about 10 minutes at 1 update/6 seconds)
if (trail.length > 100) {
trail.shift();
}
}
}
// Satellite state
let satellitePasses = [];
let selectedPass = null;
let selectedPassIndex = 0;
let countdownInterval = null;
// Start satellite countdown timer
function startCountdownTimer() {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(updateSatelliteCountdown, 1000);
}
// Update satellite countdown display
function updateSatelliteCountdown() {
// Update both main and popout countdowns
updateCountdownDisplay('');
updateCountdownDisplay('Popout');
}
// Helper to update countdown elements by suffix
function updateCountdownDisplay(suffix) {
const container = document.getElementById('satelliteCountdown' + suffix);
if (!container) return;
// Use the globally selected pass
if (!selectedPass || satellitePasses.length === 0) {
container.style.display = 'none';
return;
}
const now = new Date();
const startTime = parsePassTime(selectedPass.startTime);
const endTime = new Date(startTime.getTime() + selectedPass.duration * 60000);
container.style.display = 'block';
document.getElementById('countdownSatName' + suffix).textContent = selectedPass.satellite;
if (now >= startTime && now <= endTime) {
// Currently visible
const remaining = Math.max(0, Math.floor((endTime - now) / 1000));
const mins = Math.floor(remaining / 60);
const secs = remaining % 60;
document.getElementById('countdownToPass' + suffix).textContent = 'VISIBLE';
document.getElementById('countdownToPass' + suffix).classList.add('active');
document.getElementById('countdownPassTime' + suffix).textContent = 'Now overhead';
document.getElementById('countdownVisibility' + suffix).textContent = `${mins}:${secs.toString().padStart(2, '0')}`;
document.getElementById('countdownVisLabel' + suffix).textContent = 'Remaining';
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
document.getElementById('countdownStatus' + suffix).textContent = '🟢 SATELLITE CURRENTLY VISIBLE';
document.getElementById('countdownStatus' + suffix).className = 'countdown-status visible';
} else if (startTime > now) {
// Upcoming pass
const secsToPass = Math.max(0, Math.floor((startTime - now) / 1000));
const hours = Math.floor(secsToPass / 3600);
const mins = Math.floor((secsToPass % 3600) / 60);
const secs = secsToPass % 60;
let countdownStr;
if (hours > 0) {
countdownStr = `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
countdownStr = `${mins}:${secs.toString().padStart(2, '0')}`;
}
document.getElementById('countdownToPass' + suffix).textContent = countdownStr;
document.getElementById('countdownToPass' + suffix).classList.remove('active');
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
if (secsToPass < 300) {
document.getElementById('countdownStatus' + suffix).textContent = '🟡 PASS STARTING SOON';
document.getElementById('countdownStatus' + suffix).className = 'countdown-status upcoming';
} else {
document.getElementById('countdownStatus' + suffix).textContent = 'Selected pass';
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
}
} else {
// Pass already happened
document.getElementById('countdownToPass' + suffix).textContent = 'PASSED';
document.getElementById('countdownToPass' + suffix).classList.remove('active');
document.getElementById('countdownPassTime' + suffix).textContent = selectedPass.startTime;
document.getElementById('countdownVisibility' + suffix).textContent = selectedPass.duration + 'm';
document.getElementById('countdownVisLabel' + suffix).textContent = 'Duration';
document.getElementById('countdownMaxEl' + suffix).textContent = selectedPass.maxEl + '°';
document.getElementById('countdownDirection' + suffix).textContent = selectedPass.direction || 'Pass';
document.getElementById('countdownStatus' + suffix).textContent = 'Pass has ended';
document.getElementById('countdownStatus' + suffix).className = 'countdown-status';
}
}
// Parse pass time string to Date object
function parsePassTime(timeStr) {
// Expected format: "2025-12-21 14:32 UTC"
// Remove "UTC" suffix and parse as ISO-like format
const cleanTime = timeStr.replace(' UTC', '').replace(' ', 'T') + ':00Z';
const parsed = new Date(cleanTime);
// Fallback if that doesn't work
if (isNaN(parsed.getTime())) {
// Try parsing as-is
return new Date(timeStr.replace(' UTC', ''));
}
return parsed;
}
// Make sections collapsible
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.section h3').forEach(h3 => {
h3.addEventListener('click', function() {
this.parentElement.classList.toggle('collapsed');
});
});
// Collapse all sections by default (except SDR Device which is first)
document.querySelectorAll('.section').forEach((section, index) => {
// Keep first section expanded, collapse rest
if (index > 0) {
section.classList.add('collapsed');
}
});
// Load bias-T setting from localStorage
loadBiasTSetting();
// Initialize observer location input fields from saved location
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
// Auto-connect to gpsd if available
autoConnectGps();
// Load pager message filters
loadPagerFilters();
});
// Toggle section collapse
function toggleSection(el) {
el.closest('.section').classList.toggle('collapsed');
}
// Mode switching
function switchMode(mode) {
// Stop any running scans when switching modes
if (isRunning) stopDecoding();
if (isSensorRunning) stopSensorDecoding();
if (isWifiRunning) stopWifiScan();
if (isBtRunning) stopBtScan();
if (isAdsbRunning) stopAdsbScan();
currentMode = mode;
// Remove active from all nav buttons, then add to the correct one
document.querySelectorAll('.mode-nav-btn').forEach(btn => btn.classList.remove('active'));
const modeMap = {
'pager': 'pager', 'sensor': '433', 'aircraft': 'aircraft',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening'
};
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
const label = btn.querySelector('.nav-label');
if (label && label.textContent.toLowerCase().includes(modeMap[mode])) {
btn.classList.add('active');
}
});
document.getElementById('pagerMode').classList.toggle('active', mode === 'pager');
document.getElementById('sensorMode').classList.toggle('active', mode === 'sensor');
document.getElementById('aircraftMode').classList.toggle('active', mode === 'aircraft');
document.getElementById('satelliteMode').classList.toggle('active', mode === 'satellite');
document.getElementById('wifiMode').classList.toggle('active', mode === 'wifi');
document.getElementById('bluetoothMode').classList.toggle('active', mode === 'bluetooth');
document.getElementById('listeningPostMode').classList.toggle('active', mode === 'listening');
document.getElementById('pagerStats').style.display = mode === 'pager' ? 'flex' : 'none';
document.getElementById('sensorStats').style.display = mode === 'sensor' ? 'flex' : 'none';
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';
// Update header stats groups
document.getElementById('headerPagerStats').classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats').classList.toggle('active', mode === 'sensor');
document.getElementById('headerAircraftStats').classList.toggle('active', mode === 'aircraft');
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
document.getElementById('adsbDashboardBtn').style.display = mode === 'aircraft' ? 'inline-flex' : 'none';
document.getElementById('satelliteDashboardBtn').style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'aircraft': 'AIRCRAFT',
'satellite': 'SATELLITE',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST'
};
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>' + modeNames[mode];
document.getElementById('wifiLayoutContainer').style.display = mode === 'wifi' ? 'flex' : 'none';
document.getElementById('btLayoutContainer').style.display = mode === 'bluetooth' ? 'flex' : 'none';
// Respect the "Show Radar Display" checkbox for aircraft mode
const showRadar = document.getElementById('adsbEnableMap').checked;
document.getElementById('aircraftVisuals').style.display = (mode === 'aircraft' && showRadar) ? 'grid' : 'none';
document.getElementById('satelliteVisuals').style.display = mode === 'satellite' ? 'block' : 'none';
document.getElementById('listeningPostVisuals').style.display = mode === 'listening' ? 'grid' : 'none';
// Update output panel title based on mode
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'aircraft': 'ADS-B Aircraft Tracker',
'satellite': 'Satellite Monitor',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post'
};
document.getElementById('outputTitle').textContent = titles[mode] || 'Signal Monitor';
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
if (mode === 'satellite' || mode === 'aircraft' || mode === 'listening') {
document.getElementById('reconPanel').style.display = 'none';
if (reconBtn) reconBtn.style.display = 'none';
if (intelBtn) intelBtn.style.display = 'none';
} else {
if (reconBtn) reconBtn.style.display = 'inline-block';
if (intelBtn) intelBtn.style.display = 'inline-block';
// Restore panel visibility based on reconEnabled state
if (reconEnabled) {
document.getElementById('reconPanel').style.display = 'block';
}
}
// Show RTL-SDR device section for modes that use it
document.getElementById('rtlDeviceSection').style.display = (mode === 'pager' || mode === 'sensor' || mode === 'aircraft' || mode === 'listening') ? 'block' : 'none';
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager').style.display = (mode === 'pager') ? 'grid' : 'none';
document.getElementById('toolStatusSensor').style.display = (mode === 'sensor') ? 'grid' : 'none';
document.getElementById('toolStatusAircraft').style.display = (mode === 'aircraft') ? 'grid' : 'none';
// Hide waterfall and output console for modes with their own visualizations
document.querySelector('.waterfall-container').style.display = (mode === 'satellite' || mode === 'listening' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth') ? 'none' : 'block';
document.getElementById('output').style.display = (mode === 'satellite' || mode === 'aircraft' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening') ? 'none' : 'block';
document.querySelector('.status-bar').style.display = (mode === 'satellite') ? 'none' : 'flex';
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
refreshWifiInterfaces();
initRadar();
initWatchList();
} else if (mode === 'bluetooth') {
refreshBtInterfaces();
initBtRadar();
} else if (mode === 'aircraft') {
checkAdsbTools();
initAircraftRadar();
} else if (mode === 'satellite') {
initPolarPlot();
initSatelliteList();
}
}
// Track unique sensor devices
let uniqueDevices = new Set();
// Sensor frequency
function setSensorFreq(freq) {
document.getElementById('sensorFrequency').value = freq;
if (isSensorRunning) {
fetch('/stop_sensor', {method: 'POST'})
.then(() => setTimeout(() => startSensorDecoding(), 500));
}
}
// Start sensor decoding
function startSensorDecoding() {
const freq = document.getElementById('sensorFrequency').value;
const gain = document.getElementById('sensorGain').value;
const ppm = document.getElementById('sensorPpm').value;
const device = getSelectedDevice();
// Check if device is available
if (!checkDeviceAvailability('sensor')) {
return;
}
// Check for remote SDR
const remoteConfig = getRemoteSDRConfig();
if (remoteConfig === false) return; // Validation failed
const config = {
frequency: freq,
gain: gain,
ppm: ppm,
device: device,
sdr_type: getSelectedSDRType(),
bias_t: getBiasTEnabled()
};
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
fetch('/start_sensor', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
if (data.status === 'started') {
reserveDevice(parseInt(device), 'sensor');
setSensorRunning(true);
startSensorStream();
} else {
alert('Error: ' + data.message);
}
});
}
// Stop sensor decoding
function stopSensorDecoding() {
fetch('/stop_sensor', {method: 'POST'})
.then(r => r.json())
.then(data => {
releaseDevice('sensor');
setSensorRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
function setSensorRunning(running) {
isSensorRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
document.getElementById('startSensorBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopSensorBtn').style.display = running ? 'block' : 'none';
}
function startSensorStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/stream_sensor');
eventSource.onopen = function() {
showInfo('Sensor stream connected...');
};
eventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'sensor') {
addSensorReading(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setSensorRunning(false);
}
} else if (data.type === 'info' || data.type === 'raw') {
showInfo(data.text);
}
};
eventSource.onerror = function(e) {
console.error('Sensor stream error');
};
}
function addSensorReading(data) {
const output = document.getElementById('output');
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
// Store for export
allMessages.push(data);
playAlert();
pulseSignal();
addWaterfallPoint(Date.now(), 0.8);
sensorCount++;
document.getElementById('sensorCount').textContent = sensorCount;
// Track unique devices by model + id
const deviceKey = (data.model || 'Unknown') + '_' + (data.id || data.channel || '0');
if (!uniqueDevices.has(deviceKey)) {
uniqueDevices.add(deviceKey);
document.getElementById('deviceCount').textContent = uniqueDevices.size;
}
const card = document.createElement('div');
card.className = 'sensor-card';
let dataItems = '';
const skipKeys = ['type', 'time', 'model', 'raw'];
for (const [key, value] of Object.entries(data)) {
if (!skipKeys.includes(key) && value !== null && value !== undefined) {
const label = key.replace(/_/g, ' ');
let displayValue = value;
if (key === 'temperature_C') displayValue = value + ' °C';
else if (key === 'temperature_F') displayValue = value + ' °F';
else if (key === 'humidity') displayValue = value + ' %';
else if (key === 'pressure_hPa') displayValue = value + ' hPa';
else if (key === 'wind_avg_km_h') displayValue = value + ' km/h';
else if (key === 'rain_mm') displayValue = value + ' mm';
else if (key === 'battery_ok') displayValue = value ? 'OK' : 'Low';
dataItems += '<div class="data-item"><div class="data-label">' + label + '</div><div class="data-value">' + displayValue + '</div></div>';
}
}
const relTime = data.time ? getRelativeTime(data.time.split(' ')[1] || data.time) : 'now';
card.innerHTML =
'<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">' +
'<span class="device-name">' + (data.model || 'Unknown Device') + '</span>' +
'<span class="msg-time" data-timestamp="' + (data.time || '') + '" style="color: #444; font-size: 10px;">' + relTime + '</span>' +
'</div>' +
'<div class="sensor-data">' + dataItems + '</div>';
output.insertBefore(card, output.firstChild);
if (autoScroll) output.scrollTop = 0;
while (output.children.length > 100) {
output.removeChild(output.lastChild);
}
}
function toggleSensorLogging() {
const enabled = document.getElementById('sensorLogging').checked;
fetch('/logging', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled, log_file: 'sensor_data.log'})
});
}
// NOTE: Audio alert settings moved to static/js/core/audio.js
// Message storage for export
let allMessages = [];
function exportCSV() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
const csv = [headers.join(',')];
allMessages.forEach(msg => {
const row = [
msg.timestamp || '',
msg.protocol || '',
msg.address || '',
msg.function || '',
msg.msg_type || '',
'"' + (msg.message || '').replace(/"/g, '""') + '"'
];
csv.push(row.join(','));
});
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
}
function exportJSON() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Auto-scroll setting
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
function toggleAutoScroll() {
autoScroll = !autoScroll;
localStorage.setItem('autoScroll', autoScroll);
updateAutoScrollButton();
}
function updateAutoScrollButton() {
const btn = document.getElementById('autoScrollBtn');
if (btn) {
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
btn.classList.toggle('active', autoScroll);
}
}
// Signal activity meter
let signalActivity = 0;
let lastMessageTime = 0;
function updateSignalMeter() {
const now = Date.now();
const timeSinceLastMsg = now - lastMessageTime;
// Decay signal activity over time
if (timeSinceLastMsg > 1000) {
signalActivity = Math.max(0, signalActivity - 0.05);
}
const meter = document.getElementById('signalMeter');
const bars = meter?.querySelectorAll('.signal-bar');
if (bars) {
const activeBars = Math.ceil(signalActivity * bars.length);
bars.forEach((bar, i) => {
bar.classList.toggle('active', i < activeBars);
});
}
}
function pulseSignal() {
signalActivity = Math.min(1, signalActivity + 0.4);
lastMessageTime = Date.now();
// Flash waterfall canvas
const canvas = document.getElementById('waterfallCanvas');
if (canvas) {
canvas.classList.add('active');
setTimeout(() => canvas.classList.remove('active'), 500);
}
}
// Waterfall display
const waterfallData = [];
const maxWaterfallRows = 50;
function addWaterfallPoint(timestamp, intensity) {
waterfallData.push({ time: timestamp, intensity });
if (waterfallData.length > maxWaterfallRows * 100) {
waterfallData.shift();
}
renderWaterfall();
}
function renderWaterfall() {
const canvas = document.getElementById('waterfallCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const width = canvas.width;
const height = canvas.height;
// Shift existing image down
const imageData = ctx.getImageData(0, 0, width, height - 2);
ctx.putImageData(imageData, 0, 2);
// Draw new row at top
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, 2);
// Add activity markers
const now = Date.now();
const recentData = waterfallData.filter(d => now - d.time < 100);
recentData.forEach(d => {
const x = Math.random() * width;
const hue = 180 + (d.intensity * 60); // cyan to green
ctx.fillStyle = `hsla(${hue}, 100%, 50%, ${d.intensity})`;
ctx.fillRect(x - 2, 0, 4, 2);
});
}
// Relative timestamps
function getRelativeTime(timestamp) {
if (!timestamp) return '';
const now = new Date();
const parts = timestamp.split(':');
const msgTime = new Date();
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
const diff = Math.floor((now - msgTime) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return timestamp;
}
function updateRelativeTimes() {
document.querySelectorAll('.msg-time').forEach(el => {
const ts = el.dataset.timestamp;
if (ts) el.textContent = getRelativeTime(ts);
});
}
// Update timers
setInterval(updateSignalMeter, 100);
setInterval(updateRelativeTimes, 10000);
// Default presets (UK frequencies)
const defaultPresets = ['153.350', '153.025'];
// Load presets from localStorage or use defaults
function loadPresets() {
const saved = localStorage.getItem('pagerPresets');
return saved ? JSON.parse(saved) : [...defaultPresets];
}
function savePresets(presets) {
localStorage.setItem('pagerPresets', JSON.stringify(presets));
}
function renderPresets() {
const presets = loadPresets();
const container = document.getElementById('presetButtons');
container.innerHTML = presets.map(freq =>
`<button class="preset-btn" onclick="setFreq('${freq}')" oncontextmenu="removePreset('${freq}'); return false;" title="Right-click to remove">${freq}</button>`
).join('');
}
function addPreset() {
const input = document.getElementById('newPresetFreq');
const freq = input.value.trim();
if (!freq || isNaN(parseFloat(freq))) {
alert('Please enter a valid frequency');
return;
}
const presets = loadPresets();
if (!presets.includes(freq)) {
presets.push(freq);
savePresets(presets);
renderPresets();
}
input.value = '';
}
function removePreset(freq) {
if (confirm('Remove preset ' + freq + ' MHz?')) {
let presets = loadPresets();
presets = presets.filter(p => p !== freq);
savePresets(presets);
renderPresets();
}
}
function resetPresets() {
if (confirm('Reset to default presets?')) {
savePresets([...defaultPresets]);
renderPresets();
}
}
// Initialize presets on load
renderPresets();
// Initialize button states on load
updateMuteButton();
updateAutoScrollButton();
// NOTE: Audio context initialization moved to static/js/core/audio.js
function setFreq(freq) {
document.getElementById('frequency').value = freq;
// Auto-restart decoder with new frequency if currently running
if (isRunning) {
fetch('/stop', {method: 'POST'})
.then(() => {
setTimeout(() => startDecoding(), 500);
});
}
}
// SDR hardware capabilities
const sdrCapabilities = {
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }
};
// Current device list with SDR type info
let currentDeviceList = [];
// SDR Device Usage Tracking
// Tracks which mode is using which device index
const sdrDeviceUsage = {
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner')
};
function getDeviceInUseBy(deviceIndex) {
return sdrDeviceUsage[deviceIndex] || null;
}
function isDeviceInUse(deviceIndex) {
return sdrDeviceUsage[deviceIndex] !== undefined;
}
function reserveDevice(deviceIndex, modeName) {
sdrDeviceUsage[deviceIndex] = modeName;
updateDeviceSelectStatus();
}
function releaseDevice(modeName) {
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
if (mode === modeName) {
delete sdrDeviceUsage[idx];
}
}
updateDeviceSelectStatus();
}
function getAvailableDevice() {
// Find first device not in use
for (const device of currentDeviceList) {
if (!isDeviceInUse(device.index)) {
return device.index;
}
}
return null;
}
function updateDeviceSelectStatus() {
// Update device dropdown to show which devices are in use
const select = document.getElementById('deviceSelect');
if (!select) return;
const options = select.querySelectorAll('option');
options.forEach(opt => {
const idx = parseInt(opt.value);
const usedBy = getDeviceInUseBy(idx);
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
if (usedBy) {
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
opt.style.color = 'var(--accent-orange)';
} else {
opt.textContent = baseName;
opt.style.color = '';
}
});
}
function checkDeviceAvailability(modeName) {
const selectedDevice = parseInt(getSelectedDevice());
const usedBy = getDeviceInUseBy(selectedDevice);
if (usedBy && usedBy !== modeName) {
// Device is in use by another mode
const availableDevice = getAvailableDevice();
if (availableDevice !== null) {
// Another device is available - offer to switch
const switchDevice = confirm(
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}.\n\n` +
`Device ${availableDevice} is available. Switch to it?`
);
if (switchDevice) {
document.getElementById('deviceSelect').value = availableDevice;
return true; // Can proceed with new device
}
return false; // User declined to switch
} else {
// No other devices available
showNotification('SDR In Use',
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}. ` +
`No other SDR devices available. Stop ${usedBy} first or connect another SDR.`
);
return false;
}
}
return true; // Device is available
}
function onSDRTypeChanged() {
const sdrType = document.getElementById('sdrTypeSelect').value;
const select = document.getElementById('deviceSelect');
// Filter devices by selected SDR type
const filteredDevices = currentDeviceList.filter(d =>
(d.sdr_type || 'rtlsdr') === sdrType
);
if (filteredDevices.length === 0) {
select.innerHTML = `<option value="0">No ${sdrCapabilities[sdrType]?.name || sdrType} devices found</option>`;
} else {
select.innerHTML = filteredDevices.map(d =>
`<option value="${d.index}" data-sdr-type="${d.sdr_type || 'rtlsdr'}">${d.index}: ${d.name}</option>`
).join('');
}
// Update capabilities display
updateCapabilitiesDisplay(sdrType);
}
function updateCapabilitiesDisplay(sdrType) {
const caps = sdrCapabilities[sdrType];
if (caps) {
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
}
}
function refreshDevices() {
fetch('/devices')
.then(r => r.json())
.then(devices => {
// Store full device list with SDR type info
currentDeviceList = devices;
deviceList = devices;
// Auto-select SDR type if devices found
if (devices.length > 0) {
const firstType = devices[0].sdr_type || 'rtlsdr';
document.getElementById('sdrTypeSelect').value = firstType;
}
// Trigger filter update
onSDRTypeChanged();
})
.catch(err => {
console.error('Failed to refresh devices:', err);
const select = document.getElementById('deviceSelect');
select.innerHTML = '<option value="0">Error loading devices</option>';
});
}
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedSDRType() {
return document.getElementById('sdrTypeSelect').value;
}
// Bias-T power setting
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
}
function getBiasTEnabled() {
return document.getElementById('biasT')?.checked || false;
}
function loadBiasTSetting() {
const saved = localStorage.getItem('biasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('biasT');
if (checkbox) checkbox.checked = true;
}
}
function toggleRemoteSDR() {
const useRemote = document.getElementById('useRemoteSDR').checked;
const configDiv = document.getElementById('remoteSDRConfig');
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
configDiv.style.display = useRemote ? 'block' : 'none';
// Dim local device controls when using remote
localControls.forEach(el => {
el.style.opacity = useRemote ? '0.5' : '1';
el.disabled = useRemote;
});
}
function getRemoteSDRConfig() {
const useRemote = document.getElementById('useRemoteSDR').checked;
if (!useRemote) return null;
const host = document.getElementById('rtlTcpHost').value.trim();
const port = parseInt(document.getElementById('rtlTcpPort').value) || 1234;
if (!host) {
alert('Please enter rtl_tcp host address');
return false;
}
return { host, port };
}
function getSelectedProtocols() {
const protocols = [];
if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512');
if (document.getElementById('proto_pocsag1200').checked) protocols.push('POCSAG1200');
if (document.getElementById('proto_pocsag2400').checked) protocols.push('POCSAG2400');
if (document.getElementById('proto_flex').checked) protocols.push('FLEX');
return protocols;
}
function startDecoding() {
const freq = document.getElementById('frequency').value;
const gain = document.getElementById('gain').value;
const squelch = document.getElementById('squelch').value;
const ppm = document.getElementById('ppm').value;
const device = getSelectedDevice();
const protocols = getSelectedProtocols();
if (protocols.length === 0) {
alert('Please select at least one protocol');
return;
}
// Check if device is available
if (!checkDeviceAvailability('pager')) {
return;
}
// Check for remote SDR
const remoteConfig = getRemoteSDRConfig();
if (remoteConfig === false) return; // Validation failed
const config = {
frequency: freq,
gain: gain,
squelch: squelch,
ppm: ppm,
device: device,
sdr_type: getSelectedSDRType(),
protocols: protocols,
bias_t: getBiasTEnabled()
};
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
fetch('/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
if (data.status === 'started') {
reserveDevice(parseInt(device), 'pager');
setRunning(true);
startStream();
} else {
alert('Error: ' + data.message);
}
})
.catch(err => {
console.error('Start error:', err);
});
}
function stopDecoding() {
fetch('/stop', {method: 'POST'})
.then(r => r.json())
.then(data => {
releaseDevice('pager');
setRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
function killAll() {
fetch('/killall', {method: 'POST'})
.then(r => r.json())
.then(data => {
// Release all devices
Object.keys(sdrDeviceUsage).forEach(idx => delete sdrDeviceUsage[idx]);
updateDeviceSelectStatus();
setRunning(false);
setSensorRunning(false);
isAdsbRunning = false;
isScannerRunning = false;
isAudioPlaying = false;
if (eventSource) {
eventSource.close();
eventSource = null;
}
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
});
}
function checkStatus() {
fetch('/status')
.then(r => r.json())
.then(data => {
if (data.running !== isRunning) {
setRunning(data.running);
if (data.running && !eventSource) {
startStream();
}
}
})
.catch(() => {
// Silently ignore - server may be restarting or network issue
});
}
// Periodic status check every 5 seconds
setInterval(checkStatus, 5000);
function toggleLogging() {
const enabled = document.getElementById('loggingEnabled').checked;
const logFile = document.getElementById('logFilePath').value;
fetch('/logging', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled, log_file: logFile})
}).then(r => r.json())
.then(data => {
showInfo(data.logging ? 'Logging enabled: ' + data.log_file : 'Logging disabled');
});
}
function setRunning(running) {
isRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Decoding...' : 'Idle';
document.getElementById('startBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopBtn').style.display = running ? 'block' : 'none';
}
function startStream() {
if (eventSource) {
eventSource.close();
}
eventSource = new EventSource('/stream');
eventSource.onopen = function() {
showInfo('Stream connected...');
};
eventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'message') {
addMessage(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setRunning(false);
} else if (data.text === 'started') {
showInfo('Decoder started, waiting for signals...');
}
} else if (data.type === 'info') {
showInfo(data.text);
} else if (data.type === 'raw') {
showInfo(data.text);
}
};
eventSource.onerror = function(e) {
checkStatus();
};
}
function addMessage(msg) {
const output = document.getElementById('output');
// Remove placeholder if present
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
// Store message for export (always, even if filtered)
allMessages.push(msg);
// Check if message should be filtered from display
const isFiltered = shouldFilterMessage(msg);
// Update counts (always, even if filtered)
msgCount++;
document.getElementById('msgCount').textContent = msgCount;
let protoClass = '';
if (msg.protocol.includes('POCSAG')) {
pocsagCount++;
protoClass = 'pocsag';
document.getElementById('pocsagCount').textContent = pocsagCount;
} else if (msg.protocol.includes('FLEX')) {
flexCount++;
protoClass = 'flex';
document.getElementById('flexCount').textContent = flexCount;
}
// If filtered, skip display but update filtered count
if (isFiltered) {
filteredCount++;
return;
}
// Play audio alert (only for non-filtered messages)
playAlert();
// Update signal meter
pulseSignal();
// Add to waterfall
addWaterfallPoint(Date.now(), 0.8);
const isNumeric = /^[0-9\s\-\*\#U]+$/.test(msg.message);
const relativeTime = getRelativeTime(msg.timestamp);
const msgEl = document.createElement('div');
msgEl.className = 'message ' + protoClass;
msgEl.innerHTML = `
<div class="header">
<span class="protocol">${escapeHtml(msg.protocol)}</span>
<span class="msg-time" data-timestamp="${escapeAttr(msg.timestamp)}" title="${escapeAttr(msg.timestamp)}">${escapeHtml(relativeTime)}</span>
</div>
<div class="address">Address: ${escapeHtml(msg.address)}${msg.function ? ' | Func: ' + escapeHtml(msg.function) : ''}</div>
<div class="content ${isNumeric ? 'numeric' : ''}">${escapeHtml(msg.message)}</div>
`;
output.insertBefore(msgEl, output.firstChild);
// Auto-scroll to top (newest messages)
if (autoScroll) {
output.scrollTop = 0;
}
// Limit messages displayed
while (output.children.length > 100) {
output.removeChild(output.lastChild);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeAttr(text) {
// Escape for use in HTML attributes (especially onclick handlers)
if (text === null || text === undefined) return '';
var s = String(text);
s = s.replace(/&/g, '&amp;');
s = s.replace(/'/g, '&#39;');
s = s.replace(/"/g, '&quot;');
s = s.replace(/</g, '&lt;');
s = s.replace(/>/g, '&gt;');
return s;
}
function isValidMac(mac) {
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
}
function isValidChannel(ch) {
// Validate WiFi channel (1-200 covers all bands)
const num = parseInt(ch, 10);
return !isNaN(num) && num >= 1 && num <= 200;
}
function showInfo(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function showError(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const errorEl = document.createElement('div');
errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild);
}
function clearMessages() {
document.getElementById('output').innerHTML = `
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
</div>
`;
msgCount = 0;
pocsagCount = 0;
flexCount = 0;
sensorCount = 0;
filteredCount = 0;
allMessages = [];
uniqueDevices.clear();
document.getElementById('msgCount').textContent = '0';
document.getElementById('pocsagCount').textContent = '0';
document.getElementById('flexCount').textContent = '0';
document.getElementById('sensorCount').textContent = '0';
document.getElementById('deviceCount').textContent = '0';
// Reset recon data
deviceDatabase.clear();
newDeviceAlerts = 0;
anomalyAlerts = 0;
document.getElementById('trackedCount').textContent = '0';
document.getElementById('newDeviceCount').textContent = '0';
document.getElementById('anomalyCount').textContent = '0';
document.getElementById('reconContent').innerHTML = '<div style="color: #444; text-align: center; padding: 30px; font-size: 11px;">Device intelligence data will appear here as signals are intercepted.</div>';
}
// ============== DEVICE INTELLIGENCE & RECONNAISSANCE ==============
// Device tracking database
const deviceDatabase = new Map(); // key: deviceId, value: device profile
// Default to true if not set, so device intelligence works by default
let reconEnabled = localStorage.getItem('reconEnabled') !== 'false';
let newDeviceAlerts = 0;
let anomalyAlerts = 0;
// Device profile structure
function createDeviceProfile(deviceId, protocol, firstSeen) {
return {
id: deviceId,
protocol: protocol,
firstSeen: firstSeen,
lastSeen: firstSeen,
transmissionCount: 1,
transmissions: [firstSeen], // timestamps of recent transmissions
avgInterval: null, // average time between transmissions
addresses: new Set(),
models: new Set(),
messages: [],
isNew: true,
anomalies: [],
signalStrength: [],
encrypted: null // null = unknown, true/false
};
}
// Analyze transmission patterns for anomalies
function analyzeTransmissions(profile) {
const anomalies = [];
const now = Date.now();
// Need at least 3 transmissions to analyze patterns
if (profile.transmissions.length < 3) {
return anomalies;
}
// Calculate intervals between transmissions
const intervals = [];
for (let i = 1; i < profile.transmissions.length; i++) {
intervals.push(profile.transmissions[i] - profile.transmissions[i-1]);
}
// Calculate average and standard deviation
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
profile.avgInterval = avg;
const variance = intervals.reduce((a, b) => a + Math.pow(b - avg, 2), 0) / intervals.length;
const stdDev = Math.sqrt(variance);
// Check for burst transmission (sudden increase in frequency)
const lastInterval = intervals[intervals.length - 1];
if (avg > 0 && lastInterval < avg * 0.2) {
anomalies.push({
type: 'burst',
severity: 'medium',
message: 'Burst transmission detected - interval ' + Math.round(lastInterval/1000) + 's vs avg ' + Math.round(avg/1000) + 's'
});
}
// Check for silence break (device was quiet, now transmitting again)
if (avg > 0 && lastInterval > avg * 5) {
anomalies.push({
type: 'silence_break',
severity: 'low',
message: 'Device resumed after ' + Math.round(lastInterval/60000) + ' min silence'
});
}
return anomalies;
}
// Check for encryption indicators
function detectEncryption(message) {
if (!message || message === '[No Message]' || message === '[Tone Only]') {
return null; // Can't determine
}
// Check for high entropy (random-looking data)
const printableRatio = (message.match(/[a-zA-Z0-9\s.,!?-]/g) || []).length / message.length;
// Check for common encrypted patterns (hex strings, base64-like)
const hexPattern = /^[0-9A-Fa-f\s]+$/;
const hasNonPrintable = /[^\x20-\x7E]/.test(message);
if (printableRatio > 0.8 && !hasNonPrintable) {
return false; // Likely plaintext
} else if (hexPattern.test(message.replace(/\s/g, '')) || hasNonPrintable) {
return true; // Likely encrypted or encoded
}
return null; // Unknown
}
// Generate device fingerprint
function generateDeviceId(data) {
if (data.protocol && data.protocol.includes('POCSAG')) {
return 'PAGER_' + (data.address || 'UNK');
} else if (data.protocol === 'FLEX') {
return 'FLEX_' + (data.address || 'UNK');
} else if (data.protocol === 'WiFi-AP') {
return 'WIFI_AP_' + (data.address || 'UNK').replace(/:/g, '');
} else if (data.protocol === 'WiFi-Client') {
return 'WIFI_CLIENT_' + (data.address || 'UNK').replace(/:/g, '');
} else if (data.protocol === 'Bluetooth' || data.protocol === 'BLE') {
return 'BT_' + (data.address || 'UNK').replace(/:/g, '');
} else if (data.model) {
// 433MHz sensor
const id = data.id || data.channel || data.unit || '0';
return 'SENSOR_' + data.model.replace(/\s+/g, '_') + '_' + id;
}
return 'UNKNOWN_' + Date.now();
}
// Track a device transmission
function trackDevice(data) {
const now = Date.now();
const deviceId = generateDeviceId(data);
const protocol = data.protocol || data.model || 'Unknown';
let profile = deviceDatabase.get(deviceId);
let isNewDevice = false;
if (!profile) {
// New device discovered
profile = createDeviceProfile(deviceId, protocol, now);
isNewDevice = true;
newDeviceAlerts++;
document.getElementById('newDeviceCount').textContent = newDeviceAlerts;
} else {
// Update existing profile
profile.lastSeen = now;
profile.transmissionCount++;
profile.transmissions.push(now);
profile.isNew = false;
// Keep only last 100 transmissions for analysis
if (profile.transmissions.length > 100) {
profile.transmissions = profile.transmissions.slice(-100);
}
}
// Track addresses
if (data.address) profile.addresses.add(data.address);
if (data.model) profile.models.add(data.model);
// Store recent messages (keep last 10)
if (data.message) {
profile.messages.unshift({
text: data.message,
time: now
});
if (profile.messages.length > 10) profile.messages.pop();
// Detect encryption
const encrypted = detectEncryption(data.message);
if (encrypted !== null) profile.encrypted = encrypted;
}
// Analyze for anomalies
const newAnomalies = analyzeTransmissions(profile);
if (newAnomalies.length > 0) {
profile.anomalies = profile.anomalies.concat(newAnomalies);
anomalyAlerts += newAnomalies.length;
document.getElementById('anomalyCount').textContent = anomalyAlerts;
}
deviceDatabase.set(deviceId, profile);
document.getElementById('trackedCount').textContent = deviceDatabase.size;
// Update recon display
if (reconEnabled) {
updateReconDisplay(deviceId, profile, isNewDevice, newAnomalies);
}
return { deviceId, profile, isNewDevice, anomalies: newAnomalies };
}
// Update reconnaissance display
function updateReconDisplay(deviceId, profile, isNewDevice, anomalies) {
const content = document.getElementById('reconContent');
// Remove placeholder if present
const placeholder = content.querySelector('div[style*="text-align: center"]');
if (placeholder) placeholder.remove();
// Check if device row already exists
let row = document.getElementById('device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_'));
if (!row) {
// Create new row
row = document.createElement('div');
row.id = 'device_' + deviceId.replace(/[^a-zA-Z0-9]/g, '_');
row.className = 'device-row' + (isNewDevice ? ' new-device' : '');
content.insertBefore(row, content.firstChild);
}
// Determine protocol badge class
let badgeClass = 'proto-unknown';
if (profile.protocol.includes('POCSAG')) badgeClass = 'proto-pocsag';
else if (profile.protocol === 'FLEX') badgeClass = 'proto-flex';
else if (profile.protocol.includes('SENSOR') || profile.models.size > 0) badgeClass = 'proto-433';
// Calculate transmission rate bar width
const maxRate = 100; // Max expected transmissions
const rateWidth = Math.min(100, (profile.transmissionCount / maxRate) * 100);
// Determine timeline status
const timeSinceLast = Date.now() - profile.lastSeen;
let timelineDot = 'recent';
if (timeSinceLast > 300000) timelineDot = 'old'; // > 5 min
else if (timeSinceLast > 60000) timelineDot = 'stale'; // > 1 min
// Build encryption indicator
let encStatus = 'Unknown';
let encClass = '';
if (profile.encrypted === true) { encStatus = 'Encrypted'; encClass = 'encrypted'; }
else if (profile.encrypted === false) { encStatus = 'Plaintext'; encClass = 'plaintext'; }
// Format time
const lastSeenStr = getRelativeTime(new Date(profile.lastSeen).toTimeString().split(' ')[0]);
const firstSeenStr = new Date(profile.firstSeen).toLocaleTimeString();
// Update row content
row.className = 'device-row' + (isNewDevice ? ' new-device' : '') + (anomalies.length > 0 ? ' anomaly' : '');
row.innerHTML = `
<div class="device-info">
<div class="device-name-row">
<span class="timeline-dot ${timelineDot}"></span>
<span class="badge ${badgeClass}">${profile.protocol.substring(0, 10)}</span>
${deviceId.substring(0, 30)}
</div>
<div class="device-id">
First: ${firstSeenStr} | Last: ${lastSeenStr} | TX: ${profile.transmissionCount}
${profile.avgInterval ? ' | Interval: ' + Math.round(profile.avgInterval/1000) + 's' : ''}
</div>
</div>
<div class="device-meta ${encClass}">${encStatus}</div>
<div>
<div class="transmission-bar">
<div class="transmission-bar-fill" style="width: ${rateWidth}%"></div>
</div>
</div>
<div class="device-meta">${Array.from(profile.addresses).slice(0, 2).join(', ')}</div>
`;
// Show anomaly alerts
if (anomalies.length > 0) {
anomalies.forEach(a => {
const alertEl = document.createElement('div');
alertEl.style.cssText = 'padding: 5px 15px; background: rgba(255,51,102,0.1); border-left: 2px solid var(--accent-red); font-size: 10px; color: var(--accent-red);';
alertEl.textContent = '⚠ ' + a.message;
row.appendChild(alertEl);
});
}
// Limit displayed devices
while (content.children.length > 50) {
content.removeChild(content.lastChild);
}
}
// Toggle recon panel visibility
function toggleRecon() {
reconEnabled = !reconEnabled;
localStorage.setItem('reconEnabled', reconEnabled);
document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
document.getElementById('reconBtn').classList.toggle('active', reconEnabled);
// Populate recon display if enabled and we have data
if (reconEnabled && deviceDatabase.size > 0) {
deviceDatabase.forEach((profile, deviceId) => {
updateReconDisplay(deviceId, profile, false, []);
});
}
}
// Initialize recon state
if (reconEnabled) {
document.getElementById('reconPanel').style.display = 'block';
document.getElementById('reconBtn').classList.add('active');
} else {
document.getElementById('reconPanel').style.display = 'none';
}
// Hook into existing message handlers to track devices
const originalAddMessage = addMessage;
addMessage = function(msg) {
originalAddMessage(msg);
trackDevice(msg);
};
const originalAddSensorReading = addSensorReading;
addSensorReading = function(data) {
originalAddSensorReading(data);
trackDevice(data);
};
// Export device database
function exportDeviceDB() {
const data = [];
deviceDatabase.forEach((profile, id) => {
data.push({
id: id,
protocol: profile.protocol,
firstSeen: new Date(profile.firstSeen).toISOString(),
lastSeen: new Date(profile.lastSeen).toISOString(),
transmissionCount: profile.transmissionCount,
avgIntervalSeconds: profile.avgInterval ? Math.round(profile.avgInterval / 1000) : null,
addresses: Array.from(profile.addresses),
models: Array.from(profile.models),
encrypted: profile.encrypted,
anomalyCount: profile.anomalies.length,
recentMessages: profile.messages.slice(0, 5).map(m => m.text)
});
});
downloadFile(JSON.stringify(data, null, 2), 'intercept_device_intelligence.json', 'application/json');
}
// Toggle recon panel collapse
function toggleReconCollapse() {
const panel = document.getElementById('reconPanel');
const icon = document.getElementById('reconCollapseIcon');
panel.classList.toggle('collapsed');
icon.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
}
// ============== WIFI RECONNAISSANCE ==============
let wifiEventSource = null;
let monitorInterface = null;
let wifiNetworks = {};
let wifiClients = {};
let apCount = 0;
let clientCount = 0;
let handshakeCount = 0;
let rogueApCount = 0;
let droneCount = 0;
let detectedDrones = {}; // Track detected drones by BSSID
let ssidToBssids = {}; // Track SSIDs to their BSSIDs for rogue AP detection
let rogueApDetails = {}; // Store details about rogue APs: {ssid: [{bssid, signal, channel, firstSeen}]}
let rogueBssids = new Set(); // Track all BSSIDs that are suspected rogues
let activeCapture = null; // {bssid, channel, file, startTime, pollInterval}
let watchMacs = JSON.parse(localStorage.getItem('watchMacs') || '[]');
let alertedMacs = new Set(); // Prevent duplicate alerts per session
let selectedWifiDevice = null; // Selected network or client for details view
let selectedWifiType = null; // 'network' or 'client'
// 5GHz channel mapping for the graph
const channels5g = ['36', '40', '44', '48', '52', '56', '60', '64', '100', '149', '153', '157', '161', '165'];
// Drone SSID patterns for detection
const dronePatterns = [
/^DJI[-_]/i, /Mavic/i, /Phantom/i, /^Spark[-_]/i, /^Mini[-_]/i, /^Air[-_]/i,
/Inspire/i, /Matrice/i, /Avata/i, /^FPV[-_]/i, /Osmo/i, /RoboMaster/i, /Tello/i,
/Parrot/i, /Bebop/i, /Anafi/i, /^Disco[-_]/i, /Mambo/i, /Swing/i,
/Autel/i, /^EVO[-_]/i, /Dragonfish/i, /Skydio/i,
/Holy.?Stone/i, /Potensic/i, /SYMA/i, /Hubsan/i, /Eachine/i, /FIMI/i,
/Yuneec/i, /Typhoon/i, /PowerVision/i, /PowerEgg/i,
/Drone/i, /^UAV[-_]/i, /Quadcopter/i, /^RC[-_]Drone/i
];
// Drone OUI prefixes
const droneOuiPrefixes = {
'60:60:1F': 'DJI', '48:1C:B9': 'DJI', '34:D2:62': 'DJI', 'E0:DB:55': 'DJI',
'C8:6C:87': 'DJI', 'A0:14:3D': 'DJI', '70:D7:11': 'DJI', '98:3A:56': 'DJI',
'90:03:B7': 'Parrot', '00:12:1C': 'Parrot', '00:26:7E': 'Parrot',
'8C:F5:A3': 'Autel', 'D8:E0:E1': 'Autel', 'F8:0F:6F': 'Skydio'
};
// Check if network is a drone
function isDrone(ssid, bssid) {
// Check SSID patterns
if (ssid) {
for (const pattern of dronePatterns) {
if (pattern.test(ssid)) {
return { isDrone: true, method: 'SSID', brand: ssid.split(/[-_\s]/)[0] };
}
}
}
// Check OUI prefix
if (bssid) {
const prefix = bssid.substring(0, 8).toUpperCase();
if (droneOuiPrefixes[prefix]) {
return { isDrone: true, method: 'OUI', brand: droneOuiPrefixes[prefix] };
}
}
return { isDrone: false };
}
// Handle drone detection
function handleDroneDetection(net, droneInfo) {
if (detectedDrones[net.bssid]) return; // Already detected
detectedDrones[net.bssid] = {
ssid: net.essid,
bssid: net.bssid,
brand: droneInfo.brand,
method: droneInfo.method,
signal: net.power,
channel: net.channel,
firstSeen: new Date().toISOString()
};
droneCount++;
document.getElementById('droneCount').textContent = droneCount;
// Calculate approximate distance from signal strength
const rssi = parseInt(net.power) || -70;
const distance = estimateDroneDistance(rssi);
// Triple alert for drones
playAlert();
setTimeout(playAlert, 200);
setTimeout(playAlert, 400);
// Show drone alert
showDroneAlert(net.essid, net.bssid, droneInfo.brand, distance, rssi);
}
// Estimate distance from RSSI (rough approximation)
function estimateDroneDistance(rssi) {
// Using free-space path loss model (very approximate)
// Reference: -30 dBm at 1 meter
const txPower = -30;
const n = 2.5; // Path loss exponent (2-4, higher for obstacles)
const distance = Math.pow(10, (txPower - rssi) / (10 * n));
return Math.round(distance);
}
// Show drone alert popup
function showDroneAlert(ssid, bssid, brand, distance, rssi) {
const alertDiv = document.createElement('div');
alertDiv.className = 'drone-alert';
alertDiv.innerHTML = `
<div style="font-weight: bold; color: var(--accent-orange); font-size: 16px;">🚁 DRONE DETECTED</div>
<div style="margin: 10px 0;">
<div><strong>SSID:</strong> ${escapeHtml(ssid || 'Unknown')}</div>
<div><strong>BSSID:</strong> ${bssid}</div>
<div><strong>Brand:</strong> ${brand || 'Unknown'}</div>
<div><strong>Signal:</strong> ${rssi} dBm</div>
<div><strong>Est. Distance:</strong> ~${distance}m</div>
</div>
<button onclick="this.parentElement.remove()" style="padding: 6px 16px; cursor: pointer; background: var(--accent-orange); border: none; color: #000; border-radius: 4px;">Dismiss</button>
`;
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-orange); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,165,0,0.5); min-width: 280px;';
document.body.appendChild(alertDiv);
setTimeout(() => { if (alertDiv.parentElement) alertDiv.remove(); }, 15000);
}
// Initialize watch list display
function initWatchList() {
updateWatchListDisplay();
}
// Add MAC to watch list
function addWatchMac() {
const input = document.getElementById('watchMacInput');
const mac = input.value.trim().toUpperCase();
if (!mac || !/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(mac)) {
alert('Please enter a valid MAC address (AA:BB:CC:DD:EE:FF)');
return;
}
if (!watchMacs.includes(mac)) {
watchMacs.push(mac);
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
updateWatchListDisplay();
}
input.value = '';
}
// Remove MAC from watch list
function removeWatchMac(mac) {
watchMacs = watchMacs.filter(m => m !== mac);
localStorage.setItem('watchMacs', JSON.stringify(watchMacs));
alertedMacs.delete(mac);
updateWatchListDisplay();
}
// Update watch list display
function updateWatchListDisplay() {
const container = document.getElementById('watchList');
if (!container) return;
if (watchMacs.length === 0) {
container.innerHTML = '<div style="color: #555;">No MACs in watch list</div>';
} else {
container.innerHTML = watchMacs.map(mac =>
`<div style="display: flex; justify-content: space-between; align-items: center; padding: 2px 0;">
<span>${mac}</span>
<button onclick="removeWatchMac('${mac}')" style="background: none; border: none; color: var(--accent-red); cursor: pointer; font-size: 10px;">✕</button>
</div>`
).join('');
}
}
// Check if MAC is in watch list and alert
function checkWatchList(mac, type) {
const upperMac = mac.toUpperCase();
if (watchMacs.includes(upperMac) && !alertedMacs.has(upperMac)) {
alertedMacs.add(upperMac);
// Play alert sound multiple times for urgency
playAlert();
setTimeout(playAlert, 300);
setTimeout(playAlert, 600);
// Show prominent alert
showProximityAlert(mac, type);
}
}
// Show proximity alert popup
function showProximityAlert(mac, type) {
const alertDiv = document.createElement('div');
alertDiv.className = 'proximity-alert';
alertDiv.innerHTML = `
<div style="font-weight: bold; color: var(--accent-red);">⚠ PROXIMITY ALERT</div>
<div>Watched ${type} detected:</div>
<div style="font-family: monospace; font-size: 14px;">${mac}</div>
<button onclick="this.parentElement.remove()" style="margin-top: 8px; padding: 4px 12px; cursor: pointer;">Dismiss</button>
`;
alertDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #1a1a2e; border: 2px solid var(--accent-red); padding: 20px; border-radius: 8px; z-index: 10000; text-align: center; box-shadow: 0 0 30px rgba(255,0,0,0.5);';
document.body.appendChild(alertDiv);
// Auto-dismiss after 10 seconds
setTimeout(() => alertDiv.remove(), 10000);
}
// Check for rogue APs (same SSID, different BSSID)
// Extract OUI (manufacturer ID) from MAC address
function getOui(mac) {
if (!mac) return '';
return mac.toUpperCase().substring(0, 8); // First 3 octets: "AA:BB:CC"
}
function checkRogueAP(ssid, bssid, channel, signal) {
if (!ssid || ssid === 'Hidden' || ssid === '[Hidden]') return false;
if (!ssidToBssids[ssid]) {
ssidToBssids[ssid] = new Set();
}
// Store details for this BSSID
if (!rogueApDetails[ssid]) {
rogueApDetails[ssid] = [];
}
// Check if we already have this BSSID stored
const existingEntry = rogueApDetails[ssid].find(e => e.bssid === bssid);
if (!existingEntry) {
rogueApDetails[ssid].push({
bssid: bssid,
channel: channel || '?',
signal: signal || '?',
oui: getOui(bssid),
firstSeen: new Date().toLocaleTimeString()
});
}
const isNewBssid = !ssidToBssids[ssid].has(bssid);
ssidToBssids[ssid].add(bssid);
// Only flag as rogue if multiple BSSIDs AND different manufacturers (OUIs)
// This prevents false positives from mesh WiFi systems and enterprise networks
if (ssidToBssids[ssid].size > 1 && isNewBssid) {
// Check if all BSSIDs have the same OUI (manufacturer)
const ouis = new Set(rogueApDetails[ssid].map(e => e.oui));
// If all BSSIDs have the same OUI, it's likely a mesh system - not rogue
if (ouis.size === 1) {
// Same manufacturer - probably mesh system, not rogue
return false;
}
// Different manufacturers detected - this is suspicious!
rogueApCount++;
document.getElementById('rogueApCount').textContent = rogueApCount;
playAlert();
// Mark ALL BSSIDs with this SSID as suspected rogues
ssidToBssids[ssid].forEach(b => rogueBssids.add(b));
// Get the BSSIDs to show in alert
const bssidList = rogueApDetails[ssid].map(e => e.bssid).join(', ');
showInfo(`⚠ Rogue AP: "${ssid}" has ${ouis.size} different vendors: ${bssidList}`);
showNotification('⚠️ Rogue AP Detected!', `"${ssid}" has different vendor BSSIDs`);
// Update all network cards with this SSID to show rogue indicator
ssidToBssids[ssid].forEach(rogueBssid => {
const net = wifiNetworks[rogueBssid];
if (net) addWifiNetworkCard(net, false);
});
return true;
}
return false;
}
// Show rogue AP details popup
function showRogueApDetails() {
const rogueSSIDs = Object.keys(rogueApDetails).filter(ssid =>
rogueApDetails[ssid].length > 1
);
if (rogueSSIDs.length === 0) {
showInfo('No rogue APs detected. Rogue AP = same SSID on multiple BSSIDs.');
return;
}
// Remove existing popup if any
const existing = document.getElementById('rogueApPopup');
if (existing) existing.remove();
// Build details HTML
let html = '<div style="max-height: 300px; overflow-y: auto;">';
rogueSSIDs.forEach(ssid => {
const aps = rogueApDetails[ssid];
html += `<div style="margin-bottom: 12px;">
<div style="color: var(--accent-red); font-weight: bold; margin-bottom: 4px;">
📡 "${ssid}" (${aps.length} BSSIDs)
</div>
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<tr style="color: var(--text-dim);">
<th style="text-align: left; padding: 2px 8px;">BSSID</th>
<th style="text-align: left; padding: 2px 8px;">CH</th>
<th style="text-align: left; padding: 2px 8px;">Signal</th>
<th style="text-align: left; padding: 2px 8px;">First Seen</th>
</tr>`;
aps.forEach((ap, idx) => {
const bgColor = idx % 2 === 0 ? 'rgba(255,255,255,0.05)' : 'transparent';
html += `<tr style="background: ${bgColor};">
<td style="padding: 2px 8px; font-family: monospace;">${ap.bssid}</td>
<td style="padding: 2px 8px;">${ap.channel}</td>
<td style="padding: 2px 8px;">${ap.signal} dBm</td>
<td style="padding: 2px 8px;">${ap.firstSeen}</td>
</tr>`;
});
html += '</table></div>';
});
html += '</div>';
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">⚠ Multiple BSSIDs for same SSID may indicate rogue AP or legitimate multi-AP setup</div>';
// Create popup
const popup = document.createElement('div');
popup.id = 'rogueApPopup';
popup.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-primary);
border: 1px solid var(--accent-red);
border-radius: 8px;
padding: 16px;
z-index: 10000;
min-width: 400px;
max-width: 600px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
`;
popup.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: bold; color: var(--accent-red);">🚨 Rogue AP Details</span>
<button onclick="this.parentElement.parentElement.remove()"
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
</div>
${html}
`;
document.body.appendChild(popup);
}
// Show drone details popup
function showDroneDetails() {
const drones = Object.values(detectedDrones);
if (drones.length === 0) {
showInfo('No drones detected. Drones are identified by SSID patterns and manufacturer OUI.');
return;
}
// Remove existing popup if any
const existing = document.getElementById('droneDetailsPopup');
if (existing) existing.remove();
// Build details HTML
let html = '<div style="max-height: 300px; overflow-y: auto;">';
html += `<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<tr style="color: var(--text-dim);">
<th style="text-align: left; padding: 4px 8px;">Brand</th>
<th style="text-align: left; padding: 4px 8px;">SSID</th>
<th style="text-align: left; padding: 4px 8px;">BSSID</th>
<th style="text-align: left; padding: 4px 8px;">CH</th>
<th style="text-align: left; padding: 4px 8px;">Signal</th>
<th style="text-align: left; padding: 4px 8px;">Distance</th>
<th style="text-align: left; padding: 4px 8px;">Detected</th>
</tr>`;
drones.forEach((drone, idx) => {
const bgColor = idx % 2 === 0 ? 'rgba(255,165,0,0.1)' : 'transparent';
const rssi = parseInt(drone.signal) || -70;
const distance = estimateDroneDistance(rssi);
const timeStr = new Date(drone.firstSeen).toLocaleTimeString();
html += `<tr style="background: ${bgColor};">
<td style="padding: 4px 8px; font-weight: bold; color: var(--accent-orange);">${drone.brand || 'Unknown'}</td>
<td style="padding: 4px 8px;">${drone.ssid || '[Hidden]'}</td>
<td style="padding: 4px 8px; font-family: monospace; font-size: 9px;">${drone.bssid}</td>
<td style="padding: 4px 8px;">${drone.channel || '?'}</td>
<td style="padding: 4px 8px;">${drone.signal || '?'} dBm</td>
<td style="padding: 4px 8px;">~${distance}m</td>
<td style="padding: 4px 8px;">${timeStr}</td>
</tr>`;
});
html += '</table></div>';
html += '<div style="margin-top: 8px; font-size: 9px; color: var(--text-dim);">Detection via: SSID pattern matching and manufacturer OUI lookup</div>';
// Create popup
const popup = document.createElement('div');
popup.id = 'droneDetailsPopup';
popup.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-primary);
border: 1px solid var(--accent-orange);
border-radius: 8px;
padding: 16px;
z-index: 10000;
min-width: 500px;
max-width: 700px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
`;
popup.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<span style="font-weight: bold; color: var(--accent-orange);">🚁 Detected Drones (${drones.length})</span>
<button onclick="this.parentElement.parentElement.remove()"
style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px;">✕</button>
</div>
${html}
`;
document.body.appendChild(popup);
}
// Update 5GHz channel graph
function updateChannel5gGraph() {
const bars = document.querySelectorAll('#channelGraph5g .channel-bar');
const labels = document.querySelectorAll('#channelGraph5g .channel-label');
// Count networks per 5GHz channel
const channelCounts = {};
channels5g.forEach(ch => channelCounts[ch] = 0);
Object.values(wifiNetworks).forEach(net => {
const ch = net.channel?.toString().trim();
if (channels5g.includes(ch)) {
channelCounts[ch]++;
}
});
const maxCount = Math.max(1, ...Object.values(channelCounts));
bars.forEach((bar, i) => {
const ch = channels5g[i];
const count = channelCounts[ch] || 0;
const height = Math.max(2, (count / maxCount) * 50);
bar.style.height = height + 'px';
bar.className = 'channel-bar' + (count > 0 ? ' active' : '') + (count > 3 ? ' congested' : '') + (count > 5 ? ' very-congested' : '');
});
}
// ============== NEW FEATURES ==============
// Network Topology Graph
function drawNetworkGraph() {
const canvas = document.getElementById('networkGraph');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const width = canvas.offsetWidth;
const height = canvas.offsetHeight;
canvas.width = width;
canvas.height = height;
// Clear
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, height);
const networks = Object.values(wifiNetworks);
const clients = Object.values(wifiClients);
if (networks.length === 0) {
ctx.fillStyle = '#444';
ctx.font = '12px sans-serif';
ctx.fillText('Start scanning to see network topology', width/2 - 100, height/2);
return;
}
// Calculate positions for APs (top row)
const apPositions = {};
const apSpacing = width / (networks.length + 1);
networks.forEach((net, i) => {
apPositions[net.bssid] = {
x: apSpacing * (i + 1),
y: 40,
ssid: net.essid,
isDrone: isDrone(net.essid, net.bssid).isDrone
};
});
// Draw connections from clients to APs
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 1;
clients.forEach(client => {
if (client.ap && apPositions[client.ap]) {
const ap = apPositions[client.ap];
const clientY = 120 + (Math.random() * 60);
const clientX = ap.x + (Math.random() - 0.5) * 80;
ctx.beginPath();
ctx.moveTo(ap.x, ap.y + 15);
ctx.lineTo(clientX, clientY - 10);
ctx.stroke();
// Draw client node
ctx.beginPath();
ctx.arc(clientX, clientY, 6, 0, Math.PI * 2);
ctx.fillStyle = '#00ff88';
ctx.fill();
}
});
// Draw AP nodes
Object.entries(apPositions).forEach(([bssid, pos]) => {
ctx.beginPath();
ctx.arc(pos.x, pos.y, 12, 0, Math.PI * 2);
ctx.fillStyle = pos.isDrone ? '#ff8800' : '#00d4ff';
ctx.fill();
// Draw label
ctx.fillStyle = '#888';
ctx.font = '9px sans-serif';
ctx.textAlign = 'center';
const label = (pos.ssid || 'Hidden').substring(0, 12);
ctx.fillText(label, pos.x, pos.y + 25);
});
ctx.textAlign = 'left';
}
// Channel Recommendation
function updateChannelRecommendation() {
const channelCounts24 = {};
const channelCounts5 = {};
// Initialize
for (let i = 1; i <= 13; i++) channelCounts24[i] = 0;
channels5g.forEach(ch => channelCounts5[ch] = 0);
// Count networks per channel
Object.values(wifiNetworks).forEach(net => {
const ch = parseInt(net.channel);
if (ch >= 1 && ch <= 13) {
// 2.4 GHz channels overlap, so count neighbors too
for (let i = Math.max(1, ch - 2); i <= Math.min(13, ch + 2); i++) {
channelCounts24[i] = (channelCounts24[i] || 0) + (i === ch ? 1 : 0.5);
}
} else if (channels5g.includes(ch.toString())) {
channelCounts5[ch.toString()]++;
}
});
// Count total networks for context
const totalNetworks = Object.keys(wifiNetworks).length;
// Find best 2.4 GHz channel (1, 6, or 11 preferred - non-overlapping)
const preferred24 = [1, 6, 11];
let best24 = 1;
let minCount24 = Infinity;
let channelUsage24 = [];
preferred24.forEach(ch => {
channelUsage24.push({ channel: ch, count: channelCounts24[ch] || 0 });
if ((channelCounts24[ch] || 0) < minCount24) {
minCount24 = channelCounts24[ch] || 0;
best24 = ch;
}
});
// Find best 5 GHz channel
let best5 = '36';
let minCount5 = Infinity;
let used5g = 0;
channels5g.forEach(ch => {
const count = channelCounts5[ch] || 0;
if (count > 0) used5g++;
if (count < minCount5) {
minCount5 = count;
best5 = ch;
}
});
// Update UI with more context
document.getElementById('rec24Channel').textContent = best24;
if (totalNetworks === 0) {
document.getElementById('rec24Reason').textContent = '(no networks detected)';
} else {
const usage = channelUsage24.map(c => `CH${c.channel}:${Math.round(c.count)}`).join(', ');
document.getElementById('rec24Reason').textContent =
minCount24 === 0 ? '(clear)' : `(${Math.round(minCount24)} interference) [${usage}]`;
}
document.getElementById('rec5Channel').textContent = best5;
if (totalNetworks === 0) {
document.getElementById('rec5Reason').textContent = '(no networks detected)';
} else {
document.getElementById('rec5Reason').textContent =
minCount5 === 0 ? `(clear, ${channels5g.length - used5g} unused)` : `(${minCount5} networks)`;
}
}
// Device Correlation (WiFi <-> Bluetooth)
let deviceCorrelations = [];
let correlationFetchPending = false;
function correlateDevices() {
// Use server-side correlation API for better analysis
if (correlationFetchPending) return;
correlationFetchPending = true;
fetch('/correlation?min_confidence=0.4')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
deviceCorrelations = data.correlations || [];
updateCorrelationDisplay();
}
})
.catch(err => {
console.warn('Correlation fetch failed:', err);
// Fallback to local OUI matching
correlateDevicesLocal();
})
.finally(() => {
correlationFetchPending = false;
});
}
function correlateDevicesLocal() {
// Fallback: simple OUI-based correlation
deviceCorrelations = [];
const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients));
const btMacs = Object.keys(btDevices || {});
wifiMacs.forEach(wifiMac => {
const wifiOui = wifiMac.substring(0, 8).toUpperCase();
btMacs.forEach(btMac => {
const btOui = btMac.substring(0, 8).toUpperCase();
if (wifiOui === btOui) {
const wifiDev = wifiNetworks[wifiMac] || wifiClients[wifiMac];
const btDev = btDevices[btMac];
deviceCorrelations.push({
wifi_mac: wifiMac,
bt_mac: btMac,
wifi_name: wifiDev?.essid || wifiDev?.mac || wifiMac,
bt_name: btDev?.name || btMac,
confidence: 0.5,
reason: 'same OUI'
});
}
});
});
updateCorrelationDisplay();
}
function updateCorrelationDisplay() {
const list = document.getElementById('correlationList');
if (!list) return;
if (deviceCorrelations.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No correlated devices found yet</div>';
return;
}
list.innerHTML = deviceCorrelations.slice(0, 10).map(c => {
const confidence = Math.round((c.confidence || 0.5) * 100);
const confidenceColor = confidence >= 70 ? 'var(--accent-green)' :
confidence >= 50 ? 'var(--accent-orange)' : 'var(--text-dim)';
return `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--accent-cyan);">📶 ${c.wifi_name || c.wifi_mac}</span>
<span class="correlation-badge" style="background: ${confidenceColor};">${confidence}%</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #6495ED;">🔵 ${c.bt_name || c.bt_mac}</span>
<span style="font-size: 9px; color: var(--text-dim);">${c.reason || ''}</span>
</div>
</div>
`;
}).join('');
}
// Hidden SSID Revealer
let revealedSsids = {}; // {bssid: ssid}
function revealHiddenSsid(bssid, ssid) {
if (ssid && ssid !== '' && ssid !== 'Hidden' && ssid !== '[Hidden]') {
if (!revealedSsids[bssid]) {
revealedSsids[bssid] = ssid;
updateHiddenSsidDisplay();
showNotification('Hidden SSID Revealed', `"${ssid}" on ${bssid}`);
}
}
}
function updateHiddenSsidDisplay() {
const list = document.getElementById('hiddenSsidList');
if (!list) return;
const entries = Object.entries(revealedSsids);
const hiddenCount = Object.keys(hiddenNetworks).length;
if (entries.length === 0) {
if (hiddenCount > 0) {
list.innerHTML = `<div style="color: var(--text-dim);">Monitoring ${hiddenCount} hidden network${hiddenCount > 1 ? 's' : ''}...</div>`;
} else {
list.innerHTML = '<div style="color: var(--text-dim);">No hidden networks detected</div>';
}
return;
}
let html = entries.map(([bssid, ssid]) => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--accent-green);">✓ "${escapeHtml(ssid)}"</span>
<span style="color: var(--text-dim); font-size: 9px;"> (${bssid})</span>
</div>
`).join('');
if (hiddenCount > 0) {
html += `<div style="color: var(--text-dim); margin-top: 4px; font-size: 10px;">+ ${hiddenCount} hidden still monitoring</div>`;
}
list.innerHTML = html;
}
// NOTE: Browser Notifications code moved to static/js/core/audio.js
// Update visualizations periodically
setInterval(() => {
if (currentMode === 'wifi') {
updateChannelRecommendation();
correlateDevices();
updateHiddenSsidDisplay();
updateProbeAnalysis();
}
}, 2000);
// Refresh WiFi interfaces
function refreshWifiInterfaces() {
const select = document.getElementById('wifiInterfaceSelect');
select.innerHTML = '<option value="">Loading interfaces...</option>';
fetch('/wifi/interfaces')
.then(r => {
if (!r.ok) throw new Error('Failed to fetch interfaces');
return r.json();
})
.then(data => {
if (!data.interfaces || data.interfaces.length === 0) {
select.innerHTML = '<option value="">No WiFi interfaces found</option>';
showNotification('WiFi', 'No WiFi interfaces detected. Make sure you have a WiFi adapter connected.');
} else {
select.innerHTML = data.interfaces.map(i => {
// Build descriptive label with available info
let label = i.name;
let details = [];
if (i.chipset) details.push(i.chipset);
else if (i.driver) details.push(i.driver);
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
if (details.length > 0) label += ' - ' + details.join(' | ');
label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name}">${label}</option>`;
}).join('');
showNotification('WiFi', `Found ${data.interfaces.length} interface(s)`);
}
// Update tool status
const statusDiv = document.getElementById('wifiToolStatus');
if (statusDiv) {
statusDiv.innerHTML = `
<span>airmon-ng:</span><span class="tool-status ${data.tools?.airmon ? 'ok' : 'missing'}">${data.tools?.airmon ? 'OK' : 'Missing'}</span>
<span>airodump-ng:</span><span class="tool-status ${data.tools?.airodump ? 'ok' : 'missing'}">${data.tools?.airodump ? 'OK' : 'Missing'}</span>
`;
}
// Update monitor status
if (data.monitor_interface) {
monitorInterface = data.monitor_interface;
updateMonitorStatus(true);
}
})
.catch(err => {
console.error('Error fetching WiFi interfaces:', err);
select.innerHTML = '<option value="">Error loading interfaces</option>';
showNotification('WiFi Error', 'Could not detect WiFi interfaces: ' + err.message);
});
}
// Enable monitor mode
function enableMonitorMode() {
const iface = document.getElementById('wifiInterfaceSelect').value;
if (!iface) {
alert('Please select an interface');
return;
}
const killProcesses = document.getElementById('killProcesses').checked;
// Show loading state
const btn = document.getElementById('monitorStartBtn');
const originalText = btn.textContent;
btn.textContent = 'Enabling...';
btn.disabled = true;
fetch('/wifi/monitor', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({interface: iface, action: 'start', kill_processes: killProcesses})
}).then(r => r.json())
.then(data => {
btn.textContent = originalText;
btn.disabled = false;
if (data.status === 'success') {
monitorInterface = data.monitor_interface;
updateMonitorStatus(true);
showInfo('Monitor mode enabled on ' + monitorInterface + ' - Ready to scan!');
// Refresh interface list and auto-select the monitor interface
fetch('/wifi/interfaces')
.then(r => r.json())
.then(ifaceData => {
const select = document.getElementById('wifiInterfaceSelect');
if (ifaceData.interfaces.length > 0) {
select.innerHTML = ifaceData.interfaces.map(i => {
let label = i.name;
let details = [];
if (i.chipset) details.push(i.chipset);
else if (i.driver) details.push(i.driver);
if (i.mac) details.push(i.mac.substring(0, 8) + '...');
if (details.length > 0) label += ' - ' + details.join(' | ');
label += ` (${i.type})`;
if (i.monitor_capable) label += ' [Monitor OK]';
return `<option value="${i.name}" ${i.name === monitorInterface ? 'selected' : ''}>${label}</option>`;
}).join('');
}
});
} else {
alert('Error: ' + data.message);
}
})
.catch(err => {
btn.textContent = originalText;
btn.disabled = false;
alert('Error: ' + err.message);
});
}
// Disable monitor mode
function disableMonitorMode() {
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
fetch('/wifi/monitor', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({interface: iface, action: 'stop'})
}).then(r => r.json())
.then(data => {
if (data.status === 'success') {
monitorInterface = null;
updateMonitorStatus(false);
showInfo('Monitor mode disabled');
} else {
alert('Error: ' + data.message);
}
});
}
function updateMonitorStatus(enabled) {
document.getElementById('monitorStartBtn').style.display = enabled ? 'none' : 'block';
document.getElementById('monitorStopBtn').style.display = enabled ? 'block' : 'none';
document.getElementById('monitorStatus').innerHTML = enabled
? 'Monitor mode: <span style="color: var(--accent-green);">Active (' + monitorInterface + ')</span>'
: 'Monitor mode: <span style="color: var(--accent-red);">Inactive</span>';
}
// Start WiFi scan - auto-enables monitor mode if needed
async function startWifiScan() {
console.log('startWifiScan called');
const band = document.getElementById('wifiBand').value;
const channel = document.getElementById('wifiChannel').value;
// Auto-enable monitor mode if not already enabled
if (!monitorInterface) {
const iface = document.getElementById('wifiInterfaceSelect').value;
console.log('Selected interface:', iface);
if (!iface) {
showNotification('WiFi Error', 'No WiFi interface selected. Please select an adapter from the dropdown.');
alert('No WiFi interface selected. Please select an adapter from the dropdown above.');
return;
}
// Show status
document.getElementById('statusText').textContent = 'Enabling monitor mode...';
document.getElementById('statusDot').classList.add('running');
showNotification('WiFi', 'Enabling monitor mode on ' + iface + '...');
try {
const killProcesses = document.getElementById('killProcesses').checked;
console.log('Enabling monitor mode, kill processes:', killProcesses);
const monitorResp = await fetch('/wifi/monitor', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({interface: iface, action: 'start', kill_processes: killProcesses})
});
const monitorData = await monitorResp.json();
console.log('Monitor response:', monitorData);
if (monitorData.status === 'success') {
monitorInterface = monitorData.monitor_interface;
updateMonitorStatus(true);
showNotification('Monitor Mode', 'Enabled on ' + monitorInterface);
} else {
document.getElementById('statusText').textContent = 'Idle';
document.getElementById('statusDot').classList.remove('running');
showNotification('Monitor Error', monitorData.message || 'Failed to enable monitor mode');
alert('Monitor mode failed: ' + (monitorData.message || 'Unknown error'));
return;
}
} catch (err) {
console.error('Monitor mode error:', err);
document.getElementById('statusText').textContent = 'Idle';
document.getElementById('statusDot').classList.remove('running');
showNotification('Monitor Error', err.message);
alert('Monitor mode error: ' + err.message);
return;
}
}
// Now start the scan
document.getElementById('statusText').textContent = 'Starting scan...';
console.log('Starting scan on', monitorInterface);
try {
const scanResp = await fetch('/wifi/scan/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
interface: monitorInterface,
band: band,
channel: channel || null
})
});
const scanData = await scanResp.json();
console.log('Scan response:', scanData);
if (scanData.status === 'started') {
setWifiRunning(true);
startWifiStream();
showNotification('WiFi Scanner', 'Scanning started on ' + monitorInterface);
} else {
document.getElementById('statusText').textContent = 'Idle';
document.getElementById('statusDot').classList.remove('running');
showNotification('Scan Error', scanData.message || 'Failed to start scan');
alert('Scan failed: ' + (scanData.message || 'Unknown error'));
}
} catch (err) {
console.error('Scan error:', err);
document.getElementById('statusText').textContent = 'Idle';
document.getElementById('statusDot').classList.remove('running');
showNotification('Scan Error', err.message);
alert('Scan error: ' + err.message);
}
}
// Stop WiFi scan
function stopWifiScan() {
fetch('/wifi/scan/stop', {method: 'POST'})
.then(r => r.json())
.then(data => {
setWifiRunning(false);
if (wifiEventSource) {
wifiEventSource.close();
wifiEventSource = null;
}
});
}
function setWifiRunning(running) {
isWifiRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle';
document.getElementById('startWifiBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopWifiBtn').style.display = running ? 'block' : 'none';
}
// Batching state for WiFi updates
let pendingWifiUpdate = false;
let pendingWifiNetworks = [];
let pendingWifiClients = [];
function scheduleWifiUIUpdate() {
if (pendingWifiUpdate) return;
pendingWifiUpdate = true;
requestAnimationFrame(() => {
// Process networks
pendingWifiNetworks.forEach(data => handleWifiNetworkImmediate(data));
pendingWifiNetworks = [];
// Process clients (limit to last 5 per frame)
const clientsToProcess = pendingWifiClients.slice(-5);
pendingWifiClients = [];
clientsToProcess.forEach(data => handleWifiClientImmediate(data));
// Update graphs once per frame instead of per-network
updateChannelGraph();
updateChannel5gGraph();
// Update selected device panel
updateWifiSelectedDevice();
// Update probe analysis (throttled)
if (clientsToProcess.length > 0) {
scheduleProbeAnalysisUpdate();
}
pendingWifiUpdate = false;
});
}
// Start WiFi event stream
function startWifiStream() {
if (wifiEventSource) {
wifiEventSource.close();
}
wifiEventSource = new EventSource('/wifi/stream');
wifiEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'network') {
pendingWifiNetworks.push(data);
scheduleWifiUIUpdate();
} else if (data.type === 'client') {
pendingWifiClients.push(data);
scheduleWifiUIUpdate();
} else if (data.type === 'info' || data.type === 'raw') {
showInfo(data.text);
} else if (data.type === 'error') {
showError(data.text);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setWifiRunning(false);
}
}
};
wifiEventSource.onerror = function() {
console.error('WiFi stream error');
};
}
// Track networks that were originally hidden
let hiddenNetworks = {}; // {bssid: true} for networks first seen with hidden ESSID
// Handle discovered WiFi network (called from batched update)
function handleWifiNetworkImmediate(net) {
const isNew = !wifiNetworks[net.bssid];
const previousNet = wifiNetworks[net.bssid];
wifiNetworks[net.bssid] = net;
// Track if this network was originally hidden
if (isNew) {
const isHidden = !net.essid || net.essid === '' || net.essid === 'Hidden' || net.essid === '[Hidden]';
if (isHidden) {
hiddenNetworks[net.bssid] = true;
}
}
// Check if a previously hidden network now has a revealed SSID
if (hiddenNetworks[net.bssid] && net.essid && net.essid !== '' && net.essid !== 'Hidden' && net.essid !== '[Hidden]') {
revealHiddenSsid(net.bssid, net.essid);
delete hiddenNetworks[net.bssid]; // No longer hidden
}
if (isNew) {
apCount++;
document.getElementById('apCount').textContent = apCount;
playAlert();
pulseSignal();
// Check for rogue AP (same SSID, different BSSID)
checkRogueAP(net.essid, net.bssid, net.channel, net.power);
// Check proximity watch list
checkWatchList(net.bssid, 'AP');
// Check for drone
const droneCheck = isDrone(net.essid, net.bssid);
if (droneCheck.isDrone) {
handleDroneDetection(net, droneCheck);
showNotification('🚁 Drone Detected!', `${droneCheck.brand}: ${net.essid}`);
}
}
// Update recon display
const droneInfo = isDrone(net.essid, net.bssid);
trackDevice({
protocol: droneInfo.isDrone ? 'DRONE' : 'WiFi-AP',
address: net.bssid,
message: net.essid || '[Hidden SSID]',
model: net.essid,
channel: net.channel,
privacy: net.privacy,
isDrone: droneInfo.isDrone,
droneBrand: droneInfo.brand
});
// Add to output
addWifiNetworkCard(net, isNew);
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
}
// Handle discovered WiFi client (called from batched update)
function handleWifiClientImmediate(client) {
const isNew = !wifiClients[client.mac];
wifiClients[client.mac] = client;
if (isNew) {
clientCount++;
document.getElementById('clientCount').textContent = clientCount;
// Check proximity watch list
checkWatchList(client.mac, 'Client');
}
// If client is connected to a hidden network and has probes, try to reveal the SSID
if (client.bssid && hiddenNetworks[client.bssid] && client.probes) {
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
if (probes.length > 0) {
// Use the first probe as the likely SSID for this hidden network
revealHiddenSsid(client.bssid, probes[0]);
delete hiddenNetworks[client.bssid];
}
}
// Track in device intelligence with vendor info
const vendorInfo = client.vendor && client.vendor !== 'Unknown' ? ` [${client.vendor}]` : '';
trackDevice({
protocol: 'WiFi-Client',
address: client.mac,
message: (client.probes || '[No probes]') + vendorInfo,
bssid: client.bssid,
vendor: client.vendor
});
// Update probe analysis when we get client data with probes
if (client.probes && client.probes.trim()) {
scheduleProbeAnalysisUpdate();
}
// Add client card to device list
addWifiClientCard(client, isNew);
}
// Throttled probe analysis (called less frequently)
let lastProbeAnalysisUpdate = 0;
function scheduleProbeAnalysisUpdate() {
const now = Date.now();
if (now - lastProbeAnalysisUpdate > 2000) {
lastProbeAnalysisUpdate = now;
updateProbeAnalysis();
}
}
// Update client probe analysis panel
function updateProbeAnalysis() {
const list = document.getElementById('probeAnalysisList');
if (!list) return;
const clientsWithProbes = Object.values(wifiClients).filter(c => c.probes && c.probes.trim());
const allProbes = new Set();
let privacyLeaks = 0;
// Count unique probes and privacy leaks
clientsWithProbes.forEach(client => {
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
probes.forEach(p => allProbes.add(p));
// Check for sensitive network names (home networks, corporate, etc.)
probes.forEach(probe => {
const lowerProbe = probe.toLowerCase();
if (lowerProbe.includes('home') || lowerProbe.includes('office') ||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i)) {
privacyLeaks++;
}
});
});
// Update counters
document.getElementById('probeClientCount').textContent = clientsWithProbes.length;
document.getElementById('probeSSIDCount').textContent = allProbes.size;
document.getElementById('probePrivacyCount').textContent = privacyLeaks;
if (clientsWithProbes.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">Waiting for client probe requests...</div>';
return;
}
// Sort by number of probes (most revealing first)
clientsWithProbes.sort((a, b) => {
const aCount = (a.probes || '').split(',').length;
const bCount = (b.probes || '').split(',').length;
return bCount - aCount;
});
let html = '<div style="display: flex; flex-direction: column; gap: 8px;">';
clientsWithProbes.forEach(client => {
const probes = client.probes.split(',').map(p => p.trim()).filter(p => p);
const vendorBadge = client.vendor && client.vendor !== 'Unknown'
? `<span style="background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; font-size: 9px; margin-left: 5px;">${escapeHtml(client.vendor)}</span>`
: '';
// Check for privacy-revealing probes
const probeHtml = probes.map(probe => {
const lowerProbe = probe.toLowerCase();
const isSensitive = lowerProbe.includes('home') || lowerProbe.includes('office') ||
lowerProbe.includes('corp') || lowerProbe.includes('work') ||
lowerProbe.includes('private') || lowerProbe.includes('hotel') ||
lowerProbe.includes('airport') || lowerProbe.match(/^[a-z]+-[a-z]+$/i);
const style = isSensitive
? 'background: var(--accent-orange); color: #000; padding: 1px 4px; border-radius: 2px; margin: 1px;'
: 'background: var(--bg-tertiary); padding: 1px 4px; border-radius: 2px; margin: 1px;';
return `<span style="${style}" title="${isSensitive ? 'Potentially sensitive - reveals user location history' : ''}">${escapeHtml(probe)}</span>`;
}).join(' ');
html += `
<div style="border-left: 2px solid var(--accent-cyan); padding-left: 8px; cursor: pointer;" onclick="selectWifiDevice('${escapeAttr(client.mac)}', 'client')" title="Click for details">
<div style="display: flex; align-items: center; gap: 5px; margin-bottom: 3px;">
<span style="color: var(--accent-cyan); font-family: monospace; font-size: 10px;">${escapeHtml(client.mac)}</span>
${vendorBadge}
<span style="color: var(--text-dim); font-size: 9px;">(${probes.length} probe${probes.length !== 1 ? 's' : ''})</span>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 2px; font-size: 10px;">
${probeHtml}
</div>
</div>
`;
});
html += '</div>';
list.innerHTML = html;
}
// Select a WiFi network or client for detailed view
function selectWifiDevice(id, type) {
selectedWifiDevice = id;
selectedWifiType = type;
updateWifiSelectedDevice();
}
// Update the selected WiFi device panel
function updateWifiSelectedDevice() {
const panel = document.getElementById('wifiSelectedDevice');
if (!panel) return;
if (!selectedWifiDevice) {
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Click a network or client to view details</div>';
return;
}
if (selectedWifiType === 'network') {
const net = wifiNetworks[selectedWifiDevice];
if (!net) {
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Network no longer visible</div>';
return;
}
const power = parseInt(net.power) || -100;
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
const isRogue = rogueBssids.has(net.bssid);
panel.innerHTML = `
${isRogue ? '<div class="rogue-indicator" style="margin: -10px -10px 10px -10px; padding: 8px;">⚠️ SUSPECTED ROGUE ACCESS POINT</div>' : ''}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
<div style="font-size: 18px; color: ${isRogue ? 'var(--accent-red)' : 'var(--accent-cyan)'}; font-weight: bold;">${escapeHtml(net.essid || '[Hidden]')}</div>
<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(net.bssid)}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">CHANNEL</div>
<div style="color: var(--accent-cyan); font-size: 16px; font-weight: bold;">${net.channel}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">SECURITY</div>
<div style="color: ${(net.privacy || '').includes('WPA3') ? 'var(--accent-green)' : (net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : 'var(--accent-red)'};">${escapeHtml(net.privacy || 'Unknown')}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">BEACONS</div>
<div style="color: var(--text-secondary);">${net.beacons || 0}</div>
</div>
<div style="grid-column: span 2; display: flex; gap: 8px; margin-top: 8px;">
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1;">Target</button>
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="flex: 1; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
</div>
</div>
`;
} else if (selectedWifiType === 'client') {
const client = wifiClients[selectedWifiDevice];
if (!client) {
panel.innerHTML = '<div style="color: var(--text-dim); padding: 20px; text-align: center;">Client no longer visible</div>';
return;
}
const power = parseInt(client.power) || -100;
const signalPercent = Math.max(0, Math.min(100, (power + 100) * 2));
const signalColor = power >= -50 ? 'var(--accent-green)' : power >= -70 ? 'var(--accent-orange)' : 'var(--accent-red)';
const probes = (client.probes || '').split(',').map(p => p.trim()).filter(p => p);
const associatedNet = client.bssid && wifiNetworks[client.bssid];
panel.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div style="grid-column: span 2; text-align: center; padding-bottom: 10px; border-bottom: 1px solid var(--border-color);">
<div style="font-size: 14px; color: var(--accent-orange); font-weight: bold;">CLIENT DEVICE</div>
<div style="font-size: 12px; color: var(--text-secondary);">${escapeHtml(client.mac)}</div>
${client.vendor ? `<div style="font-size: 10px; color: var(--text-muted);">${escapeHtml(client.vendor)}</div>` : ''}
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">SIGNAL</div>
<div style="color: ${signalColor}; font-size: 16px; font-weight: bold;">${power} dBm</div>
<div style="background: var(--bg-tertiary); height: 4px; border-radius: 2px; margin-top: 4px;">
<div style="background: ${signalColor}; height: 100%; width: ${signalPercent}%; border-radius: 2px;"></div>
</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">PACKETS</div>
<div style="color: var(--text-secondary);">${client.packets || 0}</div>
</div>
${associatedNet ? `
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">CONNECTED TO</div>
<div style="color: var(--accent-cyan);">${escapeHtml(associatedNet.essid || associatedNet.bssid)}</div>
</div>
` : ''}
${probes.length > 0 ? `
<div style="grid-column: span 2; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">PROBING FOR</div>
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
${probes.slice(0, 5).map(p => `<span style="background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px;">${escapeHtml(p)}</span>`).join('')}
${probes.length > 5 ? `<span style="color: var(--text-muted);">+${probes.length - 5} more</span>` : ''}
</div>
</div>
` : ''}
</div>
`;
}
}
// Add WiFi network card to device list
function addWifiNetworkCard(net, isNew) {
// Use the WiFi device list panel instead of the generic output
const deviceList = document.getElementById('wifiDeviceListContent');
if (!deviceList) return;
// Remove placeholder if present
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
if (placeholder && placeholder.textContent.includes('Start scanning')) {
placeholder.remove();
}
// Check if card already exists
let card = document.getElementById('wifi_' + net.bssid.replace(/:/g, ''));
if (!card) {
card = document.createElement('div');
card.id = 'wifi_' + net.bssid.replace(/:/g, '');
card.className = 'sensor-card wifi-network-card';
card.style.borderLeftColor = net.privacy.includes('WPA') ? 'var(--accent-orange)' :
net.privacy.includes('WEP') ? 'var(--accent-red)' :
'var(--accent-green)';
card.style.cursor = 'pointer';
card.onclick = () => selectWifiDevice(net.bssid, 'network');
deviceList.insertBefore(card, deviceList.firstChild);
// Update device count
const countEl = document.getElementById('wifiDeviceListCount');
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length;
}
// Handle signal strength - airodump returns -1 when not measured
let signalStrength = parseInt(net.power);
if (isNaN(signalStrength) || signalStrength === -1) {
signalStrength = null; // No reading available
}
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
const wpsEnabled = net.wps === '1' || net.wps === 'Yes' || (net.privacy || '').includes('WPS');
const wpsHtml = wpsEnabled ? '<span class="wps-enabled">WPS</span>' : '';
const isRogue = rogueBssids.has(net.bssid);
const rogueHtml = isRogue ? '<div class="rogue-indicator">⚠️ SUSPECTED ROGUE AP</div>' : '';
// Update card border for rogue APs
if (isRogue) {
card.style.borderLeftColor = 'var(--accent-red)';
card.style.borderLeftWidth = '4px';
card.style.background = 'rgba(255, 0, 0, 0.1)';
}
card.innerHTML = `
${rogueHtml}
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span class="device-name">${escapeHtml(net.essid || '[Hidden]')}${wpsHtml}</span>
<span style="color: #444; font-size: 10px;">CH ${net.channel}</span>
</div>
<div class="sensor-data">
<div class="data-item">
<div class="data-label">BSSID</div>
<div class="data-value" style="font-size: 11px;">${escapeHtml(net.bssid)}</div>
</div>
<div class="data-item">
<div class="data-label">Security</div>
<div class="data-value" style="color: ${(net.privacy || '').includes('WPA') ? 'var(--accent-orange)' : net.privacy === 'OPN' ? 'var(--accent-green)' : 'var(--accent-red)'}">${escapeHtml(net.privacy || '')}</div>
</div>
<div class="data-item">
<div class="data-label">Signal</div>
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5-signalBars)}</div>
</div>
<div class="data-item">
<div class="data-label">Beacons</div>
<div class="data-value">${net.beacons}</div>
</div>
</div>
<div style="margin-top: 8px; display: flex; gap: 5px; flex-wrap: wrap;">
<button class="preset-btn" onclick="targetNetwork('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
<button class="preset-btn" onclick="captureHandshake('${escapeAttr(net.bssid)}', '${escapeAttr(net.channel)}')" style="font-size: 10px; padding: 4px 8px; border-color: var(--accent-orange); color: var(--accent-orange);">Handshake</button>
</div>
`;
if (autoScroll) output.scrollTop = 0;
}
// Add WiFi client card to device list
function addWifiClientCard(client, isNew) {
const deviceList = document.getElementById('wifiDeviceListContent');
if (!deviceList) return;
// Remove placeholder if present
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
if (placeholder && placeholder.textContent.includes('Start scanning')) {
placeholder.remove();
}
// Check if card already exists
let card = document.getElementById('client_' + client.mac.replace(/:/g, ''));
if (!card) {
card = document.createElement('div');
card.id = 'client_' + client.mac.replace(/:/g, '');
card.className = 'sensor-card wifi-client-card';
card.style.borderLeftColor = 'var(--accent-purple)';
card.style.cursor = 'pointer';
card.onclick = () => selectWifiDevice(client.mac, 'client');
deviceList.appendChild(card); // Clients go after networks
// Update device count
const countEl = document.getElementById('wifiDeviceListCount');
if (countEl) countEl.textContent = Object.keys(wifiNetworks).length + Object.keys(wifiClients).length;
}
// Handle signal strength
let signalStrength = parseInt(client.power);
if (isNaN(signalStrength) || signalStrength === -1) {
signalStrength = null;
}
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
// Get connected AP info
const connectedAP = client.bssid && wifiNetworks[client.bssid];
const apName = connectedAP ? (connectedAP.essid || '[Hidden]') : (client.bssid || 'Not Associated');
// Format probes
const probes = client.probes ? client.probes.split(',').map(p => p.trim()).filter(p => p) : [];
const probesDisplay = probes.length > 0 ? probes.slice(0, 3).join(', ') + (probes.length > 3 ? ` +${probes.length - 3}` : '') : 'None';
card.innerHTML = `
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span class="device-name" style="color: var(--accent-purple);">📱 ${escapeHtml(client.vendor || 'Client')}</span>
<span style="font-size: 10px; color: var(--text-dim);">CLIENT</span>
</div>
<div class="sensor-data">
<div class="data-item">
<div class="data-label">MAC</div>
<div class="data-value" style="font-size: 11px;">${escapeHtml(client.mac)}</div>
</div>
<div class="data-item">
<div class="data-label">Connected To</div>
<div class="data-value" style="color: var(--accent-cyan);">${escapeHtml(apName)}</div>
</div>
<div class="data-item">
<div class="data-label">Signal</div>
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5-signalBars)}</div>
</div>
<div class="data-item">
<div class="data-label">Probes</div>
<div class="data-value" style="font-size: 10px;">${escapeHtml(probesDisplay)}</div>
</div>
</div>
`;
}
// Target a network for attack
function targetNetwork(bssid, channel) {
document.getElementById('targetBssid').value = bssid;
document.getElementById('wifiChannel').value = channel;
showInfo('Targeted: ' + bssid + ' on channel ' + channel);
}
// Start handshake capture
async function captureHandshake(bssid, channel) {
if (!confirm('Start handshake capture for ' + bssid + '? This will stop the current scan.')) {
return;
}
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
if (!iface) {
showError('No monitor interface available. Enable monitor mode first.');
return;
}
// Stop any existing scan first
if (isWifiRunning) {
showInfo('Stopping current scan...');
try {
await fetch('/wifi/scan/stop', {method: 'POST'});
if (wifiEventSource) {
wifiEventSource.close();
wifiEventSource = null;
}
setWifiRunning(false);
// Brief delay to ensure process stops
await new Promise(resolve => setTimeout(resolve, 500));
} catch (e) {
console.error('Error stopping scan:', e);
}
}
try {
const response = await fetch('/wifi/handshake/capture', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bssid: bssid, channel: channel, interface: iface})
});
const data = await response.json();
if (data.status === 'started') {
showInfo('🎯 Capturing handshakes for ' + bssid);
setWifiRunning(true);
// Update handshake indicator to show active capture
const hsSpan = document.getElementById('handshakeCount');
hsSpan.style.animation = 'pulse 1s infinite';
hsSpan.title = 'Capturing: ' + bssid;
// Show capture status panel
const panel = document.getElementById('captureStatusPanel');
panel.style.display = 'block';
document.getElementById('captureTargetBssid').textContent = bssid;
document.getElementById('captureTargetChannel').textContent = channel;
document.getElementById('captureFilePath').textContent = data.capture_file;
document.getElementById('captureStatus').textContent = 'Waiting for handshake...';
document.getElementById('captureStatus').style.color = 'var(--accent-orange)';
// Store active capture info and start polling
activeCapture = {
bssid: bssid,
channel: channel,
file: data.capture_file,
startTime: Date.now(),
pollInterval: setInterval(checkCaptureStatus, 5000) // Check every 5 seconds
};
} else {
showError('Handshake capture failed: ' + (data.message || 'Unknown error'));
}
} catch (err) {
showError('Handshake capture error: ' + err.message);
console.error('Handshake capture error:', err);
}
}
// Check handshake capture status
function checkCaptureStatus() {
if (!activeCapture) {
showInfo('No active handshake capture');
return;
}
fetch('/wifi/handshake/status', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({file: activeCapture.file, bssid: activeCapture.bssid})
}).then(r => r.json())
.then(data => {
const statusSpan = document.getElementById('captureStatus');
const elapsed = Math.round((Date.now() - activeCapture.startTime) / 1000);
const elapsedStr = elapsed < 60 ? elapsed + 's' : Math.floor(elapsed/60) + 'm ' + (elapsed%60) + 's';
if (data.handshake_found) {
// Handshake captured!
statusSpan.textContent = '✓ HANDSHAKE CAPTURED!';
statusSpan.style.color = 'var(--accent-green)';
handshakeCount++;
document.getElementById('handshakeCount').textContent = handshakeCount;
playAlert();
showInfo('🎉 Handshake captured for ' + activeCapture.bssid + '! File: ' + data.file);
showNotification('🤝 Handshake Captured!', `Target: ${activeCapture.bssid}`);
// Stop polling
if (activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
document.getElementById('handshakeCount').style.animation = '';
// Show crack button in the capture panel
const panel = document.getElementById('captureStatusPanel');
const existingCrackBtn = panel.querySelector('.crack-btn');
if (!existingCrackBtn) {
const crackDiv = document.createElement('div');
crackDiv.style.marginTop = '10px';
crackDiv.innerHTML = `
<button class="preset-btn crack-btn" onclick="crackHandshake('${data.file}', '${activeCapture.bssid}')" style="width: 100%; background: var(--accent-green); border-color: var(--accent-green); color: #000; font-weight: bold;">
🔓 Crack with Aircrack-ng
</button>
`;
panel.querySelector('.section') ? panel.querySelector('.section').appendChild(crackDiv) : panel.appendChild(crackDiv);
}
// Store the captured file for later use
activeCapture.captured = true;
activeCapture.capturedFile = data.file;
} else if (data.file_exists) {
const sizeKB = (data.file_size / 1024).toFixed(1);
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')';
statusSpan.style.color = 'var(--accent-orange)';
} else if (data.status === 'stopped') {
statusSpan.textContent = 'Capture stopped';
statusSpan.style.color = 'var(--text-dim)';
if (activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
} else {
statusSpan.textContent = 'Waiting for data... (' + elapsedStr + ')';
statusSpan.style.color = 'var(--accent-orange)';
}
})
.catch(err => {
console.error('Capture status check failed:', err);
});
}
// Stop handshake capture
function stopHandshakeCapture() {
if (activeCapture && activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
// Stop the WiFi scan (which stops airodump-ng)
stopWifiScan();
document.getElementById('captureStatus').textContent = 'Stopped';
document.getElementById('captureStatus').style.color = 'var(--text-dim)';
document.getElementById('handshakeCount').style.animation = '';
// Keep the panel visible so user can see the file path
showInfo('Handshake capture stopped. Check ' + (activeCapture ? activeCapture.file : 'capture file'));
activeCapture = null;
}
// Crack handshake with aircrack-ng
function crackHandshake(captureFile, bssid) {
const wordlist = prompt('Enter path to wordlist file:\n\nCommon locations:\n- /usr/share/wordlists/rockyou.txt\n- /usr/share/john/password.lst', '/usr/share/wordlists/rockyou.txt');
if (!wordlist) {
showInfo('Cracking cancelled');
return;
}
showInfo('Starting aircrack-ng... This may take a while.');
fetch('/wifi/handshake/crack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capture_file: captureFile,
bssid: bssid,
wordlist: wordlist
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.password) {
showInfo('🎉 PASSWORD FOUND: ' + data.password);
showNotification('🔓 Password Cracked!', data.password);
alert('Password found!\n\n' + data.password + '\n\nThis has been logged.');
} else if (data.status === 'not_found') {
showInfo('Password not found in wordlist. Try a different wordlist.');
alert('Password not found in wordlist.\n\nTry using a larger or different wordlist.');
} else if (data.status === 'running') {
showInfo('Aircrack-ng is running in background. Check terminal for progress.');
} else {
showError('Crack failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => {
showError('Crack error: ' + err.message);
console.error('Crack error:', err);
});
}
// Beacon Flood Detection
let beaconHistory = [];
let lastBeaconCheck = Date.now();
function checkBeaconFlood(networks) {
const now = Date.now();
const windowMs = 5000; // 5 second window
// Add current networks to history
beaconHistory.push({ time: now, count: Object.keys(networks).length });
// Remove old entries
beaconHistory = beaconHistory.filter(h => now - h.time < windowMs);
// Calculate rate of new networks
if (beaconHistory.length >= 2) {
const oldest = beaconHistory[0];
const newest = beaconHistory[beaconHistory.length - 1];
const timeDiff = (newest.time - oldest.time) / 1000;
const countDiff = newest.count - oldest.count;
if (timeDiff > 0) {
const rate = countDiff / timeDiff;
// Alert if more than 10 new networks per second
if (rate > 10) {
document.getElementById('beaconFloodAlert').style.display = 'block';
document.getElementById('beaconFloodRate').textContent = rate.toFixed(1);
if (!muted) playAlertSound();
} else if (rate < 2) {
document.getElementById('beaconFloodAlert').style.display = 'none';
}
}
}
}
// Send deauth
function sendDeauth() {
const bssid = document.getElementById('targetBssid').value;
const client = document.getElementById('targetClient').value || 'FF:FF:FF:FF:FF:FF';
const count = document.getElementById('deauthCount').value || '5';
if (!bssid) {
alert('Enter target BSSID');
return;
}
if (!confirm('Send ' + count + ' deauth packets to ' + bssid + '?\\n\\n⚠ Only use on networks you own or have authorization to test!')) {
return;
}
fetch('/wifi/deauth', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({bssid: bssid, client: client, count: parseInt(count)})
}).then(r => r.json())
.then(data => {
if (data.status === 'success') {
showInfo(data.message);
} else {
alert('Error: ' + data.message);
}
});
}
// ============== WIFI VISUALIZATIONS ==============
let radarCtx = null;
let radarAngle = 0;
let radarAnimFrame = null;
let radarNetworks = []; // {x, y, strength, ssid, bssid}
let targetBssidForSignal = null;
// Initialize radar canvas
function initRadar() {
const canvas = document.getElementById('radarCanvas');
if (!canvas) return;
radarCtx = canvas.getContext('2d');
canvas.width = 150;
canvas.height = 150;
// Start animation
if (!radarAnimFrame) {
animateRadar();
}
}
// Animate radar sweep
function animateRadar() {
if (!radarCtx) {
radarAnimFrame = null;
return;
}
const canvas = radarCtx.canvas;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 5;
// Clear canvas
radarCtx.fillStyle = 'rgba(0, 10, 10, 0.1)';
radarCtx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid circles
radarCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
radarCtx.lineWidth = 1;
for (let r = radius / 4; r <= radius; r += radius / 4) {
radarCtx.beginPath();
radarCtx.arc(cx, cy, r, 0, Math.PI * 2);
radarCtx.stroke();
}
// Draw crosshairs
radarCtx.beginPath();
radarCtx.moveTo(cx, cy - radius);
radarCtx.lineTo(cx, cy + radius);
radarCtx.moveTo(cx - radius, cy);
radarCtx.lineTo(cx + radius, cy);
radarCtx.stroke();
// Draw sweep line
radarCtx.strokeStyle = 'rgba(0, 255, 136, 0.8)';
radarCtx.lineWidth = 2;
radarCtx.beginPath();
radarCtx.moveTo(cx, cy);
radarCtx.lineTo(
cx + Math.cos(radarAngle) * radius,
cy + Math.sin(radarAngle) * radius
);
radarCtx.stroke();
// Draw sweep gradient
const gradient = radarCtx.createConicalGradient ?
null : // Not supported in all browsers
radarCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
radarCtx.fillStyle = 'rgba(0, 255, 136, 0.05)';
radarCtx.beginPath();
radarCtx.moveTo(cx, cy);
radarCtx.arc(cx, cy, radius, radarAngle - 0.5, radarAngle);
radarCtx.closePath();
radarCtx.fill();
// Draw network blips
radarNetworks.forEach(net => {
const age = Date.now() - net.timestamp;
const alpha = Math.max(0.1, 1 - age / 10000);
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha})`;
radarCtx.beginPath();
radarCtx.arc(net.x, net.y, 4 + (1 - alpha) * 3, 0, Math.PI * 2);
radarCtx.fill();
// Glow effect
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha * 0.3})`;
radarCtx.beginPath();
radarCtx.arc(net.x, net.y, 8 + (1 - alpha) * 5, 0, Math.PI * 2);
radarCtx.fill();
});
// Update angle
radarAngle += 0.03;
if (radarAngle > Math.PI * 2) radarAngle = 0;
radarAnimFrame = requestAnimationFrame(animateRadar);
}
// Add network to radar
function addNetworkToRadar(net) {
const canvas = document.getElementById('radarCanvas');
if (!canvas) return;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 10;
// Convert signal strength to distance (stronger = closer)
const power = parseInt(net.power) || -80;
const distance = Math.max(0.1, Math.min(1, (power + 100) / 60));
const r = radius * (1 - distance);
// Random angle based on BSSID hash
let angle = 0;
for (let i = 0; i < net.bssid.length; i++) {
angle += net.bssid.charCodeAt(i);
}
angle = (angle % 360) * Math.PI / 180;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
// Update or add
const existing = radarNetworks.find(n => n.bssid === net.bssid);
if (existing) {
existing.x = x;
existing.y = y;
existing.timestamp = Date.now();
} else {
radarNetworks.push({
x, y,
bssid: net.bssid,
ssid: net.essid,
timestamp: Date.now()
});
}
// Limit to 50 networks
if (radarNetworks.length > 50) {
radarNetworks.shift();
}
}
// Update channel graph
function updateChannelGraph() {
const channels = {};
for (let i = 1; i <= 13; i++) channels[i] = 0;
// Count networks per channel
Object.values(wifiNetworks).forEach(net => {
const ch = parseInt(net.channel);
if (ch >= 1 && ch <= 13) {
channels[ch]++;
}
});
// Find max for scaling
const maxCount = Math.max(1, ...Object.values(channels));
// Update bars
const bars = document.querySelectorAll('#channelGraph .channel-bar');
bars.forEach((bar, i) => {
const ch = i + 1;
const count = channels[ch] || 0;
const height = Math.max(2, (count / maxCount) * 55);
bar.style.height = height + 'px';
bar.classList.remove('active', 'congested', 'very-congested');
if (count > 0) bar.classList.add('active');
if (count >= 3) bar.classList.add('congested');
if (count >= 5) bar.classList.add('very-congested');
});
}
// Update security donut chart
function updateSecurityDonut() {
const canvas = document.getElementById('securityCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 2;
const innerRadius = radius * 0.6;
// Count security types
let wpa3 = 0, wpa2 = 0, wep = 0, open = 0;
Object.values(wifiNetworks).forEach(net => {
const priv = (net.privacy || '').toUpperCase();
if (priv.includes('WPA3')) wpa3++;
else if (priv.includes('WPA')) wpa2++;
else if (priv.includes('WEP')) wep++;
else if (priv === 'OPN' || priv === '' || priv === 'OPEN') open++;
else wpa2++; // Default to WPA2
});
const total = wpa3 + wpa2 + wep + open;
// Update legend
document.getElementById('wpa3Count').textContent = wpa3;
document.getElementById('wpa2Count').textContent = wpa2;
document.getElementById('wepCount').textContent = wep;
document.getElementById('openCount').textContent = open;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (total === 0) {
// Draw empty circle
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = radius - innerRadius;
ctx.beginPath();
ctx.arc(cx, cy, (radius + innerRadius) / 2, 0, Math.PI * 2);
ctx.stroke();
return;
}
// Draw segments
const colors = {
wpa3: '#00ff88',
wpa2: '#ff8800',
wep: '#ff3366',
open: '#00d4ff'
};
const data = [
{ value: wpa3, color: colors.wpa3 },
{ value: wpa2, color: colors.wpa2 },
{ value: wep, color: colors.wep },
{ value: open, color: colors.open }
];
let startAngle = -Math.PI / 2;
data.forEach(segment => {
if (segment.value === 0) return;
const sliceAngle = (segment.value / total) * Math.PI * 2;
ctx.fillStyle = segment.color;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
startAngle += sliceAngle;
});
// Draw inner circle (donut hole)
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
ctx.fill();
// Draw total in center
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px JetBrains Mono';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total, cx, cy);
}
// Update signal strength meter for targeted network
function updateSignalMeter(net) {
if (!net) return;
targetBssidForSignal = net.bssid;
const ssidEl = document.getElementById('targetSsid');
const valueEl = document.getElementById('signalValue');
const barsEl = document.querySelectorAll('.signal-bar-large');
ssidEl.textContent = net.essid || net.bssid;
const power = parseInt(net.power) || -100;
valueEl.textContent = power + ' dBm';
// Determine signal quality
let quality = 'weak';
let activeBars = 1;
if (power >= -50) { quality = 'strong'; activeBars = 5; }
else if (power >= -60) { quality = 'strong'; activeBars = 4; }
else if (power >= -70) { quality = 'medium'; activeBars = 3; }
else if (power >= -80) { quality = 'medium'; activeBars = 2; }
else { quality = 'weak'; activeBars = 1; }
valueEl.className = 'signal-value ' + quality;
barsEl.forEach((bar, i) => {
bar.className = 'signal-bar-large';
if (i < activeBars) {
bar.classList.add('active', quality);
}
});
}
// Hook into handleWifiNetworkImmediate to update visualizations
const originalHandleWifiNetworkImmediate = handleWifiNetworkImmediate;
handleWifiNetworkImmediate = function(net) {
originalHandleWifiNetworkImmediate(net);
// Update radar
addNetworkToRadar(net);
// Update security donut
updateSecurityDonut();
// Update signal meter if this is the targeted network
if (targetBssidForSignal === net.bssid) {
updateSignalMeter(net);
}
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
};
// Update targetNetwork to also set signal meter
const originalTargetNetwork = targetNetwork;
targetNetwork = function(bssid, channel) {
originalTargetNetwork(bssid, channel);
const net = wifiNetworks[bssid];
if (net) {
updateSignalMeter(net);
}
};
// ============== BLUETOOTH RECONNAISSANCE ==============
let btEventSource = null;
let btDevices = {};
let btDeviceCount = 0;
let btBeaconCount = 0;
let btRadarCtx = null;
let btRadarAngle = 0;
let btRadarAnimFrame = null;
let btRadarDevices = [];
// Refresh Bluetooth interfaces
function refreshBtInterfaces() {
fetch('/bt/interfaces')
.then(r => r.json())
.then(data => {
const select = document.getElementById('btInterfaceSelect');
if (data.interfaces.length === 0) {
select.innerHTML = '<option value="">No BT interfaces found</option>';
} else {
select.innerHTML = data.interfaces.map(i =>
`<option value="${i.name}">${i.name} (${i.type}) [${i.status}]</option>`
).join('');
}
// Update tool status
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>
`;
});
}
// Start Bluetooth scan
function startBtScan() {
const scanMode = document.querySelector('input[name="btScanMode"]:checked').value;
const iface = document.getElementById('btInterfaceSelect').value;
const duration = document.getElementById('btScanDuration').value;
const scanBLE = document.getElementById('btScanBLE').checked;
const scanClassic = document.getElementById('btScanClassic').checked;
fetch('/bt/scan/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
mode: scanMode,
interface: iface,
duration: parseInt(duration),
scan_ble: scanBLE,
scan_classic: scanClassic
})
}).then(r => r.json())
.then(data => {
if (data.status === 'started') {
setBtRunning(true);
startBtStream();
} else {
alert('Error: ' + data.message);
}
});
}
// Stop Bluetooth scan
function stopBtScan() {
fetch('/bt/scan/stop', {method: 'POST'})
.then(r => r.json())
.then(data => {
setBtRunning(false);
if (btEventSource) {
btEventSource.close();
btEventSource = null;
}
});
}
function resetBtAdapter() {
const iface = document.getElementById('btInterfaceSelect')?.value || 'hci0';
fetch('/bt/reset', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({interface: iface})
}).then(r => r.json())
.then(data => {
setBtRunning(false);
if (btEventSource) {
btEventSource.close();
btEventSource = null;
}
if (data.status === 'success') {
showInfo('Bluetooth adapter reset. Status: ' + (data.is_up ? 'UP' : 'DOWN'));
// Refresh interface list
if (typeof refreshBtInterfaces === 'function') refreshBtInterfaces();
} else {
showError('Reset failed: ' + data.message);
}
});
}
function setBtRunning(running) {
isBtRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Scanning...' : 'Idle';
document.getElementById('startBtBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopBtBtn').style.display = running ? 'block' : 'none';
}
// Batching state for Bluetooth updates
let pendingBtUpdate = false;
let pendingBtDevices = [];
function scheduleBtUIUpdate() {
if (pendingBtUpdate) return;
pendingBtUpdate = true;
requestAnimationFrame(() => {
// Process devices (limit to 10 per frame)
const devicesToProcess = pendingBtDevices.slice(0, 10);
pendingBtDevices = pendingBtDevices.slice(10);
devicesToProcess.forEach(data => handleBtDeviceImmediate(data));
// If more pending, schedule another frame
if (pendingBtDevices.length > 0) {
pendingBtUpdate = false;
scheduleBtUIUpdate();
return;
}
pendingBtUpdate = false;
});
}
// Start Bluetooth event stream
function startBtStream() {
if (btEventSource) btEventSource.close();
btEventSource = new EventSource('/bt/stream');
btEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'device') {
pendingBtDevices.push(data);
scheduleBtUIUpdate();
} else if (data.type === 'info' || data.type === 'raw') {
showInfo(data.text);
} else if (data.type === 'error') {
showError(data.text);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setBtRunning(false);
}
}
};
btEventSource.onerror = function() {
console.error('BT stream error');
};
}
// Tracker following detection
let trackerHistory = {}; // MAC -> { firstSeen, lastSeen, seenCount, locations: [] }
const FOLLOWING_THRESHOLD_MINUTES = 30;
const FOLLOWING_MIN_DETECTIONS = 5;
// Find My network detection patterns
const FINDMY_PATTERNS = {
// Apple Find My / AirTag
apple: {
prefixes: ['4C:00'],
mfgData: [0x004C], // Apple company ID
names: ['AirTag', 'Find My']
},
// Samsung SmartTag
samsung: {
prefixes: ['58:4D', 'A0:75', 'DC:0C', 'E4:5F'],
mfgData: [0x0075], // Samsung company ID
names: ['SmartTag', 'Galaxy SmartTag']
},
// Tile
tile: {
prefixes: ['C4:E7', 'DC:54', 'E4:B0', 'F8:8A', 'D0:03'],
names: ['Tile', 'Tile Pro', 'Tile Mate', 'Tile Slim']
},
// Chipolo
chipolo: {
prefixes: ['00:0D'],
names: ['Chipolo', 'CHIPOLO']
}
};
function detectFindMyDevice(device) {
const mac = device.mac.toUpperCase();
const macPrefix = mac.substring(0, 5);
const name = (device.name || '').toLowerCase();
for (const [network, patterns] of Object.entries(FINDMY_PATTERNS)) {
// Check MAC prefix
if (patterns.prefixes && patterns.prefixes.some(p => mac.startsWith(p))) {
return { network: network, type: 'Find My Network', icon: '📍' };
}
// Check name patterns
if (patterns.names && patterns.names.some(n => name.includes(n.toLowerCase()))) {
return { network: network, type: 'Find My Network', icon: '📍' };
}
}
// Check manufacturer data for Apple continuity
if (device.manufacturer_data) {
const mfgData = device.manufacturer_data;
if (mfgData.includes('4c00') || mfgData.includes('004c')) {
// Check for Find My payload (manufacturer specific data type 0x12)
if (mfgData.includes('12') || mfgData.length > 40) {
return { network: 'apple', type: 'Apple Find My', icon: '🍎' };
}
}
}
return null;
}
function checkTrackerFollowing(device) {
if (!device.tracker && !detectFindMyDevice(device)) return;
const mac = device.mac;
const now = Date.now();
if (!trackerHistory[mac]) {
trackerHistory[mac] = {
firstSeen: now,
lastSeen: now,
seenCount: 1,
name: device.name || device.mac
};
} else {
trackerHistory[mac].lastSeen = now;
trackerHistory[mac].seenCount++;
}
const tracker = trackerHistory[mac];
const durationMinutes = (now - tracker.firstSeen) / 60000;
// Alert if tracker has been following for a while
if (durationMinutes >= FOLLOWING_THRESHOLD_MINUTES && tracker.seenCount >= FOLLOWING_MIN_DETECTIONS) {
showTrackerFollowingAlert(mac, tracker);
}
}
function showTrackerFollowingAlert(mac, tracker) {
const alertDiv = document.getElementById('trackerFollowingAlert');
if (!alertDiv) return;
const durationMinutes = Math.floor((Date.now() - tracker.firstSeen) / 60000);
alertDiv.style.display = 'block';
alertDiv.innerHTML = `
<h4>⚠️ POSSIBLE TRACKING DETECTED</h4>
<div style="font-size: 12px;">
<div><strong>Device:</strong> ${escapeHtml(tracker.name)}</div>
<div><strong>MAC:</strong> ${escapeHtml(mac)}</div>
<div><strong>Duration:</strong> ${durationMinutes} minutes</div>
<div><strong>Detections:</strong> ${tracker.seenCount}</div>
<div style="margin-top: 10px; color: #ff6666;">
This tracker has been detected near you for an extended period.
If you don't recognize this device, consider your safety.
</div>
<button onclick="dismissTrackerAlert('${mac}')" class="preset-btn" style="margin-top: 10px; border-color: #ff4444; color: #ff4444;">
Dismiss
</button>
</div>
`;
if (!muted) {
// Play warning sound
for (let i = 0; i < 3; i++) {
setTimeout(() => playAlertSound(), i * 300);
}
}
showNotification('⚠️ Tracking Alert', `${tracker.name} detected for ${durationMinutes} min`);
}
function dismissTrackerAlert(mac) {
document.getElementById('trackerFollowingAlert').style.display = 'none';
// Reset the tracker history for this device
if (trackerHistory[mac]) {
trackerHistory[mac].firstSeen = Date.now();
trackerHistory[mac].seenCount = 0;
}
}
// Handle discovered Bluetooth device (called from batched update)
function handleBtDeviceImmediate(device) {
const isNew = !btDevices[device.mac];
// Check for Find My network
const findMyInfo = detectFindMyDevice(device);
if (findMyInfo) {
device.findmy = findMyInfo;
device.tracker = device.tracker || { name: findMyInfo.type };
}
// Merge with existing device data to preserve RSSI if not in update
if (btDevices[device.mac] && !device.rssi && btDevices[device.mac].rssi) {
device.rssi = btDevices[device.mac].rssi;
}
btDevices[device.mac] = device;
if (isNew) {
btDeviceCount++;
document.getElementById('btDeviceCount').textContent = btDeviceCount;
playAlert();
pulseSignal();
}
// Update selected device panel if this device is selected
if (selectedBtDevice === device.mac) {
updateBtSelectedDevice(device);
}
// Check for tracker following
checkTrackerFollowing(device);
// Track in device intelligence
trackDevice({
protocol: 'Bluetooth',
address: device.mac,
message: device.name,
model: device.manufacturer,
device_type: device.device_type || device.type || 'other'
});
// Update visualizations
addBtDeviceToRadar(device);
// Add device card
addBtDeviceCard(device, isNew);
// Update device list panel
updateBtDeviceList();
// Check for trackers and update tracker list
if (device.tracker || device.findmy) {
updateBtTrackerList();
}
}
// Currently selected BT device for signal tracking
let selectedBtDevice = null;
// Update the Bluetooth device list panel
function updateBtDeviceList() {
const listEl = document.getElementById('btDeviceList');
const countEl = document.getElementById('btListCount');
if (!listEl) return;
const devices = Object.values(btDevices);
countEl.textContent = devices.length;
if (devices.length === 0) {
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Start scanning to discover devices...</div>';
return;
}
// Sort by RSSI (strongest first)
devices.sort((a, b) => (b.rssi || -100) - (a.rssi || -100));
listEl.innerHTML = devices.map(d => {
const typeIcon = {
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
'computer': '💻', 'input': '⌨️', 'other': '📶'
}[d.device_type || d.type] || '📶';
const rssiColor = d.rssi > -50 ? 'var(--accent-green)' :
d.rssi > -70 ? 'var(--accent-cyan)' :
d.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)';
const isSelected = selectedBtDevice === d.mac;
const trackerBadge = d.findmy ? `<span style="color:#007aff;font-size:9px;">FindMy</span>` :
d.tracker ? `<span style="color:var(--accent-red);font-size:9px;">Tracker</span>` : '';
return `
<div onclick="selectBtDevice('${d.mac}')" style="
padding: 8px;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
background: ${isSelected ? 'rgba(0,212,255,0.1)' : 'transparent'};
border-left: 2px solid ${isSelected ? 'var(--accent-cyan)' : 'transparent'};
" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='${isSelected ? 'rgba(0,212,255,0.1)' : 'transparent'}'">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>${typeIcon} ${escapeHtml(d.name || 'Unknown')}</span>
<span style="color:${rssiColor};font-weight:bold;">${d.rssi || '--'} dBm</span>
</div>
<div style="display:flex;justify-content:space-between;color:var(--text-dim);font-size:10px;margin-top:2px;">
<span>${escapeHtml(d.mac)}</span>
${trackerBadge}
</div>
</div>
`;
}).join('');
}
// Select a BT device for details
function selectBtDevice(mac) {
selectedBtDevice = mac;
const device = btDevices[mac];
if (device) {
document.getElementById('btTargetMac').value = mac;
updateBtSelectedDevice(device);
}
updateBtDeviceList(); // Refresh to show selection
}
// Update the selected device details panel
function updateBtSelectedDevice(device) {
const panel = document.getElementById('btSelectedDevice');
if (!panel || !device) return;
const typeIcon = {
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
'computer': '💻', 'input': '⌨️', 'other': '📶'
}[device.device_type || device.type] || '📶';
const rssiColor = device.rssi > -50 ? 'var(--accent-green)' :
device.rssi > -70 ? 'var(--accent-cyan)' :
device.rssi > -85 ? 'var(--accent-orange)' : 'var(--accent-red)';
const signalBars = Math.max(1, Math.min(5, Math.floor((device.rssi + 100) / 10)));
const barsHtml = Array(5).fill(0).map((_, i) =>
`<div style="width:4px;height:${8 + i * 4}px;background:${i < signalBars ? rssiColor : 'rgba(255,255,255,0.1)'};border-radius:1px;"></div>`
).join('');
let trackerInfo = '';
if (device.findmy) {
trackerInfo = `
<div style="background:rgba(0,122,255,0.15);border:1px solid #007aff;border-radius:6px;padding:8px;margin-top:8px;">
<div style="color:#007aff;font-weight:bold;">🍎 ${escapeHtml(device.findmy.type)}</div>
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">${escapeHtml(device.findmy.network)} Network Device</div>
</div>`;
} else if (device.tracker) {
trackerInfo = `
<div style="background:rgba(255,100,100,0.15);border:1px solid var(--accent-red);border-radius:6px;padding:8px;margin-top:8px;">
<div style="color:var(--accent-red);font-weight:bold;">📍 ${escapeHtml(device.tracker.name)}</div>
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">Tracking Device Detected</div>
</div>`;
}
panel.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px;">
<div>
<div style="font-size:16px;font-weight:bold;color:var(--accent-cyan);">${typeIcon} ${escapeHtml(device.name || 'Unknown Device')}</div>
<div style="color:var(--text-dim);font-size:10px;margin-top:2px;">${escapeHtml((device.device_type || device.type || 'unknown').toUpperCase())}</div>
</div>
<div style="text-align:right;">
<div style="font-size:18px;font-weight:bold;color:${rssiColor};">${device.rssi || '--'} dBm</div>
<div style="display:flex;gap:2px;justify-content:flex-end;align-items:flex-end;height:24px;margin-top:4px;">${barsHtml}</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
<div style="color:var(--text-dim);font-size:9px;">MAC ADDRESS</div>
<div style="font-family:monospace;font-size:11px;">${escapeHtml(device.mac)}</div>
</div>
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
<div style="color:var(--text-dim);font-size:9px;">MANUFACTURER</div>
<div style="font-size:11px;">${escapeHtml(device.manufacturer || 'Unknown')}</div>
</div>
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
<div style="color:var(--text-dim);font-size:9px;">ADDRESS TYPE</div>
<div style="font-size:11px;">${escapeHtml(device.address_type || 'Unknown')}</div>
</div>
<div style="background:rgba(0,0,0,0.3);padding:6px;border-radius:4px;">
<div style="color:var(--text-dim);font-size:9px;">LAST SEEN</div>
<div style="font-size:11px;">${device.last_seen ? new Date(device.last_seen * 1000).toLocaleTimeString() : 'Now'}</div>
</div>
</div>
${trackerInfo}
<div style="display:flex;gap:6px;margin-top:10px;">
<button class="preset-btn" onclick="btEnumServicesFor('${escapeAttr(device.mac)}')" style="flex:1;font-size:10px;padding:6px;">Enumerate Services</button>
<button class="preset-btn" onclick="copyToClipboard('${escapeAttr(device.mac)}')" style="flex:1;font-size:10px;padding:6px;">Copy MAC</button>
</div>
`;
}
// Copy text to clipboard helper
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showNotification('Copied', text);
}).catch(() => {
showInfo('Failed to copy to clipboard');
});
}
// Update tracker list panel
function updateBtTrackerList() {
const listEl = document.getElementById('btTrackerList');
if (!listEl) return;
const trackers = Object.values(btDevices).filter(d => d.tracker || d.findmy);
if (trackers.length === 0) {
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for AirTags, Tiles, and other trackers...</div>';
return;
}
listEl.innerHTML = trackers.map(d => {
const icon = d.findmy ? '🍎' : '📍';
const type = d.findmy ? d.findmy.type : (d.tracker ? d.tracker.name : 'Unknown');
const color = d.findmy ? '#007aff' : 'var(--accent-red)';
return `
<div style="padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<div style="display:flex;justify-content:space-between;">
<span style="color:${color};">${icon} ${escapeHtml(type)}</span>
<span style="color:var(--text-dim);">${d.rssi || '--'} dBm</span>
</div>
<div style="color:var(--text-dim);font-size:10px;">${escapeHtml(d.mac)}</div>
</div>
`;
}).join('');
}
// Add Bluetooth device card to device list panel
function addBtDeviceCard(device, isNew) {
// Add to new device list panel
const deviceList = document.getElementById('btDeviceListContent');
if (deviceList) {
// Remove placeholder if present
const placeholder = deviceList.querySelector('div[style*="text-align: center"]');
if (placeholder && placeholder.textContent.includes('Start scanning')) {
placeholder.remove();
}
let card = document.getElementById('btcard_' + device.mac.replace(/:/g, ''));
const devType = device.device_type || device.type || 'other';
if (!card) {
card = document.createElement('div');
card.id = 'btcard_' + device.mac.replace(/:/g, '');
card.className = 'sensor-card bt-device-card' +
(device.findmy ? ' findmy' : '') +
(device.tracker && !device.findmy ? ' tracker' : '');
card.style.cursor = 'pointer';
card.onclick = () => selectBtDevice(device.mac);
deviceList.insertBefore(card, deviceList.firstChild);
// Update device count
const countEl = document.getElementById('btDeviceListCount');
if (countEl) countEl.textContent = Object.keys(btDevices).length;
}
const typeIcon = {
'phone': '📱', 'audio': '🎧', 'wearable': '⌚', 'tracker': '📍',
'computer': '💻', 'input': '⌨️', 'other': '📶'
}[devType] || '📶';
// Handle signal strength
const rssi = device.rssi || -100;
const signalBars = Math.max(0, Math.min(5, Math.floor((rssi + 100) / 15)));
const signalDisplay = rssi > -100 ? `${rssi} dBm` : 'N/A';
const findMyBadge = device.findmy
? `<span style="background: #007aff; color: #fff; padding: 2px 6px; border-radius: 3px; font-size: 9px; margin-left: 5px;">${device.findmy.network.toUpperCase()}</span>`
: '';
const trackerBadge = device.tracker && !device.findmy
? `<span style="background: var(--accent-orange); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 9px; margin-left: 5px;">TRACKER</span>`
: '';
card.innerHTML = `
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span class="device-name" style="color: var(--accent-purple);">${typeIcon} ${escapeHtml(device.name || 'Unknown')}${findMyBadge}${trackerBadge}</span>
<span style="font-size: 10px; color: var(--text-dim);">${escapeHtml(devType.toUpperCase())}</span>
</div>
<div class="sensor-data">
<div class="data-item">
<div class="data-label">MAC</div>
<div class="data-value" style="font-size: 11px;">${escapeHtml(device.mac)}</div>
</div>
<div class="data-item">
<div class="data-label">Manufacturer</div>
<div class="data-value">${escapeHtml(device.manufacturer || 'Unknown')}</div>
</div>
<div class="data-item">
<div class="data-label">Signal</div>
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5-signalBars)}</div>
</div>
</div>
<div style="margin-top: 8px; display: flex; gap: 5px;">
<button class="preset-btn" onclick="event.stopPropagation(); btTargetDevice('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Target</button>
<button class="preset-btn" onclick="event.stopPropagation(); btEnumServicesFor('${escapeAttr(device.mac)}')" style="font-size: 10px; padding: 4px 8px;">Services</button>
</div>
`;
}
// Update statistics panels
updateBtStatsPanels();
}
// Select a Bluetooth device
function selectBtDevice(mac) {
selectedBtDevice = mac;
const device = btDevices[mac];
if (device) {
updateBtSelectedDevice(device);
}
}
// Update Bluetooth statistics panels
function updateBtStatsPanels() {
const devices = Object.values(btDevices);
// Device type counts
let phones = 0, computers = 0, audio = 0, wearables = 0, other = 0;
let strong = 0, medium = 0, weak = 0;
devices.forEach(d => {
const devType = d.device_type || d.type || 'other';
if (devType === 'phone') phones++;
else if (devType === 'computer') computers++;
else if (devType === 'audio') audio++;
else if (devType === 'wearable') wearables++;
else other++;
const rssi = d.rssi || -100;
if (rssi >= -50) strong++;
else if (rssi >= -70) medium++;
else weak++;
});
// Update type counts
const phoneEl = document.getElementById('btPhoneCount');
const compEl = document.getElementById('btComputerCount');
const audioEl = document.getElementById('btAudioCount');
const wearEl = document.getElementById('btWearableCount');
const otherEl = document.getElementById('btOtherCount');
if (phoneEl) phoneEl.textContent = phones;
if (compEl) compEl.textContent = computers;
if (audioEl) audioEl.textContent = audio;
if (wearEl) wearEl.textContent = wearables;
if (otherEl) otherEl.textContent = other;
// Update signal distribution
const total = devices.length || 1;
const strongBar = document.getElementById('btSignalStrong');
const mediumBar = document.getElementById('btSignalMedium');
const weakBar = document.getElementById('btSignalWeak');
const strongCount = document.getElementById('btSignalStrongCount');
const mediumCount = document.getElementById('btSignalMediumCount');
const weakCount = document.getElementById('btSignalWeakCount');
if (strongBar) strongBar.style.width = (strong / total * 100) + '%';
if (mediumBar) mediumBar.style.width = (medium / total * 100) + '%';
if (weakBar) weakBar.style.width = (weak / total * 100) + '%';
if (strongCount) strongCount.textContent = strong;
if (mediumCount) mediumCount.textContent = medium;
if (weakCount) weakCount.textContent = weak;
// Update FindMy list
updateBtFindMyList();
}
// Update FindMy device list
function updateBtFindMyList() {
const listEl = document.getElementById('btFindMyList');
if (!listEl) return;
const findMyDevices = Object.values(btDevices).filter(d => d.findmy);
if (findMyDevices.length === 0) {
listEl.innerHTML = '<div style="color: var(--text-dim); padding: 10px; text-align: center;">Scanning for FindMy-compatible devices...</div>';
return;
}
listEl.innerHTML = findMyDevices.map(d => `
<div style="display: flex; justify-content: space-between; padding: 5px 8px; background: rgba(0,122,255,0.1); border-radius: 3px; margin-bottom: 4px; cursor: pointer;" onclick="selectBtDevice('${escapeAttr(d.mac)}')">
<span>${d.findmy.icon || '📍'} ${escapeHtml(d.name || d.findmy.type)}</span>
<span style="color: #007aff;">${d.rssi || '--'} dBm</span>
</div>
`).join('');
}
// Target a Bluetooth device
function btTargetDevice(mac) {
document.getElementById('btTargetMac').value = mac;
showInfo('Targeted: ' + mac);
}
// Enumerate services for a device
function btEnumServicesFor(mac) {
document.getElementById('btTargetMac').value = mac;
btEnumServices();
}
// Enumerate services
function btEnumServices() {
const mac = document.getElementById('btTargetMac').value;
if (!mac) { alert('Enter target MAC'); return; }
showInfo('Enumerating services for ' + mac + '...');
fetch('/bt/enum', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mac: mac})
}).then(r => r.json())
.then(data => {
if (data.status === 'success') {
let msg = 'Services for ' + mac + ': ';
if (data.services.length === 0) {
msg += 'None found';
} else {
msg += data.services.map(s => s.name).join(', ');
}
showInfo(msg);
} else {
showInfo('Error: ' + data.message);
}
});
}
// Initialize Bluetooth radar
function initBtRadar() {
const canvas = document.getElementById('btRadarCanvas');
if (!canvas) return;
btRadarCtx = canvas.getContext('2d');
canvas.width = 150;
canvas.height = 150;
if (!btRadarAnimFrame) {
animateBtRadar();
}
}
// Animate Bluetooth radar
function animateBtRadar() {
if (!btRadarCtx) { btRadarAnimFrame = null; return; }
const canvas = btRadarCtx.canvas;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 5;
btRadarCtx.fillStyle = 'rgba(0, 10, 20, 0.1)';
btRadarCtx.fillRect(0, 0, canvas.width, canvas.height);
// Grid circles
btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.2)';
btRadarCtx.lineWidth = 1;
for (let r = radius / 4; r <= radius; r += radius / 4) {
btRadarCtx.beginPath();
btRadarCtx.arc(cx, cy, r, 0, Math.PI * 2);
btRadarCtx.stroke();
}
// Sweep line (purple for BT)
btRadarCtx.strokeStyle = 'rgba(138, 43, 226, 0.8)';
btRadarCtx.lineWidth = 2;
btRadarCtx.beginPath();
btRadarCtx.moveTo(cx, cy);
btRadarCtx.lineTo(cx + Math.cos(btRadarAngle) * radius, cy + Math.sin(btRadarAngle) * radius);
btRadarCtx.stroke();
// Device blips
btRadarDevices.forEach(dev => {
const age = Date.now() - dev.timestamp;
const alpha = Math.max(0.1, 1 - age / 15000);
const color = dev.isTracker ? '255, 51, 102' : '138, 43, 226';
btRadarCtx.fillStyle = `rgba(${color}, ${alpha})`;
btRadarCtx.beginPath();
btRadarCtx.arc(dev.x, dev.y, dev.isTracker ? 6 : 4, 0, Math.PI * 2);
btRadarCtx.fill();
});
btRadarAngle += 0.025;
if (btRadarAngle > Math.PI * 2) btRadarAngle = 0;
btRadarAnimFrame = requestAnimationFrame(animateBtRadar);
}
// Add device to BT radar
function addBtDeviceToRadar(device) {
const canvas = document.getElementById('btRadarCanvas');
if (!canvas) return;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 10;
// Random position based on MAC hash
let angle = 0;
for (let i = 0; i < device.mac.length; i++) {
angle += device.mac.charCodeAt(i);
}
angle = (angle % 360) * Math.PI / 180;
const r = radius * (0.3 + Math.random() * 0.6);
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
const existing = btRadarDevices.find(d => d.mac === device.mac);
if (existing) {
existing.timestamp = Date.now();
} else {
btRadarDevices.push({
x, y,
mac: device.mac,
isTracker: !!device.tracker,
timestamp: Date.now()
});
}
if (btRadarDevices.length > 50) btRadarDevices.shift();
}
// ============================================
// AIRCRAFT (ADS-B) MODE FUNCTIONS
// ============================================
function checkAdsbTools() {
fetch('/adsb/tools')
.then(r => r.json())
.then(data => {
// Update aircraft mode panel status
const dump1090Status = document.getElementById('dump1090Status');
const rtlAdsbStatus = document.getElementById('rtlAdsbStatus');
if (dump1090Status) {
dump1090Status.textContent = data.dump1090 ? 'OK' : 'Missing';
dump1090Status.className = 'tool-status ' + (data.dump1090 ? 'ok' : 'missing');
}
if (rtlAdsbStatus) {
rtlAdsbStatus.textContent = data.rtl_adsb ? 'OK' : 'Missing';
rtlAdsbStatus.className = 'tool-status ' + (data.rtl_adsb ? 'ok' : 'missing');
}
// Update sidebar status
const dump1090Sidebar = document.getElementById('dump1090StatusSidebar');
const rtlAdsbSidebar = document.getElementById('rtlAdsbStatusSidebar');
if (dump1090Sidebar) {
dump1090Sidebar.textContent = data.dump1090 ? 'OK' : 'Missing';
dump1090Sidebar.className = 'tool-status ' + (data.dump1090 ? 'ok' : 'missing');
}
if (rtlAdsbSidebar) {
rtlAdsbSidebar.textContent = data.rtl_adsb ? 'OK' : 'Missing';
rtlAdsbSidebar.className = 'tool-status ' + (data.rtl_adsb ? 'ok' : 'missing');
}
});
}
// Leaflet map for aircraft tracking
let aircraftMap = null;
let aircraftMarkers = {};
let aircraftClusterGroup = null;
let clusteringEnabled = false;
let mapRefreshInterval = null;
function initAircraftRadar() {
const mapContainer = document.getElementById('aircraftMap');
if (!mapContainer || aircraftMap) return;
// Use GPS position if available, otherwise use observerLocation or default
let initialLat = observerLocation.lat || 51.5;
let initialLon = observerLocation.lon || -0.1;
// Check if GPS has a recent position
if (gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
initialLat = gpsLastPosition.latitude;
initialLon = gpsLastPosition.longitude;
observerLocation.lat = initialLat;
observerLocation.lon = initialLon;
console.log('GPS: Initializing map with GPS position', initialLat, initialLon);
}
// Initialize Leaflet map
aircraftMap = L.map('aircraftMap', {
center: [initialLat, initialLon],
zoom: 8,
zoomControl: true,
attributionControl: true
});
// Add OpenStreetMap tiles (will be inverted by CSS for dark theme)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18
}).addTo(aircraftMap);
// Initialize cluster group (but don't add to map yet)
aircraftClusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
let size = 'small';
if (count > 10) size = 'medium';
if (count > 25) size = 'large';
return L.divIcon({
html: '<div class="marker-cluster marker-cluster-' + size + '">' + count + '</div>',
className: '',
iconSize: L.point(40, 40)
});
}
});
// Update time display
updateRadarTime();
setInterval(updateRadarTime, 1000);
// Refresh aircraft markers every second
if (!mapRefreshInterval) {
mapRefreshInterval = setInterval(updateAircraftMarkers, 1000);
}
// Setup interaction tracking
setupMapInteraction();
// Initial update
updateAircraftMarkers();
// Update input fields with current position
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = observerLocation.lat.toFixed(4);
if (adsbLonInput) adsbLonInput.value = observerLocation.lon.toFixed(4);
// Draw initial range rings if GPS is connected
if (gpsConnected) {
drawRangeRings();
}
}
function toggleAircraftClustering() {
clusteringEnabled = document.getElementById('adsbEnableClustering').checked;
if (!aircraftMap || !aircraftClusterGroup) return;
if (clusteringEnabled) {
// Move all markers to cluster group
Object.values(aircraftMarkers).forEach(marker => {
if (aircraftMap.hasLayer(marker)) {
aircraftMap.removeLayer(marker);
}
aircraftClusterGroup.addLayer(marker);
});
aircraftMap.addLayer(aircraftClusterGroup);
} else {
// Move all markers back to map directly
aircraftClusterGroup.clearLayers();
aircraftMap.removeLayer(aircraftClusterGroup);
Object.values(aircraftMarkers).forEach(marker => {
marker.addTo(aircraftMap);
});
}
}
function toggleAircraftRadar() {
const enabled = document.getElementById('adsbEnableMap').checked;
const visuals = document.getElementById('aircraftVisuals');
if (visuals && currentMode === 'aircraft') {
visuals.style.display = enabled ? 'grid' : 'none';
}
}
function applyAircraftFilter() {
// Clear all markers and redraw with new filter
Object.keys(aircraftMarkers).forEach(icao => {
if (clusteringEnabled && aircraftClusterGroup) {
aircraftClusterGroup.removeLayer(aircraftMarkers[icao]);
} else if (aircraftMap) {
aircraftMap.removeLayer(aircraftMarkers[icao]);
}
delete aircraftMarkers[icao];
delete aircraftMarkerState[icao];
});
// Trail lines should also be cleared for filtered-out aircraft
Object.keys(aircraftTrailLines).forEach(icao => {
if (aircraftMap) {
aircraftMap.removeLayer(aircraftTrailLines[icao]);
}
delete aircraftTrailLines[icao];
});
updateAircraftMarkers();
}
function updateRadarTime() {
const now = new Date();
const time = now.toTimeString().substring(0, 8);
const el = document.getElementById('radarTime');
if (el) el.textContent = time;
}
function createAircraftIcon(heading, emergency, customColor) {
const color = customColor || (emergency ? '#ff4444' : '#00d4ff');
const rotation = heading || 0;
return L.divIcon({
className: 'aircraft-marker' + (emergency ? ' squawk-emergency' : ''),
html: `<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color};">
<path fill="currentColor" d="M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
let aircraftTrailLines = {}; // ICAO -> Leaflet polyline
let aircraftMarkerState = {}; // Cache marker state to avoid unnecessary updates
const MAX_AIRCRAFT_MARKERS = 150; // Limit markers to prevent browser freeze
function buildTooltipText(aircraft, showLabels, showAltitude) {
if (!showLabels && !showAltitude) return '';
let text = '';
if (showLabels && aircraft.callsign) text = aircraft.callsign;
if (showAltitude && aircraft.altitude) {
if (text) text += ' ';
text += 'FL' + Math.round(aircraft.altitude / 100).toString().padStart(3, '0');
}
return text;
}
function buildPopupContent(icao) {
const aircraft = adsbAircraft[icao];
if (!aircraft) return '';
const squawkInfo = checkSquawkCode(aircraft);
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
let content = '<div class="aircraft-popup">';
if (militaryInfo.military) {
content += `<div style="background: #556b2f; color: white; padding: 2px 8px; border-radius: 3px; font-size: 10px; margin-bottom: 5px;">🎖️ MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>`;
}
if (squawkInfo) {
content += `<div style="background: ${squawkInfo.color}; color: white; padding: 4px 8px; border-radius: 3px; font-size: 11px; margin-bottom: 5px; font-weight: bold;">⚠️ ${squawkInfo.name}</div>`;
}
content += `<div class="callsign">${aircraft.callsign || icao}</div>`;
if (aircraft.altitude) {
content += `<div class="data-row"><span class="label">Altitude:</span><span class="value">${aircraft.altitude.toLocaleString()} ft</span></div>`;
}
if (aircraft.speed) {
content += `<div class="data-row"><span class="label">Speed:</span><span class="value">${aircraft.speed} kts</span></div>`;
}
if (aircraft.heading !== undefined) {
content += `<div class="data-row"><span class="label">Heading:</span><span class="value">${aircraft.heading}°</span></div>`;
}
if (aircraft.squawk) {
const squawkStyle = squawkInfo ? `color: ${squawkInfo.color}; font-weight: bold;` : '';
content += `<div class="data-row"><span class="label">Squawk:</span><span class="value" style="${squawkStyle}">${aircraft.squawk}</span></div>`;
}
content += '</div>';
return content;
}
function updateAircraftMarkers() {
if (!aircraftMap) return;
const showLabels = document.getElementById('adsbShowLabels')?.checked;
const showAltitude = document.getElementById('adsbShowAltitude')?.checked;
const showTrails = document.getElementById('adsbShowTrails')?.checked ?? true;
const aircraftFilter = document.getElementById('adsbAircraftFilter')?.value || 'all';
const currentIds = new Set();
// Sort aircraft by altitude and limit to prevent DOM explosion
const sortedAircraft = Object.entries(adsbAircraft)
.filter(([_, a]) => a.lat != null && a.lon != null)
.filter(([icao, a]) => {
if (aircraftFilter === 'all') return true;
const militaryInfo = isMilitaryAircraft(icao, a.callsign);
const squawkInfo = checkSquawkCode(a);
if (aircraftFilter === 'military') return militaryInfo.military;
if (aircraftFilter === 'civil') return !militaryInfo.military;
if (aircraftFilter === 'emergency') return !!squawkInfo;
return true;
})
.sort((a, b) => (b[1].altitude || 0) - (a[1].altitude || 0))
.slice(0, MAX_AIRCRAFT_MARKERS);
// Update or create markers for each aircraft
sortedAircraft.forEach(([icao, aircraft]) => {
currentIds.add(icao);
// Update trail history
updateAircraftTrail(icao, aircraft.lat, aircraft.lon);
// Check for emergency squawk codes
const squawkInfo = checkSquawkCode(aircraft);
// Check for military aircraft
const militaryInfo = isMilitaryAircraft(icao, aircraft.callsign);
aircraft.military = militaryInfo.military;
// Determine icon color
let iconColor = '#00d4ff'; // Default cyan
if (squawkInfo) iconColor = squawkInfo.color;
else if (militaryInfo.military) iconColor = '#556b2f'; // Olive drab
else if (aircraft.emergency) iconColor = '#ff4444';
// Round heading to reduce icon recreations
const roundedHeading = Math.round((aircraft.heading || 0) / 5) * 5;
// Check if icon state actually changed
const prevState = aircraftMarkerState[icao] || {};
const iconChanged = prevState.heading !== roundedHeading ||
prevState.color !== iconColor ||
prevState.emergency !== (squawkInfo || aircraft.emergency);
if (aircraftMarkers[icao]) {
// Update existing marker - position is cheap
aircraftMarkers[icao].setLatLng([aircraft.lat, aircraft.lon]);
// Only update icon if it actually changed
if (iconChanged) {
const icon = createAircraftIcon(roundedHeading, squawkInfo || aircraft.emergency, iconColor);
aircraftMarkers[icao].setIcon(icon);
aircraftMarkerState[icao] = { heading: roundedHeading, color: iconColor, emergency: squawkInfo || aircraft.emergency };
}
} else {
const icon = createAircraftIcon(roundedHeading, squawkInfo || aircraft.emergency, iconColor);
aircraftMarkerState[icao] = { heading: roundedHeading, color: iconColor, emergency: squawkInfo || aircraft.emergency };
// Create new marker
const marker = L.marker([aircraft.lat, aircraft.lon], { icon: icon });
if (clusteringEnabled && aircraftClusterGroup) {
aircraftClusterGroup.addLayer(marker);
} else {
marker.addTo(aircraftMap);
}
aircraftMarkers[icao] = marker;
}
// Draw flight trail
if (showTrails && aircraftTrails[icao] && aircraftTrails[icao].length > 1) {
const trailCoords = aircraftTrails[icao].map(p => [p.lat, p.lon]);
if (aircraftTrailLines[icao]) {
aircraftTrailLines[icao].setLatLngs(trailCoords);
} else {
aircraftTrailLines[icao] = L.polyline(trailCoords, {
color: militaryInfo.military ? '#556b2f' : '#00d4ff',
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(aircraftMap);
}
} else if (aircraftTrailLines[icao]) {
aircraftMap.removeLayer(aircraftTrailLines[icao]);
delete aircraftTrailLines[icao];
}
// Only update popup/tooltip if data changed (expensive operations)
const tooltipText = buildTooltipText(aircraft, showLabels, showAltitude);
const prevTooltip = prevState.tooltipText;
// Only rebind tooltip if content changed
if (tooltipText !== prevTooltip) {
aircraftMarkerState[icao].tooltipText = tooltipText;
aircraftMarkers[icao].unbindTooltip();
if (tooltipText) {
aircraftMarkers[icao].bindTooltip(tooltipText, {
permanent: true,
direction: 'right',
className: 'aircraft-tooltip'
});
}
}
// Bind popup lazily - content is built on open, not every update
if (!aircraftMarkers[icao]._hasPopupBound) {
aircraftMarkers[icao].bindPopup(() => buildPopupContent(icao));
aircraftMarkers[icao]._hasPopupBound = true;
}
});
// Remove markers for aircraft no longer tracked
Object.keys(aircraftMarkers).forEach(icao => {
if (!currentIds.has(icao)) {
if (clusteringEnabled && aircraftClusterGroup) {
aircraftClusterGroup.removeLayer(aircraftMarkers[icao]);
} else {
aircraftMap.removeLayer(aircraftMarkers[icao]);
}
// Also remove trail
if (aircraftTrailLines[icao]) {
aircraftMap.removeLayer(aircraftTrailLines[icao]);
delete aircraftTrailLines[icao];
}
delete aircraftTrails[icao];
delete aircraftMarkers[icao];
delete aircraftMarkerState[icao];
delete activeSquawkAlerts[icao];
}
});
// Update status display
const aircraftCount = Object.keys(adsbAircraft).length;
document.getElementById('radarStatus').textContent = isAdsbRunning ?
`TRACKING ${aircraftCount}` : 'STANDBY';
document.getElementById('aircraftCount').textContent = aircraftCount;
// Update map center display
const center = aircraftMap.getCenter();
document.getElementById('mapCenter').textContent =
`${center.lat.toFixed(2)}, ${center.lng.toFixed(2)}`;
// Auto-fit bounds if we have aircraft (throttled to avoid performance issues)
const now = Date.now();
if (aircraftCount > 0 && !aircraftMap._userInteracted &&
(!aircraftMap._lastFitBounds || now - aircraftMap._lastFitBounds > 5000)) {
const bounds = [];
Object.values(adsbAircraft).forEach(a => {
if (a.lat !== undefined && a.lon !== undefined) {
bounds.push([a.lat, a.lon]);
}
});
if (bounds.length > 0) {
aircraftMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
aircraftMap._lastFitBounds = now;
}
}
}
// Track user interaction to stop auto-fitting
function setupMapInteraction() {
if (aircraftMap) {
aircraftMap.on('dragstart zoomstart', () => {
aircraftMap._userInteracted = true;
});
}
}
// Calculate distance between two points in nautical miles
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065; // Earth radius in nautical miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Update ADS-B statistics
function updateAdsbStatistics(icao, aircraft) {
if (!aircraft.lat || !aircraft.lon) return;
// Track unique aircraft
adsbStats.totalAircraftSeen.add(icao);
// Calculate distance from observer
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
aircraft.lat, aircraft.lon
);
// Update max range if this is further
if (distance > adsbStats.maxRange) {
adsbStats.maxRange = distance;
adsbStats.maxRangeAircraft = aircraft.callsign || icao;
}
// Track hourly aircraft count
const hour = new Date().getHours();
if (!adsbStats.hourlyCount[hour]) {
adsbStats.hourlyCount[hour] = new Set();
}
adsbStats.hourlyCount[hour].add(icao);
// Update messages per second calculation
const now = Date.now();
adsbStats.messageTimestamps.push(now);
// Keep only last 5 seconds of timestamps
adsbStats.messageTimestamps = adsbStats.messageTimestamps.filter(t => now - t < 5000);
adsbStats.messagesPerSecond = adsbStats.messageTimestamps.length / 5;
// Update stats display
updateStatsDisplay();
}
// Update the statistics display
function updateStatsDisplay() {
const maxRangeEl = document.getElementById('adsbMaxRange');
const totalSeenEl = document.getElementById('adsbTotalSeen');
const msgRateEl = document.getElementById('adsbMsgRate');
const busiestHourEl = document.getElementById('adsbBusiestHour');
if (maxRangeEl) {
maxRangeEl.textContent = `${adsbStats.maxRange.toFixed(1)} nm`;
if (adsbStats.maxRangeAircraft) {
maxRangeEl.title = `Aircraft: ${adsbStats.maxRangeAircraft}`;
}
}
if (totalSeenEl) {
totalSeenEl.textContent = adsbStats.totalAircraftSeen.size;
}
if (msgRateEl) {
msgRateEl.textContent = `${adsbStats.messagesPerSecond.toFixed(1)}/s`;
}
if (busiestHourEl) {
let busiestHour = 0;
let maxCount = 0;
Object.entries(adsbStats.hourlyCount).forEach(([hour, aircraftSet]) => {
if (aircraftSet.size > maxCount) {
maxCount = aircraftSet.size;
busiestHour = hour;
}
});
busiestHourEl.textContent = maxCount > 0 ? `${busiestHour}:00 (${maxCount})` : '--';
}
}
// Draw range rings on the map
function drawRangeRings() {
if (!aircraftMap) return;
// Remove existing rings
if (rangeRingsLayer) {
aircraftMap.removeLayer(rangeRingsLayer);
}
const showRings = document.getElementById('adsbShowRangeRings')?.checked;
if (!showRings) return;
rangeRingsLayer = L.layerGroup();
// Range ring distances in nautical miles
const distances = [25, 50, 100, 150, 200];
distances.forEach(nm => {
// Convert nm to meters for Leaflet circle
const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters,
color: '#00d4ff',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.4,
dashArray: '5, 5'
});
// Add label
const labelLatLng = L.latLng(
observerLocation.lat + (nm * 0.0166), // Approx degrees per nm
observerLocation.lon
);
const label = L.marker(labelLatLng, {
icon: L.divIcon({
className: 'range-ring-label',
html: `<span style="color: #00d4ff; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${nm} nm</span>`,
iconSize: [40, 12],
iconAnchor: [20, 6]
})
});
rangeRingsLayer.addLayer(circle);
rangeRingsLayer.addLayer(label);
});
// Add observer marker
if (observerMarkerAdsb) {
aircraftMap.removeLayer(observerMarkerAdsb);
}
observerMarkerAdsb = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(aircraftMap);
rangeRingsLayer.addTo(aircraftMap);
}
// Update observer location from input fields
function updateObserverLocation() {
const latInput = document.getElementById('adsbObsLat');
const lonInput = document.getElementById('adsbObsLon');
if (latInput && lonInput) {
const lat = parseFloat(latInput.value);
const lon = parseFloat(lonInput.value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Center map on location
if (aircraftMap) {
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
aircraftMap._userInteracted = true;
}
// Redraw range rings
drawRangeRings();
}
}
}
// Get user's geolocation (only works on HTTPS or localhost)
function getAdsbGeolocation() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
// Check if we're on a secure context
if (!window.isSecureContext) {
alert('GPS location requires HTTPS. Please enter your coordinates manually in the lat/lon fields above.');
return;
}
const btn = document.getElementById('adsbGeolocateBtn');
if (btn) btn.textContent = '📍 Locating...';
navigator.geolocation.getCurrentPosition(
(position) => {
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
// Update input fields
const latInput = document.getElementById('adsbObsLat');
const lonInput = document.getElementById('adsbObsLon');
if (latInput) latInput.value = observerLocation.lat.toFixed(4);
if (lonInput) lonInput.value = observerLocation.lon.toFixed(4);
// Center map on location
if (aircraftMap) {
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
aircraftMap._userInteracted = true;
}
// Redraw range rings
drawRangeRings();
if (btn) btn.textContent = '📍 Use GPS Location';
showInfo(`Location set: ${observerLocation.lat.toFixed(4)}, ${observerLocation.lon.toFixed(4)}`);
},
(error) => {
if (btn) btn.textContent = '📍 Use GPS Location';
alert('Unable to get location. Please enter coordinates manually.\n\nError: ' + error.message);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// Reset ADS-B statistics
function resetAdsbStats() {
adsbStats = {
totalAircraftSeen: new Set(),
maxRange: 0,
maxRangeAircraft: null,
hourlyCount: {},
messagesPerSecond: 0,
messageTimestamps: [],
sessionStart: Date.now()
};
updateStatsDisplay();
}
function startAdsbScan() {
const gain = document.getElementById('adsbGain').value;
const device = getSelectedDevice();
const sdr_type = getSelectedSDRType();
// Check if device is available
if (!checkDeviceAvailability('adsb')) {
return;
}
fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gain, device, sdr_type, bias_t: getBiasTEnabled() })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
reserveDevice(parseInt(device), 'adsb');
isAdsbRunning = true;
document.getElementById('startAdsbBtn').style.display = 'none';
document.getElementById('stopAdsbBtn').style.display = 'block';
document.getElementById('statusDot').className = 'status-dot active';
document.getElementById('statusText').textContent = 'ADS-B Tracking';
resetAdsbStats(); // Reset statistics for new session
adsbStats.sessionStart = Date.now();
startAdsbStream();
drawRangeRings(); // Draw range rings if enabled
} else {
alert('Error: ' + data.message);
}
});
}
function stopAdsbScan() {
fetch('/adsb/stop', { method: 'POST' })
.then(r => r.json())
.then(data => {
releaseDevice('adsb');
isAdsbRunning = false;
document.getElementById('startAdsbBtn').style.display = 'block';
document.getElementById('stopAdsbBtn').style.display = 'none';
document.getElementById('statusDot').className = 'status-dot';
document.getElementById('statusText').textContent = 'Idle';
if (adsbEventSource) {
adsbEventSource.close();
adsbEventSource = null;
}
});
}
// Batching state for aircraft updates to prevent browser freeze
let pendingAircraftUpdate = false;
let pendingAircraftData = [];
function scheduleAircraftUIUpdate() {
if (pendingAircraftUpdate) return;
pendingAircraftUpdate = true;
requestAnimationFrame(() => {
updateAdsbStats();
updateAircraftMarkers();
updateAircraftListPanel();
updateSelectedAircraftInfo();
// Batch output updates - only show last 10 to prevent DOM explosion
const toOutput = pendingAircraftData.slice(-10);
pendingAircraftData = [];
toOutput.forEach(data => addAircraftToOutput(data));
pendingAircraftUpdate = false;
});
}
function startAdsbStream() {
if (adsbEventSource) adsbEventSource.close();
adsbEventSource = new EventSource('/adsb/stream');
adsbEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'aircraft') {
adsbAircraft[data.icao] = {
...adsbAircraft[data.icao],
...data,
lastSeen: Date.now()
};
adsbMsgCount++;
pendingAircraftData.push(data);
// Check for military/emergency aircraft and alert
checkAndAlertAircraft(data.icao, adsbAircraft[data.icao]);
// Update statistics
updateAdsbStatistics(data.icao, adsbAircraft[data.icao]);
// Use batched update instead of immediate
scheduleAircraftUIUpdate();
}
};
// Periodic cleanup of stale aircraft
setInterval(() => {
const now = Date.now();
let needsUpdate = false;
Object.keys(adsbAircraft).forEach(icao => {
if (now - adsbAircraft[icao].lastSeen > 60000) {
delete adsbAircraft[icao];
needsUpdate = true;
}
});
if (needsUpdate) {
scheduleAircraftUIUpdate();
}
}, 5000);
}
function updateAdsbStats() {
const count = Object.keys(adsbAircraft).length;
document.getElementById('aircraftCount').textContent = count;
document.getElementById('adsbMsgCount').textContent = adsbMsgCount;
document.getElementById('icaoCount').textContent = count;
}
// Update aircraft list panel in main tab
function updateAircraftListPanel() {
const listPanel = document.getElementById('aircraftListPanel');
if (!listPanel) return;
const aircraft = Object.entries(adsbAircraft)
.filter(([_, a]) => a.lat != null && a.lon != null)
.sort((a, b) => (b[1].altitude || 0) - (a[1].altitude || 0))
.slice(0, 20);
if (aircraft.length === 0) {
listPanel.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">No aircraft detected</div>';
return;
}
listPanel.innerHTML = aircraft.map(([icao, a]) => {
const isSelected = selectedMainAircraft === icao;
const militaryInfo = isMilitaryAircraft ? isMilitaryAircraft(icao, a.callsign) : { military: false };
const bgColor = isSelected ? 'rgba(0, 212, 255, 0.2)' : 'transparent';
const borderColor = isSelected ? 'var(--accent-cyan)' : 'var(--border-color)';
const typeColor = militaryInfo.military ? '#556b2f' : 'var(--accent-cyan)';
return `
<div onclick="selectMainAircraft('${icao}')" style="padding: 8px; margin-bottom: 5px; background: ${bgColor}; border: 1px solid ${borderColor}; cursor: pointer; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: ${typeColor}; font-weight: bold;">${a.callsign || icao}</span>
<span style="color: var(--text-muted); font-size: 10px;">${a.altitude ? Math.round(a.altitude).toLocaleString() + ' ft' : '--'}</span>
</div>
<div style="color: var(--text-dim); font-size: 10px; margin-top: 3px;">
${a.registration || ''} ${a.type || ''} ${militaryInfo.military ? '🎖️' : ''}
</div>
</div>
`;
}).join('');
}
// Select an aircraft in main tab
function selectMainAircraft(icao) {
selectedMainAircraft = icao;
updateAircraftListPanel();
updateSelectedAircraftInfo();
// Center map on aircraft
const aircraft = adsbAircraft[icao];
if (aircraft && aircraft.lat && aircraft.lon && aircraftMap) {
aircraftMap.setView([aircraft.lat, aircraft.lon], 10);
}
}
// Update selected aircraft info panel
function updateSelectedAircraftInfo() {
const infoPanel = document.getElementById('selectedAircraftInfo');
if (!infoPanel) return;
if (!selectedMainAircraft || !adsbAircraft[selectedMainAircraft]) {
infoPanel.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 20px;">Click an aircraft to view details</div>';
showMainAircraftPhotoState('placeholder');
return;
}
const a = adsbAircraft[selectedMainAircraft];
const militaryInfo = isMilitaryAircraft ? isMilitaryAircraft(selectedMainAircraft, a.callsign) : { military: false };
infoPanel.innerHTML = `
<div style="text-align: center; margin-bottom: 10px;">
<div style="font-size: 24px; color: var(--accent-cyan); font-weight: bold;">${a.callsign || selectedMainAircraft}</div>
${a.registration ? `<div style="color: var(--text-secondary);">${a.registration}</div>` : ''}
${militaryInfo.military ? '<div style="color: #556b2f; font-size: 10px;">🎖️ MILITARY</div>' : ''}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div style="background: rgba(0,0,0,0.3); padding: 6px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">ALTITUDE</div>
<div style="color: var(--accent-cyan);">${a.altitude ? Math.round(a.altitude).toLocaleString() + ' ft' : '--'}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 6px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">SPEED</div>
<div style="color: var(--accent-cyan);">${a.speed ? Math.round(a.speed) + ' kts' : '--'}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 6px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">HEADING</div>
<div style="color: var(--accent-cyan);">${a.heading ? Math.round(a.heading) + '°' : '--'}</div>
</div>
<div style="background: rgba(0,0,0,0.3); padding: 6px; border-radius: 4px;">
<div style="color: var(--text-dim); font-size: 9px;">SQUAWK</div>
<div style="color: var(--accent-cyan);">${a.squawk || '--'}</div>
</div>
</div>
${a.type ? `<div style="margin-top: 8px; color: var(--text-dim); font-size: 10px; text-align: center;">Aircraft: ${a.type}</div>` : ''}
<div style="margin-top: 8px; color: var(--text-dim); font-size: 9px; text-align: center;">ICAO: ${selectedMainAircraft}</div>
`;
// Fetch aircraft photo if registration is available
if (a.registration) {
fetchMainAircraftPhoto(a.registration);
} else {
// No registration - show placeholder or no photo state
showMainAircraftPhotoState('noregistration');
}
}
// Cache for aircraft photos to avoid repeated API calls
const mainPhotoCache = {};
// Show different states for the aircraft photo panel
function showMainAircraftPhotoState(state) {
const placeholder = document.getElementById('mainAircraftPhotoPlaceholder');
const wrapper = document.getElementById('mainAircraftPhotoWrapper');
const loading = document.getElementById('mainAircraftPhotoLoading');
const noPhoto = document.getElementById('mainAircraftPhotoNoPhoto');
if (!placeholder || !wrapper || !loading || !noPhoto) return;
placeholder.style.display = state === 'placeholder' ? 'block' : 'none';
wrapper.style.display = state === 'photo' ? 'block' : 'none';
loading.style.display = state === 'loading' ? 'block' : 'none';
noPhoto.style.display = (state === 'nophoto' || state === 'noregistration') ? 'block' : 'none';
// Update no photo message for no registration case
if (state === 'noregistration' && noPhoto) {
noPhoto.innerHTML = `
<div style="font-size: 32px; opacity: 0.5; margin-bottom: 8px;">📷</div>
<div>No photo available</div>
<div style="font-size: 10px; margin-top: 4px; color: var(--text-dim);">Aircraft registration unknown</div>
`;
} else if (state === 'nophoto' && noPhoto) {
noPhoto.innerHTML = `
<div style="font-size: 32px; opacity: 0.5; margin-bottom: 8px;">📷</div>
<div>No photo available</div>
<div style="font-size: 10px; margin-top: 4px; color: var(--text-dim);">Registration not found in database</div>
`;
}
}
// Fetch aircraft photo from the API
async function fetchMainAircraftPhoto(registration) {
const img = document.getElementById('mainAircraftPhoto');
const link = document.getElementById('mainAircraftPhotoLink');
const credit = document.getElementById('mainAircraftPhotoCredit');
const regDisplay = document.getElementById('mainAircraftPhotoReg');
if (!img) return;
// Check cache first
if (mainPhotoCache[registration]) {
const cached = mainPhotoCache[registration];
if (cached.thumbnail) {
img.src = cached.thumbnail;
link.href = cached.link || '#';
credit.textContent = cached.photographer ? `Photo: ${cached.photographer}` : '';
if (regDisplay) regDisplay.textContent = registration;
showMainAircraftPhotoState('photo');
} else {
showMainAircraftPhotoState('nophoto');
}
return;
}
// Show loading state
showMainAircraftPhotoState('loading');
try {
const response = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
const data = await response.json();
// Cache the result
mainPhotoCache[registration] = data;
if (data.success && data.thumbnail) {
img.src = data.thumbnail;
link.href = data.link || '#';
credit.textContent = data.photographer ? `Photo: ${data.photographer}` : '';
if (regDisplay) regDisplay.textContent = registration;
showMainAircraftPhotoState('photo');
} else {
showMainAircraftPhotoState('nophoto');
}
} catch (err) {
console.debug('Failed to fetch aircraft photo:', err);
showMainAircraftPhotoState('nophoto');
}
}
function addAircraftToOutput(aircraft) {
const output = document.getElementById('output');
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
// Check if card for this ICAO already exists
let card = output.querySelector(`[data-icao="${aircraft.icao}"]`);
const isNew = !card;
if (isNew) {
card = document.createElement('div');
card.className = 'aircraft-card';
card.setAttribute('data-icao', aircraft.icao);
}
card.innerHTML = `
<div class="aircraft-icon" style="--heading: ${aircraft.heading || 0}deg;">✈️</div>
<div class="aircraft-info">
<div class="aircraft-callsign">${aircraft.callsign || aircraft.icao}</div>
<div class="aircraft-data">ICAO: <span>${aircraft.icao}</span></div>
<div class="aircraft-data">Alt: <span>${aircraft.altitude ? aircraft.altitude + ' ft' : 'N/A'}</span></div>
<div class="aircraft-data">Speed: <span>${aircraft.speed ? aircraft.speed + ' kts' : 'N/A'}</span></div>
<div class="aircraft-data">Heading: <span>${aircraft.heading ? aircraft.heading + '°' : 'N/A'}</span></div>
</div>
`;
if (isNew) {
output.insertBefore(card, output.firstChild);
// Limit cards
while (output.children.length > 50) {
output.removeChild(output.lastChild);
}
}
}
// ============================================
// SATELLITE MODE FUNCTIONS
// ============================================
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
document.getElementById('obsLat').value = position.coords.latitude.toFixed(4);
document.getElementById('obsLon').value = position.coords.longitude.toFixed(4);
showInfo('Location updated!');
},
error => {
alert('Could not get location: ' + error.message);
}
);
} else {
alert('Geolocation not supported by browser');
}
}
// ============================================
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
async function autoConnectGps() {
// Automatically try to connect to gpsd on page load
try {
const response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
console.log('GPS: Auto-connect failed -', e.message);
}
}
let gpsReconnectTimeout = null;
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
if (gpsReconnectTimeout) {
clearTimeout(gpsReconnectTimeout);
gpsReconnectTimeout = null;
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'position') {
gpsLastPosition = data;
updateLocationFromGps(data);
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
// Don't log every error - connection suspends are normal
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
// Auto-reconnect after 5 seconds if still connected
if (gpsConnected && !gpsReconnectTimeout) {
gpsReconnectTimeout = setTimeout(() => {
gpsReconnectTimeout = null;
if (gpsConnected) {
startGpsStream();
}
}, 5000);
}
};
}
// Reconnect GPS stream when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && gpsConnected && !gpsEventSource) {
startGpsStream();
}
});
function updateLocationFromGps(position) {
if (!position || !position.latitude || !position.longitude) {
return;
}
// Update satellite observer location
const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
// Update ADS-B observer location
const adsbLatInput = document.getElementById('adsbObsLat');
const adsbLonInput = document.getElementById('adsbObsLon');
if (adsbLatInput) adsbLatInput.value = position.latitude.toFixed(4);
if (adsbLonInput) adsbLonInput.value = position.longitude.toFixed(4);
// Update observerLocation for ADS-B calculations
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
// Center ADS-B map on GPS location (on first fix)
if (typeof aircraftMap !== 'undefined' && aircraftMap && !aircraftMap._gpsInitialized) {
aircraftMap.setView([position.latitude, position.longitude], aircraftMap.getZoom());
aircraftMap._gpsInitialized = true;
}
// Trigger range rings update
if (typeof drawRangeRings === 'function') {
drawRangeRings();
}
}
function showGpsIndicator(show) {
// Show/hide all GPS indicators (by class and by ID)
document.querySelectorAll('.gps-indicator').forEach(el => {
el.style.display = show ? 'inline-flex' : 'none';
});
// Also target specific IDs in case class selector doesn't work
['adsbGpsIndicator', 'satGpsIndicator'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? 'inline-flex' : 'none';
});
}
function initPolarPlot() {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const container = canvas.parentElement;
const size = Math.min(container.offsetWidth, 400);
canvas.width = size;
canvas.height = size;
drawPolarPlot();
}
function drawPolarPlot(pass = null) {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = canvas.width;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 30;
// Clear
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, size, size);
// Draw elevation rings
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
ctx.lineWidth = 1;
for (let el = 0; el <= 90; el += 30) {
const r = radius * (90 - el) / 90;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Label
if (el > 0) {
ctx.fillStyle = '#444';
ctx.font = '10px JetBrains Mono';
ctx.textAlign = 'center';
ctx.fillText(el + '°', cx, cy - r + 12);
}
}
// Draw azimuth lines
for (let az = 0; az < 360; az += 45) {
const rad = az * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.sin(rad) * radius, cy - Math.cos(rad) * radius);
ctx.stroke();
}
// Draw cardinal directions
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 14px Rajdhani';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - radius - 8);
ctx.fillStyle = '#888';
ctx.fillText('S', cx, cy + radius + 16);
ctx.fillText('E', cx + radius + 12, cy + 4);
ctx.fillText('W', cx - radius - 12, cy + 4);
// Draw zenith
ctx.fillStyle = '#00ffff';
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
// Draw selected pass trajectory
if (pass && pass.trajectory) {
ctx.strokeStyle = pass.color || '#00ff00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
pass.trajectory.forEach((point, i) => {
// Backend returns 'el' and 'az' properties
const el = point.el !== undefined ? point.el : point.elevation;
const az = point.az !== undefined ? point.az : point.azimuth;
const r = radius * (90 - el) / 90;
const rad = az * Math.PI / 180;
const x = cx + Math.sin(rad) * r;
const y = cy - Math.cos(rad) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
// Draw max elevation point
const maxPoint = pass.trajectory.reduce((max, p) => {
const pEl = p.el !== undefined ? p.el : p.elevation;
const maxEl = max.el !== undefined ? max.el : max.elevation;
return pEl > maxEl ? p : max;
}, { el: 0, elevation: 0 });
const maxEl = maxPoint.el !== undefined ? maxPoint.el : maxPoint.elevation;
const maxAz = maxPoint.az !== undefined ? maxPoint.az : maxPoint.azimuth;
const maxR = radius * (90 - maxEl) / 90;
const maxRad = maxAz * Math.PI / 180;
const maxX = cx + Math.sin(maxRad) * maxR;
const maxY = cy - Math.cos(maxRad) * maxR;
ctx.fillStyle = pass.color || '#00ff00';
ctx.beginPath();
ctx.arc(maxX, maxY, 6, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px JetBrains Mono';
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
}
}
function calculatePasses() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const hours = parseInt(document.getElementById('predictionHours').value);
const minEl = parseInt(document.getElementById('minElevation').value);
const satellites = getSelectedSatellites();
if (satellites.length === 0) {
alert('Please select at least one satellite to track');
return;
}
fetch('/satellite/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, hours, minEl, satellites })
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
satellitePasses = data.passes;
renderPassList();
document.getElementById('passCount').textContent = data.passes.length;
if (data.passes.length > 0) {
selectPass(0);
document.getElementById('satelliteCountdown').style.display = 'block';
updateSatelliteCountdown();
startCountdownTimer();
} else {
document.getElementById('satelliteCountdown').style.display = 'none';
}
} else {
alert('Error: ' + data.message);
}
});
}
function renderPassList() {
const container = document.getElementById('passList');
container.innerHTML = '';
if (satellitePasses.length === 0) {
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found for selected criteria.</div>';
return;
}
document.getElementById('passListCount').textContent = satellitePasses.length + ' passes';
satellitePasses.forEach((pass, index) => {
const card = document.createElement('div');
card.className = 'pass-card' + (index === 0 ? ' active' : '');
card.onclick = () => selectPass(index);
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
card.innerHTML = `
<div class="pass-satellite">${pass.satellite}</div>
<div class="pass-time">${pass.startTime}</div>
<div class="pass-details">
<div>Max El: <span>${pass.maxEl}°</span></div>
<div>Duration: <span>${pass.duration}m</span></div>
<div class="pass-quality ${quality}">${quality.toUpperCase()}</div>
</div>
`;
container.appendChild(card);
});
}
function selectPass(index) {
selectedPass = satellitePasses[index];
selectedPassIndex = index;
document.querySelectorAll('.pass-card').forEach((card, i) => {
card.classList.toggle('active', i === index);
});
drawPolarPlot(selectedPass);
updateGroundTrack(selectedPass);
// Update countdown to show selected pass
updateSatelliteCountdown();
// Start real-time position updates for full orbit track
startSatellitePositionUpdates();
// Fetch position immediately
updateRealTimePosition();
}
// Ground Track Map
let groundTrackMap = null;
let groundTrackLine = null;
let satMarker = null;
let observerMarker = null;
let satPositionInterval = null;
function initGroundTrackMap() {
const mapContainer = document.getElementById('groundTrackMap');
if (!mapContainer || groundTrackMap) return;
groundTrackMap = L.map('groundTrackMap', {
center: [20, 0],
zoom: 1,
zoomControl: true,
attributionControl: false
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(groundTrackMap);
// Add observer marker
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
observerMarker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: '#ff6600',
color: '#fff',
weight: 2,
fillOpacity: 1
}).addTo(groundTrackMap).bindPopup('Observer Location');
}
function updateGroundTrack(pass) {
if (!groundTrackMap) initGroundTrackMap();
if (!pass || !pass.groundTrack) return;
// Remove old track and marker
if (groundTrackLine) {
groundTrackMap.removeLayer(groundTrackLine);
groundTrackLine = null;
}
if (satMarker) {
groundTrackMap.removeLayer(satMarker);
satMarker = null;
}
if (orbitTrackLine) {
groundTrackMap.removeLayer(orbitTrackLine);
orbitTrackLine = null;
}
if (pastOrbitLine) {
groundTrackMap.removeLayer(pastOrbitLine);
pastOrbitLine = null;
}
// Split ground track only at true antimeridian crossings (±180° line)
const segments = [];
let currentSegment = [];
for (let i = 0; i < pass.groundTrack.length; i++) {
const p = pass.groundTrack[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// Only split when crossing the antimeridian (one side > 90, other < -90)
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
if (crossesAntimeridian) {
if (currentSegment.length >= 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length >= 1) segments.push(currentSegment);
// Draw ground track segments
groundTrackLine = L.layerGroup();
const allCoords = [];
segments.forEach(seg => {
L.polyline(seg, {
color: pass.color || '#00ff00',
weight: 2,
opacity: 0.8,
dashArray: '5, 5'
}).addTo(groundTrackLine);
allCoords.push(...seg);
});
groundTrackLine.addTo(groundTrackMap);
// Add current position marker
if (pass.currentPosition) {
satMarker = L.marker([pass.currentPosition.lat, pass.currentPosition.lon], {
icon: L.divIcon({
className: 'sat-marker',
html: '<div style="background:#ffff00;width:12px;height:12px;border-radius:50%;border:2px solid #000;box-shadow:0 0 10px #ffff00;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(groundTrackMap).bindPopup(pass.satellite);
}
// Update observer marker position
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
if (observerMarker) {
observerMarker.setLatLng([lat, lon]);
}
// Fit bounds to show track
if (allCoords.length > 0) {
groundTrackMap.fitBounds(L.latLngBounds(allCoords), { padding: [20, 20] });
}
}
function toggleGroundTrack() {
const show = document.getElementById('showGroundTrack').checked;
document.getElementById('groundTrackMap').style.display = show ? 'block' : 'none';
if (show && groundTrackMap) {
groundTrackMap.invalidateSize();
}
}
function startSatellitePositionUpdates() {
if (satPositionInterval) clearInterval(satPositionInterval);
satPositionInterval = setInterval(() => {
if (selectedPass) {
updateRealTimePosition();
}
}, 5000);
}
function updateRealTimePosition() {
let satellites = getSelectedSatellites();
// Ensure selected pass's satellite is included in the request
if (selectedPass && selectedPass.satellite) {
if (!satellites.includes(selectedPass.satellite)) {
satellites = [selectedPass.satellite, ...satellites];
}
}
if (satellites.length === 0) return;
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
fetch('/satellite/position', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, satellites, includeTrack: true })
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.positions) {
updateRealTimeIndicators(data.positions);
}
});
}
let orbitTrackLine = null;
let pastOrbitLine = null;
function updateRealTimeIndicators(positions) {
// Update ground track map markers
positions.forEach(pos => {
if (selectedPass && pos.satellite === selectedPass.satellite) {
// Update satellite marker position
if (satMarker) {
satMarker.setLatLng([pos.lat, pos.lon]);
satMarker.setPopupContent(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km<br>El: ' + pos.elevation.toFixed(1) + '°');
} else if (groundTrackMap) {
satMarker = L.marker([pos.lat, pos.lon], {
icon: L.divIcon({
className: 'sat-marker',
html: '<div style="background:#ffff00;width:14px;height:14px;border-radius:50%;border:2px solid #000;box-shadow:0 0 15px #ffff00;animation:pulse-sat 1s infinite;"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7]
})
}).addTo(groundTrackMap).bindPopup(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km');
}
// Draw full orbit track from position endpoint
// Backend returns 'track' property
const orbitData = pos.track || pos.orbitTrack;
if (orbitData && orbitData.length > 0 && groundTrackMap) {
// Split into past and future, handling antimeridian crossings
const pastPoints = orbitData.filter(p => p.past);
const futurePoints = orbitData.filter(p => !p.past);
// Helper to split coords only at true antimeridian crossings (±180° line)
function splitAtAntimeridian(points) {
const segments = [];
let currentSegment = [];
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// Only split when crossing the antimeridian (one side > 90, other < -90)
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
if (crossesAntimeridian) {
if (currentSegment.length >= 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length >= 1) segments.push(currentSegment);
return segments;
}
// Remove old lines
if (orbitTrackLine) groundTrackMap.removeLayer(orbitTrackLine);
if (pastOrbitLine) groundTrackMap.removeLayer(pastOrbitLine);
// Draw past track segments (dimmer)
const pastSegments = splitAtAntimeridian(pastPoints);
if (pastSegments.length > 0) {
pastOrbitLine = L.layerGroup();
pastSegments.forEach(seg => {
L.polyline(seg, {
color: '#666666',
weight: 2,
opacity: 0.5,
dashArray: '3, 6'
}).addTo(pastOrbitLine);
});
pastOrbitLine.addTo(groundTrackMap);
}
// Draw future track segments (brighter)
const futureSegments = splitAtAntimeridian(futurePoints);
if (futureSegments.length > 0) {
orbitTrackLine = L.layerGroup();
futureSegments.forEach(seg => {
L.polyline(seg, {
color: selectedPass.color || '#00ff00',
weight: 3,
opacity: 0.8
}).addTo(orbitTrackLine);
});
orbitTrackLine.addTo(groundTrackMap);
}
}
// Update polar plot with pass trajectory and real-time position
if (selectedPass) {
drawPolarPlot(selectedPass);
// Draw current position on top if satellite is visible
if (pos.elevation > 0) {
drawRealTimePositionOnPolar(pos);
}
}
}
});
}
function drawRealTimePositionOnPolar(pos) {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = canvas.width;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 30;
// Draw pulsing indicator for current position
const r = radius * (90 - pos.elevation) / 90;
const rad = pos.azimuth * Math.PI / 180;
const x = cx + Math.sin(rad) * r;
const y = cy - Math.cos(rad) * r;
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 12, 0, Math.PI * 2);
ctx.stroke();
}
function updateTLE() {
fetch('/satellite/update-tle', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
showInfo('TLE data updated!');
} else {
alert('Error updating TLE: ' + data.message);
}
});
}
// Satellite management
let trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'NOAA-15', name: 'NOAA 15', norad: '25338', builtin: true, checked: true },
{ id: 'NOAA-18', name: 'NOAA 18', norad: '28654', builtin: true, checked: true },
{ id: 'NOAA-19', name: 'NOAA 19', norad: '33591', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
function renderSatelliteList() {
const list = document.getElementById('satelliteList');
if (!list) return;
list.innerHTML = trackedSatellites.map((sat, idx) => `
<div class="sat-item ${sat.builtin ? 'builtin' : ''}">
<label>
<input type="checkbox" ${sat.checked ? 'checked' : ''} onchange="toggleSatellite(${idx})">
<span class="sat-name">${sat.name}</span>
<span class="sat-norad">#${sat.norad}</span>
</label>
<button class="sat-remove" onclick="removeSatellite(${idx})" title="Remove">✕</button>
</div>
`).join('');
}
function toggleSatellite(idx) {
trackedSatellites[idx].checked = !trackedSatellites[idx].checked;
}
function removeSatellite(idx) {
if (!trackedSatellites[idx].builtin) {
trackedSatellites.splice(idx, 1);
renderSatelliteList();
}
}
function getSelectedSatellites() {
return trackedSatellites.filter(s => s.checked).map(s => s.id);
}
function showAddSatelliteModal() {
document.getElementById('satModal').classList.add('active');
}
function closeSatModal() {
document.getElementById('satModal').classList.remove('active');
}
function switchSatModalTab(tab) {
document.querySelectorAll('.sat-modal-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sat-modal-section').forEach(s => s.classList.remove('active'));
if (tab === 'tle') {
document.querySelector('.sat-modal-tab:first-child').classList.add('active');
document.getElementById('tleSection').classList.add('active');
} else {
document.querySelector('.sat-modal-tab:last-child').classList.add('active');
document.getElementById('celestrakSection').classList.add('active');
}
}
function addFromTLE() {
const tleText = document.getElementById('tleInput').value.trim();
if (!tleText) {
alert('Please paste TLE data');
return;
}
const lines = tleText.split('\\n').map(l => l.trim()).filter(l => l);
let added = 0;
for (let i = 0; i < lines.length; i += 3) {
if (i + 2 < lines.length) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const norad = line1.substring(2, 7).trim();
const id = name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase();
// Check if already exists
if (!trackedSatellites.find(s => s.norad === norad)) {
trackedSatellites.push({
id: id,
name: name,
norad: norad,
builtin: false,
checked: true,
tle: [name, line1, line2]
});
added++;
}
}
}
}
if (added > 0) {
renderSatelliteList();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${added} satellite(s)`);
} else {
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
}
}
function fetchCelestrak() {
showAddSatelliteModal();
switchSatModalTab('celestrak');
}
function fetchCelestrakCategory(category) {
const status = document.getElementById('celestrakStatus');
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
fetch('/satellite/celestrak/' + category)
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites) {
let added = 0;
data.satellites.forEach(sat => {
if (!trackedSatellites.find(s => s.norad === sat.norad)) {
trackedSatellites.push({
id: sat.id,
name: sat.name,
norad: sat.norad,
builtin: false,
checked: false, // Don't auto-select
tle: sat.tle
});
added++;
}
});
renderSatelliteList();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${added} satellites (${data.satellites.length} total in category)</span>`;
} else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
}
})
.catch(err => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`;
});
}
// Initialize satellite list when satellite mode is loaded
function initSatelliteList() {
renderSatelliteList();
}
// Utility function
function showInfo(message) {
// Simple notification - could be enhanced
const existing = document.querySelector('.info-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'info-toast';
toast.textContent = message;
toast.style.cssText = 'position: fixed; bottom: 20px; right: 20px; background: var(--accent-cyan); color: #000; padding: 10px 20px; border-radius: 4px; z-index: 10001; font-size: 12px;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Theme toggle functions
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
if (newTheme === 'dark') {
html.removeAttribute('data-theme');
} else {
html.setAttribute('data-theme', newTheme);
}
// Save to localStorage for instant load on next visit
localStorage.setItem('intercept-theme', newTheme);
// Persist to server for cross-device sync
fetch('/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: newTheme })
}).catch(err => console.warn('Failed to save theme to server:', err));
}
// Load saved theme on page load
(function() {
// First apply localStorage theme for instant load (no flash)
const localTheme = localStorage.getItem('intercept-theme');
if (localTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
// Then fetch from server to sync (in case changed on another device)
fetch('/settings/theme')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.value) {
const serverTheme = data.value;
if (serverTheme !== localTheme) {
// Server has different theme, apply it
if (serverTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('intercept-theme', serverTheme);
}
}
})
.catch(() => {}); // Ignore errors, localStorage is fallback
})();
// Help modal functions
function showHelp() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function hideHelp() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
}
function switchHelpTab(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
document.querySelector(`.help-tab[data-tab="${tab}"]`).classList.add('active');
document.getElementById(`help-${tab}`).classList.add('active');
}
// Keyboard shortcuts for help
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
if ((e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !document.getElementById('helpModal').classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
// NOTE: Scanner and Audio Receiver code moved to static/js/modes/listening-post.js
</script>
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
<!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
<div class="help-content">
<button class="help-close" onclick="hideHelp()">×</button>
<h2>📡 iNTERCEPT Help</h2>
<div class="help-tabs">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">📟</span><span class="desc">POCSAG messages decoded</span></div>
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX messages decoded</span></div>
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span></div>
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors detected</span></div>
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span></div>
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft being tracked</span></div>
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellites monitored</span></div>
<div class="icon-item"><span class="icon">📡</span><span class="desc">WiFi Access Points</span></div>
<div class="icon-item"><span class="icon">👤</span><span class="desc">Connected WiFi clients</span></div>
<div class="icon-item"><span class="icon">🤝</span><span class="desc">Captured handshakes</span></div>
<div class="icon-item"><span class="icon">🚁</span><span class="desc">Detected drones (click for details)</span></div>
<div class="icon-item"><span class="icon">⚠️</span><span class="desc">Rogue APs (click for details)</span></div>
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth devices</span></div>
<div class="icon-item"><span class="icon">📍</span><span class="desc">BLE beacons detected</span></div>
</div>
<h3>Mode Tab Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">📟</span><span class="desc">Pager - POCSAG/FLEX decoder</span></div>
<div class="icon-item"><span class="icon">📡</span><span class="desc">433MHz - Sensor decoder</span></div>
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - ADS-B tracker</span></div>
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">Satellite - Pass prediction</span></div>
<div class="icon-item"><span class="icon">📶</span><span class="desc">WiFi - Network scanner</span></div>
<div class="icon-item"><span class="icon">🔵</span><span class="desc">Bluetooth - BT/BLE scanner</span></div>
</div>
</div>
<!-- Modes Section -->
<div id="help-modes" class="help-section">
<h3>📟 Pager Mode</h3>
<ul class="tip-list">
<li>Decodes POCSAG and FLEX pager signals using RTL-SDR</li>
<li>Set frequency to local pager frequencies (common: 152-158 MHz)</li>
<li>Messages are displayed in real-time as they're decoded</li>
<li>Use presets for common pager frequencies</li>
</ul>
<h3>📡 433MHz Sensor Mode</h3>
<ul class="tip-list">
<li>Decodes wireless sensors on 433.92 MHz ISM band</li>
<li>Detects temperature, humidity, weather stations, tire pressure monitors</li>
<li>Supports many common protocols (Acurite, LaCrosse, Oregon Scientific, etc.)</li>
<li>Device intelligence builds profiles of recurring devices</li>
</ul>
<h3>✈️ Aircraft Mode</h3>
<ul class="tip-list">
<li>Tracks aircraft via ADS-B using dump1090 or rtl_adsb</li>
<li>Interactive map with real OpenStreetMap tiles</li>
<li>Click aircraft markers to see callsign, altitude, speed, heading</li>
<li>Map auto-fits to show all tracked aircraft</li>
<li>Emergency squawk codes highlighted in red</li>
</ul>
<h3>🛰️ Satellite Mode</h3>
<ul class="tip-list">
<li>Track satellites using TLE (Two-Line Element) data</li>
<li>Add satellites manually or fetch from Celestrak by category</li>
<li>Categories: Amateur, Weather, ISS, Starlink, GPS, and more</li>
<li>View next pass predictions with elevation and duration</li>
</ul>
<h3>📶 WiFi Mode</h3>
<ul class="tip-list">
<li>Requires a WiFi adapter capable of monitor mode</li>
<li>Click "Enable Monitor" to put adapter in monitor mode</li>
<li>Scans all channels or lock to a specific channel</li>
<li>Detects drones by SSID patterns and manufacturer OUI</li>
<li>Rogue AP detection flags same SSID on multiple BSSIDs</li>
<li>Click network rows to target for deauth or handshake capture</li>
</ul>
<h3>🔵 Bluetooth Mode</h3>
<ul class="tip-list">
<li>Scans for classic Bluetooth and BLE devices</li>
<li>Shows device names, addresses, and signal strength</li>
<li>Manufacturer lookup from MAC address OUI</li>
<li>Radar visualization shows device proximity</li>
</ul>
</div>
<!-- WiFi Section -->
<div id="help-wifi" class="help-section">
<h3>Monitor Mode</h3>
<ul class="tip-list">
<li><strong>Enable Monitor:</strong> Puts WiFi adapter in monitor mode for passive scanning</li>
<li><strong>Kill Processes:</strong> Optional - stops NetworkManager/wpa_supplicant (may drop other connections)</li>
<li>Some adapters rename when entering monitor mode (e.g., wlan0 → wlan0mon)</li>
</ul>
<h3>Handshake Capture</h3>
<ul class="tip-list">
<li>Click "Capture" on a network to start targeted handshake capture</li>
<li>Status panel shows capture progress and file location</li>
<li>Use deauth to force clients to reconnect (only on authorized networks!)</li>
<li>Handshake files saved to /tmp/intercept_handshake_*.cap</li>
</ul>
<h3>Drone Detection</h3>
<ul class="tip-list">
<li>Drones detected by SSID patterns (DJI, Parrot, Autel, etc.)</li>
<li>Also detected by manufacturer OUI in MAC address</li>
<li>Distance estimated from signal strength (approximate)</li>
<li>Click drone count in stats bar to see all detected drones</li>
</ul>
<h3>Rogue AP Detection</h3>
<ul class="tip-list">
<li>Flags networks where same SSID appears on multiple BSSIDs</li>
<li>Could indicate evil twin attack or legitimate multi-AP setup</li>
<li>Click rogue count to see which SSIDs are flagged</li>
</ul>
<h3>Proximity Alerts</h3>
<ul class="tip-list">
<li>Add MAC addresses to watch list for alerts when detected</li>
<li>Watch list persists in browser localStorage</li>
<li>Useful for tracking specific devices</li>
</ul>
<h3>Client Probe Analysis</h3>
<ul class="tip-list">
<li>Shows what networks client devices are looking for</li>
<li>Orange highlights indicate sensitive/private network names</li>
<li>Reveals user location history (home, work, hotels, airports)</li>
<li>Useful for security awareness and pen test reports</li>
</ul>
</div>
<!-- Tips Section -->
<div id="help-tips" class="help-section">
<h3>General Tips</h3>
<ul class="tip-list">
<li><strong>Collapsible sections:</strong> Click any section header (▼) to collapse/expand</li>
<li><strong>Sound alerts:</strong> Toggle sound on/off in settings for each mode</li>
<li><strong>Export data:</strong> Use export buttons to save captured data as JSON</li>
<li><strong>Device Intelligence:</strong> Tracks device patterns over time</li>
<li><strong>Theme toggle:</strong> Click 🌙/☀️ button in header to switch dark/light mode</li>
</ul>
<h3>Keyboard Shortcuts</h3>
<ul class="tip-list">
<li><strong>F1</strong> - Open this help page</li>
<li><strong>?</strong> - Open help (when not typing in a field)</li>
<li><strong>Escape</strong> - Close help and modal dialogs</li>
</ul>
<h3>Requirements</h3>
<ul class="tip-list">
<li><strong>Pager/433MHz:</strong> RTL-SDR dongle, rtl_fm, multimon-ng, rtl_433</li>
<li><strong>Aircraft:</strong> RTL-SDR dongle, dump1090 or rtl_adsb</li>
<li><strong>Satellite:</strong> Internet connection for Celestrak (optional)</li>
<li><strong>WiFi:</strong> Monitor-mode capable adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, hcitool/bluetoothctl</li>
<li>Run as root/sudo for full functionality</li>
</ul>
<h3>Legal Notice</h3>
<ul class="tip-list">
<li>Only use on networks and devices you own or have authorization to test</li>
<li>Passive monitoring may be legal; active attacks require authorization</li>
<li>Check local laws regarding radio frequency monitoring</li>
</ul>
</div>
</div>
</div>
<!-- Dependencies Modal -->
<div id="depsModal" class="help-modal" onclick="if(event.target === this) hideDependencies()">
<div class="help-content" style="max-width: 800px;">
<button class="help-close" onclick="hideDependencies()">×</button>
<h2>🔧 Tool Dependencies</h2>
<p style="color: var(--text-dim); margin-bottom: 15px;">Check which tools are installed for each mode. <span style="color: var(--accent-green);"></span> = Installed, <span style="color: var(--accent-red);"></span> = Missing</p>
<div id="depsContent" style="max-height: 60vh; overflow-y: auto;">
<div style="text-align: center; padding: 40px; color: var(--text-dim);">
Loading dependencies...
</div>
</div>
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--border-color);">
<h3 style="margin-bottom: 10px;">Quick Install (Debian/Ubuntu)</h3>
<div style="background: var(--bg-tertiary); padding: 10px; border-radius: 4px; font-family: monospace; font-size: 11px; overflow-x: auto;">
<div>sudo apt install rtl-sdr multimon-ng rtl-433 aircrack-ng bluez dump1090-mutability hcxtools</div>
<div style="margin-top: 5px;">pip install skyfield flask</div>
</div>
</div>
</div>
</div>
<script>
function showDependencies() {
document.getElementById('depsModal').classList.add('active');
loadDependencies();
}
function hideDependencies() {
document.getElementById('depsModal').classList.remove('active');
}
function loadDependencies() {
const content = document.getElementById('depsContent');
content.innerHTML = '<div style="text-align: center; padding: 40px; color: var(--text-dim);">Loading dependencies...</div>';
fetch('/dependencies')
.then(r => r.json())
.then(data => {
if (data.status !== 'success') {
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies</div>';
return;
}
let html = '';
let totalMissing = 0;
for (const [modeKey, mode] of Object.entries(data.modes)) {
const statusColor = mode.ready ? 'var(--accent-green)' : 'var(--accent-red)';
const statusIcon = mode.ready ? '✓' : '✗';
html += `
<div style="background: var(--bg-tertiary); border-radius: 8px; padding: 15px; margin-bottom: 15px; border-left: 3px solid ${statusColor};">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: var(--accent-cyan);">${mode.name}</h3>
<span style="color: ${statusColor}; font-weight: bold;">${statusIcon} ${mode.ready ? 'Ready' : 'Missing Required'}</span>
</div>
<div style="display: grid; gap: 8px;">
`;
for (const [toolName, tool] of Object.entries(mode.tools)) {
const installed = tool.installed;
const dotColor = installed ? 'var(--accent-green)' : 'var(--accent-red)';
const requiredBadge = tool.required ? '<span style="background: var(--accent-orange); color: #000; padding: 1px 5px; border-radius: 3px; font-size: 9px; margin-left: 5px;">REQUIRED</span>' : '';
if (!installed) totalMissing++;
// Get install command for current OS
let installCmd = '';
if (tool.install) {
if (tool.install.pip) {
installCmd = tool.install.pip;
} else if (data.pkg_manager && tool.install[data.pkg_manager]) {
installCmd = tool.install[data.pkg_manager];
} else if (tool.install.manual) {
installCmd = tool.install.manual;
}
}
html += `
<div style="display: flex; align-items: center; gap: 10px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;">
<span style="color: ${dotColor}; font-size: 16px;">●</span>
<div style="flex: 1;">
<div style="font-weight: bold;">${toolName}${requiredBadge}</div>
<div style="font-size: 11px; color: var(--text-dim);">${tool.description}</div>
</div>
${!installed && installCmd ? `
<code style="font-size: 10px; background: var(--bg-tertiary); padding: 4px 8px; border-radius: 3px; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${installCmd}">${installCmd}</code>
` : ''}
<span style="font-size: 11px; color: ${dotColor}; font-weight: bold;">${installed ? 'OK' : 'MISSING'}</span>
</div>
`;
}
html += '</div></div>';
}
// Summary at top
const summaryHtml = `
<div style="background: ${totalMissing > 0 ? 'rgba(255, 100, 0, 0.1)' : 'rgba(0, 255, 100, 0.1)'}; border: 1px solid ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'}; border-radius: 8px; padding: 15px; margin-bottom: 20px;">
<div style="font-size: 16px; font-weight: bold; color: ${totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)'};">
${totalMissing > 0 ? '⚠️ ' + totalMissing + ' tool(s) not found' : '✓ All tools installed'}
</div>
<div style="font-size: 12px; color: var(--text-dim); margin-top: 5px;">
OS: ${data.os} | Package Manager: ${data.pkg_manager}
</div>
</div>
`;
content.innerHTML = summaryHtml + html;
// Update button indicator
const btn = document.getElementById('depsBtn');
if (btn) {
btn.style.color = totalMissing > 0 ? 'var(--accent-orange)' : 'var(--accent-green)';
}
})
.catch(err => {
content.innerHTML = '<div style="color: var(--accent-red);">Error loading dependencies: ' + err.message + '</div>';
});
}
// Check dependencies on page load
document.addEventListener('DOMContentLoaded', function() {
// Check if user dismissed the startup check
const dismissed = localStorage.getItem('depsCheckDismissed');
// Quick check for missing dependencies
fetch('/dependencies')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
let missingModes = 0;
let missingTools = [];
for (const [modeKey, mode] of Object.entries(data.modes)) {
if (!mode.ready) {
missingModes++;
mode.missing_required.forEach(tool => {
if (!missingTools.includes(tool)) {
missingTools.push(tool);
}
});
}
}
const btn = document.getElementById('depsBtn');
if (btn && missingModes > 0) {
btn.style.color = 'var(--accent-orange)';
btn.title = missingModes + ' mode(s) have missing tools - click to see details';
}
// Show startup prompt if tools are missing and not dismissed
// Only show if disclaimer has been accepted
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
showStartupDepsPrompt(missingModes, missingTools.length);
}
}
});
});
function showStartupDepsPrompt(modeCount, toolCount) {
const notice = document.createElement('div');
notice.id = 'startupDepsModal';
notice.style.cssText = `
position: fixed;
top: 20px;
left: 20px;
z-index: 10000;
background: var(--bg-secondary);
border: 1px solid var(--accent-orange);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 165, 0, 0.2);
max-width: 380px;
animation: slideIn 0.3s ease-out;
`;
notice.innerHTML = `
<style>
@keyframes slideIn {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
<div style="padding: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="margin: 0; color: var(--accent-orange); font-size: 14px; display: flex; align-items: center; gap: 8px;">
<span>🔧</span> Missing Dependencies
</h3>
<button onclick="closeStartupDeps()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 18px; padding: 0; line-height: 1;">&times;</button>
</div>
<p style="color: var(--text-secondary); margin: 0 0 15px 0; font-size: 13px; line-height: 1.4;">
<strong style="color: var(--accent-orange);">${modeCount} mode(s)</strong> require tools that aren't installed.
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="action-btn" onclick="closeStartupDeps(); showDependencies();" style="padding: 10px 16px; font-size: 12px;">
View Details & Install
</button>
<label style="display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); cursor: pointer;">
<input type="checkbox" id="dontShowAgain" style="cursor: pointer;">
Don't show again
</label>
</div>
</div>
`;
document.body.appendChild(notice);
}
function closeStartupDeps() {
const modal = document.getElementById('startupDepsModal');
if (modal) {
if (document.getElementById('dontShowAgain')?.checked) {
localStorage.setItem('depsCheckDismissed', 'true');
}
modal.remove();
}
}
</script>
</body>
</html>