mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
- Changed to orange styling to stand out - Added "Power Settings" header with lightning icon - Larger checkbox - Added description text explaining purpose Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
10178 lines
504 KiB
HTML
10178 lines
504 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>INTERCEPT // Signal Intelligence</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>
|
||
<!-- Disclaimer Modal -->
|
||
<div class="disclaimer-overlay" id="disclaimerModal">
|
||
<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">
|
||
<!-- Outer hexagon -->
|
||
<path d="M50 5 L90 27.5 L90 72.5 L50 95 L10 72.5 L10 27.5 Z" stroke="#00d4ff" stroke-width="2" fill="none"/>
|
||
<!-- Inner signal waves -->
|
||
<path d="M30 50 Q40 35, 50 50 Q60 65, 70 50" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||
<path d="M35 50 Q42 40, 50 50 Q58 60, 65 50" stroke="#00ff88" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||
<path d="M40 50 Q45 45, 50 50 Q55 55, 60 50" stroke="#ffffff" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||
<!-- Center dot -->
|
||
<circle cx="50" cy="50" r="3" fill="#00d4ff"/>
|
||
<!-- Corner accents -->
|
||
<path d="M50 12 L55 17 L50 17 Z" fill="#00d4ff"/>
|
||
<path d="M50 88 L45 83 L50 83 Z" fill="#00d4ff"/>
|
||
</svg>
|
||
</div>
|
||
<h1>INTERCEPT <span class="version-badge">v{{ version }}</span></h1>
|
||
<p>Signal Intelligence // by smittix <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>
|
||
<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>
|
||
|
||
<!-- Global SDR Power Settings -->
|
||
<div class="section" style="padding: 10px; margin: 10px 0; background: rgba(255, 100, 0, 0.1); border: 1px solid rgba(255, 100, 0, 0.3); border-radius: 4px;">
|
||
<div style="font-size: 10px; color: var(--accent-orange); text-transform: uppercase; margin-bottom: 6px; font-weight: bold;">⚡ Power Settings</div>
|
||
<div class="checkbox-group" style="margin: 0;">
|
||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()" style="width: 16px; height: 16px;">
|
||
<span style="color: var(--accent-orange); font-weight: bold;">Bias-T Power</span>
|
||
<span style="font-size: 10px; color: #888;">(LNA/Preamp)</span>
|
||
</label>
|
||
</div>
|
||
<div style="font-size: 9px; color: #666; margin-top: 6px;">
|
||
Enable to power external LNA via antenna cable
|
||
</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="satellite-tabs">
|
||
<button class="satellite-tab active">🛰️ Pass Predictor</button>
|
||
</div>
|
||
|
||
<!-- Pass Predictor Sub-tab -->
|
||
<div id="predictorTab" class="satellite-content active">
|
||
<div class="section">
|
||
<h3 style="display: flex; align-items: center; gap: 8px;">
|
||
Observer Location
|
||
<span id="satGpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd">
|
||
<span class="gps-dot"></span> GPS
|
||
</span>
|
||
</h3>
|
||
<div class="form-group">
|
||
<label>Latitude</label>
|
||
<input type="text" id="obsLat" value="51.5074" placeholder="51.5074">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Longitude</label>
|
||
<input type="text" id="obsLon" value="-0.1278" placeholder="-0.1278">
|
||
</div>
|
||
<button class="preset-btn" onclick="getLocation()" style="width: 100%;">
|
||
📍 Use Browser Location
|
||
</button>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Satellites to Track</h3>
|
||
<div id="satelliteList" class="satellite-list">
|
||
<!-- Dynamically populated -->
|
||
</div>
|
||
<div style="margin-top: 10px; display: flex; gap: 5px;">
|
||
<button class="preset-btn" onclick="showAddSatelliteModal()" style="flex: 1;">
|
||
➕ Add Satellite
|
||
</button>
|
||
<button class="preset-btn" onclick="fetchCelestrak()" style="flex: 1;">
|
||
🌐 Celestrak
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h3>Prediction Settings</h3>
|
||
<div class="form-group">
|
||
<label>Time Range</label>
|
||
<select id="predictionHours">
|
||
<option value="12">12 hours</option>
|
||
<option value="24" selected>24 hours</option>
|
||
<option value="48">48 hours</option>
|
||
<option value="72">72 hours</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Min Elevation</label>
|
||
<select id="minElevation">
|
||
<option value="0">0° (All passes)</option>
|
||
<option value="10" selected>10° (Good)</option>
|
||
<option value="30">30° (Best)</option>
|
||
<option value="45">45° (Overhead)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="run-btn" onclick="calculatePasses()">
|
||
Calculate Passes
|
||
</button>
|
||
<button class="preset-btn" onclick="updateTLE()" style="width: 100%; margin-top: 5px;">
|
||
🔄 Update TLE Data
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- LISTENING POST MODE -->
|
||
<div id="listeningPostMode" class="mode-content">
|
||
<div class="section">
|
||
<h3>Frequency Scanner</h3>
|
||
<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 Dependencies:</strong><br>
|
||
<span id="scannerToolsWarningText"></span>
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>SDR Device</label>
|
||
<select id="scannerDeviceSelect">
|
||
<option value="0">Device 0 (Default)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Band Preset</label>
|
||
<select id="scannerPreset" onchange="applyScannerPreset()">
|
||
<option value="custom">Custom Range</option>
|
||
<option value="fm">FM Broadcast (88-108 MHz)</option>
|
||
<option value="air" selected>Airband (118-137 MHz)</option>
|
||
<option value="marine">Marine VHF (156-163 MHz)</option>
|
||
<option value="amateur2m">Amateur 2m (144-148 MHz)</option>
|
||
<option value="pager">Pager Band (152-160 MHz)</option>
|
||
<option value="amateur70cm">Amateur 70cm (420-450 MHz)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Frequency Range (MHz)</label>
|
||
<div style="display: flex; gap: 5px; align-items: center;">
|
||
<input type="number" id="scannerStartFreq" value="118" step="0.1" style="flex: 1;" placeholder="Start">
|
||
<span>-</span>
|
||
<input type="number" id="scannerEndFreq" value="137" step="0.1" style="flex: 1;" placeholder="End">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Step Size (kHz)</label>
|
||
<select id="scannerStep">
|
||
<option value="12.5">12.5 kHz (Narrow)</option>
|
||
<option value="25" selected>25 kHz (Standard)</option>
|
||
<option value="50">50 kHz</option>
|
||
<option value="100">100 kHz</option>
|
||
<option value="200">200 kHz (FM Broadcast)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Modulation</label>
|
||
<select id="scannerModulation">
|
||
<option value="am" selected>AM (Airband)</option>
|
||
<option value="fm">Narrow FM</option>
|
||
<option value="wfm">Wide FM (Broadcast)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Squelch Level</label>
|
||
<input type="range" id="scannerSquelch" min="0" max="100" value="30" oninput="document.getElementById('scannerSquelchValue').textContent = this.value">
|
||
<span id="scannerSquelchValue">30</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Dwell Time (seconds)</label>
|
||
<select id="scannerDwell">
|
||
<option value="2">2s (Quick)</option>
|
||
<option value="5">5s (Short)</option>
|
||
<option value="10" selected>10s (Normal)</option>
|
||
<option value="30">30s (Extended)</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||
<button class="run-btn" id="scannerStartBtn" onclick="toggleScanner()" style="flex: 2;">
|
||
Start Scanner
|
||
</button>
|
||
<button class="preset-btn" id="scannerPauseBtn" onclick="pauseScanner()" style="flex: 1;" disabled>
|
||
⏸ Pause
|
||
</button>
|
||
</div>
|
||
<!-- Prominent frequency display -->
|
||
<div id="scannerFreqDisplay" style="background: rgba(0,0,0,0.4); border-radius: 8px; padding: 15px; margin: 10px 0; text-align: center;">
|
||
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; margin-bottom: 5px;">
|
||
<span id="scannerModeLabel">STOPPED</span>
|
||
</div>
|
||
<div id="scannerCurrentFreq" style="font-size: 28px; font-weight: bold; font-family: 'JetBrains Mono', monospace; color: var(--text-muted);">
|
||
---.--- MHz
|
||
</div>
|
||
<div id="scannerModLabel" style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">
|
||
--
|
||
</div>
|
||
<!-- Progress bar for scan range -->
|
||
<div id="scannerProgress" style="display: none; margin-top: 10px;">
|
||
<div style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted);">
|
||
<span id="scannerRangeStart">88.0</span>
|
||
<span id="scannerRangeEnd">108.0</span>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.1); height: 4px; border-radius: 2px; margin-top: 2px;">
|
||
<div id="scannerProgressBar" style="background: var(--accent-cyan); height: 100%; border-radius: 2px; width: 0%; transition: width 0.2s;"></div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal level meter -->
|
||
<div id="scannerLevelMeter" style="display: none; margin-top: 10px;">
|
||
<div style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted);">
|
||
<span>Signal Level</span>
|
||
<span id="scannerLevelValue">0</span>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.1); height: 8px; border-radius: 4px; margin-top: 2px; overflow: hidden;">
|
||
<div id="scannerLevelBar" style="background: var(--accent-green); height: 100%; border-radius: 4px; width: 0%; transition: width 0.1s, background 0.1s;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal detected panel -->
|
||
<div id="scannerSignalPanel" style="display: none; background: rgba(0, 255, 100, 0.1); border: 1px solid var(--accent-green); border-radius: 6px; padding: 10px; margin: 10px 0;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
<div>
|
||
<div style="font-size: 11px; color: var(--accent-green); font-weight: bold;">SIGNAL DETECTED</div>
|
||
<div style="font-size: 10px; color: var(--text-secondary);">Audio streaming...</div>
|
||
</div>
|
||
<button class="preset-btn" id="scannerSkipBtn" onclick="skipSignal()" style="background: var(--accent-orange); border-color: var(--accent-orange); color: #000;">
|
||
Skip >>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; font-size: 11px; color: var(--text-muted);">
|
||
<span>Signals: <strong id="scannerSignalCount" style="color: var(--accent-green);">0</strong></span>
|
||
<span>|</span>
|
||
<span id="scannerStatusText">Ready</span>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h3>Manual Audio Receiver</h3>
|
||
<div id="audioToolsWarning" 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 Dependencies:</strong><br>
|
||
<span id="audioToolsWarningText"></span>
|
||
</p>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>SDR Device</label>
|
||
<select id="audioDeviceSelect">
|
||
<option value="0">Device 0 (Default)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Frequency Preset</label>
|
||
<select id="audioPreset" onchange="applyAudioPreset()">
|
||
<option value="custom">Custom Frequency</option>
|
||
<option value="fm">FM Radio (87.5-108 MHz)</option>
|
||
<option value="airband">Airband (118-137 MHz)</option>
|
||
<option value="marine">Marine VHF (156-163 MHz)</option>
|
||
<option value="amateur2m">Amateur 2m (144-148 MHz)</option>
|
||
<option value="amateur70cm">Amateur 70cm (420-450 MHz)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Tune Frequency (MHz)</label>
|
||
<div style="display: flex; gap: 5px; align-items: center;">
|
||
<input type="number" id="audioFrequency" value="98.1" step="0.1" style="flex: 1;">
|
||
<button class="preset-btn" onclick="audioFreqDown()" style="padding: 5px 10px;">-</button>
|
||
<button class="preset-btn" onclick="audioFreqUp()" style="padding: 5px 10px;">+</button>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Modulation</label>
|
||
<select id="audioModulation">
|
||
<option value="wfm">Wide FM (Broadcast)</option>
|
||
<option value="fm">Narrow FM</option>
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB (Upper Sideband)</option>
|
||
<option value="lsb">LSB (Lower Sideband)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Squelch</label>
|
||
<input type="range" id="audioSquelch" min="0" max="100" value="0" oninput="document.getElementById('audioSquelchValue').textContent = this.value">
|
||
<span id="audioSquelchValue">0</span>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Gain (dB)</label>
|
||
<input type="range" id="audioGain" min="0" max="50" value="40" oninput="document.getElementById('audioGainValue').textContent = this.value">
|
||
<span id="audioGainValue">40</span>
|
||
</div>
|
||
<div style="display: flex; gap: 10px;">
|
||
<button class="run-btn" id="audioStartBtn" onclick="toggleAudio()" style="flex: 1;">
|
||
▶ Play Audio
|
||
</button>
|
||
</div>
|
||
<div class="status-line" style="margin-top: 10px;">
|
||
<span>Status:</span>
|
||
<span id="audioStatus" style="color: var(--text-muted);">STOPPED</span>
|
||
</div>
|
||
<div class="status-line">
|
||
<span>Tuned:</span>
|
||
<span id="audioTunedFreq">-- MHz</span>
|
||
</div>
|
||
<div class="status-line">
|
||
<span>Device:</span>
|
||
<span id="audioDeviceStatus">--</span>
|
||
</div>
|
||
<!-- Audio Visualizer -->
|
||
<div class="audio-visualizer" id="audioVisualizerContainer" style="display: none; margin-top: 12px;">
|
||
<div class="signal-meter">
|
||
<label>Signal</label>
|
||
<div class="meter-bar">
|
||
<div class="meter-fill" id="audioSignalMeter"></div>
|
||
<div class="meter-peak" id="audioSignalPeak"></div>
|
||
</div>
|
||
<span class="meter-value" id="audioSignalValue">-∞ dB</span>
|
||
</div>
|
||
<canvas id="audioSpectrumCanvas" width="280" height="60"></canvas>
|
||
</div>
|
||
<!-- Hidden audio element for browser playback -->
|
||
<audio id="audioPlayer" style="display: none;" crossorigin="anonymous"></audio>
|
||
<div class="form-group" style="margin-top: 10px;">
|
||
<label>Volume</label>
|
||
<input type="range" id="audioVolume" min="0" max="100" value="80"
|
||
oninput="updateAudioVolume()" style="width: 100%;">
|
||
</div>
|
||
<p style="font-size: 11px; color: var(--text-muted); margin-top: 8px;">
|
||
Audio streams to your <strong>browser</strong>.<br>
|
||
Requires rtl_fm and ffmpeg installed on server.
|
||
</p>
|
||
</div>
|
||
<div class="section">
|
||
<h3>Activity Log</h3>
|
||
<div id="scannerLogContainer" style="max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 4px; padding: 8px; font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
||
<div id="scannerLog" style="color: var(--text-secondary);">
|
||
<div style="color: var(--text-muted);">Scanner activity will appear here...</div>
|
||
</div>
|
||
</div>
|
||
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
||
<button class="preset-btn" onclick="clearScannerLog()" style="flex: 1;">Clear Log</button>
|
||
<button class="preset-btn" onclick="exportScannerLog()" style="flex: 1;">Export Log</button>
|
||
</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 -->
|
||
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
|
||
<!-- Large frequency display panel -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 4;">
|
||
<div style="display: flex; gap: 20px; align-items: stretch;">
|
||
<!-- Big frequency display -->
|
||
<div style="flex: 2; background: rgba(0,0,0,0.4); border-radius: 8px; padding: 20px; text-align: center;">
|
||
<div style="font-size: 12px; color: var(--text-muted); text-transform: uppercase; margin-bottom: 10px;">
|
||
<span id="mainScannerModeLabel">SCANNER STOPPED</span>
|
||
</div>
|
||
<div id="mainScannerFreq" style="font-size: 56px; font-weight: bold; font-family: 'JetBrains Mono', monospace; color: var(--accent-cyan); line-height: 1;">
|
||
---.---
|
||
</div>
|
||
<div style="font-size: 24px; color: var(--text-muted);">MHz</div>
|
||
<div id="mainScannerMod" style="font-size: 14px; color: var(--text-secondary); margin-top: 10px;">
|
||
--
|
||
</div>
|
||
<!-- Scanning animation -->
|
||
<div id="mainScannerAnimation" style="display: none; margin-top: 15px;">
|
||
<div style="display: flex; justify-content: center; gap: 4px;">
|
||
<div class="scan-dot" style="width: 8px; height: 8px; background: var(--accent-cyan); border-radius: 50%; animation: pulse 1s infinite;"></div>
|
||
<div class="scan-dot" style="width: 8px; height: 8px; background: var(--accent-cyan); border-radius: 50%; animation: pulse 1s infinite 0.2s;"></div>
|
||
<div class="scan-dot" style="width: 8px; height: 8px; background: var(--accent-cyan); border-radius: 50%; animation: pulse 1s infinite 0.4s;"></div>
|
||
</div>
|
||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 5px;">Scanning...</div>
|
||
</div>
|
||
<!-- Progress bar -->
|
||
<div id="mainScannerProgress" style="display: none; margin-top: 15px;">
|
||
<div style="display: flex; justify-content: space-between; font-size: 10px; color: var(--text-muted);">
|
||
<span id="mainRangeStart">--</span>
|
||
<span id="mainRangeEnd">--</span>
|
||
</div>
|
||
<div style="background: rgba(255,255,255,0.1); height: 6px; border-radius: 3px; margin-top: 4px;">
|
||
<div id="mainProgressBar" style="background: var(--accent-cyan); height: 100%; border-radius: 3px; width: 0%; transition: width 0.3s;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Status panel -->
|
||
<div style="flex: 1; display: flex; flex-direction: column; gap: 10px;">
|
||
<!-- Signal found alert -->
|
||
<div id="mainSignalAlert" style="display: none; background: rgba(0, 255, 100, 0.15); border: 2px solid var(--accent-green); border-radius: 8px; padding: 15px; text-align: center;">
|
||
<div style="font-size: 16px; color: var(--accent-green); font-weight: bold;">SIGNAL FOUND!</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary); margin: 5px 0;">Audio streaming to browser</div>
|
||
<button class="run-btn" onclick="skipSignal()" style="margin-top: 10px; background: var(--accent-orange); border-color: var(--accent-orange);">
|
||
Skip & Continue >>
|
||
</button>
|
||
</div>
|
||
<!-- Stats -->
|
||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px; flex: 1;">
|
||
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">Statistics</div>
|
||
<div style="margin-top: 10px; font-size: 12px;">
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||
<span style="color: var(--text-secondary);">Signals Found</span>
|
||
<span id="mainSignalCount" style="color: var(--accent-green); font-weight: bold;">0</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
|
||
<span style="color: var(--text-secondary);">Frequencies Scanned</span>
|
||
<span id="mainFreqsScanned" style="color: var(--text-primary);">0</span>
|
||
</div>
|
||
<div style="display: flex; justify-content: space-between;">
|
||
<span style="color: var(--text-secondary);">Scan Cycles</span>
|
||
<span id="mainScanCycles" style="color: var(--text-primary);">0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Audio player for scanner -->
|
||
<div style="background: rgba(0,0,0,0.3); border-radius: 6px; padding: 12px;">
|
||
<div style="font-size: 10px; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px;">Scanner Audio</div>
|
||
<audio id="scannerAudioPlayer" style="width: 100%; height: 30px;" controls></audio>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Activity log -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<div class="signal-graph-header">
|
||
<h4>Activity Log</h4>
|
||
<button class="preset-btn" onclick="clearScannerLog()" style="padding: 2px 8px; font-size: 10px;">Clear</button>
|
||
</div>
|
||
<div id="scannerActivityLog" style="height: 200px; overflow-y: auto; background: rgba(0,0,0,0.2); border-radius: 4px; padding: 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px;">
|
||
<div class="scanner-log-entry" style="color: var(--text-muted);">
|
||
Waiting for scanner to start...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal hits table -->
|
||
<div class="wifi-visual-panel" style="grid-column: span 2;">
|
||
<div class="signal-graph-header">
|
||
<h4>Signal Hits</h4>
|
||
<span class="signal-graph-device" id="scannerHitCount">0 signals found</span>
|
||
</div>
|
||
<div id="scannerHitsList" style="max-height: 200px; overflow-y: auto;">
|
||
<table style="width: 100%; font-size: 11px; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
|
||
<th style="text-align: left; padding: 5px;">Time</th>
|
||
<th style="text-align: left; padding: 5px;">Frequency</th>
|
||
<th style="text-align: center; padding: 5px;">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="scannerHitsBody">
|
||
<tr style="color: var(--text-muted);">
|
||
<td colspan="3" style="padding: 20px; text-align: center;">No signals detected yet</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<style>
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||
50% { opacity: 1; transform: scale(1); }
|
||
}
|
||
</style>
|
||
|
||
<!-- Satellite Visualizations -->
|
||
<div id="satelliteVisuals" style="display: none;">
|
||
<div class="pass-predictor">
|
||
<!-- Cell 1: Polar Plot (Top Left) -->
|
||
<div class="polar-plot-container">
|
||
<div class="polar-plot-header">
|
||
<span class="polar-plot-title">Sky View</span>
|
||
<button class="popout-btn" onclick="popoutSatellite()">⛶ Pop Out</button>
|
||
</div>
|
||
<div class="polar-plot">
|
||
<canvas id="polarPlotCanvas"></canvas>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 10px; font-size: 10px; color: var(--text-secondary);">
|
||
<span style="color: var(--accent-cyan);">N</span> = North |
|
||
Center = Overhead (90°) |
|
||
Edge = Horizon (0°)
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 2: Ground Track Map (Top Right) -->
|
||
<div class="ground-track-cell">
|
||
<div class="ground-track-header">
|
||
<span class="ground-track-title">🌍 Ground Track</span>
|
||
<label style="font-size: 11px; display: flex; align-items: center; gap: 5px;">
|
||
<input type="checkbox" id="showGroundTrack" checked onchange="toggleGroundTrack()">
|
||
Show Track
|
||
</label>
|
||
</div>
|
||
<div id="groundTrackMap"></div>
|
||
<div style="text-align: center; margin-top: 8px; font-size: 10px; color: var(--text-secondary);">
|
||
<span style="color: #666;">---</span> Past |
|
||
<span style="color: #ffff00;">●</span> Current |
|
||
<span style="color: #00ff00;">―</span> Future |
|
||
<span style="color: #ff6600;">◉</span> Observer
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 3: Countdown (Bottom Left) -->
|
||
<div class="countdown-cell">
|
||
<div class="pass-list-header">
|
||
<span>Next Pass Countdown</span>
|
||
</div>
|
||
<div id="satelliteCountdown" class="satellite-countdown">
|
||
<div class="countdown-satellite-name" id="countdownSatName">--</div>
|
||
<div class="countdown-grid">
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Next Pass In</div>
|
||
<div class="countdown-value" id="countdownToPass">--:--:--</div>
|
||
<div class="countdown-sublabel" id="countdownPassTime">--</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Visibility</div>
|
||
<div class="countdown-value" id="countdownVisibility">--:--</div>
|
||
<div class="countdown-sublabel" id="countdownVisLabel">Duration</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Max Elevation</div>
|
||
<div class="countdown-value" id="countdownMaxEl">--°</div>
|
||
<div class="countdown-sublabel" id="countdownDirection">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="countdown-status" id="countdownStatus">Calculate passes to see countdown</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cell 4: Pass List (Bottom Right) -->
|
||
<div class="pass-list-cell">
|
||
<div class="pass-list-header">
|
||
<span>Upcoming Passes</span>
|
||
<span id="passListCount">0 passes</span>
|
||
</div>
|
||
<div id="passList">
|
||
<div style="color: #666; text-align: center; padding: 30px; font-size: 11px;">
|
||
Click "Calculate Passes" to predict satellite passes for your location.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Satellite Popout Container -->
|
||
<div id="satellitePopout" class="popout-container">
|
||
<div class="popout-header">
|
||
<span class="popout-title">🛰️ Satellite Pass Predictor</span>
|
||
<button class="popout-close" onclick="closeSatellitePopout()">✕ Close</button>
|
||
</div>
|
||
<div class="popout-body">
|
||
<div class="pass-predictor" style="height: 100%;">
|
||
<div class="polar-plot-container" style="height: 100%;">
|
||
<div class="polar-plot-header">
|
||
<span class="polar-plot-title">Sky View - Full Screen</span>
|
||
</div>
|
||
<div style="height: calc(100% - 80px);">
|
||
<canvas id="polarPlotCanvasPopout" style="width: 100%; height: 100%;"></canvas>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 10px; font-size: 12px; color: var(--text-secondary);">
|
||
<span style="color: var(--accent-cyan);">N</span> = North |
|
||
Center = Overhead (90°) |
|
||
Edge = Horizon (0°)
|
||
</div>
|
||
</div>
|
||
<div class="pass-list-container" style="height: 100%; max-height: none;">
|
||
<div class="pass-list-header">
|
||
<span>Upcoming Passes</span>
|
||
</div>
|
||
<!-- Countdown Block for Popout -->
|
||
<div id="satelliteCountdownPopout" class="satellite-countdown" style="display: none;">
|
||
<div class="countdown-satellite-name" id="countdownSatNamePopout">--</div>
|
||
<div class="countdown-grid">
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Next Pass In</div>
|
||
<div class="countdown-value" id="countdownToPassPopout">--:--:--</div>
|
||
<div class="countdown-sublabel" id="countdownPassTimePopout">--</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Visibility</div>
|
||
<div class="countdown-value" id="countdownVisibilityPopout">--:--</div>
|
||
<div class="countdown-sublabel" id="countdownVisLabelPopout">Duration</div>
|
||
</div>
|
||
<div class="countdown-block">
|
||
<div class="countdown-label">Max Elevation</div>
|
||
<div class="countdown-value" id="countdownMaxElPopout">--°</div>
|
||
<div class="countdown-sublabel" id="countdownDirectionPopout">--</div>
|
||
</div>
|
||
</div>
|
||
<div class="countdown-status" id="countdownStatusPopout">Waiting for pass data...</div>
|
||
</div>
|
||
<div id="passListPopout" style="height: calc(100% - 40px); overflow-y: auto;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Satellite Add Modal -->
|
||
<div id="satModal" class="sat-modal">
|
||
<div class="sat-modal-content">
|
||
<div class="sat-modal-header">
|
||
<h3>🛰️ Add Satellites</h3>
|
||
<button class="sat-modal-close" onclick="closeSatModal()">×</button>
|
||
</div>
|
||
<div class="sat-modal-tabs">
|
||
<button class="sat-modal-tab active" onclick="switchSatModalTab('tle')">Paste TLE</button>
|
||
<button class="sat-modal-tab" onclick="switchSatModalTab('celestrak')">Celestrak</button>
|
||
</div>
|
||
<div id="tleSection" class="sat-modal-section active">
|
||
<p style="font-size: 11px; color: var(--text-secondary); margin-bottom: 10px;">
|
||
Paste TLE data (3 lines per satellite: name, line 1, line 2)
|
||
</p>
|
||
<textarea id="tleInput" class="tle-textarea" placeholder="SATELLITE NAME 1 NNNNN... 2 NNNNN..."></textarea>
|
||
<button class="run-btn" onclick="addFromTLE()" style="margin-top: 10px;">
|
||
Add Satellites from TLE
|
||
</button>
|
||
</div>
|
||
<div id="celestrakSection" class="sat-modal-section">
|
||
<p style="font-size: 11px; color: var(--text-secondary); margin-bottom: 10px;">
|
||
Select a category to fetch satellites from Celestrak
|
||
</p>
|
||
<div class="celestrak-categories">
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('stations')">🚀 Space Stations</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('visual')">👁️ Brightest</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('weather')">🌤️ Weather</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('noaa')">📡 NOAA</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('amateur')">📻 Amateur Radio</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('starlink')">⭐ Starlink</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('gps-ops')">🛰️ GPS</button>
|
||
<button class="celestrak-cat" onclick="fetchCelestrakCategory('iridium')">📱 Iridium</button>
|
||
</div>
|
||
<div id="celestrakStatus" style="margin-top: 10px; font-size: 11px; color: var(--text-secondary);"></div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
<script>
|
||
// Disclaimer handling
|
||
function checkDisclaimer() {
|
||
const accepted = localStorage.getItem('disclaimerAccepted');
|
||
if (accepted === 'true') {
|
||
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
|
||
// Check disclaimer on load
|
||
checkDisclaimer();
|
||
|
||
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') ? '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') ? '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'})
|
||
});
|
||
}
|
||
|
||
// Audio alert settings
|
||
let audioMuted = localStorage.getItem('audioMuted') === 'true';
|
||
let audioContext = null;
|
||
|
||
function initAudio() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
}
|
||
|
||
function playAlert() {
|
||
if (audioMuted || !audioContext) return;
|
||
const oscillator = audioContext.createOscillator();
|
||
const gainNode = audioContext.createGain();
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(audioContext.destination);
|
||
oscillator.frequency.value = 880;
|
||
oscillator.type = 'sine';
|
||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||
oscillator.start(audioContext.currentTime);
|
||
oscillator.stop(audioContext.currentTime + 0.2);
|
||
}
|
||
|
||
function toggleMute() {
|
||
audioMuted = !audioMuted;
|
||
localStorage.setItem('audioMuted', audioMuted);
|
||
updateMuteButton();
|
||
}
|
||
|
||
function updateMuteButton() {
|
||
const btn = document.getElementById('muteBtn');
|
||
if (btn) {
|
||
btn.innerHTML = audioMuted ? '🔇 UNMUTE' : '🔊 MUTE';
|
||
btn.classList.toggle('muted', audioMuted);
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
|
||
// Initialize audio context on first user interaction (required by browsers)
|
||
document.addEventListener('click', function initAudioOnClick() {
|
||
initAudio();
|
||
document.removeEventListener('click', initAudioOnClick);
|
||
}, { once: true });
|
||
|
||
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, '&');
|
||
s = s.replace(/'/g, ''');
|
||
s = s.replace(/"/g, '"');
|
||
s = s.replace(/</g, '<');
|
||
s = s.replace(/>/g, '>');
|
||
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;
|
||
}
|
||
|
||
// Browser Notifications
|
||
let notificationsEnabled = false;
|
||
|
||
function requestNotificationPermission() {
|
||
if ('Notification' in window) {
|
||
Notification.requestPermission().then(permission => {
|
||
notificationsEnabled = permission === 'granted';
|
||
if (notificationsEnabled) {
|
||
showInfo('🔔 Desktop notifications enabled');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function showNotification(title, body) {
|
||
if (notificationsEnabled && document.hidden) {
|
||
new Notification(title, {
|
||
body: body,
|
||
icon: '/favicon.ico',
|
||
tag: 'intercept-' + Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
// Request notification permission on load
|
||
if ('Notification' in window && Notification.permission === 'default') {
|
||
// Will request on first interaction
|
||
document.addEventListener('click', function requestOnce() {
|
||
requestNotificationPermission();
|
||
document.removeEventListener('click', requestOnce);
|
||
}, { once: true });
|
||
} else if (Notification.permission === 'granted') {
|
||
notificationsEnabled = true;
|
||
}
|
||
|
||
// 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: '© 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();
|
||
}
|
||
|
||
function popoutSatellite() {
|
||
document.getElementById('satellitePopout').classList.add('active');
|
||
document.body.style.overflow = 'hidden';
|
||
|
||
// Initialize popout canvas
|
||
setTimeout(() => {
|
||
const canvas = document.getElementById('polarPlotCanvasPopout');
|
||
if (canvas) {
|
||
const container = canvas.parentElement;
|
||
const size = Math.min(container.offsetWidth, container.offsetHeight - 50);
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
drawPolarPlotPopout(selectedPass);
|
||
}
|
||
|
||
// Render pass list in popout with working click handlers
|
||
renderPassListPopout();
|
||
|
||
// Show countdown in popout if passes exist
|
||
if (satellitePasses.length > 0) {
|
||
document.getElementById('satelliteCountdownPopout').style.display = 'block';
|
||
updateCountdownDisplay('Popout');
|
||
}
|
||
}, 100);
|
||
}
|
||
|
||
function renderPassListPopout() {
|
||
const container = document.getElementById('passListPopout');
|
||
container.innerHTML = '';
|
||
|
||
if (satellitePasses.length === 0) {
|
||
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found.</div>';
|
||
return;
|
||
}
|
||
|
||
satellitePasses.forEach((pass, index) => {
|
||
const card = document.createElement('div');
|
||
card.className = 'pass-card' + (pass === selectedPass ? ' active' : '');
|
||
card.onclick = () => selectPassPopout(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 selectPassPopout(index) {
|
||
selectedPass = satellitePasses[index];
|
||
selectedPassIndex = index;
|
||
|
||
// Update active state in popout
|
||
document.querySelectorAll('#passListPopout .pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
|
||
// Also update main list
|
||
document.querySelectorAll('#passList .pass-card').forEach((card, i) => {
|
||
card.classList.toggle('active', i === index);
|
||
});
|
||
|
||
// Update polar plot in popout
|
||
drawPolarPlotPopout(selectedPass);
|
||
|
||
// Update countdown
|
||
updateSatelliteCountdown();
|
||
}
|
||
|
||
function closeSatellitePopout() {
|
||
document.getElementById('satellitePopout').classList.remove('active');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function drawPolarPlotPopout(pass) {
|
||
const canvas = document.getElementById('polarPlotCanvasPopout');
|
||
if (!canvas) return;
|
||
// Same as drawPolarPlot but for popout canvas
|
||
const ctx = canvas.getContext('2d');
|
||
const size = canvas.width;
|
||
const cx = size / 2;
|
||
const cy = size / 2;
|
||
const radius = size / 2 - 40;
|
||
|
||
ctx.fillStyle = '#0a0a0a';
|
||
ctx.fillRect(0, 0, size, size);
|
||
|
||
// Elevation rings
|
||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
|
||
ctx.lineWidth = 1;
|
||
for (let el = 0; el <= 90; el += 15) {
|
||
const r = radius * (90 - el) / 90;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
if (el > 0 && el % 30 === 0) {
|
||
ctx.fillStyle = '#444';
|
||
ctx.font = '12px JetBrains Mono';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(el + '°', cx, cy - r + 14);
|
||
}
|
||
}
|
||
|
||
// Azimuth lines
|
||
for (let az = 0; az < 360; az += 30) {
|
||
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();
|
||
}
|
||
|
||
// Cardinals
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.font = 'bold 16px Rajdhani';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy - radius - 12);
|
||
ctx.fillStyle = '#888';
|
||
ctx.fillText('S', cx, cy + radius + 20);
|
||
ctx.fillText('E', cx + radius + 16, cy + 5);
|
||
ctx.fillText('W', cx - radius - 16, cy + 5);
|
||
|
||
ctx.fillStyle = '#00ffff';
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
if (pass && pass.trajectory) {
|
||
ctx.strokeStyle = pass.color || '#00ff00';
|
||
ctx.lineWidth = 3;
|
||
ctx.setLineDash([8, 4]);
|
||
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([]);
|
||
|
||
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;
|
||
ctx.fillStyle = pass.color || '#00ff00';
|
||
ctx.beginPath();
|
||
ctx.arc(cx + Math.sin(maxRad) * maxR, cy - Math.cos(maxRad) * maxR, 8, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '14px JetBrains Mono';
|
||
ctx.fillText(pass.satellite, cx + Math.sin(maxRad) * maxR + 15, cy - Math.cos(maxRad) * maxR - 10);
|
||
}
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
});
|
||
|
||
// ============================================
|
||
// FREQUENCY SCANNER (Listening Post)
|
||
// ============================================
|
||
let isScannerRunning = false;
|
||
let isScannerPaused = false;
|
||
let scannerEventSource = null;
|
||
let scannerSignalCount = 0;
|
||
let scannerLogEntries = [];
|
||
let scannerFreqsScanned = 0;
|
||
let scannerCycles = 0;
|
||
let scannerStartFreq = 118;
|
||
let scannerEndFreq = 137;
|
||
let scannerSignalActive = false;
|
||
|
||
// Scanner presets
|
||
const scannerPresets = {
|
||
fm: { start: 88, end: 108, step: 200, mod: 'wfm' },
|
||
air: { start: 118, end: 137, step: 25, mod: 'am' },
|
||
marine: { start: 156, end: 163, step: 25, mod: 'fm' },
|
||
amateur2m: { start: 144, end: 148, step: 12.5, mod: 'fm' },
|
||
pager: { start: 152, end: 160, step: 25, mod: 'fm' },
|
||
amateur70cm: { start: 420, end: 450, step: 25, mod: 'fm' }
|
||
};
|
||
|
||
// Check scanner tools on load
|
||
function checkScannerTools() {
|
||
fetch('/listening/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
const warnings = [];
|
||
if (!data.rtl_fm) {
|
||
warnings.push('rtl_fm not found - install rtl-sdr tools');
|
||
}
|
||
if (!data.ffmpeg) {
|
||
warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
|
||
}
|
||
|
||
const warningDiv = document.getElementById('scannerToolsWarning');
|
||
const warningText = document.getElementById('scannerToolsWarningText');
|
||
if (warnings.length > 0) {
|
||
warningText.innerHTML = warnings.join('<br>');
|
||
warningDiv.style.display = 'block';
|
||
document.getElementById('scannerStartBtn').disabled = true;
|
||
document.getElementById('scannerStartBtn').style.opacity = '0.5';
|
||
} else {
|
||
warningDiv.style.display = 'none';
|
||
document.getElementById('scannerStartBtn').disabled = false;
|
||
document.getElementById('scannerStartBtn').style.opacity = '1';
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Populate scanner device selector
|
||
function populateScannerDeviceSelect() {
|
||
fetch('/devices')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
const select = document.getElementById('scannerDeviceSelect');
|
||
select.innerHTML = '';
|
||
if (devices.length === 0) {
|
||
select.innerHTML = '<option value="0">No SDR Found</option>';
|
||
} else {
|
||
devices.forEach((dev, i) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = dev.index || i;
|
||
opt.textContent = `${dev.name || 'SDR'} (${dev.index || i})`;
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function applyScannerPreset() {
|
||
const preset = document.getElementById('scannerPreset').value;
|
||
if (preset !== 'custom' && scannerPresets[preset]) {
|
||
const p = scannerPresets[preset];
|
||
document.getElementById('scannerStartFreq').value = p.start;
|
||
document.getElementById('scannerEndFreq').value = p.end;
|
||
document.getElementById('scannerStep').value = p.step;
|
||
document.getElementById('scannerModulation').value = p.mod;
|
||
}
|
||
}
|
||
|
||
function toggleScanner() {
|
||
if (isScannerRunning) {
|
||
stopScanner();
|
||
} else {
|
||
startScanner();
|
||
}
|
||
}
|
||
|
||
function startScanner() {
|
||
const startFreq = parseFloat(document.getElementById('scannerStartFreq').value);
|
||
const endFreq = parseFloat(document.getElementById('scannerEndFreq').value);
|
||
const step = parseFloat(document.getElementById('scannerStep').value);
|
||
const modulation = document.getElementById('scannerModulation').value;
|
||
const squelch = parseInt(document.getElementById('scannerSquelch').value);
|
||
const dwell = parseInt(document.getElementById('scannerDwell').value);
|
||
const device = parseInt(document.getElementById('scannerDeviceSelect').value);
|
||
|
||
if (startFreq >= endFreq) {
|
||
showNotification('Scanner Error', 'End frequency must be greater than start');
|
||
return;
|
||
}
|
||
|
||
// Check if device is available
|
||
if (!checkDeviceAvailability('scanner')) {
|
||
return;
|
||
}
|
||
|
||
// Store scanner range for progress calculation
|
||
scannerStartFreq = startFreq;
|
||
scannerEndFreq = endFreq;
|
||
scannerFreqsScanned = 0;
|
||
scannerCycles = 0;
|
||
|
||
// Update sidebar display
|
||
document.getElementById('scannerModeLabel').textContent = 'STARTING...';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-orange)';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--accent-orange)';
|
||
document.getElementById('scannerModLabel').textContent = modulation.toUpperCase();
|
||
|
||
// Update main display
|
||
document.getElementById('mainScannerModeLabel').textContent = 'STARTING...';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--accent-orange)';
|
||
document.getElementById('mainScannerMod').textContent = modulation.toUpperCase();
|
||
|
||
// Show progress bars
|
||
document.getElementById('scannerProgress').style.display = 'block';
|
||
document.getElementById('scannerRangeStart').textContent = startFreq.toFixed(1);
|
||
document.getElementById('scannerRangeEnd').textContent = endFreq.toFixed(1);
|
||
document.getElementById('mainScannerProgress').style.display = 'block';
|
||
document.getElementById('mainRangeStart').textContent = startFreq.toFixed(1) + ' MHz';
|
||
document.getElementById('mainRangeEnd').textContent = endFreq.toFixed(1) + ' MHz';
|
||
|
||
fetch('/listening/scanner/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
start_freq: startFreq,
|
||
end_freq: endFreq,
|
||
step: step,
|
||
modulation: modulation,
|
||
squelch: squelch,
|
||
dwell_time: dwell,
|
||
device: device,
|
||
bias_t: getBiasTEnabled()
|
||
})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
reserveDevice(device, 'scanner');
|
||
isScannerRunning = true;
|
||
isScannerPaused = false;
|
||
scannerSignalActive = false;
|
||
|
||
// Update sidebar controls
|
||
document.getElementById('scannerStartBtn').textContent = 'Stop Scanner';
|
||
document.getElementById('scannerStartBtn').classList.add('active');
|
||
document.getElementById('scannerPauseBtn').disabled = false;
|
||
document.getElementById('scannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerStatusText').textContent = 'Scanning...';
|
||
|
||
// Update main display
|
||
document.getElementById('mainScannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('mainScannerAnimation').style.display = 'block';
|
||
|
||
// Show level meter
|
||
document.getElementById('scannerLevelMeter').style.display = 'block';
|
||
|
||
connectScannerStream();
|
||
addScannerLogEntry('Scanner started', `Range: ${startFreq}-${endFreq} MHz, Step: ${step} kHz`);
|
||
showNotification('Scanner Started', `Scanning ${startFreq} - ${endFreq} MHz`);
|
||
} else {
|
||
document.getElementById('scannerModeLabel').textContent = 'ERROR';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-red)';
|
||
document.getElementById('mainScannerModeLabel').textContent = 'ERROR';
|
||
showNotification('Scanner Error', data.message || 'Failed to start');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
document.getElementById('scannerStatusText').textContent = 'ERROR';
|
||
document.getElementById('scannerStatusText').style.color = 'var(--accent-red)';
|
||
showNotification('Scanner Error', err.message);
|
||
});
|
||
}
|
||
|
||
function stopScanner() {
|
||
fetch('/listening/scanner/stop', { method: 'POST' })
|
||
.then(() => {
|
||
releaseDevice('scanner');
|
||
isScannerRunning = false;
|
||
isScannerPaused = false;
|
||
scannerSignalActive = false;
|
||
|
||
// Update sidebar
|
||
document.getElementById('scannerStartBtn').textContent = 'Start Scanner';
|
||
document.getElementById('scannerStartBtn').classList.remove('active');
|
||
document.getElementById('scannerPauseBtn').disabled = true;
|
||
document.getElementById('scannerPauseBtn').textContent = '⏸ Pause';
|
||
document.getElementById('scannerModeLabel').textContent = 'STOPPED';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--text-muted)';
|
||
document.getElementById('scannerCurrentFreq').textContent = '---.--- MHz';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--text-muted)';
|
||
document.getElementById('scannerModLabel').textContent = '--';
|
||
document.getElementById('scannerProgress').style.display = 'none';
|
||
document.getElementById('scannerSignalPanel').style.display = 'none';
|
||
document.getElementById('scannerLevelMeter').style.display = 'none';
|
||
document.getElementById('scannerStatusText').textContent = 'Ready';
|
||
|
||
// Update main display
|
||
document.getElementById('mainScannerModeLabel').textContent = 'SCANNER STOPPED';
|
||
document.getElementById('mainScannerFreq').textContent = '---.---';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--text-muted)';
|
||
document.getElementById('mainScannerMod').textContent = '--';
|
||
document.getElementById('mainScannerAnimation').style.display = 'none';
|
||
document.getElementById('mainScannerProgress').style.display = 'none';
|
||
document.getElementById('mainSignalAlert').style.display = 'none';
|
||
|
||
// Stop scanner audio
|
||
const scannerAudio = document.getElementById('scannerAudioPlayer');
|
||
if (scannerAudio) {
|
||
scannerAudio.pause();
|
||
scannerAudio.src = '';
|
||
}
|
||
|
||
if (scannerEventSource) {
|
||
scannerEventSource.close();
|
||
scannerEventSource = null;
|
||
}
|
||
addScannerLogEntry('Scanner stopped', '');
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function pauseScanner() {
|
||
const endpoint = isScannerPaused ? '/listening/scanner/resume' : '/listening/scanner/pause';
|
||
fetch(endpoint, { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
isScannerPaused = !isScannerPaused;
|
||
document.getElementById('scannerPauseBtn').textContent = isScannerPaused ? '▶ Resume' : '⏸ Pause';
|
||
document.getElementById('scannerStatusText').textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
|
||
document.getElementById('scannerStatusText').style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
|
||
document.getElementById('scannerActivityStatus').textContent = isScannerPaused ? 'PAUSED' : 'SCANNING';
|
||
document.getElementById('scannerActivityStatus').style.color = isScannerPaused ? 'var(--accent-orange)' : 'var(--accent-green)';
|
||
addScannerLogEntry(isScannerPaused ? 'Scanner paused' : 'Scanner resumed', '');
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
function connectScannerStream() {
|
||
if (scannerEventSource) {
|
||
scannerEventSource.close();
|
||
}
|
||
|
||
scannerEventSource = new EventSource('/listening/scanner/stream');
|
||
|
||
scannerEventSource.onmessage = function(e) {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
|
||
if (data.type === 'freq_change') {
|
||
// Update frequency displays
|
||
const freqStr = data.frequency.toFixed(3);
|
||
document.getElementById('scannerCurrentFreq').textContent = freqStr + ' MHz';
|
||
document.getElementById('mainScannerFreq').textContent = freqStr;
|
||
|
||
// Update progress bar
|
||
const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100;
|
||
document.getElementById('scannerProgressBar').style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||
document.getElementById('mainProgressBar').style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||
|
||
scannerFreqsScanned++;
|
||
document.getElementById('mainFreqsScanned').textContent = scannerFreqsScanned;
|
||
|
||
// Check if this is scanning or dwelling on signal
|
||
if (data.scanning) {
|
||
document.getElementById('scannerStatusText').textContent = 'Scanning ' + freqStr + ' MHz...';
|
||
}
|
||
|
||
} else if (data.type === 'scan_update') {
|
||
// Update frequency and level displays
|
||
const freqStr = data.frequency.toFixed(3);
|
||
document.getElementById('scannerCurrentFreq').textContent = freqStr + ' MHz';
|
||
document.getElementById('mainScannerFreq').textContent = freqStr;
|
||
|
||
// Update progress bar
|
||
const progress = ((data.frequency - scannerStartFreq) / (scannerEndFreq - scannerStartFreq)) * 100;
|
||
document.getElementById('scannerProgressBar').style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||
document.getElementById('mainProgressBar').style.width = Math.max(0, Math.min(100, progress)) + '%';
|
||
|
||
// Update level meter (scale 0-5000 to 0-100%)
|
||
const levelPercent = Math.min(100, (data.level / 5000) * 100);
|
||
const levelBar = document.getElementById('scannerLevelBar');
|
||
levelBar.style.width = levelPercent + '%';
|
||
// Color based on level vs threshold
|
||
if (data.detected) {
|
||
levelBar.style.background = 'var(--accent-green)';
|
||
} else if (data.level > data.threshold * 0.7) {
|
||
levelBar.style.background = 'var(--accent-orange)';
|
||
} else {
|
||
levelBar.style.background = 'var(--accent-cyan)';
|
||
}
|
||
document.getElementById('scannerLevelValue').textContent = data.level;
|
||
|
||
scannerFreqsScanned++;
|
||
document.getElementById('mainFreqsScanned').textContent = scannerFreqsScanned;
|
||
document.getElementById('scannerStatusText').textContent = `${freqStr} MHz (level: ${data.level})`;
|
||
|
||
} else if (data.type === 'signal_found') {
|
||
// Signal detected!
|
||
scannerSignalCount++;
|
||
scannerSignalActive = true;
|
||
const freqStr = data.frequency.toFixed(3);
|
||
|
||
document.getElementById('scannerSignalCount').textContent = scannerSignalCount;
|
||
document.getElementById('mainSignalCount').textContent = scannerSignalCount;
|
||
|
||
// Update sidebar
|
||
document.getElementById('scannerModeLabel').textContent = 'SIGNAL FOUND';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-green)';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--accent-green)';
|
||
document.getElementById('scannerSignalPanel').style.display = 'block';
|
||
document.getElementById('scannerStatusText').textContent = 'Listening to signal...';
|
||
|
||
// Update main display
|
||
document.getElementById('mainScannerModeLabel').textContent = 'SIGNAL DETECTED';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--accent-green)';
|
||
document.getElementById('mainScannerAnimation').style.display = 'none';
|
||
document.getElementById('mainSignalAlert').style.display = 'block';
|
||
|
||
// Start audio playback
|
||
if (data.audio_streaming) {
|
||
const scannerAudio = document.getElementById('scannerAudioPlayer');
|
||
scannerAudio.src = '/listening/audio/stream?' + Date.now();
|
||
scannerAudio.play().catch(e => console.warn('Audio autoplay blocked'));
|
||
}
|
||
|
||
addScannerLogEntry('SIGNAL FOUND', `${freqStr} MHz (${data.modulation.toUpperCase()})`, 'signal');
|
||
addSignalHit(data);
|
||
showNotification('Signal Found!', `${freqStr} MHz - Audio streaming`);
|
||
|
||
} else if (data.type === 'signal_lost') {
|
||
scannerSignalActive = false;
|
||
|
||
// Update sidebar
|
||
document.getElementById('scannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerSignalPanel').style.display = 'none';
|
||
document.getElementById('scannerStatusText').textContent = 'Scanning...';
|
||
|
||
// Update main display
|
||
document.getElementById('mainScannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('mainScannerAnimation').style.display = 'block';
|
||
document.getElementById('mainSignalAlert').style.display = 'none';
|
||
|
||
// Stop audio
|
||
const scannerAudio = document.getElementById('scannerAudioPlayer');
|
||
scannerAudio.pause();
|
||
scannerAudio.src = '';
|
||
|
||
addScannerLogEntry('Signal lost', `${data.frequency.toFixed(3)} MHz`, 'info');
|
||
|
||
} else if (data.type === 'signal_skipped') {
|
||
scannerSignalActive = false;
|
||
|
||
// Update displays back to scanning mode
|
||
document.getElementById('scannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('scannerModeLabel').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerCurrentFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('scannerSignalPanel').style.display = 'none';
|
||
|
||
document.getElementById('mainScannerModeLabel').textContent = 'SCANNING';
|
||
document.getElementById('mainScannerFreq').style.color = 'var(--accent-cyan)';
|
||
document.getElementById('mainScannerAnimation').style.display = 'block';
|
||
document.getElementById('mainSignalAlert').style.display = 'none';
|
||
|
||
// Stop audio
|
||
const scannerAudio = document.getElementById('scannerAudioPlayer');
|
||
scannerAudio.pause();
|
||
scannerAudio.src = '';
|
||
|
||
addScannerLogEntry('Signal skipped', `${data.frequency.toFixed(3)} MHz`, 'info');
|
||
|
||
} else if (data.type === 'log') {
|
||
// Activity log entry from server
|
||
if (data.entry && data.entry.type === 'scan_cycle') {
|
||
scannerCycles++;
|
||
document.getElementById('mainScanCycles').textContent = scannerCycles;
|
||
}
|
||
|
||
} else if (data.type === 'stopped') {
|
||
stopScanner();
|
||
}
|
||
} catch (err) {
|
||
console.warn('Scanner parse error:', err);
|
||
}
|
||
};
|
||
|
||
scannerEventSource.onerror = function() {
|
||
if (isScannerRunning) {
|
||
setTimeout(connectScannerStream, 2000);
|
||
}
|
||
};
|
||
}
|
||
|
||
function addScannerLogEntry(title, detail, type = 'info') {
|
||
const now = new Date();
|
||
const timestamp = now.toLocaleTimeString();
|
||
const entry = { timestamp, title, detail, type };
|
||
scannerLogEntries.unshift(entry);
|
||
|
||
// Keep only last 100 entries
|
||
if (scannerLogEntries.length > 100) {
|
||
scannerLogEntries.pop();
|
||
}
|
||
|
||
// Update sidebar log
|
||
const sidebarLog = document.getElementById('scannerLog');
|
||
const color = type === 'signal' ? 'var(--accent-green)' : 'var(--text-secondary)';
|
||
sidebarLog.innerHTML = scannerLogEntries.slice(0, 20).map(e =>
|
||
`<div style="margin-bottom: 4px; color: ${e.type === 'signal' ? 'var(--accent-green)' : 'var(--text-secondary)'};">
|
||
<span style="color: var(--text-muted);">[${e.timestamp}]</span>
|
||
<strong>${e.title}</strong> ${e.detail}
|
||
</div>`
|
||
).join('');
|
||
|
||
// Update main activity log
|
||
const activityLog = document.getElementById('scannerActivityLog');
|
||
activityLog.innerHTML = scannerLogEntries.slice(0, 50).map(e =>
|
||
`<div class="scanner-log-entry" style="margin-bottom: 6px; padding: 4px; border-left: 2px solid ${e.type === 'signal' ? 'var(--accent-green)' : 'var(--border-color)'};">
|
||
<span style="color: var(--text-muted);">[${e.timestamp}]</span>
|
||
<strong style="color: ${e.type === 'signal' ? 'var(--accent-green)' : 'var(--text-primary)'};">${e.title}</strong>
|
||
<span style="color: var(--text-secondary);">${e.detail}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
// Track recent signal hits to prevent duplicates
|
||
let recentSignalHits = new Map(); // frequency -> timestamp
|
||
|
||
function addSignalHit(data) {
|
||
const tbody = document.getElementById('scannerHitsBody');
|
||
const now = Date.now();
|
||
const freqKey = data.frequency.toFixed(3);
|
||
|
||
// Check for duplicate - same frequency within last 5 seconds
|
||
if (recentSignalHits.has(freqKey)) {
|
||
const lastHit = recentSignalHits.get(freqKey);
|
||
if (now - lastHit < 5000) {
|
||
// Duplicate, skip
|
||
return;
|
||
}
|
||
}
|
||
recentSignalHits.set(freqKey, now);
|
||
|
||
// Clean up old entries (older than 30 seconds)
|
||
for (const [freq, time] of recentSignalHits) {
|
||
if (now - time > 30000) {
|
||
recentSignalHits.delete(freq);
|
||
}
|
||
}
|
||
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
|
||
// Remove "no signals" placeholder if present
|
||
if (tbody.innerHTML.includes('No signals detected')) {
|
||
tbody.innerHTML = '';
|
||
}
|
||
|
||
const mod = data.modulation || 'fm';
|
||
const row = document.createElement('tr');
|
||
row.style.borderBottom = '1px solid var(--border-color)';
|
||
row.innerHTML = `
|
||
<td style="padding: 5px; color: var(--text-secondary);">${timestamp}</td>
|
||
<td style="padding: 5px; color: var(--accent-green); font-weight: bold;">${data.frequency.toFixed(3)} MHz (${mod.toUpperCase()})</td>
|
||
<td style="padding: 5px; text-align: center;">
|
||
<button class="preset-btn" onclick="tuneToFrequency(${data.frequency}, '${mod}')" style="padding: 2px 8px; font-size: 10px;">Listen</button>
|
||
</td>
|
||
`;
|
||
tbody.insertBefore(row, tbody.firstChild);
|
||
|
||
// Keep only last 50 hits in table
|
||
while (tbody.children.length > 50) {
|
||
tbody.removeChild(tbody.lastChild);
|
||
}
|
||
|
||
document.getElementById('scannerHitCount').textContent = `${tbody.children.length} signals found`;
|
||
}
|
||
|
||
async function tuneToFrequency(freq, mod) {
|
||
try {
|
||
// Stop scanner if running
|
||
if (isScannerRunning) {
|
||
await fetch('/listening/scanner/stop', { method: 'POST' });
|
||
isScannerRunning = false;
|
||
document.getElementById('scannerStartBtn').textContent = '▶ Start Scanner';
|
||
document.getElementById('scannerStartBtn').classList.remove('active');
|
||
document.getElementById('scannerStatusText').textContent = 'STOPPED';
|
||
document.getElementById('scannerStatusText').style.color = 'var(--text-muted)';
|
||
}
|
||
|
||
// Stop any current audio and wait for it to complete
|
||
if (isAudioPlaying) {
|
||
await stopAudio();
|
||
// Extra delay to ensure backend processes are fully stopped
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
|
||
// Set frequency in manual audio form
|
||
document.getElementById('audioFrequency').value = freq.toFixed(3);
|
||
document.getElementById('audioPreset').value = 'custom';
|
||
if (mod) {
|
||
document.getElementById('audioModulation').value = mod;
|
||
}
|
||
|
||
// Small delay before starting to ensure backend is ready
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
|
||
// Start playing
|
||
startAudio();
|
||
showNotification('Tuned', `Now listening to ${freq.toFixed(3)} MHz`);
|
||
} catch (err) {
|
||
console.error('Error tuning to frequency:', err);
|
||
showNotification('Tune Error', 'Failed to tune to frequency: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function skipSignal() {
|
||
if (!isScannerRunning) {
|
||
showNotification('Scanner', 'Scanner is not running');
|
||
return;
|
||
}
|
||
|
||
fetch('/listening/scanner/skip', { method: 'POST' })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'skipped') {
|
||
showNotification('Signal Skipped', `Continuing scan from ${data.frequency.toFixed(3)} MHz`);
|
||
}
|
||
})
|
||
.catch(err => {
|
||
showNotification('Skip Error', err.message);
|
||
});
|
||
}
|
||
|
||
function clearScannerLog() {
|
||
scannerLogEntries = [];
|
||
scannerSignalCount = 0;
|
||
scannerFreqsScanned = 0;
|
||
scannerCycles = 0;
|
||
document.getElementById('scannerSignalCount').textContent = '0';
|
||
document.getElementById('mainSignalCount').textContent = '0';
|
||
document.getElementById('mainFreqsScanned').textContent = '0';
|
||
document.getElementById('mainScanCycles').textContent = '0';
|
||
document.getElementById('scannerLog').innerHTML = '<div style="color: var(--text-muted);">Scanner activity will appear here...</div>';
|
||
document.getElementById('scannerActivityLog').innerHTML = '<div class="scanner-log-entry" style="color: var(--text-muted);">Waiting for scanner to start...</div>';
|
||
document.getElementById('scannerHitsBody').innerHTML = '<tr style="color: var(--text-muted);"><td colspan="3" style="padding: 20px; text-align: center;">No signals detected yet</td></tr>';
|
||
document.getElementById('scannerHitCount').textContent = '0 signals found';
|
||
}
|
||
|
||
function exportScannerLog() {
|
||
if (scannerLogEntries.length === 0) {
|
||
showNotification('Export', 'No log entries to export');
|
||
return;
|
||
}
|
||
|
||
const csv = 'Timestamp,Event,Details\n' + scannerLogEntries.map(e =>
|
||
`"${e.timestamp}","${e.title}","${e.detail}"`
|
||
).join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `scanner_log_${new Date().toISOString().slice(0,10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showNotification('Export', 'Log exported to CSV');
|
||
}
|
||
|
||
// ============================================
|
||
// AUDIO RECEIVER (Manual Listening)
|
||
// Audio plays through server speakers (not browser)
|
||
// ============================================
|
||
|
||
let isAudioPlaying = false;
|
||
let audioToolsAvailable = { rtl_fm: false, ffmpeg: false };
|
||
let audioReconnectAttempts = 0;
|
||
const MAX_AUDIO_RECONNECT = 3;
|
||
|
||
// Set up audio player error handling
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
if (audioPlayer) {
|
||
audioPlayer.addEventListener('error', function(e) {
|
||
console.warn('Audio player error:', e);
|
||
if (isAudioPlaying && audioReconnectAttempts < MAX_AUDIO_RECONNECT) {
|
||
audioReconnectAttempts++;
|
||
console.log(`Reconnecting audio (attempt ${audioReconnectAttempts})...`);
|
||
setTimeout(() => {
|
||
audioPlayer.src = '/listening/audio/stream?' + Date.now();
|
||
audioPlayer.play().catch(() => {});
|
||
}, 500);
|
||
}
|
||
});
|
||
|
||
audioPlayer.addEventListener('stalled', function() {
|
||
console.warn('Audio stalled, attempting recovery...');
|
||
if (isAudioPlaying) {
|
||
audioPlayer.load();
|
||
audioPlayer.play().catch(() => {});
|
||
}
|
||
});
|
||
|
||
audioPlayer.addEventListener('playing', function() {
|
||
audioReconnectAttempts = 0; // Reset on successful play
|
||
});
|
||
}
|
||
});
|
||
|
||
// Web Audio API for visualization
|
||
let visualizerContext = null;
|
||
let visualizerAnalyser = null;
|
||
let visualizerSource = null;
|
||
let visualizerAnimationId = null;
|
||
let peakLevel = 0;
|
||
let peakDecay = 0.95;
|
||
|
||
function initAudioVisualizer() {
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
|
||
// Create audio context if not exists
|
||
if (!visualizerContext) {
|
||
visualizerContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
|
||
// Resume audio context (required for autoplay policies)
|
||
if (visualizerContext.state === 'suspended') {
|
||
visualizerContext.resume();
|
||
}
|
||
|
||
// Only create source once per audio element
|
||
if (!visualizerSource) {
|
||
try {
|
||
visualizerSource = visualizerContext.createMediaElementSource(audioPlayer);
|
||
visualizerAnalyser = visualizerContext.createAnalyser();
|
||
visualizerAnalyser.fftSize = 256;
|
||
visualizerAnalyser.smoothingTimeConstant = 0.7;
|
||
|
||
visualizerSource.connect(visualizerAnalyser);
|
||
visualizerAnalyser.connect(visualizerContext.destination);
|
||
} catch (e) {
|
||
console.warn('Could not create audio source:', e);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Show visualizer
|
||
document.getElementById('audioVisualizerContainer').style.display = 'block';
|
||
|
||
// Start animation
|
||
drawAudioVisualizer();
|
||
}
|
||
|
||
function drawAudioVisualizer() {
|
||
if (!visualizerAnalyser) return;
|
||
|
||
const canvas = document.getElementById('audioSpectrumCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const bufferLength = visualizerAnalyser.frequencyBinCount;
|
||
const dataArray = new Uint8Array(bufferLength);
|
||
|
||
function draw() {
|
||
visualizerAnimationId = requestAnimationFrame(draw);
|
||
|
||
visualizerAnalyser.getByteFrequencyData(dataArray);
|
||
|
||
// Calculate average level for signal meter
|
||
let sum = 0;
|
||
for (let i = 0; i < bufferLength; i++) {
|
||
sum += dataArray[i];
|
||
}
|
||
const average = sum / bufferLength;
|
||
const levelPercent = (average / 255) * 100;
|
||
|
||
// Update peak hold
|
||
if (levelPercent > peakLevel) {
|
||
peakLevel = levelPercent;
|
||
} else {
|
||
peakLevel *= peakDecay;
|
||
}
|
||
|
||
// Update signal meter
|
||
const meterFill = document.getElementById('audioSignalMeter');
|
||
const meterPeak = document.getElementById('audioSignalPeak');
|
||
const meterValue = document.getElementById('audioSignalValue');
|
||
|
||
meterFill.style.width = levelPercent + '%';
|
||
meterPeak.style.left = Math.min(peakLevel, 100) + '%';
|
||
|
||
// Convert to dB-like scale for display
|
||
const db = average > 0 ? Math.round(20 * Math.log10(average / 255)) : -60;
|
||
meterValue.textContent = db + ' dB';
|
||
|
||
// Draw spectrum
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const barWidth = canvas.width / bufferLength * 2.5;
|
||
let x = 0;
|
||
|
||
for (let i = 0; i < bufferLength; i++) {
|
||
const barHeight = (dataArray[i] / 255) * canvas.height;
|
||
|
||
// Color gradient based on frequency and amplitude
|
||
const hue = 200 - (i / bufferLength) * 60; // Blue to cyan
|
||
const lightness = 40 + (dataArray[i] / 255) * 30;
|
||
ctx.fillStyle = `hsl(${hue}, 80%, ${lightness}%)`;
|
||
|
||
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
|
||
x += barWidth;
|
||
}
|
||
|
||
// Draw frequency labels
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||
ctx.font = '8px JetBrains Mono';
|
||
ctx.fillText('0', 2, canvas.height - 2);
|
||
ctx.fillText('4kHz', canvas.width / 4, canvas.height - 2);
|
||
ctx.fillText('8kHz', canvas.width / 2, canvas.height - 2);
|
||
}
|
||
|
||
draw();
|
||
}
|
||
|
||
function stopAudioVisualizer() {
|
||
if (visualizerAnimationId) {
|
||
cancelAnimationFrame(visualizerAnimationId);
|
||
visualizerAnimationId = null;
|
||
}
|
||
|
||
// Reset meter
|
||
const meterFill = document.getElementById('audioSignalMeter');
|
||
const meterPeak = document.getElementById('audioSignalPeak');
|
||
const meterValue = document.getElementById('audioSignalValue');
|
||
|
||
if (meterFill) meterFill.style.width = '0%';
|
||
if (meterPeak) meterPeak.style.left = '0%';
|
||
if (meterValue) meterValue.textContent = '-∞ dB';
|
||
|
||
peakLevel = 0;
|
||
|
||
// Hide visualizer
|
||
const container = document.getElementById('audioVisualizerContainer');
|
||
if (container) container.style.display = 'none';
|
||
}
|
||
|
||
// Check audio tools availability on load
|
||
function checkAudioTools() {
|
||
fetch('/listening/tools')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
audioToolsAvailable.rtl_fm = data.rtl_fm;
|
||
audioToolsAvailable.ffmpeg = data.ffmpeg;
|
||
|
||
const warnings = [];
|
||
if (!data.rtl_fm) {
|
||
warnings.push('rtl_fm not found - install rtl-sdr tools');
|
||
}
|
||
if (!data.ffmpeg) {
|
||
warnings.push('ffmpeg not found - install: brew install ffmpeg (macOS) or apt install ffmpeg (Linux)');
|
||
}
|
||
|
||
const warningDiv = document.getElementById('audioToolsWarning');
|
||
const warningText = document.getElementById('audioToolsWarningText');
|
||
if (warnings.length > 0) {
|
||
warningText.innerHTML = warnings.join('<br>');
|
||
warningDiv.style.display = 'block';
|
||
document.getElementById('audioStartBtn').disabled = true;
|
||
document.getElementById('audioStartBtn').style.opacity = '0.5';
|
||
document.getElementById('audioStartBtn').style.cursor = 'not-allowed';
|
||
} else {
|
||
warningDiv.style.display = 'none';
|
||
document.getElementById('audioStartBtn').disabled = false;
|
||
document.getElementById('audioStartBtn').style.opacity = '1';
|
||
document.getElementById('audioStartBtn').style.cursor = 'pointer';
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Populate audio device selector
|
||
function populateAudioDeviceSelect() {
|
||
fetch('/devices')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
const select = document.getElementById('audioDeviceSelect');
|
||
select.innerHTML = '';
|
||
if (devices.length === 0) {
|
||
select.innerHTML = '<option value="0">No SDR devices found</option>';
|
||
} else {
|
||
devices.forEach((dev, i) => {
|
||
const opt = document.createElement('option');
|
||
opt.value = dev.index || i;
|
||
opt.textContent = `Device ${dev.index || i}: ${dev.name || 'Unknown SDR'}`;
|
||
select.appendChild(opt);
|
||
});
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
|
||
// Audio presets
|
||
function applyAudioPreset() {
|
||
const preset = document.getElementById('audioPreset').value;
|
||
const freqInput = document.getElementById('audioFrequency');
|
||
const modSelect = document.getElementById('audioModulation');
|
||
|
||
switch(preset) {
|
||
case 'fm':
|
||
freqInput.value = '98.1';
|
||
modSelect.value = 'wfm';
|
||
break;
|
||
case 'airband':
|
||
freqInput.value = '121.5'; // Emergency/guard frequency
|
||
modSelect.value = 'am';
|
||
break;
|
||
case 'marine':
|
||
freqInput.value = '156.8'; // Channel 16 - distress
|
||
modSelect.value = 'fm';
|
||
break;
|
||
case 'amateur2m':
|
||
freqInput.value = '146.52'; // 2m calling frequency
|
||
modSelect.value = 'fm';
|
||
break;
|
||
case 'amateur70cm':
|
||
freqInput.value = '446.0';
|
||
modSelect.value = 'fm';
|
||
break;
|
||
}
|
||
}
|
||
|
||
function toggleAudio() {
|
||
if (isAudioPlaying) {
|
||
stopAudio();
|
||
} else {
|
||
startAudio();
|
||
}
|
||
}
|
||
|
||
function startAudio() {
|
||
const frequency = parseFloat(document.getElementById('audioFrequency').value);
|
||
const modulation = document.getElementById('audioModulation').value;
|
||
const squelch = parseInt(document.getElementById('audioSquelch').value);
|
||
const gain = parseInt(document.getElementById('audioGain').value);
|
||
const device = parseInt(document.getElementById('audioDeviceSelect').value);
|
||
|
||
if (isNaN(frequency) || frequency <= 0) {
|
||
showNotification('Audio Error', 'Invalid frequency');
|
||
return;
|
||
}
|
||
|
||
// Check if this device is already in use by another mode
|
||
const usedBy = getDeviceInUseBy(device);
|
||
if (usedBy && usedBy !== 'audio') {
|
||
showNotification('SDR In Use', `Device ${device} is being used by ${usedBy.toUpperCase()}. Select a different device or stop ${usedBy} first.`);
|
||
return;
|
||
}
|
||
|
||
document.getElementById('audioStatus').textContent = 'STARTING...';
|
||
document.getElementById('audioStatus').style.color = 'var(--accent-orange)';
|
||
|
||
fetch('/listening/audio/start', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
frequency: frequency,
|
||
modulation: modulation,
|
||
squelch: squelch,
|
||
gain: gain,
|
||
device: device
|
||
})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'started') {
|
||
reserveDevice(device, 'audio');
|
||
isAudioPlaying = true;
|
||
|
||
// Start browser audio playback
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
audioPlayer.src = '/listening/audio/stream?' + Date.now(); // Cache bust
|
||
audioPlayer.volume = document.getElementById('audioVolume').value / 100;
|
||
|
||
// Initialize visualizer before playing (needs audio context)
|
||
initAudioVisualizer();
|
||
|
||
audioPlayer.play().catch(e => {
|
||
console.warn('Audio autoplay blocked:', e);
|
||
showNotification('Audio Ready', 'Click Play button again if audio does not start');
|
||
});
|
||
|
||
document.getElementById('audioStartBtn').textContent = '⏹ Stop Audio';
|
||
document.getElementById('audioStartBtn').classList.add('active');
|
||
document.getElementById('audioStatus').textContent = 'STREAMING';
|
||
document.getElementById('audioStatus').style.color = 'var(--accent-green)';
|
||
document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz (' + modulation.toUpperCase() + ')';
|
||
document.getElementById('audioDeviceStatus').textContent = 'SDR ' + device;
|
||
showNotification('Audio Started', `Streaming ${frequency} MHz to browser`);
|
||
} else {
|
||
document.getElementById('audioStatus').textContent = 'ERROR';
|
||
document.getElementById('audioStatus').style.color = 'var(--accent-red)';
|
||
showNotification('Audio Error', data.message || 'Failed to start audio');
|
||
}
|
||
})
|
||
.catch(err => {
|
||
document.getElementById('audioStatus').textContent = 'ERROR';
|
||
document.getElementById('audioStatus').style.color = 'var(--accent-red)';
|
||
showNotification('Audio Error', err.message);
|
||
});
|
||
}
|
||
|
||
async function stopAudio() {
|
||
// Stop visualizer
|
||
stopAudioVisualizer();
|
||
|
||
// Stop browser audio
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
audioPlayer.pause();
|
||
audioPlayer.src = '';
|
||
|
||
try {
|
||
await fetch('/listening/audio/stop', { method: 'POST' });
|
||
releaseDevice('audio');
|
||
isAudioPlaying = false;
|
||
document.getElementById('audioStartBtn').textContent = '▶ Play Audio';
|
||
document.getElementById('audioStartBtn').classList.remove('active');
|
||
document.getElementById('audioStatus').textContent = 'STOPPED';
|
||
document.getElementById('audioStatus').style.color = 'var(--text-muted)';
|
||
document.getElementById('audioDeviceStatus').textContent = '--';
|
||
} catch (e) {
|
||
console.error('Error stopping audio:', e);
|
||
}
|
||
}
|
||
|
||
function updateAudioVolume() {
|
||
const audioPlayer = document.getElementById('audioPlayer');
|
||
audioPlayer.volume = document.getElementById('audioVolume').value / 100;
|
||
}
|
||
|
||
function audioFreqUp() {
|
||
const input = document.getElementById('audioFrequency');
|
||
const mod = document.getElementById('audioModulation').value;
|
||
const step = (mod === 'wfm') ? 0.2 : 0.025;
|
||
input.value = (parseFloat(input.value) + step).toFixed(2);
|
||
// Retune if playing
|
||
if (isAudioPlaying) {
|
||
tuneAudioFrequency(parseFloat(input.value));
|
||
}
|
||
}
|
||
|
||
function audioFreqDown() {
|
||
const input = document.getElementById('audioFrequency');
|
||
const mod = document.getElementById('audioModulation').value;
|
||
const step = (mod === 'wfm') ? 0.2 : 0.025;
|
||
input.value = (parseFloat(input.value) - step).toFixed(2);
|
||
// Retune if playing
|
||
if (isAudioPlaying) {
|
||
tuneAudioFrequency(parseFloat(input.value));
|
||
}
|
||
}
|
||
|
||
function tuneAudioFrequency(frequency) {
|
||
fetch('/listening/audio/tune', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ frequency: frequency })
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.status === 'tuned') {
|
||
document.getElementById('audioTunedFreq').textContent = frequency.toFixed(2) + ' MHz';
|
||
}
|
||
})
|
||
.catch(() => {
|
||
// If tune fails, restart audio
|
||
stopAudio();
|
||
setTimeout(startAudio, 300);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// Initialize scanner
|
||
checkScannerTools();
|
||
populateScannerDeviceSelect();
|
||
// Initialize audio receiver
|
||
checkAudioTools();
|
||
populateAudioDeviceSelect();
|
||
});
|
||
</script>
|
||
|
||
<!-- 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;">×</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>
|