Files
intercept/templates/index.html
Smittix 8e204725b2 feat: Add ISS SSTV decoder mode
Add slow-scan television decoder for receiving images from ISS.
Includes new Space dropdown in navigation grouping Satellite and SSTV modes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 14:51:06 +00:00

12122 lines
607 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iNTERCEPT // See the Invisible</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<!-- Disclaimer gate - must accept before seeing welcome page -->
<script>
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
if (localStorage.getItem('disclaimerAccepted') !== 'true') {
document.write('<style id="disclaimer-gate">.welcome-overlay{display:none !important}</style>');
window._showDisclaimerOnLoad = true;
}
</script>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet.js for APRS map - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<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>
{% endif %}
<!-- Chart.js for signal strength graphs - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
<script src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
{% else %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/acars.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/aprs.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/tscm.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/signal-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/activity-timeline.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/device-cards.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/proximity-viz.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
</head>
<body>
<!-- Welcome Page -->
<div class="welcome-overlay" id="welcomePage">
<!-- Spinning Globe Background -->
<div class="globe-background">
<svg class="globe-svg" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<!-- Outer circle -->
<circle cx="200" cy="200" r="180" fill="none" stroke="currentColor" stroke-width="0.5"/>
<!-- Equator -->
<ellipse cx="200" cy="200" rx="180" ry="40" fill="none" stroke="currentColor" stroke-width="0.5"/>
<!-- Latitude lines -->
<ellipse cx="200" cy="140" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
<ellipse cx="200" cy="260" rx="145" ry="30" fill="none" stroke="currentColor" stroke-width="0.3"/>
<ellipse cx="200" cy="90" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
<ellipse cx="200" cy="310" rx="85" ry="15" fill="none" stroke="currentColor" stroke-width="0.3"/>
<!-- Prime meridian -->
<ellipse cx="200" cy="200" rx="40" ry="180" fill="none" stroke="currentColor" stroke-width="0.5" class="meridian meridian-1"/>
<!-- Additional meridians -->
<ellipse cx="200" cy="200" rx="100" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-2"/>
<ellipse cx="200" cy="200" rx="150" ry="180" fill="none" stroke="currentColor" stroke-width="0.3" class="meridian meridian-3"/>
<!-- Rotating meridian group -->
<g class="rotating-meridians">
<ellipse cx="200" cy="200" rx="70" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
<ellipse cx="200" cy="200" rx="130" ry="180" fill="none" stroke="currentColor" stroke-width="0.3"/>
<ellipse cx="200" cy="200" rx="170" ry="180" fill="none" stroke="currentColor" stroke-width="0.2"/>
</g>
</svg>
</div>
<div class="welcome-container">
<!-- Header Section -->
<div class="welcome-header">
<div class="welcome-logo">
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none"
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none"
stroke-linecap="round" class="signal-wave signal-wave-3" />
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none"
stroke-linecap="round" opacity="0.5" class="signal-wave signal-wave-1" />
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none"
stroke-linecap="round" opacity="0.7" class="signal-wave signal-wave-2" />
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none"
stroke-linecap="round" class="signal-wave signal-wave-3" />
<circle cx="50" cy="22" r="6" fill="#00ff88" class="logo-dot" />
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg>
</div>
<div class="welcome-title-block">
<h1 class="welcome-title">iNTERCEPT</h1>
<p class="welcome-tagline">// See the Invisible</p>
<span class="welcome-version">v{{ version }}</span>
</div>
</div>
<!-- Main Content Grid -->
<div class="welcome-content">
<!-- Left: Changelog -->
<div class="welcome-changelog">
<h2>What's New</h2>
{% for release in changelog[:2] %}
<div class="changelog-release">
<div class="changelog-version-header">
<span class="changelog-version">v{{ release.version }}</span>
<span class="changelog-date">{{ release.date }}</span>
</div>
<ul class="changelog-list">
{% for item in release.highlights %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
<!-- Right: Mode Selection -->
<div class="welcome-modes">
<h2>Select Mode</h2>
<!-- SDR / Radio Frequency -->
<div class="mode-category">
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span> SDR / Radio</h3>
<div class="mode-grid mode-grid-compact">
<button class="mode-card mode-card-sm" onclick="selectMode('pager')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span>
<span class="mode-name">Pager</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('sensor')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span>
<span class="mode-name">433MHz</span>
</button>
<a href="/adsb/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span>
<span class="mode-name">Aircraft</span>
</a>
<a href="/ais/dashboard" class="mode-card mode-card-sm" style="text-decoration: none;">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/></svg></span>
<span class="mode-name">Vessels</span>
</a>
<button class="mode-card mode-card-sm" onclick="selectMode('aprs')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span>
<span class="mode-name">APRS</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('rtlamr')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
<span class="mode-name">Meters</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span>
<span class="mode-name">Satellite</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('listening')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span>
<span class="mode-name">Scanner</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('spystations')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
<span class="mode-name">Spy Stations</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('meshtastic')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span>
<span class="mode-name">Meshtastic</span>
</button>
</div>
</div>
<!-- Wireless -->
<div class="mode-category">
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> Wireless</h3>
<div class="mode-grid mode-grid-compact">
<button class="mode-card mode-card-sm" onclick="selectMode('wifi')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="mode-name">WiFi</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('bluetooth')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
<span class="mode-name">Bluetooth</span>
</button>
</div>
</div>
<!-- Security -->
<div class="mode-category">
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span> Security</h3>
<div class="mode-grid mode-grid-compact">
<button class="mode-card mode-card-sm" onclick="selectMode('tscm')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="mode-name">TSCM</span>
</button>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="welcome-footer">
<p>Signal Intelligence & Counter Surveillance Platform</p>
</div>
</div>
<div class="welcome-scanline"></div>
</div>
<!-- Disclaimer Modal -->
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
<div class="disclaimer-modal">
<div class="warning-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></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>
<!-- TSCM Device Details Modal -->
<div class="tscm-modal-overlay" id="tscmDeviceModal" style="display: none;"
onclick="if(event.target === this) closeTscmDeviceModal()">
<div class="tscm-modal">
<button class="tscm-modal-close" onclick="closeTscmDeviceModal()">&times;</button>
<div id="tscmDeviceModalContent"></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>
<!-- Hamburger Menu Button (Mobile) -->
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Toggle navigation menu">
<span></span>
<span></span>
<span></span>
</button>
<a href="https://smittix.github.io/intercept" target="_blank" rel="noopener noreferrer" class="logo">
<svg width="50" height="50" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Signal brackets - left side -->
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
opacity="0.5" />
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
opacity="0.7" />
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
<!-- Signal brackets - right side -->
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round"
opacity="0.5" />
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round"
opacity="0.7" />
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round" />
<!-- The 'i' letter -->
<!-- dot of i -->
<circle cx="50" cy="22" r="6" fill="#00ff88" />
<!-- stem of i with styled terminals -->
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff" />
<!-- top terminal bar -->
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff" />
<!-- bottom terminal bar -->
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff" />
</svg>
</a>
<h1>iNTERCEPT <span class="tagline">// See the Invisible</span> <span class="version-badge">v{{ version
}}</span></h1>
<p class="subtitle">Signal Intelligence & Counter Surveillance Platform <span class="active-mode-indicator"
id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span></p>
</header>
<!-- Mode Navigation Bar -->
<nav class="mode-nav">
<div class="mode-nav-dropdown" data-group="sdr">
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('sdr')">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>
<span class="nav-label">SDR / RF</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
<button class="mode-nav-btn active" onclick="switchMode('pager')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span><span class="nav-label">Pager</span></button>
<button class="mode-nav-btn" onclick="switchMode('sensor')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span><span class="nav-label">433MHz</span></button>
<button class="mode-nav-btn" onclick="switchMode('rtlamr')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span><span class="nav-label">Meters</span></button>
<a href="/adsb/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span><span class="nav-label">Aircraft</span></a>
<a href="/ais/dashboard" class="mode-nav-btn" style="text-decoration: none;"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span><span class="nav-label">Vessels</span></a>
<button class="mode-nav-btn" onclick="switchMode('aprs')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span><span class="nav-label">APRS</span></button>
<button class="mode-nav-btn" onclick="switchMode('listening')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span><span class="nav-label">Listening Post</span></button>
<button class="mode-nav-btn" onclick="switchMode('spystations')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span><span class="nav-label">Spy Stations</span></button>
<button class="mode-nav-btn" onclick="switchMode('meshtastic')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span><span class="nav-label">Meshtastic</span></button>
</div>
</div>
<div class="mode-nav-dropdown" data-group="wireless">
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('wireless')">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="nav-label">Wireless</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
<button class="mode-nav-btn" onclick="switchMode('wifi')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span><span class="nav-label">WiFi</span></button>
<button class="mode-nav-btn" onclick="switchMode('bluetooth')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span><span class="nav-label">Bluetooth</span></button>
</div>
</div>
<div class="mode-nav-dropdown" data-group="security">
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('security')">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="nav-label">Security</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
<button class="mode-nav-btn" onclick="switchMode('tscm')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span><span class="nav-label">TSCM</span></button>
</div>
</div>
<div class="mode-nav-dropdown" data-group="space">
<button class="mode-nav-dropdown-btn" onclick="toggleNavDropdown('space')">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span>
<span class="nav-label">Space</span>
<span class="dropdown-arrow icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<div class="mode-nav-dropdown-menu">
<button class="mode-nav-btn" onclick="switchMode('satellite')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span><span class="nav-label">Satellite</span></button>
<button class="mode-nav-btn" onclick="switchMode('sstv')"><span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span><span class="nav-label">ISS SSTV</span></button>
</div>
</div>
<div class="mode-nav-actions">
<a href="/satellite/dashboard" target="_blank" class="nav-action-btn" id="satelliteDashboardBtn"
style="display: none;">
<span class="nav-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></span><span class="nav-label">Full Dashboard</span>
</a>
</div>
<div class="nav-utilities">
<div class="nav-clock">
<span class="utc-label">UTC</span>
<span class="utc-time" id="headerUtcTime">--:--:--</span>
</div>
<div class="nav-divider"></div>
<div class="nav-tools">
<button class="nav-tool-btn" onclick="toggleAnimations()" title="Toggle Animations">
<span class="icon-effects-on icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
<span class="icon-effects-off icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/><line x1="2" y1="2" x2="22" y2="22"/></svg></span>
</button>
<button class="nav-tool-btn" onclick="toggleTheme()" title="Toggle Light/Dark Theme">
<span class="icon-moon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></span>
<span class="icon-sun icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
</button>
<a href="/controller/monitor" class="nav-tool-btn" title="Network Monitor - Multi-Agent View" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span></a>
<a href="/controller/manage" class="nav-tool-btn" title="Manage Remote Agents" style="text-decoration: none;"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span></a>
<button class="nav-tool-btn" onclick="showSettings()" title="Settings"><span class="icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span></button>
<button class="nav-tool-btn" onclick="showHelp()" title="Help & Documentation">?</button>
<button class="nav-tool-btn" onclick="logout(event)" title="Logout">
<span class="power-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg></span>
</button>
</div>
</div>
</nav>
<!-- Mobile Navigation Bar (simplified mode switching) -->
<nav class="mobile-nav-bar" id="mobileNavBar">
<button class="mobile-nav-btn active" data-mode="pager" onclick="switchMode('pager')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> Pager</button>
<button class="mobile-nav-btn" data-mode="sensor" onclick="switchMode('sensor')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> 433MHz</button>
<button class="mobile-nav-btn" data-mode="rtlamr" onclick="switchMode('rtlamr')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span> Meters</button>
<a href="/adsb/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z"/></svg></span> Aircraft</a>
<a href="/ais/dashboard" class="mobile-nav-btn" style="text-decoration: none;"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18l2 2h14l2-2"/><path d="M5 18v-4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v4"/><path d="M12 12V6"/><path d="M12 6l4 3"/></svg></span> Vessels</a>
<button class="mobile-nav-btn" data-mode="aprs" onclick="switchMode('aprs')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg></span> APRS</button>
<button class="mobile-nav-btn" data-mode="wifi" onclick="switchMode('wifi')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> WiFi</button>
<button class="mobile-nav-btn" data-mode="bluetooth" onclick="switchMode('bluetooth')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span> BT</button>
<button class="mobile-nav-btn" data-mode="tscm" onclick="switchMode('tscm')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span> TSCM</button>
<button class="mobile-nav-btn" data-mode="satellite" onclick="switchMode('satellite')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> Sat</button>
<button class="mobile-nav-btn" data-mode="sstv" onclick="switchMode('sstv')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg></span> SSTV</button>
<button class="mobile-nav-btn" data-mode="listening" onclick="switchMode('listening')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg></span> Scanner</button>
<button class="mobile-nav-btn" data-mode="spystations" onclick="switchMode('spystations')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span> Spy</button>
<button class="mobile-nav-btn" data-mode="meshtastic" onclick="switchMode('meshtastic')"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg></span> Mesh</button>
</nav>
<!-- Mobile Drawer Overlay -->
<div class="drawer-overlay" id="drawerOverlay"></div>
<div class="container">
<div class="main-content">
<div class="sidebar mobile-drawer" id="mainSidebar">
<!-- Agent Selector -->
<div class="section" id="agentSection">
<h3>Signal Source</h3>
<div class="form-group">
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Agent</label>
<div style="display: flex; align-items: center; gap: 8px;">
<select id="agentSelect" style="flex: 1;">
<option value="local">Local (This Device)</option>
</select>
<span id="agentStatusDot" class="agent-status-dot online" title="Agent status"></span>
</div>
</div>
<div id="agentInfo" class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
<span id="agentStatusText">Local</span>
<span id="agentLatencyText" style="margin-left: 6px; color: var(--accent-cyan);"></span>
</div>
<!-- Agent health panel (shows all agents when expanded) -->
<details style="margin-top: 8px;">
<summary style="font-size: 10px; color: #888; cursor: pointer;">All Agents Health</summary>
<div id="agentHealthPanel" style="margin-top: 6px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; max-height: 120px; overflow-y: auto;">
<div style="color: var(--text-muted); font-size: 11px;">Loading...</div>
</div>
</details>
<!-- Multi-agent mode toggle -->
<div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" id="showAllAgents" onchange="toggleMultiAgentMode()">
<span style="font-size: 11px;">Show All Agents Combined</span>
</label>
</div>
<a href="/controller/manage" class="preset-btn" style="display: block; text-align: center; text-decoration: none; margin-top: 8px; font-size: 11px;">
Manage Agents
</a>
</div>
<div class="section" id="rtlDeviceSection">
<h3>SDR Device</h3>
<div class="form-group">
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Hardware Type</label>
<select id="sdrTypeSelect" onchange="onSDRTypeChanged()">
<option value="rtlsdr">RTL-SDR</option>
<option value="limesdr">LimeSDR</option>
<option value="hackrf">HackRF</option>
<option value="airspy">Airspy</option>
</select>
</div>
<div class="form-group">
<label style="font-size: 11px; color: #888; margin-bottom: 4px;">Device</label>
<select id="deviceSelect">
{% if devices %}
{% for device in devices %}
<option value="{{ device.index }}"
data-sdr-type="{{ device.sdr_type | default('rtlsdr') }}">{{ device.index }}: {{
device.name }}</option>
{% endfor %}
{% else %}
<option value="0">No devices found</option>
{% endif %}
</select>
</div>
<div id="deviceCapabilities" class="info-text"
style="font-size: 11px; margin-bottom: 8px; padding: 6px; background: #0a0a1a; border-radius: 4px;">
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">Freq:</span><span id="capFreqRange">24-1766 MHz</span>
<span style="color: #888;">Gain:</span><span id="capGainRange">0-50 dB</span>
</div>
</div>
<!-- Bias-T Power Toggle - Prominent Location -->
<div
style="display: flex; align-items: center; gap: 10px; padding: 8px; margin-bottom: 8px; background: linear-gradient(90deg, rgba(255,100,0,0.2), rgba(255,100,0,0.05)); border: 1px solid var(--accent-orange); border-radius: 4px;">
<input type="checkbox" id="biasT" onchange="saveBiasTSetting()"
style="width: 18px; height: 18px; accent-color: var(--accent-orange);">
<div>
<div style="color: var(--accent-orange); font-weight: bold; font-size: 12px;">Bias-T Power
</div>
<div style="color: #888; font-size: 9px;">Powers external LNA/preamp</div>
</div>
</div>
<button class="preset-btn" onclick="refreshDevices()" style="width: 100%;">
Refresh Devices
</button>
<!-- Remote SDR (rtl_tcp) -->
<div class="form-group" style="margin-top: 10px;">
<label class="inline-checkbox">
<input type="checkbox" id="useRemoteSDR" onchange="toggleRemoteSDR()">
Use Remote SDR (rtl_tcp)
</label>
</div>
<div id="remoteSDRConfig" style="display: none; margin-bottom: 10px;">
<div class="form-group">
<label style="font-size: 11px; color: #888;">Host</label>
<input type="text" id="rtlTcpHost" placeholder="192.168.1.100" style="width: 100%;">
</div>
<div class="form-group">
<label style="font-size: 11px; color: #888;">Port</label>
<input type="number" id="rtlTcpPort" value="1234" min="1" max="65535" style="width: 100%;">
</div>
<div class="info-text" style="font-size: 10px; color: #666; margin-top: 4px;">
Connect to rtl_tcp server on remote machine.<br>
Start server with: <code style="color: #00d4ff;">rtl_tcp -a 0.0.0.0</code>
</div>
</div>
<div id="toolStatusPager" class="info-text tool-status-section"
style="display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>rtl_fm:</span><span class="tool-status {{ 'ok' if tools.rtl_fm else 'missing' }}">{{ 'OK'
if tools.rtl_fm else 'Missing' }}</span>
<span>multimon-ng:</span><span
class="tool-status {{ 'ok' if tools.multimon else 'missing' }}">{{ 'OK' if tools.multimon
else 'Missing' }}</span>
</div>
<div id="toolStatusSensor" class="info-text tool-status-section"
style="display: none; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;">
<span>rtl_433:</span><span class="tool-status {{ 'ok' if tools.rtl_433 else 'missing' }}">{{
'OK' if tools.rtl_433 else 'Missing' }}</span>
</div>
</div>
{% include 'partials/modes/pager.html' %}
{% include 'partials/modes/sensor.html' %}
{% include 'partials/modes/rtlamr.html' %}
{% include 'partials/modes/wifi.html' %}
{% include 'partials/modes/bluetooth.html' %}
{% include 'partials/modes/aprs.html' %}
{% include 'partials/modes/satellite.html' %}
{% include 'partials/modes/sstv.html' %}
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/spy-stations.html' %}
{% include 'partials/modes/meshtastic.html' %}
<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 class="stats" id="pagerStats">
<div title="Total Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg></span> <span id="msgCount">0</span></div>
<div title="POCSAG Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="pocsagCount">0</span></div>
<div title="FLEX Messages"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="5" width="16" height="14" rx="2"/><line x1="8" y1="10" x2="16" y2="10"/><line x1="8" y1="14" x2="12" y2="14"/></svg></span> <span id="flexCount">0</span></div>
</div>
<div class="stats" id="sensorStats" style="display: none;">
<div title="Unique Sensors"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span> <span id="sensorCount">0</span></div>
<div title="Device Types"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg></span> <span id="deviceCount">0</span></div>
</div>
<div class="stats" id="wifiStats" style="display: none;">
<div title="Access Points"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor"/></svg></span> <span id="apCount">0</span></div>
<div title="Connected Clients"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span> <span id="clientCount">0</span></div>
<div title="Captured Handshakes" style="color: var(--accent-green);"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg></span> <span id="handshakeCount">0</span></div>
<div style="color: var(--accent-orange); cursor: pointer;" onclick="showDroneDetails()"
title="Click: Drone details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M3 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 9a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M3 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M17 15a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M9 9l-4 -1"/><path d="M15 9l4 -1"/><path d="M9 15l-4 1"/><path d="M15 15l4 1"/></svg></span> <span id="droneCount">0</span></div>
<div style="color: var(--accent-red); cursor: pointer;" onclick="showRogueApDetails()"
title="Click: Rogue AP details"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span> <span id="rogueApCount">0</span></div>
</div>
<div class="stats" id="satelliteStats" style="display: none;">
<div title="Upcoming Passes"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/></svg></span> <span id="passCount">0</span></div>
</div>
</div>
</div>
<!-- WiFi Layout Container -->
<div class="wifi-layout-container" id="wifiLayoutContainer" style="display: none;">
<!-- Status Bar -->
<div class="wifi-status-bar">
<div class="wifi-status-item">
<span class="wifi-status-label">Networks:</span>
<span class="wifi-status-value" id="wifiNetworkCount">0</span>
</div>
<div class="wifi-status-item">
<span class="wifi-status-label">Clients:</span>
<span class="wifi-status-value" id="wifiClientCount">0</span>
</div>
<div class="wifi-status-item">
<span class="wifi-status-label">Hidden:</span>
<span class="wifi-status-value" id="wifiHiddenCount">0</span>
</div>
<div class="wifi-status-item" id="wifiScanStatus">
<span class="wifi-status-indicator idle"></span>
<span>Ready</span>
</div>
</div>
<!-- Main Content: 3-column layout -->
<div class="wifi-main-content">
<!-- LEFT: Networks Table -->
<div class="wifi-networks-panel">
<div class="wifi-networks-header">
<h5>Discovered Networks</h5>
<div class="wifi-network-filters" id="wifiNetworkFilters">
<button class="wifi-filter-btn active" data-filter="all">All</button>
<button class="wifi-filter-btn" data-filter="2.4">2.4G</button>
<button class="wifi-filter-btn" data-filter="5">5G</button>
<button class="wifi-filter-btn" data-filter="open">Open</button>
<button class="wifi-filter-btn" data-filter="hidden">Hidden</button>
</div>
</div>
<div class="wifi-networks-table-wrapper">
<table class="wifi-networks-table" id="wifiNetworkTable">
<thead>
<tr>
<th class="sortable" data-sort="essid">SSID</th>
<th class="sortable" data-sort="bssid">BSSID</th>
<th class="sortable" data-sort="channel">Ch</th>
<th class="sortable" data-sort="rssi">Signal</th>
<th class="sortable" data-sort="security">Security</th>
<th class="sortable" data-sort="clients">Clients</th>
<th class="col-agent sortable" data-sort="agent">Source</th>
</tr>
</thead>
<tbody id="wifiNetworkTableBody">
<tr class="wifi-network-placeholder">
<td colspan="7">
<div class="placeholder-text">Start scanning to discover networks</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- CENTER: Proximity Radar -->
<div class="wifi-radar-panel">
<h5>Proximity Radar</h5>
<div id="wifiProximityRadar" class="wifi-radar-container"></div>
<div class="wifi-zone-summary">
<div class="wifi-zone near">
<span class="wifi-zone-count" id="wifiZoneImmediate">0</span>
<span class="wifi-zone-label">Near</span>
</div>
<div class="wifi-zone mid">
<span class="wifi-zone-count" id="wifiZoneNear">0</span>
<span class="wifi-zone-label">Mid</span>
</div>
<div class="wifi-zone far">
<span class="wifi-zone-count" id="wifiZoneFar">0</span>
<span class="wifi-zone-label">Far</span>
</div>
</div>
</div>
<!-- RIGHT: Channel Analysis + Security -->
<div class="wifi-analysis-panel">
<div class="wifi-channel-section">
<h5>Channel Analysis</h5>
<div class="wifi-channel-tabs" id="wifiChannelBandTabs">
<button class="channel-band-tab active" data-band="2.4">2.4 GHz</button>
<button class="channel-band-tab" data-band="5">5 GHz</button>
</div>
<div id="wifiChannelChart" class="wifi-channel-chart"></div>
</div>
<div class="wifi-security-section">
<h5>Security Overview</h5>
<div class="wifi-security-stats">
<div class="wifi-security-item wpa3">
<span class="wifi-security-dot"></span>
<span>WPA3</span>
<span class="wifi-security-count" id="wpa3Count">0</span>
</div>
<div class="wifi-security-item wpa2">
<span class="wifi-security-dot"></span>
<span>WPA2</span>
<span class="wifi-security-count" id="wpa2Count">0</span>
</div>
<div class="wifi-security-item wep">
<span class="wifi-security-dot"></span>
<span>WEP</span>
<span class="wifi-security-count" id="wepCount">0</span>
</div>
<div class="wifi-security-item open">
<span class="wifi-security-dot"></span>
<span>Open</span>
<span class="wifi-security-count" id="openCount">0</span>
</div>
</div>
</div>
</div>
</div>
<!-- Detail Drawer (slides up on network selection) -->
<div class="wifi-detail-drawer" id="wifiDetailDrawer">
<div class="wifi-detail-header">
<div class="wifi-detail-title">
<span class="wifi-detail-essid" id="wifiDetailEssid">Network Name</span>
<span class="wifi-detail-bssid" id="wifiDetailBssid">00:00:00:00:00:00</span>
</div>
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">&times;</button>
</div>
<div class="wifi-detail-content" id="wifiDetailContent">
<div class="wifi-detail-grid">
<div class="wifi-detail-stat">
<span class="label">Signal</span>
<span class="value" id="wifiDetailRssi">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Channel</span>
<span class="value" id="wifiDetailChannel">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Band</span>
<span class="value" id="wifiDetailBand">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Security</span>
<span class="value" id="wifiDetailSecurity">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Cipher</span>
<span class="value" id="wifiDetailCipher">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Vendor</span>
<span class="value" id="wifiDetailVendor">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Clients</span>
<span class="value" id="wifiDetailClients">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">First Seen</span>
<span class="value" id="wifiDetailFirstSeen">--</span>
</div>
</div>
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
<h6>Connected Clients</h6>
<div class="wifi-client-list"></div>
</div>
</div>
</div>
</div>
<!-- Bluetooth Layout Container (visualizations left, device cards right) -->
<div class="bt-layout-container" id="btLayoutContainer" style="display: none;">
<!-- Left: Bluetooth Visualizations -->
<div class="bt-visuals-column" id="btVisuals">
<!-- Device Detail Panel (always visible) -->
<div class="bt-detail-panel" id="btDetailPanel">
<div class="bt-detail-header">
<h5>Device Details</h5>
</div>
<div class="bt-detail-body">
<!-- Placeholder shown when no device selected -->
<div class="bt-detail-placeholder" id="btDetailPlaceholder">
<span>Select a device to view details</span>
</div>
<!-- Content shown when device is selected -->
<div class="bt-detail-content" id="btDetailContent" style="display: none;">
<div class="bt-detail-top-row">
<div class="bt-detail-identity">
<div class="bt-detail-name" id="btDetailName">Device Name</div>
<div class="bt-detail-address" id="btDetailAddress">00:00:00:00:00:00</div>
</div>
<div class="bt-detail-rssi-display">
<span class="bt-detail-rssi-value" id="btDetailRssi">--</span>
<span class="bt-detail-rssi-unit">dBm</span>
</div>
</div>
<div class="bt-detail-badges" id="btDetailBadges"></div>
<div class="bt-detail-grid">
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Manufacturer</span>
<span class="bt-detail-stat-value" id="btDetailMfr">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Type</span>
<span class="bt-detail-stat-value" id="btDetailAddrType">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Seen</span>
<span class="bt-detail-stat-value" id="btDetailSeen">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Range</span>
<span class="bt-detail-stat-value" id="btDetailRange">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Min/Max</span>
<span class="bt-detail-stat-value" id="btDetailRssiRange">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">First Seen</span>
<span class="bt-detail-stat-value" id="btDetailFirstSeen">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Last Seen</span>
<span class="bt-detail-stat-value" id="btDetailLastSeen">--</span>
</div>
<div class="bt-detail-stat">
<span class="bt-detail-stat-label">Mfr ID</span>
<span class="bt-detail-stat-value" id="btDetailMfrId">--</span>
</div>
</div>
<div class="bt-detail-bottom-row">
<div class="bt-detail-services" id="btDetailServices" style="display: none;">
<span class="bt-detail-services-list" id="btDetailServicesList"></span>
</div>
<button class="bt-detail-btn" onclick="BluetoothMode.copyAddress()">Copy</button>
</div>
</div>
</div>
</div>
<!-- Main area: Side panels + Radar -->
<div class="bt-main-area">
<!-- Left side panels -->
<div class="bt-side-panels">
<div class="wifi-visual-panel bt-side-panel">
<h5>Tracker Detection</h5>
<div id="btTrackerList" style="font-size: 11px; max-height: 200px; overflow-y: auto;">
<div style="color: var(--text-dim); padding: 10px; text-align: center;">Monitoring for AirTags, Tiles...</div>
</div>
</div>
<div class="wifi-visual-panel bt-side-panel">
<h5>Signal Distribution</h5>
<div class="bt-signal-dist" id="btSignalDist">
<div class="signal-range"><span>Strong (-50+)</span>
<div class="signal-bar-bg">
<div class="signal-bar strong" id="btSignalStrong" style="width: 0%;"></div>
</div><span id="btSignalStrongCount">0</span>
</div>
<div class="signal-range"><span>Medium (-70)</span>
<div class="signal-bar-bg">
<div class="signal-bar medium" id="btSignalMedium" style="width: 0%;"></div>
</div><span id="btSignalMediumCount">0</span>
</div>
<div class="signal-range"><span>Weak (-90)</span>
<div class="signal-bar-bg">
<div class="signal-bar weak" id="btSignalWeak" style="width: 0%;"></div>
</div><span id="btSignalWeakCount">0</span>
</div>
</div>
</div>
</div>
<!-- Proximity Radar -->
<div class="wifi-visual-panel bt-radar-panel">
<h5>Proximity Radar</h5>
<div id="btProximityRadar" style="display: flex; justify-content: center; padding: 8px 0;"></div>
<div id="btRadarControls" style="display: flex; gap: 6px; justify-content: center; margin-top: 8px; flex-wrap: wrap;">
<button data-filter="newOnly" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">New Only</button>
<button data-filter="strongest" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Strongest</button>
<button data-filter="unapproved" class="bt-radar-filter-btn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Unapproved</button>
<button id="btRadarPauseBtn" style="padding: 4px 10px; font-size: 10px; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 4px; color: #888; cursor: pointer;">Pause</button>
</div>
<div id="btZoneSummary" style="display: flex; justify-content: center; gap: 24px; margin-top: 12px; font-size: 11px;">
<div style="text-align: center;">
<span id="btZoneImmediate" style="font-size: 20px; font-weight: 600; color: #22c55e;">0</span>
<div style="color: #666;">Immediate</div>
</div>
<div style="text-align: center;">
<span id="btZoneNear" style="font-size: 20px; font-weight: 600; color: #eab308;">0</span>
<div style="color: #666;">Near</div>
</div>
<div style="text-align: center;">
<span id="btZoneFar" style="font-size: 20px; font-weight: 600; color: #ef4444;">0</span>
<div style="color: #666;">Far</div>
</div>
</div>
</div>
</div>
</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="bt-device-filters" id="btDeviceFilters">
<button class="bt-filter-btn active" data-filter="all">All</button>
<button class="bt-filter-btn" data-filter="new">New</button>
<button class="bt-filter-btn" data-filter="named">Named</button>
<button class="bt-filter-btn" data-filter="strong">Strong</button>
</div>
<div class="wifi-device-list-content" id="btDeviceListContent">
<div style="color: var(--text-dim); text-align: center; padding: 30px;">
Start scanning to discover Bluetooth devices
</div>
</div>
</div>
</div>
<!-- Bluetooth Device Detail Modal -->
<div id="btDeviceModal" class="bt-modal-overlay" style="display: none;">
<div class="bt-modal">
<div class="bt-modal-header">
<h4 id="btModalTitle">Device Details</h4>
<button class="bt-modal-close" onclick="BluetoothMode.closeModal()">&times;</button>
</div>
<div class="bt-modal-body" id="btModalBody">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<!-- APRS Visualizations -->
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px;">
<!-- APRS Function Bar -->
<div class="aprs-strip">
<div class="aprs-strip-inner">
<!-- Stats -->
<div class="strip-stat">
<span class="strip-value" id="aprsStripFreq">--</span>
<span class="strip-label">MHz</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="aprsStripStations">0</span>
<span class="strip-label">STATIONS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="aprsStripPackets">0</span>
<span class="strip-label">PACKETS</span>
</div>
<div class="strip-stat signal-stat" id="aprsStripSignalStat">
<span class="strip-value" id="aprsStripSignal">--</span>
<span class="strip-label">SIGNAL</span>
</div>
<div class="strip-divider"></div>
<!-- Controls -->
<div class="strip-control">
<select id="aprsStripRegion" class="strip-select">
<option value="north_america">N. America (144.390)</option>
<option value="europe">Europe (144.800)</option>
<option value="uk">UK (144.800)</option>
<option value="australia">Australia (145.175)</option>
<option value="new_zealand">New Zealand (144.575)</option>
<option value="argentina">Argentina (144.930)</option>
<option value="brazil">Brazil (145.570)</option>
<option value="japan">Japan (144.640)</option>
<option value="china">China (144.640)</option>
<option value="custom">Custom Frequency</option>
</select>
</div>
<div class="strip-control" id="aprsStripCustomFreqControl" style="display: none;">
<span class="strip-input-label">FREQ (MHz)</span>
<input type="number" id="aprsStripCustomFreq" class="strip-input" placeholder="144.390" step="0.001" min="144" max="146">
</div>
<div class="strip-control">
<span class="strip-input-label">GAIN</span>
<input type="number" id="aprsStripGain" class="strip-input" value="40" min="0" max="50">
</div>
<div class="strip-divider"></div>
<!-- Tool Status Indicators -->
<div class="strip-tools">
<span class="strip-tool" id="aprsStripDirewolf" title="direwolf">DW</span>
<span class="strip-tool" id="aprsStripMultimon" title="multimon-ng">MM</span>
</div>
<span id="aprsGpsIndicator" class="gps-indicator" style="display: none;"
title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
<div class="strip-divider"></div>
<!-- Actions -->
<button type="button" class="strip-btn primary" id="aprsStripStartBtn"
onclick="startAprs()">
▶ START
</button>
<button type="button" class="strip-btn stop" id="aprsStripStopBtn" onclick="stopAprs()"
style="display: none;">
◼ STOP
</button>
<!-- Status -->
<div class="strip-status">
<div class="status-dot inactive" id="aprsStripDot"></div>
<span id="aprsStripStatus">STANDBY</span>
</div>
<div class="strip-time" id="aprsStripTime">--:--:-- UTC</div>
</div>
</div>
<!-- Top row: Map and Station List side by side -->
<div style="display: flex; gap: 10px; flex: 1; min-height: 0;">
<!-- Map Panel (larger) -->
<div class="wifi-visual-panel"
style="flex: 2; display: flex; flex-direction: column; min-width: 0;">
<h5
style="color: var(--accent-cyan); text-shadow: 0 0 10px var(--accent-cyan); padding: 0 10px; margin-bottom: 8px;">
APRS STATION MAP</h5>
<div class="aircraft-map-container" style="flex: 1; display: flex; flex-direction: column;">
<div class="map-header">
<span id="aprsMapTime">--:--:--</span>
<span id="aprsMapStatus">STANDBY</span>
</div>
<div id="aprsMap" style="flex: 1; min-height: 350px;"></div>
<div class="map-footer">
<span>STATIONS: <span id="aprsStationCount">0</span></span>
<span>PACKETS: <span id="aprsPacketCount">0</span></span>
</div>
</div>
</div>
<!-- Station List Panel -->
<div class="wifi-visual-panel"
style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column;">
<h5
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px;">
STATION LIST</h5>
<div id="aprsFilterBarContainer"></div>
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px;">
<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">
No stations received yet
</div>
</div>
</div>
</div>
<!-- Bottom row: Packet Log -->
<div class="wifi-visual-panel" style="display: flex; flex-direction: column; max-height: 200px;">
<h5
style="color: var(--accent-orange); text-shadow: 0 0 10px var(--accent-orange); margin-bottom: 8px;">
PACKET LOG</h5>
<div id="aprsPacketLog"
style="flex: 1; overflow-y: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; background: rgba(0,0,0,0.3); padding: 8px; border-radius: 4px;">
<div style="color: var(--text-muted);">Waiting for packets...</div>
</div>
</div>
</div>
<!-- Listening Post Visualizations - Professional Ham Radio Scanner -->
<div class="wifi-visuals" id="listeningPostVisuals" style="display: none;">
<!-- TOP: FREQUENCY DISPLAY PANEL -->
<div class="radio-module-box scanner-main" style="grid-column: span 4; padding: 12px;">
<div style="display: flex; gap: 15px; align-items: stretch;">
<!-- Main Frequency Display -->
<div
style="flex: 1; text-align: center; padding: 15px 20px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
<div class="freq-status" id="mainScannerModeLabel"
style="font-size: 10px; color: var(--text-muted); margin-bottom: 4px; letter-spacing: 3px;">
STOPPED</div>
<div style="display: flex; justify-content: center; align-items: baseline; gap: 8px;">
<div class="freq-digits" id="mainScannerFreq"
style="font-size: 52px; font-weight: bold; color: var(--accent-cyan); text-shadow: 0 0 30px var(--accent-cyan); font-family: 'JetBrains Mono', monospace; letter-spacing: 3px;">
118.000</div>
<span class="freq-unit"
style="font-size: 20px; color: var(--text-secondary); font-weight: 500;">MHz</span>
</div>
<div
style="display: flex; justify-content: center; align-items: center; margin-top: 6px;">
<span class="freq-mode-badge" id="mainScannerMod"
style="background: var(--accent-cyan); color: #000; padding: 3px 12px; border-radius: 4px; font-size: 12px; font-weight: bold;">AM</span>
</div>
<!-- Progress bar -->
<div id="mainScannerProgress" style="display: none; margin-top: 12px;">
<div
style="display: flex; justify-content: space-between; font-size: 9px; color: var(--text-muted); margin-bottom: 3px;">
<span id="mainRangeStart">--</span>
<span id="mainRangeEnd">--</span>
</div>
<div
style="height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
<div class="scan-bar" id="mainProgressBar"
style="height: 100%; background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); width: 0%; transition: width 0.1s;">
</div>
</div>
</div>
</div>
<!-- Synthesizer + Audio Output Panel -->
<div style="width: 320px; display: flex; flex-direction: column; gap: 8px;">
<!-- Synthesizer Display -->
<div
style="flex: 1; padding: 10px; background: rgba(0,0,0,0.6); border-radius: 6px; border: 1px solid var(--border-color);">
<div
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px;">
Synthesizer</div>
<canvas id="synthesizerCanvas"
style="width: 100%; height: 60px; background: rgba(0,0,0,0.4); border-radius: 4px;"></canvas>
</div>
<!-- Audio Output -->
<div
style="padding: 10px; background: rgba(0,0,0,0.4); border-radius: 6px; border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<div
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">
Audio Output</div>
</div>
<audio id="scannerAudioPlayer" style="width: 100%; height: 28px;" controls></audio>
<div style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
<span style="font-size: 7px; color: var(--text-muted);">LEVEL</span>
<div
style="flex: 1; height: 8px; background: rgba(0,0,0,0.5); border-radius: 4px; overflow: hidden;">
<div id="audioSignalBar"
style="height: 100%; background: linear-gradient(90deg, var(--accent-green), var(--accent-cyan), var(--accent-orange), var(--accent-red)); width: 0%; transition: width 0.1s;">
</div>
</div>
<span id="audioSignalDb"
style="font-size: 8px; color: var(--text-muted); min-width: 40px; text-align: right;">--
dB</span>
</div>
<!-- Signal Alert inline -->
<div id="mainSignalAlert"
style="display: none; background: rgba(0, 255, 100, 0.2); border: 1px solid var(--accent-green); border-radius: 4px; padding: 5px; text-align: center; margin-top: 8px;">
<span style="font-size: 9px; color: var(--accent-green); font-weight: bold;">
SIGNAL</span>
<button class="tune-btn" onclick="skipSignal()"
style="margin-left: 8px; padding: 2px 8px; font-size: 8px;">Skip</button>
</div>
</div>
</div>
</div>
</div>
<!-- CONTROL PANEL: Tuning Section | Mode/Band | Action Buttons -->
<div class="radio-module-box" style="grid-column: span 4; padding: 12px;">
<div style="display: flex; gap: 15px; align-items: stretch;">
<!-- LEFT: Tuning Section -->
<div
style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 20px; padding: 10px 15px; background: rgba(0,0,0,0.3); border-radius: 8px; border: 1px solid var(--border-color);">
<!-- Fine Tune Buttons (Left of dial) -->
<div style="display: flex; flex-direction: column; gap: 4px;">
<button class="tune-btn" onclick="tuneFreq(-1)"
style="padding: 8px 12px; font-size: 11px;">-1</button>
<button class="tune-btn" onclick="tuneFreq(-0.1)"
style="padding: 8px 12px; font-size: 11px;">-.1</button>
<button class="tune-btn" onclick="tuneFreq(-0.05)"
style="padding: 8px 12px; font-size: 11px;">-.05</button>
<button class="tune-btn" onclick="tuneFreq(-0.005)"
style="padding: 8px 12px; font-size: 10px;">-.005</button>
</div>
<!-- Main Tuning Dial -->
<div style="display: flex; flex-direction: column; align-items: center;">
<div class="tuning-dial" id="mainTuningDial" data-value="118" data-min="24"
data-max="1800" data-step="0.1" style="width: 100px; height: 100px;"></div>
<div
style="font-size: 9px; color: var(--text-muted); margin-top: 6px; text-transform: uppercase; letter-spacing: 1px;">
Tune</div>
<div
style="font-size: 8px; color: var(--text-muted); margin-top: 3px; opacity: 0.6;"
title="Arrow keys: &#177;0.05/0.1 MHz&#10;Shift+Arrow: &#177;0.005/1 MHz">
&#9000; arrow keys</div>
</div>
<!-- Fine Tune Buttons (Right of dial) -->
<div style="display: flex; flex-direction: column; gap: 4px;">
<button class="tune-btn" onclick="tuneFreq(1)"
style="padding: 8px 12px; font-size: 11px;">+1</button>
<button class="tune-btn" onclick="tuneFreq(0.1)"
style="padding: 8px 12px; font-size: 11px;">+.1</button>
<button class="tune-btn" onclick="tuneFreq(0.05)"
style="padding: 8px 12px; font-size: 11px;">+.05</button>
<button class="tune-btn" onclick="tuneFreq(0.005)"
style="padding: 8px 12px; font-size: 10px;">+.005</button>
</div>
<!-- Divider -->
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
<!-- Settings: Step & Dwell -->
<div style="display: flex; flex-direction: column; gap: 8px; min-width: 90px;">
<div
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
<span style="color: var(--text-muted);">Step</span>
<select id="radioScanStep"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
<option value="8.33">8.33k</option>
<option value="12.5">12.5k</option>
<option value="25" selected>25k</option>
<option value="50">50k</option>
<option value="100">100k</option>
</select>
</div>
<div
style="display: flex; justify-content: space-between; align-items: center; font-size: 10px;">
<span style="color: var(--text-muted);">Dwell</span>
<select id="radioScanDwell"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 3px 5px; border-radius: 3px; font-size: 9px;">
<option value="2">2s</option>
<option value="5">5s</option>
<option value="10" selected>10s</option>
<option value="30">30s</option>
</select>
</div>
</div>
<!-- Divider -->
<div style="width: 1px; height: 80px; background: var(--border-color);"></div>
<!-- SQL, Gain, Vol Knobs -->
<div style="display: flex; gap: 15px;">
<div class="knob-container">
<div class="radio-knob" id="radioSquelchKnob" data-value="30" data-min="0"
data-max="100" data-step="1"></div>
<div class="knob-label">SQL</div>
<div class="knob-value" id="radioSquelchValue">30</div>
</div>
<div class="knob-container">
<div class="radio-knob" id="radioGainKnob" data-value="40" data-min="0"
data-max="50" data-step="1"></div>
<div class="knob-label">GAIN</div>
<div class="knob-value" id="radioGainValue">40</div>
</div>
<div class="knob-container">
<div class="radio-knob" id="radioVolumeKnob" data-value="80" data-min="0"
data-max="100" data-step="1"></div>
<div class="knob-label">VOL</div>
<div class="knob-value" id="radioVolumeValue">80</div>
</div>
</div>
</div>
<!-- CENTER: Mode & Band (Stacked) -->
<div
style="width: 130px; display: flex; flex-direction: column; gap: 10px; justify-content: center;">
<div>
<div
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
Modulation</div>
<div class="radio-button-bank compact" id="modBtnBank"
style="flex-wrap: wrap; gap: 2px;">
<button class="radio-btn active" data-mod="am" onclick="setModulation('am')"
style="padding: 6px 10px; font-size: 10px;">AM</button>
<button class="radio-btn" data-mod="fm" onclick="setModulation('fm')"
style="padding: 6px 10px; font-size: 10px;">NFM</button>
<button class="radio-btn" data-mod="wfm" onclick="setModulation('wfm')"
style="padding: 6px 10px; font-size: 10px;">WFM</button>
<button class="radio-btn" data-mod="usb" onclick="setModulation('usb')"
style="padding: 6px 10px; font-size: 10px;">USB</button>
<button class="radio-btn" data-mod="lsb" onclick="setModulation('lsb')"
style="padding: 6px 10px; font-size: 10px;">LSB</button>
</div>
</div>
<div>
<div
style="font-size: 8px; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 1px;">
Band</div>
<div class="radio-button-bank compact" id="bandBtnBank"
style="flex-wrap: wrap; gap: 2px;">
<button class="radio-btn" data-band="fm" onclick="setBand('fm')"
style="padding: 6px 10px; font-size: 10px;">FM</button>
<button class="radio-btn active" data-band="air" onclick="setBand('air')"
style="padding: 6px 10px; font-size: 10px;">AIR</button>
<button class="radio-btn" data-band="marine" onclick="setBand('marine')"
style="padding: 6px 10px; font-size: 10px;">MAR</button>
<button class="radio-btn" data-band="amateur2m" onclick="setBand('amateur2m')"
style="padding: 6px 10px; font-size: 10px;">2M</button>
<button class="radio-btn" data-band="amateur70cm"
onclick="setBand('amateur70cm')"
style="padding: 6px 10px; font-size: 10px;">70CM</button>
</div>
</div>
</div>
<!-- RIGHT: Scan Range + Action Buttons -->
<div style="width: 175px; display: flex; flex-direction: column; gap: 8px;">
<!-- Frequency Range - Prominent -->
<div
style="background: rgba(0,0,0,0.4); border: 1px solid var(--border-color); border-radius: 6px; padding: 10px;">
<div
style="font-size: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; text-align: center;">
Scan Range (MHz)</div>
<div style="display: flex; align-items: center; gap: 6px;">
<div style="flex: 1;">
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
START</div>
<input type="number" id="radioScanStart" value="118" step="0.1"
class="radio-input"
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
</div>
<span
style="color: var(--text-muted); font-size: 16px; padding-top: 12px;"></span>
<div style="flex: 1;">
<div style="font-size: 7px; color: var(--text-muted); margin-bottom: 2px;">
END</div>
<input type="number" id="radioScanEnd" value="137" step="0.1"
class="radio-input"
style="width: 100%; font-size: 16px; padding: 8px 6px; text-align: center; font-family: 'JetBrains Mono', monospace; font-weight: bold; color: var(--accent-cyan);">
</div>
</div>
</div>
<!-- Action Buttons -->
<button class="radio-action-btn scan" id="radioScanBtn" onclick="toggleScanner()"
style="padding: 12px; font-size: 13px; width: 100%; font-weight: bold;"><span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg></span>SCAN</button>
<button class="radio-action-btn" id="radioListenBtn" onclick="toggleDirectListen()"
style="padding: 10px; font-size: 12px; width: 100%; background: var(--accent-green); border: none; color: #fff;"><span class="icon icon--sm" style="margin-right: 4px;"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg></span>LISTEN</button>
</div>
</div>
</div>
<!-- QUICK TUNE BAR -->
<div class="radio-module-box" style="grid-column: span 4; padding: 8px 12px;">
<div style="display: flex; align-items: center; gap: 10px;">
<span
style="font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px;">Quick
Tune:</span>
<button class="preset-freq-btn" onclick="quickTune(121.5, 'am')">121.5 GUARD</button>
<button class="preset-freq-btn" onclick="quickTune(156.8, 'fm')">156.8 CH16</button>
<button class="preset-freq-btn" onclick="quickTune(145.5, 'fm')">145.5 2M</button>
<button class="preset-freq-btn" onclick="quickTune(98.1, 'wfm')">98.1 FM</button>
<button class="preset-freq-btn" onclick="quickTune(462.5625, 'fm')">462.56 FRS</button>
<button class="preset-freq-btn" onclick="quickTune(446.0, 'fm')">446.0 PMR</button>
</div>
</div>
<!-- SIGNAL HITS -->
<div class="radio-module-box" style="grid-column: span 2; padding: 10px;">
<div class="module-header"
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
<span>SIGNAL HITS</span>
<span
style="font-size: 10px; color: var(--accent-cyan); background: rgba(0,212,255,0.1); padding: 2px 8px; border-radius: 3px;"
id="scannerHitCount">0 signals</span>
</div>
<div id="scannerHitsList" style="overflow-y: auto; max-height: 100px;">
<table style="width: 100%; font-size: 10px; border-collapse: collapse;">
<thead>
<tr style="color: var(--text-muted); border-bottom: 1px solid var(--border-color);">
<th style="text-align: left; padding: 4px;">Time</th>
<th style="text-align: left; padding: 4px;">Frequency</th>
<th style="text-align: left; padding: 4px;">Mod</th>
<th style="text-align: center; padding: 4px; width: 60px;">Action</th>
</tr>
</thead>
<tbody id="scannerHitsBody">
<tr style="color: var(--text-muted);">
<td colspan="4" style="padding: 15px; text-align: center; font-size: 10px;">No
signals detected</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- STATS PANEL -->
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
<div class="module-header" style="margin-bottom: 8px; font-size: 10px;">
<span>STATS</span>
</div>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">SIGNALS</span>
<span
style="color: var(--accent-green); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
id="mainSignalCount">0</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">SCANNED</span>
<span
style="color: var(--accent-cyan); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
id="mainFreqsScanned">0</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="font-size: 9px; color: var(--text-muted);">CYCLES</span>
<span
style="color: var(--accent-orange); font-size: 18px; font-weight: bold; font-family: 'JetBrains Mono', monospace;"
id="mainScanCycles">0</span>
</div>
</div>
</div>
<!-- ACTIVITY LOG -->
<div class="radio-module-box" style="grid-column: span 1; padding: 10px;">
<div class="module-header"
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px;">
<span>LOG</span>
<div style="display: flex; gap: 4px;">
<button class="tune-btn" onclick="exportScannerLog()"
style="padding: 2px 6px; font-size: 8px;">Export</button>
<button class="tune-btn" onclick="clearScannerLog()"
style="padding: 2px 6px; font-size: 8px;">Clear</button>
</div>
</div>
<div class="log-content" id="scannerActivityLog"
style="max-height: 100px; overflow-y: auto; font-size: 9px; background: rgba(0,0,0,0.2); border-radius: 3px; padding: 6px;">
<div class="scanner-log-entry" style="color: var(--text-muted);">Ready</div>
</div>
</div>
<!-- SIGNAL ACTIVITY TIMELINE -->
<div class="radio-module-box" style="grid-column: span 4; padding: 10px;">
<div id="listeningPostTimelineContainer"></div>
</div>
</div>
<!-- Satellite Dashboard (Embedded) -->
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
allowfullscreen>
</iframe>
</div>
<!-- TSCM Dashboard -->
<div id="tscmVisuals" class="tscm-dashboard" style="display: none; padding: 16px;">
<!-- Legal Disclaimer Banner -->
<div class="tscm-legal-banner"
style="margin-bottom: 12px; padding: 8px 12px; background: rgba(74, 158, 255, 0.1); border: 1px solid rgba(74, 158, 255, 0.3); border-radius: 4px; font-size: 10px; color: #ffffff;">
<strong>TSCM Screening Tool:</strong> This system identifies wireless and RF anomalies.
Findings are indicators, NOT confirmed surveillance devices.
No content is intercepted or decoded. Professional verification required.
</div>
<!-- Active Meeting Banner (hidden by default) -->
<div id="tscmMeetingBanner" class="tscm-meeting-banner" style="display: none;">
<div class="meeting-indicator">
<span class="meeting-pulse"></span>
<span class="meeting-text">MEETING WINDOW ACTIVE</span>
</div>
<div class="meeting-info">
<span id="tscmMeetingBannerName"></span>
<span id="tscmMeetingBannerTime"></span>
</div>
</div>
<!-- Capabilities Summary Bar -->
<div id="tscmCapabilitiesBar" class="tscm-capabilities-bar" style="display: none;">
<div class="cap-item" id="capWifi" title="WiFi Capability">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="cap-status" id="capWifiStatus">--</span>
</div>
<div class="cap-item" id="capBt" title="Bluetooth Capability">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
<span class="cap-status" id="capBtStatus">--</span>
</div>
<div class="cap-item" id="capRf" title="RF/SDR Capability">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
<span class="cap-status" id="capRfStatus">--</span>
</div>
<div class="cap-item" id="capRoot" title="Privilege Level">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="cap-status" id="capRootStatus">--</span>
</div>
<div class="cap-limitations" id="capLimitations" onclick="tscmShowCapabilities()"
style="cursor: pointer;">
<span class="cap-warn icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span id="capLimitationCount">0</span> limitations
</div>
</div>
<!-- Baseline Health Indicator -->
<div id="tscmBaselineHealth" class="tscm-baseline-health" style="display: none;">
<span class="health-label">Baseline:</span>
<span class="health-name" id="baselineHealthName">--</span>
<span class="health-badge" id="baselineHealthBadge">--</span>
<span class="health-age" id="baselineHealthAge"></span>
</div>
<!-- Risk Summary Banner (new scoring model) - clickable cards -->
<div class="tscm-threat-banner">
<div class="threat-card critical clickable" id="tscmHighInterestCard"
onclick="showDevicesByCategory('high_interest')"
title="Click to view high interest devices">
<span class="count" id="tscmHighInterestCount">0</span>
<span class="label">High Interest</span>
</div>
<div class="threat-card high clickable" id="tscmNeedsReviewCard"
onclick="showDevicesByCategory('review')" title="Click to view devices needing review">
<span class="count" id="tscmNeedsReviewCount">0</span>
<span class="label">Needs Review</span>
</div>
<div class="threat-card low clickable" id="tscmInformationalCard"
onclick="showDevicesByCategory('informational')"
title="Click to view informational devices">
<span class="count" id="tscmInformationalCount">0</span>
<span class="label">Informational</span>
</div>
<div class="threat-card medium clickable" id="tscmCorrelationsCard"
onclick="showDevicesByCategory('correlations')" title="Click to view correlations">
<span class="count" id="tscmCorrelationsCount">0</span>
<span class="label">Correlations</span>
</div>
</div>
<!-- Sweep Summary (shown after sweep completes) -->
<div id="tscmSweepSummary" style="display: none; margin-bottom: 16px;"></div>
<!-- Signal Activity Timeline -->
<div id="tscmTimelineContainer" style="margin-bottom: 16px;"></div>
<!-- Cross-Protocol Correlations (shown when correlations found) -->
<div id="tscmCorrelationsContainer" style="display: none;"></div>
<!-- Main Content Grid -->
<div class="tscm-main-grid">
<!-- WiFi Panel -->
<div class="tscm-panel">
<div class="tscm-panel-header">
WiFi Networks
<span class="badge" id="tscmWifiCount">0</span>
</div>
<div class="tscm-panel-content" id="tscmWifiList">
<div class="tscm-empty">Start a sweep to scan for WiFi networks</div>
</div>
</div>
<!-- Bluetooth Panel -->
<div class="tscm-panel">
<div class="tscm-panel-header">
Bluetooth Devices
<span class="badge" id="tscmBtCount">0</span>
</div>
<div class="tscm-panel-content" id="tscmBtList">
<div class="tscm-empty">Start a sweep to scan for Bluetooth devices</div>
</div>
</div>
<!-- RF Signals Panel -->
<div class="tscm-panel">
<div class="tscm-panel-header">
RF Signals
<span class="badge" id="tscmRfCount">0</span>
</div>
<div class="tscm-panel-content" id="tscmRfList">
<div class="tscm-empty">Enable RF scanning with an SDR device</div>
</div>
</div>
<!-- Threats Panel -->
<div class="tscm-panel" style="grid-column: span 2;">
<div class="tscm-panel-header">
Detected Threats
<span class="badge" id="tscmThreatCount">0</span>
</div>
<div class="tscm-panel-content" id="tscmThreatList">
<div class="tscm-empty">
<div class="tscm-empty-primary">No anomalies detected</div>
<div class="tscm-empty-secondary">Start a sweep to scan for signals of interest</div>
</div>
</div>
</div>
</div>
</div>
<!-- Spy Stations Dashboard -->
<div id="spyStationsVisuals" class="spy-stations-container" style="display: none;">
<div class="spy-stations-header">
<div class="spy-stations-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 20px; height: 20px;">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/>
<circle cx="12" cy="12" r="2"/>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/>
</svg>
Number Stations & Diplomatic Networks
</div>
<div class="spy-stations-count">
<span id="spyStationsVisibleCount">0</span> stations
</div>
</div>
<div class="spy-stations-grid" id="spyStationsGrid">
<!-- Station cards populated by JavaScript -->
</div>
</div>
<!-- Meshtastic Messages Dashboard -->
<div id="meshtasticVisuals" class="mesh-visuals-container" style="display: none;">
<!-- Compact Status Strip -->
<div class="mesh-stats-strip">
<div class="mesh-strip-group">
<button class="mesh-strip-sidebar-toggle" onclick="Meshtastic.toggleSidebar()" title="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/>
</svg>
</button>
<div class="mesh-strip-status">
<span class="mesh-strip-dot disconnected" id="meshStripDot"></span>
<span class="mesh-strip-status-text" id="meshStripStatus">Disconnected</span>
</div>
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
<option value="">Auto-detect</option>
</select>
<button class="mesh-strip-btn connect" id="meshStripConnectBtn" onclick="Meshtastic.start()">Connect</button>
<button class="mesh-strip-btn disconnect" id="meshStripDisconnectBtn" onclick="Meshtastic.stop()" style="display: none;">Disconnect</button>
</div>
<div class="mesh-strip-divider"></div>
<div class="mesh-strip-group">
<div class="mesh-strip-stat">
<span class="mesh-strip-value" id="meshStripNodeName">--</span>
<span class="mesh-strip-label">NODE</span>
</div>
<div class="mesh-strip-stat">
<span class="mesh-strip-value mesh-strip-id" id="meshStripNodeId">--</span>
<span class="mesh-strip-label">ID</span>
</div>
<div class="mesh-strip-stat">
<span class="mesh-strip-value" id="meshStripModel">--</span>
<span class="mesh-strip-label">MODEL</span>
</div>
</div>
<div class="mesh-strip-divider"></div>
<div class="mesh-strip-group">
<div class="mesh-strip-stat">
<span class="mesh-strip-value accent-cyan" id="meshStripMsgCount">0</span>
<span class="mesh-strip-label">MSGS</span>
</div>
<div class="mesh-strip-stat">
<span class="mesh-strip-value accent-green" id="meshStripNodeCount">0</span>
<span class="mesh-strip-label">NODES</span>
</div>
</div>
</div>
<!-- Main Content Row (Messages + Map side by side) -->
<div class="mesh-main-row">
<!-- Messages Section -->
<div class="mesh-messages-section">
<div class="mesh-messages-header">
<div class="mesh-messages-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Messages
</div>
<div class="mesh-messages-filter">
<select id="meshVisualsFilter" onchange="Meshtastic.applyFilter()">
<option value="">All Channels</option>
</select>
</div>
</div>
<div class="mesh-messages-list" id="meshMessagesGrid">
<div class="mesh-messages-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
</svg>
<p>Connect to a Meshtastic device to see messages</p>
</div>
</div>
<!-- Message Compose Box -->
<div class="mesh-compose" id="meshCompose" style="display: none;">
<div class="mesh-compose-header">
<select id="meshComposeChannel" class="mesh-compose-channel" title="Channel to send on">
<option value="0">CH 0</option>
</select>
<input type="text" id="meshComposeTo" placeholder="^all (broadcast)" class="mesh-compose-to" title="Destination node ID or ^all for broadcast">
</div>
<div class="mesh-compose-body">
<input type="text" id="meshComposeText" placeholder="Type a message..." maxlength="237" class="mesh-compose-input" oninput="Meshtastic.updateCharCount()" onkeydown="Meshtastic.handleComposeKeydown(event)">
<button onclick="Meshtastic.sendMessage()" class="mesh-compose-send" title="Send message">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
<div class="mesh-compose-hint">
<span id="meshComposeCount">0</span>/237
</div>
</div>
</div>
<!-- Node Map -->
<div class="mesh-map-section">
<div class="mesh-map-header">
<div class="mesh-map-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3"/>
<path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
</svg>
Node Map
</div>
<div class="mesh-map-stats">
<span>NODES: <span id="meshMapNodeCount">0</span></span>
<span>WITH GPS: <span id="meshMapGpsCount">0</span></span>
</div>
</div>
<div id="meshMap" class="mesh-map"></div>
</div>
</div>
</div>
<!-- SSTV Decoder Dashboard -->
<div id="sstvVisuals" class="sstv-visuals-container" style="display: none;">
<!-- Status Strip -->
<div class="sstv-stats-strip">
<div class="sstv-strip-group">
<div class="sstv-strip-status">
<span class="sstv-strip-dot idle" id="sstvStripDot"></span>
<span class="sstv-strip-status-text" id="sstvStripStatus">Idle</span>
</div>
<button class="sstv-strip-btn start" id="sstvStartBtn" onclick="SSTV.start()">Start</button>
<button class="sstv-strip-btn stop" id="sstvStopBtn" onclick="SSTV.stop()" style="display: none;">Stop</button>
</div>
<div class="sstv-strip-divider"></div>
<div class="sstv-strip-group">
<div class="sstv-strip-stat">
<span class="sstv-strip-value accent-cyan">145.800</span>
<span class="sstv-strip-label">MHZ</span>
</div>
<div class="sstv-strip-stat">
<span class="sstv-strip-value" id="sstvStripImageCount">0</span>
<span class="sstv-strip-label">IMAGES</span>
</div>
</div>
</div>
<!-- ISS Pass Info -->
<div id="sstvIssInfo">
<div class="sstv-iss-info">
<svg class="sstv-iss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 7L9 3 5 7l4 4"/>
<path d="m17 11 4 4-4 4-4-4"/>
<path d="m8 12 4 4 6-6-4-4-6 6"/>
</svg>
<div class="sstv-iss-details">
<div class="sstv-iss-label">Next ISS Pass</div>
<div class="sstv-iss-value">Loading...</div>
<div class="sstv-iss-note">Check ARISS.org for SSTV event schedules</div>
</div>
</div>
</div>
<!-- Main Row (Live + Gallery) -->
<div class="sstv-main-row">
<!-- Live Decode Section -->
<div class="sstv-live-section">
<div class="sstv-live-header">
<div class="sstv-live-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Live Decode
</div>
</div>
<div class="sstv-live-content" id="sstvLiveContent">
<div class="sstv-idle-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M3 9h2M19 9h2M3 15h2M19 15h2"/>
</svg>
<h4>ISS SSTV Decoder</h4>
<p>Click Start to listen for SSTV transmissions on 145.800 MHz</p>
</div>
</div>
</div>
<!-- Gallery Section -->
<div class="sstv-gallery-section">
<div class="sstv-gallery-header">
<div class="sstv-gallery-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 18px; height: 18px;">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Decoded Images
</div>
<span class="sstv-gallery-count" id="sstvImageCount">0</span>
</div>
<div class="sstv-gallery-grid" id="sstvGallery">
<div class="sstv-gallery-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
<p>No images decoded yet</p>
</div>
</div>
</div>
</div>
</div>
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
<div class="recon-panel collapsed" 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>
<!-- Filter Bar Container (populated by JavaScript based on active mode) -->
<div id="filterBarContainer" style="display: none;"></div>
<!-- Mode-specific Timeline Containers -->
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
<div id="sensorTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
<div class="output-content signal-feed" id="output">
<div class="placeholder signal-empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"/>
</svg>
<p>Configure settings and click "Start Decoding" to begin.</p>
</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()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span> MUTE</button>
<button id="autoScrollBtn" class="control-btn active" onclick="toggleAutoScroll()"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg></span> AUTO</button>
</div>
<div class="control-group">
<span class="control-group-label">Export</span>
<button class="control-btn" onclick="exportCSV()">CSV</button>
<button class="control-btn" onclick="exportJSON()">JSON</button>
<button class="control-btn" onclick="exportDeviceDB()"
title="Export Device Intelligence">INTEL</button>
</div>
<button class="clear-btn" onclick="clearMessages()">Clear</button>
</div>
</div>
</div>
</div>
</div>
<!-- Intercept JS Modules -->
<script src="{{ url_for('static', filename='js/core/utils.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/audio.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/radio-knob.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-guess.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-cards.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/signal-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/activity-timeline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-adapters/rf-adapter.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-adapters/bluetooth-adapter.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
<!-- Bluetooth v2 components -->
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/consumption-sparkline.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/meter-aggregator.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
<script src="{{ url_for('static', filename='js/components/timeline-heatmap.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/bluetooth.js') }}"></script>
<!-- WiFi v2 components -->
<script src="{{ url_for('static', filename='js/components/channel-chart.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/wifi.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/listening-post.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
<script>
// ============================================
// ACTIVITY TIMELINE MANAGEMENT
// ============================================
const modeTimelines = {};
/**
* Initialize timeline for a specific mode
*/
function initializeModeTimeline(mode) {
// Skip if already initialized
if (modeTimelines[mode]) return;
const configs = {
'pager': {
container: 'pagerTimelineContainer',
config: {
title: 'Pager Activity',
mode: 'pager',
visualMode: 'enriched',
collapsed: false,
availableWindows: ['5m', '15m', '30m', '1h'],
defaultWindow: '15m'
}
},
'sensor': {
container: 'sensorTimelineContainer',
config: {
title: 'Sensor Activity',
mode: 'sensor',
visualMode: 'enriched',
collapsed: false,
availableWindows: ['5m', '15m', '30m', '1h'],
defaultWindow: '15m'
}
},
'tscm': {
container: 'tscmTimelineContainer',
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getTscmConfig() : {
title: 'Signal Activity Timeline',
mode: 'tscm',
visualMode: 'enriched',
collapsed: true
}
},
'listening': {
container: 'listeningPostTimelineContainer',
config: typeof RFTimelineAdapter !== 'undefined' ? RFTimelineAdapter.getListeningPostConfig() : {
title: 'Signal Activity',
mode: 'listening-post',
visualMode: 'enriched',
collapsed: false
}
},
'bluetooth': {
container: 'bluetoothTimelineContainer',
config: typeof BluetoothTimelineAdapter !== 'undefined' ? BluetoothTimelineAdapter.getBluetoothConfig() : {
title: 'Device Activity',
mode: 'bluetooth',
visualMode: 'enriched',
collapsed: false
}
},
'wifi': {
container: 'wifiTimelineContainer',
config: typeof WiFiTimelineAdapter !== 'undefined' ? WiFiTimelineAdapter.getWiFiConfig() : {
title: 'Network Activity',
mode: 'wifi',
visualMode: 'enriched',
collapsed: false
}
}
};
const modeConfig = configs[mode];
if (!modeConfig) return;
const container = document.getElementById(modeConfig.container);
if (!container) return;
// Create timeline using new ActivityTimeline
// For TSCM mode, use SignalTimeline.create() to ensure backward compatibility
// with SignalTimeline.addEvent() calls used in TSCM event handlers
if (mode === 'tscm' && typeof SignalTimeline !== 'undefined') {
SignalTimeline.create(modeConfig.container, modeConfig.config);
modeTimelines[mode] = { addEvent: (e) => SignalTimeline.addEvent(e.id, e.strength, e.duration, e.label) };
} else if (typeof ActivityTimeline !== 'undefined') {
modeTimelines[mode] = ActivityTimeline.create(modeConfig.container, modeConfig.config);
}
}
/**
* Add event to a mode's timeline
*/
function addTimelineEvent(mode, eventData) {
const timeline = modeTimelines[mode];
if (timeline) {
timeline.addEvent(eventData);
}
}
/**
* Get timeline instance for a mode
*/
function getTimeline(mode) {
return modeTimelines[mode] || null;
}
// Selected mode from welcome screen
let selectedStartMode = 'pager';
// Mode selection from welcome page
function selectMode(mode) {
selectedStartMode = mode;
const welcome = document.getElementById('welcomePage');
welcome.classList.add('fade-out');
// After fade out, hide welcome and switch to mode
setTimeout(() => {
welcome.style.display = 'none';
switchMode(mode);
}, 400);
}
// Disclaimer handling - show on page load if not accepted
function showDisclaimer() {
document.getElementById('disclaimerModal').style.display = 'flex';
}
function acceptDisclaimer() {
localStorage.setItem('disclaimerAccepted', 'true');
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
// After fade out, hide disclaimer and show welcome page
setTimeout(() => {
document.getElementById('disclaimerModal').style.display = 'none';
// Remove the gate CSS that was hiding welcome page
const gateStyle = document.getElementById('disclaimer-gate');
if (gateStyle) gateStyle.remove();
// Ensure welcome page is visible
document.getElementById('welcomePage').style.display = '';
}, 300);
}
function declineDisclaimer() {
document.getElementById('disclaimerModal').classList.add('disclaimer-hidden');
document.getElementById('rejectionPage').classList.remove('disclaimer-hidden');
}
// Show disclaimer on page load if not yet accepted
document.addEventListener('DOMContentLoaded', function() {
if (window._showDisclaimerOnLoad) {
showDisclaimer();
}
});
let eventSource = null;
let isRunning = false;
let isSensorRunning = 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: []
};
// 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
const headerMsgCount = document.getElementById('headerMsgCount');
const headerPocsagCount = document.getElementById('headerPocsagCount');
const headerFlexCount = document.getElementById('headerFlexCount');
if (headerMsgCount) headerMsgCount.textContent = msgCount;
if (headerPocsagCount) headerPocsagCount.textContent = pocsagCount;
if (headerFlexCount) headerFlexCount.textContent = flexCount;
// Sensor stats
const headerSensorCount = document.getElementById('headerSensorCount');
const headerDeviceTypeCount = document.getElementById('headerDeviceTypeCount');
if (headerSensorCount) headerSensorCount.textContent = document.getElementById('sensorCount')?.textContent || '0';
if (headerDeviceTypeCount) headerDeviceTypeCount.textContent = document.getElementById('deviceCount')?.textContent || '0';
// WiFi stats
const headerApCount = document.getElementById('headerApCount');
const headerClientCount = document.getElementById('headerClientCount');
const headerHandshakeCount = document.getElementById('headerHandshakeCount');
const headerDroneCount = document.getElementById('headerDroneCount');
if (headerApCount) headerApCount.textContent = document.getElementById('apCount')?.textContent || '0';
if (headerClientCount) headerClientCount.textContent = document.getElementById('clientCount')?.textContent || '0';
if (headerHandshakeCount) headerHandshakeCount.textContent = document.getElementById('handshakeCount')?.textContent || '0';
if (headerDroneCount) headerDroneCount.textContent = document.getElementById('droneCount')?.textContent || '0';
// Satellite stats
const headerPassCount = document.getElementById('headerPassCount');
if (headerPassCount) headerPassCount.textContent = document.getElementById('passCount')?.textContent || '0';
}
// Sync stats periodically
setInterval(syncHeaderStats, 500);
// Update relative timestamps on signal cards every 30 seconds
setInterval(function() {
const output = document.getElementById('output');
if (output && typeof SignalCards !== 'undefined') {
SignalCards.updateTimestamps(output);
}
}, 30000);
// 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 };
})();
// GPS Dongle state
let gpsConnected = false;
let gpsEventSource = null;
let gpsLastPosition = null;
// 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 Signal Source and SDR Device)
document.querySelectorAll('.section').forEach((section, index) => {
// Keep first two sections expanded (Signal Source, SDR Device), collapse rest
if (index > 1) {
section.classList.add('collapsed');
}
});
// Load bias-T setting from localStorage
loadBiasTSetting();
// Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
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();
// Initialize dropdown nav active state
updateDropdownActiveState();
});
// Toggle section collapse
function toggleSection(el) {
el.closest('.section').classList.toggle('collapsed');
}
// Dropdown navigation
function toggleNavDropdown(group) {
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${group}"]`);
const isOpen = dropdown.classList.contains('open');
// Close all dropdowns first
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
// Open this one if it was closed
if (!isOpen) {
dropdown.classList.add('open');
}
}
function closeAllDropdowns() {
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('open'));
}
function updateDropdownActiveState() {
// Map modes to their dropdown groups
const modeGroups = {
'pager': 'sdr', 'sensor': 'sdr',
'aprs': 'sdr', 'listening': 'sdr',
'wifi': 'wireless', 'bluetooth': 'wireless',
'tscm': 'security',
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
'meshtastic': 'sdr',
'satellite': 'space', 'sstv': 'space'
};
// Remove has-active from all dropdowns
document.querySelectorAll('.mode-nav-dropdown').forEach(d => d.classList.remove('has-active'));
// Add has-active to the dropdown containing the current mode
const activeGroup = modeGroups[currentMode];
if (activeGroup) {
const dropdown = document.querySelector(`.mode-nav-dropdown[data-group="${activeGroup}"]`);
if (dropdown) dropdown.classList.add('has-active');
}
}
// Close dropdowns when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('.mode-nav-dropdown')) {
closeAllDropdowns();
}
});
// Mode switching
function switchMode(mode) {
// Only stop local scans if in local mode (not agent mode)
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (!isAgentMode) {
if (isRunning) stopDecoding();
if (isSensorRunning) stopSensorDecoding();
if (isWifiRunning) stopWifiScan();
if (isBtRunning) stopBtScan();
if (isAprsRunning) stopAprs();
if (isTscmRunning) stopTscmSweep();
}
currentMode = mode;
// Sync mode state with current agent/local after switching
if (isAgentMode && typeof syncAgentModeStates === 'function') {
// Re-sync with agent to update this mode's UI state
syncAgentModeStates(currentAgent);
} else if (!isAgentMode && typeof syncLocalModeStates === 'function') {
// Sync with local status
syncLocalModeStates();
}
// Close dropdowns and update active state
closeAllDropdowns();
updateDropdownActiveState();
// 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',
'satellite': 'satellite', 'wifi': 'wifi', 'bluetooth': 'bluetooth',
'listening': 'listening', 'aprs': 'aprs', 'tscm': 'tscm', 'meshtastic': 'meshtastic'
};
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('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
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('aprsMode')?.classList.toggle('active', mode === 'aprs');
document.getElementById('tscmMode')?.classList.toggle('active', mode === 'tscm');
document.getElementById('aisMode')?.classList.toggle('active', mode === 'ais');
document.getElementById('spystationsMode')?.classList.toggle('active', mode === 'spystations');
document.getElementById('meshtasticMode')?.classList.toggle('active', mode === 'meshtastic');
const pagerStats = document.getElementById('pagerStats');
const sensorStats = document.getElementById('sensorStats');
const satelliteStats = document.getElementById('satelliteStats');
const wifiStats = document.getElementById('wifiStats');
if (pagerStats) pagerStats.style.display = mode === 'pager' ? 'flex' : 'none';
if (sensorStats) sensorStats.style.display = mode === 'sensor' ? 'flex' : 'none';
if (satelliteStats) satelliteStats.style.display = mode === 'satellite' ? 'flex' : 'none';
if (wifiStats) wifiStats.style.display = mode === 'wifi' ? 'flex' : 'none';
// Update header stats groups
document.getElementById('headerPagerStats')?.classList.toggle('active', mode === 'pager');
document.getElementById('headerSensorStats')?.classList.toggle('active', mode === 'sensor');
document.getElementById('headerSatelliteStats')?.classList.toggle('active', mode === 'satellite');
document.getElementById('headerWifiStats')?.classList.toggle('active', mode === 'wifi');
// Show/hide dashboard buttons in nav bar
const satelliteDashboardBtn = document.getElementById('satelliteDashboardBtn');
if (satelliteDashboardBtn) satelliteDashboardBtn.style.display = mode === 'satellite' ? 'inline-flex' : 'none';
// Update active mode indicator
const modeNames = {
'pager': 'PAGER',
'sensor': '433MHZ',
'rtlamr': 'METERS',
'satellite': 'SATELLITE',
'sstv': 'ISS SSTV',
'wifi': 'WIFI',
'bluetooth': 'BLUETOOTH',
'listening': 'LISTENING POST',
'aprs': 'APRS',
'tscm': 'TSCM',
'ais': 'AIS VESSELS',
'spystations': 'SPY STATIONS',
'meshtastic': 'MESHTASTIC'
};
const activeModeIndicator = document.getElementById('activeModeIndicator');
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
const btLayoutContainer = document.getElementById('btLayoutContainer');
const satelliteVisuals = document.getElementById('satelliteVisuals');
const listeningPostVisuals = document.getElementById('listeningPostVisuals');
const aprsVisuals = document.getElementById('aprsVisuals');
const tscmVisuals = document.getElementById('tscmVisuals');
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
const sstvVisuals = document.getElementById('sstvVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
if (listeningPostVisuals) listeningPostVisuals.style.display = mode === 'listening' ? 'grid' : 'none';
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
// Hide sidebar by default for Meshtastic mode, show for others
const mainContent = document.querySelector('.main-content');
if (mainContent) {
if (mode === 'meshtastic') {
mainContent.classList.add('mesh-sidebar-hidden');
} else {
mainContent.classList.remove('mesh-sidebar-hidden');
}
}
// Show/hide mode-specific timeline containers
const pagerTimelineContainer = document.getElementById('pagerTimelineContainer');
const sensorTimelineContainer = document.getElementById('sensorTimelineContainer');
if (pagerTimelineContainer) pagerTimelineContainer.style.display = mode === 'pager' ? 'block' : 'none';
if (sensorTimelineContainer) sensorTimelineContainer.style.display = mode === 'sensor' ? 'block' : 'none';
// Update output panel title based on mode
const titles = {
'pager': 'Pager Decoder',
'sensor': '433MHz Sensor Monitor',
'rtlamr': 'Utility Meter Monitor',
'satellite': 'Satellite Monitor',
'sstv': 'ISS SSTV Decoder',
'wifi': 'WiFi Scanner',
'bluetooth': 'Bluetooth Scanner',
'listening': 'Listening Post',
'aprs': 'APRS Tracker',
'tscm': 'TSCM Counter-Surveillance',
'ais': 'AIS Vessel Tracker',
'spystations': 'Spy Stations',
'meshtastic': 'Meshtastic Mesh Monitor'
};
const outputTitle = document.getElementById('outputTitle');
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
// Initialize mode-specific timelines
initializeModeTimeline(mode);
// Initialize TSCM mode when selected
if (mode === 'tscm') {
loadTscmBaselines();
refreshTscmDevices();
}
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
const reconPanel = document.getElementById('reconPanel');
if (mode === 'satellite' || mode === 'sstv' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
if (reconPanel) 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 && reconPanel) {
reconPanel.style.display = 'block';
}
}
// Show agent selector for modes that support remote agents
const agentSection = document.getElementById('agentSection');
const agentModes = ['pager', 'sensor', 'rtlamr', 'listening', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais', 'acars', 'dsc'];
if (agentSection) agentSection.style.display = agentModes.includes(mode) ? 'block' : 'none';
// Show RTL-SDR device section for modes that use it
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs') ? 'block' : 'none';
// Toggle mode-specific tool status displays
const toolStatusPager = document.getElementById('toolStatusPager');
const toolStatusSensor = document.getElementById('toolStatusSensor');
if (toolStatusPager) toolStatusPager.style.display = (mode === 'pager') ? 'grid' : 'none';
if (toolStatusSensor) toolStatusSensor.style.display = (mode === 'sensor') ? 'grid' : 'none';
// Hide output console for modes with their own visualizations
const outputEl = document.getElementById('output');
const statusBar = document.querySelector('.status-bar');
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
if (mode !== 'meshtastic') {
const mainContent = document.querySelector('.main-content');
if (mainContent) {
mainContent.classList.remove('mesh-sidebar-hidden');
}
}
// Load interfaces and initialize visualizations when switching modes
if (mode === 'wifi') {
refreshWifiInterfaces();
initRadar();
initWatchList();
// Initialize v2 WiFi components
if (typeof WiFiMode !== 'undefined') {
WiFiMode.init();
}
} else if (mode === 'bluetooth') {
refreshBtInterfaces();
initBtRadar();
} else if (mode === 'aprs') {
checkAprsTools();
initAprsMap();
// Fix map sizing on mobile after container becomes visible
setTimeout(() => {
if (aprsMap) aprsMap.invalidateSize();
}, 100);
} else if (mode === 'satellite') {
initPolarPlot();
initSatelliteList();
} else if (mode === 'listening') {
// Check for incoming tune requests from Spy Stations
if (typeof checkIncomingTuneRequest === 'function') {
checkIncomingTuneRequest();
}
} else if (mode === 'spystations') {
SpyStations.init();
} else if (mode === 'meshtastic') {
Meshtastic.init();
// Fix map sizing after container becomes visible
setTimeout(() => {
Meshtastic.invalidateMap();
}, 100);
} else if (mode === 'sstv') {
SSTV.init();
}
}
// Handle window resize for maps (especially important on mobile orientation change)
window.addEventListener('resize', function () {
if (aprsMap) aprsMap.invalidateSize();
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
});
// Also handle orientation changes explicitly for mobile
window.addEventListener('orientationchange', function () {
setTimeout(() => {
if (aprsMap) aprsMap.invalidateSize();
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
}, 200);
});
// 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 using remote agent
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
// Check for conflicts with other running SDR modes
if (typeof checkAgentModeConflict === 'function' && !checkAgentModeConflict('sensor')) {
return; // User cancelled or conflict not resolved
}
// Route through agent proxy
const config = {
frequency: freq,
gain: gain,
ppm: ppm,
device: device
};
fetch(`/controller/agents/${currentAgent}/sensor/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
// Handle controller proxy response (agent response is nested in 'result')
const scanResult = data.result || data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
setSensorRunning(true);
startAgentSensorStream();
showInfo(`Sensor started on remote agent`);
} else {
alert('Error: ' + (scanResult.message || 'Failed to start sensor on agent'));
}
})
.catch(err => {
alert('Error connecting to agent: ' + err.message);
});
return;
}
// 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();
// Initialize sensor filter bar
const filterContainer = document.getElementById('filterBarContainer');
const output = document.getElementById('output');
if (filterContainer) {
filterContainer.innerHTML = '';
const filterBar = SignalCards.createSensorFilterBar(output);
filterContainer.appendChild(filterBar);
filterContainer.style.display = 'block';
}
// Clear address history for fresh session
SignalCards.clearAddressHistory('sensor');
// Clear existing output
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
} else {
alert('Error: ' + data.message);
}
});
}
// Stop sensor decoding
function stopSensorDecoding() {
// Check if using remote agent
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
fetch(`/controller/agents/${currentAgent}/sensor/stop`, { method: 'POST' })
.then(r => r.json())
.then(data => {
setSensorRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (agentPollInterval) {
clearInterval(agentPollInterval);
agentPollInterval = null;
}
showInfo('Sensor stopped on remote agent');
});
return;
}
fetch('/stop_sensor', { method: 'POST' })
.then(r => r.json())
.then(data => {
releaseDevice('sensor');
setSensorRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
});
}
// Polling interval for agent data
let agentPollInterval = null;
// Start polling agent for sensor data
function startAgentSensorStream() {
if (agentPollInterval) {
clearInterval(agentPollInterval);
}
// Poll every 2 seconds for new data
agentPollInterval = setInterval(() => {
if (!isSensorRunning || currentAgent === 'local') {
clearInterval(agentPollInterval);
agentPollInterval = null;
return;
}
fetch(`/controller/agents/${currentAgent}/sensor/data`)
.then(r => r.json())
.then(data => {
if (data.sensors) {
data.sensors.forEach(sensor => {
displaySensorMessage(sensor);
});
}
})
.catch(err => console.error('Agent poll error:', err));
}, 2000);
}
// Display a sensor message (works for both local and remote)
function displaySensorMessage(msg) {
const output = document.getElementById('output');
if (!output) return;
// Remove placeholder
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.style.display = 'none';
// Create signal card if SignalCards is available
if (typeof SignalCards !== 'undefined' && SignalCards.createFromSensor) {
const card = SignalCards.createFromSensor(msg);
if (card) {
output.insertBefore(card, output.firstChild);
sensorCount++;
updateStats();
}
}
}
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();
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;
}
// Convert rtl_433 data format to our card format
const msg = {
model: data.model || 'Unknown',
id: data.id || data.channel || 'N/A',
channel: data.channel,
timestamp: data.time || new Date().toISOString(),
raw: data.raw,
frequency: data.freq
};
// Map common sensor fields
if (data.temperature_C !== undefined) {
msg.temperature = data.temperature_C;
msg.temperature_unit = 'C';
} else if (data.temperature_F !== undefined) {
msg.temperature = data.temperature_F;
msg.temperature_unit = 'F';
}
if (data.humidity !== undefined) msg.humidity = data.humidity;
if (data.battery_ok !== undefined) msg.battery = data.battery_ok ? 'OK' : 'LOW';
if (data.pressure_hPa !== undefined) {
msg.pressure = data.pressure_hPa;
msg.pressure_unit = 'hPa';
}
if (data.wind_avg_km_h !== undefined) {
msg.wind_speed = data.wind_avg_km_h;
msg.wind_unit = 'km/h';
}
if (data.rain_mm !== undefined) {
msg.rain = data.rain_mm;
msg.rain_unit = 'mm';
}
// Create card using SignalCards component
const card = SignalCards.createSensorCard(msg);
output.insertBefore(card, output.firstChild);
// Add to activity timeline
if (typeof addTimelineEvent === 'function') {
addTimelineEvent('sensor', {
id: `${msg.model}-${msg.sensor_id}-${msg.timestamp}`,
label: msg.model || 'Unknown Sensor',
sublabel: msg.sensor_id ? `ID: ${msg.sensor_id}` : '',
timestamp: msg.timestamp || Date.now(),
type: 'sensor',
status: card.dataset.status || 'new'
});
}
// Update filter counts using sensor-specific filter bar
const sensorFilterBar = document.getElementById('sensorFilterBar');
if (sensorFilterBar && sensorFilterBar.applyFilters) {
sensorFilterBar.applyFilters();
}
if (autoScroll) output.scrollTop = 0;
// Keep list manageable
const cards = output.querySelectorAll('.signal-card');
while (cards.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' })
});
}
// ========================================
// RTLAMR Functions
// ========================================
let isRtlamrRunning = false;
function setRtlamrFreq(freq) {
document.getElementById('rtlamrFrequency').value = freq;
}
// RTLAMR mode polling timer for agent mode
let rtlamrPollTimer = null;
let rtlamrCurrentAgent = null;
function startRtlamrDecoding() {
const freq = document.getElementById('rtlamrFrequency').value;
const gain = document.getElementById('rtlamrGain').value;
const ppm = document.getElementById('rtlamrPpm').value;
const device = getSelectedDevice();
const msgtype = document.getElementById('rtlamrMsgType').value;
const filterid = document.getElementById('rtlamrFilterId').value;
const unique = document.getElementById('rtlamrUnique').checked;
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
rtlamrCurrentAgent = isAgentMode ? currentAgent : null;
// Check if device is available (only for local mode)
if (!isAgentMode && !checkDeviceAvailability('rtlamr')) {
return;
}
const config = {
frequency: freq,
gain: gain,
ppm: ppm,
device: device,
msgtype: msgtype,
filterid: filterid,
unique: unique,
format: 'json'
};
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/rtlamr/start`
: '/start_rtlamr';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
// Handle controller proxy response format
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
if (!isAgentMode) {
reserveDevice(parseInt(device), 'rtlamr');
}
setRtlamrRunning(true);
startRtlamrStream(isAgentMode);
// Initialize meter filter bar (reuse sensor filter bar since same structure)
const filterContainer = document.getElementById('filterBarContainer');
const output = document.getElementById('output');
if (filterContainer) {
filterContainer.innerHTML = '';
const filterBar = SignalCards.createSensorFilterBar(output);
filterBar.id = 'meterFilterBar';
filterBar.querySelector('#sensorSearchInput').id = 'meterSearchInput';
filterBar.querySelector('#meterSearchInput').placeholder = 'Search meter ID...';
filterContainer.appendChild(filterBar);
filterContainer.style.display = 'block';
}
// Clear address history for fresh session
SignalCards.clearAddressHistory('meter');
// Clear existing output
output.innerHTML = '<div class="placeholder signal-empty-state" style="display: none;"></div>';
} else {
alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
}
});
}
function stopRtlamrDecoding() {
const isAgentMode = rtlamrCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${rtlamrCurrentAgent}/rtlamr/stop`
: '/stop_rtlamr';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (!isAgentMode) {
releaseDevice('rtlamr');
}
rtlamrCurrentAgent = null;
setRtlamrRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear polling timer
if (rtlamrPollTimer) {
clearInterval(rtlamrPollTimer);
rtlamrPollTimer = null;
}
});
}
function setRtlamrRunning(running) {
isRtlamrRunning = running;
document.getElementById('statusDot').classList.toggle('running', running);
document.getElementById('statusText').textContent = running ? 'Listening...' : 'Idle';
document.getElementById('startRtlamrBtn').style.display = running ? 'none' : 'block';
document.getElementById('stopRtlamrBtn').style.display = running ? 'block' : 'none';
// Update mode indicator with frequency
if (running) {
const freq = document.getElementById('rtlamrFrequency').value;
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>METERS @ ' + freq + ' MHz';
} else {
document.getElementById('activeModeIndicator').innerHTML = '<span class="pulse-dot"></span>METERS';
}
}
function startRtlamrStream(isAgentMode = false) {
if (eventSource) {
eventSource.close();
}
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream_rtlamr';
eventSource = new EventSource(streamUrl);
eventSource.onopen = function () {
showInfo('RTLAMR stream connected...');
};
eventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
if (isAgentMode) {
// Handle multi-agent stream format
if (data.scan_type === 'rtlamr' && data.payload) {
const payload = data.payload;
if (payload.type === 'rtlamr') {
payload.agent_name = data.agent_name;
addRtlamrReading(payload);
} else if (payload.type === 'status') {
if (payload.text === 'stopped') {
setRtlamrRunning(false);
}
} else if (payload.type === 'info' || payload.type === 'raw') {
showInfo(`[${data.agent_name}] ${payload.text}`);
}
}
} else {
// Local stream format
if (data.type === 'rtlamr') {
addRtlamrReading(data);
} else if (data.type === 'status') {
if (data.text === 'stopped') {
setRtlamrRunning(false);
}
} else if (data.type === 'info' || data.type === 'raw') {
showInfo(data.text);
}
}
};
eventSource.onerror = function (e) {
console.error('RTLAMR stream error');
};
// Start polling fallback for agent mode
if (isAgentMode) {
startRtlamrPolling();
}
}
// Track last reading count for polling
let lastRtlamrReadingCount = 0;
function startRtlamrPolling() {
if (rtlamrPollTimer) return;
lastRtlamrReadingCount = 0;
const pollInterval = 2000;
rtlamrPollTimer = setInterval(async () => {
if (!isRtlamrRunning || !rtlamrCurrentAgent) {
clearInterval(rtlamrPollTimer);
rtlamrPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${rtlamrCurrentAgent}/rtlamr/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const readings = result.data || [];
// Process new readings
if (readings.length > lastRtlamrReadingCount) {
const newReadings = readings.slice(lastRtlamrReadingCount);
newReadings.forEach(reading => {
const displayReading = {
type: 'rtlamr',
...reading,
agent_name: result.agent_name || 'Remote Agent'
};
addRtlamrReading(displayReading);
});
lastRtlamrReadingCount = readings.length;
}
} catch (err) {
console.error('RTLAMR polling error:', err);
}
}, pollInterval);
}
function addRtlamrReading(data) {
const output = document.getElementById('output');
const placeholder = output.querySelector('.placeholder');
if (placeholder) placeholder.remove();
// Store for export (all raw readings)
allMessages.push(data);
pulseSignal();
sensorCount++;
document.getElementById('sensorCount').textContent = sensorCount;
// Aggregate meter data using MeterAggregator
const { meter, isNew } = MeterAggregator.ingest(data);
// Track unique meters by ID
const meterId = meter.id;
if (meterId !== 'Unknown') {
const deviceKey = 'METER_' + meterId;
if (!uniqueDevices.has(deviceKey)) {
uniqueDevices.add(deviceKey);
document.getElementById('deviceCount').textContent = uniqueDevices.size;
}
}
// Check if card already exists for this meter
const existingCard = document.getElementById('metercard_' + meterId);
if (existingCard) {
// Update existing card in place
SignalCards.updateAggregatedMeterCard(existingCard, meter);
} else {
// Create new aggregated meter card
const card = SignalCards.createAggregatedMeterCard(meter);
output.insertBefore(card, output.firstChild);
// Only play alert for new meters (not updates)
playAlert();
}
// Update filter counts
SignalCards.updateCounts(output);
// Limit to max 50 unique meters (cards won't pile up since we update in place)
const cards = output.querySelectorAll('.signal-card.meter-aggregated');
while (cards.length > 50) {
// Remove oldest card (last one)
const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type');
if (oldestCard) {
output.removeChild(oldestCard);
} else {
break;
}
}
}
function toggleRtlamrUnique() {
// No action needed, value is read on start
}
function toggleRtlamrLogging() {
const enabled = document.getElementById('rtlamrLogging').checked;
fetch('/logging', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: enabled, log_file: 'rtlamr_data.log' })
});
}
// NOTE: Audio alert settings moved to static/js/core/audio.js
// Message storage for export
let allMessages = [];
function exportCSV() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
const headers = ['Timestamp', 'Protocol', 'Address', 'Function', 'Type', 'Message'];
const csv = [headers.join(',')];
allMessages.forEach(msg => {
const row = [
msg.timestamp || '',
msg.protocol || '',
msg.address || '',
msg.function || '',
msg.msg_type || '',
'"' + (msg.message || '').replace(/"/g, '""') + '"'
];
csv.push(row.join(','));
});
downloadFile(csv.join('\n'), 'intercept_messages.csv', 'text/csv');
}
function exportJSON() {
if (allMessages.length === 0) {
alert('No messages to export');
return;
}
downloadFile(JSON.stringify(allMessages, null, 2), 'intercept_messages.json', 'application/json');
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Auto-scroll setting
let autoScroll = localStorage.getItem('autoScroll') !== 'false';
function toggleAutoScroll() {
autoScroll = !autoScroll;
localStorage.setItem('autoScroll', autoScroll);
updateAutoScrollButton();
}
function updateAutoScrollButton() {
const btn = document.getElementById('autoScrollBtn');
if (btn) {
btn.innerHTML = autoScroll ? '⬇ AUTO-SCROLL ON' : '⬇ AUTO-SCROLL OFF';
btn.classList.toggle('active', autoScroll);
}
}
// Signal activity meter
let signalActivity = 0;
let lastMessageTime = 0;
function updateSignalMeter() {
const now = Date.now();
const timeSinceLastMsg = now - lastMessageTime;
// Decay signal activity over time
if (timeSinceLastMsg > 1000) {
signalActivity = Math.max(0, signalActivity - 0.05);
}
const meter = document.getElementById('signalMeter');
const bars = meter?.querySelectorAll('.signal-bar');
if (bars) {
const activeBars = Math.ceil(signalActivity * bars.length);
bars.forEach((bar, i) => {
bar.classList.toggle('active', i < activeBars);
});
}
}
function pulseSignal() {
signalActivity = Math.min(1, signalActivity + 0.4);
lastMessageTime = Date.now();
}
// Relative timestamps
function getRelativeTime(timestamp) {
if (!timestamp) return '';
const now = new Date();
const parts = timestamp.split(':');
const msgTime = new Date();
msgTime.setHours(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]));
const diff = Math.floor((now - msgTime) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return timestamp;
}
function updateRelativeTimes() {
document.querySelectorAll('.msg-time').forEach(el => {
const ts = el.dataset.timestamp;
if (ts) el.textContent = getRelativeTime(ts);
});
}
// Update timers
setInterval(updateSignalMeter, 100);
setInterval(updateRelativeTimes, 10000);
// Default presets (UK frequencies)
const defaultPresets = ['153.350', '153.025'];
// Load presets from localStorage or use defaults
function loadPresets() {
const saved = localStorage.getItem('pagerPresets');
return saved ? JSON.parse(saved) : [...defaultPresets];
}
function savePresets(presets) {
localStorage.setItem('pagerPresets', JSON.stringify(presets));
}
function renderPresets() {
const presets = loadPresets();
const container = document.getElementById('presetButtons');
container.innerHTML = presets.map(freq =>
`<button class="preset-btn" onclick="setFreq('${freq}')" oncontextmenu="removePreset('${freq}'); return false;" title="Right-click to remove">${freq}</button>`
).join('');
}
function addPreset() {
const input = document.getElementById('newPresetFreq');
const freq = input.value.trim();
if (!freq || isNaN(parseFloat(freq))) {
alert('Please enter a valid frequency');
return;
}
const presets = loadPresets();
if (!presets.includes(freq)) {
presets.push(freq);
savePresets(presets);
renderPresets();
}
input.value = '';
}
function removePreset(freq) {
if (confirm('Remove preset ' + freq + ' MHz?')) {
let presets = loadPresets();
presets = presets.filter(p => p !== freq);
savePresets(presets);
renderPresets();
}
}
function resetPresets() {
if (confirm('Reset to default presets?')) {
savePresets([...defaultPresets]);
renderPresets();
}
}
// Initialize presets on load
renderPresets();
// Initialize button states on load
updateMuteButton();
updateAutoScrollButton();
// NOTE: Audio context initialization moved to static/js/core/audio.js
function setFreq(freq) {
document.getElementById('frequency').value = freq;
// Auto-restart decoder with new frequency if currently running
if (isRunning) {
fetch('/stop', { method: 'POST' })
.then(() => {
setTimeout(() => startDecoding(), 500);
});
}
}
// SDR hardware capabilities
const sdrCapabilities = {
'rtlsdr': { name: 'RTL-SDR', freq_min: 24, freq_max: 1766, gain_min: 0, gain_max: 50 },
'limesdr': { name: 'LimeSDR', freq_min: 0.1, freq_max: 3800, gain_min: 0, gain_max: 73 },
'hackrf': { name: 'HackRF', freq_min: 1, freq_max: 6000, gain_min: 0, gain_max: 62 }
};
// Current device list with SDR type info
let currentDeviceList = [];
// SDR Device Usage Tracking
// Tracks which mode is using which device index
const sdrDeviceUsage = {
// deviceIndex: 'modeName' (e.g., 0: 'pager', 1: 'scanner')
};
function getDeviceInUseBy(deviceIndex) {
return sdrDeviceUsage[deviceIndex] || null;
}
function isDeviceInUse(deviceIndex) {
return sdrDeviceUsage[deviceIndex] !== undefined;
}
function reserveDevice(deviceIndex, modeName) {
sdrDeviceUsage[deviceIndex] = modeName;
updateDeviceSelectStatus();
}
function releaseDevice(modeName) {
for (const [idx, mode] of Object.entries(sdrDeviceUsage)) {
if (mode === modeName) {
delete sdrDeviceUsage[idx];
}
}
updateDeviceSelectStatus();
}
function getAvailableDevice() {
// Find first device not in use
for (const device of currentDeviceList) {
if (!isDeviceInUse(device.index)) {
return device.index;
}
}
return null;
}
function updateDeviceSelectStatus() {
// Update device dropdown to show which devices are in use
const select = document.getElementById('deviceSelect');
if (!select) return;
const options = select.querySelectorAll('option');
options.forEach(opt => {
const idx = parseInt(opt.value);
const usedBy = getDeviceInUseBy(idx);
const baseName = opt.textContent.replace(/ \[.*\]$/, ''); // Remove existing status
if (usedBy) {
opt.textContent = `${baseName} [${usedBy.toUpperCase()}]`;
opt.style.color = 'var(--accent-orange)';
} else {
opt.textContent = baseName;
opt.style.color = '';
}
});
}
function checkDeviceAvailability(modeName) {
const selectedDevice = parseInt(getSelectedDevice());
const usedBy = getDeviceInUseBy(selectedDevice);
if (usedBy && usedBy !== modeName) {
// Device is in use by another mode
const availableDevice = getAvailableDevice();
if (availableDevice !== null) {
// Another device is available - offer to switch
const switchDevice = confirm(
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}.\n\n` +
`Device ${availableDevice} is available. Switch to it?`
);
if (switchDevice) {
document.getElementById('deviceSelect').value = availableDevice;
return true; // Can proceed with new device
}
return false; // User declined to switch
} else {
// No other devices available
showNotification('SDR In Use',
`Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}. ` +
`No other SDR devices available. Stop ${usedBy} first or connect another SDR.`
);
return false;
}
}
return true; // Device is available
}
function onSDRTypeChanged() {
const sdrType = document.getElementById('sdrTypeSelect').value;
const select = document.getElementById('deviceSelect');
// Filter devices by selected SDR type
const filteredDevices = currentDeviceList.filter(d =>
(d.sdr_type || 'rtlsdr') === sdrType
);
if (filteredDevices.length === 0) {
select.innerHTML = `<option value="0">No ${sdrCapabilities[sdrType]?.name || sdrType} devices found</option>`;
} else {
select.innerHTML = filteredDevices.map(d =>
`<option value="${d.index}" data-sdr-type="${d.sdr_type || 'rtlsdr'}">${d.index}: ${d.name}</option>`
).join('');
}
// Update capabilities display
updateCapabilitiesDisplay(sdrType);
}
function updateCapabilitiesDisplay(sdrType) {
const caps = sdrCapabilities[sdrType];
if (caps) {
document.getElementById('capFreqRange').textContent = `${caps.freq_min}-${caps.freq_max} MHz`;
document.getElementById('capGainRange').textContent = `${caps.gain_min}-${caps.gain_max} dB`;
}
}
function refreshDevices() {
fetch('/devices')
.then(r => r.json())
.then(devices => {
// Store full device list with SDR type info
currentDeviceList = devices;
deviceList = devices;
// Auto-select SDR type if devices found
if (devices.length > 0) {
const firstType = devices[0].sdr_type || 'rtlsdr';
document.getElementById('sdrTypeSelect').value = firstType;
}
// Trigger filter update
onSDRTypeChanged();
})
.catch(err => {
console.error('Failed to refresh devices:', err);
const select = document.getElementById('deviceSelect');
select.innerHTML = '<option value="0">Error loading devices</option>';
});
}
function getSelectedDevice() {
return document.getElementById('deviceSelect').value;
}
function getSelectedSDRType() {
return document.getElementById('sdrTypeSelect').value;
}
// Bias-T power setting
function saveBiasTSetting() {
const enabled = document.getElementById('biasT')?.checked || false;
localStorage.setItem('biasTEnabled', enabled);
}
function getBiasTEnabled() {
return document.getElementById('biasT')?.checked || false;
}
function loadBiasTSetting() {
const saved = localStorage.getItem('biasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('biasT');
if (checkbox) checkbox.checked = true;
}
}
function toggleRemoteSDR() {
const useRemote = document.getElementById('useRemoteSDR').checked;
const configDiv = document.getElementById('remoteSDRConfig');
const localControls = document.querySelectorAll('#sdrTypeSelect, #deviceSelect');
configDiv.style.display = useRemote ? 'block' : 'none';
// Dim local device controls when using remote
localControls.forEach(el => {
el.style.opacity = useRemote ? '0.5' : '1';
el.disabled = useRemote;
});
}
function getRemoteSDRConfig() {
const useRemote = document.getElementById('useRemoteSDR').checked;
if (!useRemote) return null;
const host = document.getElementById('rtlTcpHost').value.trim();
const port = parseInt(document.getElementById('rtlTcpPort').value) || 1234;
if (!host) {
alert('Please enter rtl_tcp host address');
return false;
}
return { host, port };
}
function getSelectedProtocols() {
const protocols = [];
if (document.getElementById('proto_pocsag512').checked) protocols.push('POCSAG512');
if (document.getElementById('proto_pocsag1200').checked) protocols.push('POCSAG1200');
if (document.getElementById('proto_pocsag2400').checked) protocols.push('POCSAG2400');
if (document.getElementById('proto_flex').checked) protocols.push('FLEX');
return protocols;
}
// Pager mode polling timer for agent mode
let pagerPollTimer = null;
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 using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
// Check if device is available (only for local mode)
if (!isAgentMode && !checkDeviceAvailability('pager')) {
return;
}
// Check for remote SDR (only for local mode)
const remoteConfig = isAgentMode ? null : 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 (local mode only)
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/pager/start`
: '/start';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
}).then(r => r.json())
.then(data => {
// Handle controller proxy response format (agent response is nested in 'result')
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
if (!isAgentMode) {
reserveDevice(parseInt(device), 'pager');
}
setRunning(true);
startStream(isAgentMode);
// Initialize filter bar
const filterContainer = document.getElementById('filterBarContainer');
const output = document.getElementById('output');
if (filterContainer) {
// Clear any existing filter bar and create pager filter
filterContainer.innerHTML = '';
const filterBar = SignalCards.createPagerFilterBar(output);
filterContainer.appendChild(filterBar);
filterContainer.style.display = 'block';
}
// Clear address history for fresh session
SignalCards.clearAddressHistory('pager');
} else {
alert('Error: ' + (scanResult.message || scanResult.error || 'Failed to start pager decoding'));
}
})
.catch(err => {
console.error('Start error:', err);
alert('Error starting pager decoding: ' + err.message);
});
}
function stopDecoding() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/pager/stop`
: '/stop';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (!isAgentMode) {
releaseDevice('pager');
}
setRunning(false);
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear polling timer if active
if (pagerPollTimer) {
clearInterval(pagerPollTimer);
pagerPollTimer = 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);
isScannerRunning = false;
isAudioPlaying = false;
if (eventSource) {
eventSource.close();
eventSource = null;
}
showInfo('Killed all processes: ' + (data.processes.length ? data.processes.join(', ') : 'none running'));
});
}
function checkStatus() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/pager/status`
: '/status';
fetch(endpoint)
.then(r => r.json())
.then(data => {
// Handle agent response format (may be nested in 'result')
const statusData = isAgentMode && data.result ? data.result : data;
const running = statusData.running;
if (running !== isRunning) {
setRunning(running);
if (running && !eventSource) {
startStream(isAgentMode);
}
}
})
.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(isAgentMode = false) {
if (eventSource) {
eventSource.close();
}
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/stream';
eventSource = new EventSource(streamUrl);
eventSource.onopen = function () {
showInfo('Stream connected...');
};
eventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
// Handle multi-agent stream format
if (isAgentMode) {
// Multi-agent stream tags data with scan_type and agent_name
if (data.scan_type === 'pager' && data.payload) {
const payload = data.payload;
if (payload.type === 'message') {
// Add agent info to the message
payload.agent_name = data.agent_name;
addMessage(payload);
} else if (payload.type === 'status') {
if (payload.text === 'stopped') {
setRunning(false);
} else if (payload.text === 'started') {
showInfo(`Decoder started on ${data.agent_name}, waiting for signals...`);
}
} else if (payload.type === 'info') {
showInfo(`[${data.agent_name}] ${payload.text}`);
}
} else if (data.type === 'keepalive') {
// Ignore keepalive messages
}
} else {
// Local stream format
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();
};
// Start polling fallback for agent mode (in case push isn't enabled)
if (isAgentMode) {
startPagerPolling();
}
}
// Track last message count to avoid duplicates during polling
let lastPagerMsgCount = 0;
function startPagerPolling() {
if (pagerPollTimer) return;
lastPagerMsgCount = 0;
const pollInterval = 2000; // 2 seconds
pagerPollTimer = setInterval(async () => {
if (!isRunning) {
clearInterval(pagerPollTimer);
pagerPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/pager/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const modeData = result.data || result;
// Process messages from polling response
if (modeData.messages && Array.isArray(modeData.messages)) {
const newMsgs = modeData.messages.slice(lastPagerMsgCount);
newMsgs.forEach(msg => {
// Convert to expected format
const displayMsg = {
type: 'message',
protocol: msg.protocol || 'UNKNOWN',
address: msg.address || '',
function: msg.function || '',
msg_type: msg.msg_type || 'Alpha',
message: msg.message || '',
timestamp: msg.received_at || new Date().toISOString(),
agent_name: result.agent_name || 'Remote Agent'
};
addMessage(displayMsg);
});
lastPagerMsgCount = modeData.messages.length;
}
} catch (err) {
console.error('Pager polling error:', err);
}
}, pollInterval);
}
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);
// Check if address is muted
const isMuted = SignalCards.isAddressMuted(msg.address);
// 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 or muted, skip display but update filtered count
if (isFiltered || isMuted) {
filteredCount++;
return;
}
// Play audio alert (only for non-filtered messages)
playAlert();
// Update signal meter
pulseSignal();
// Use SignalCards component to create the message card (auto-detects status)
const msgEl = SignalCards.createPagerCard(msg);
output.insertBefore(msgEl, output.firstChild);
// Add to activity timeline
if (typeof addTimelineEvent === 'function') {
addTimelineEvent('pager', {
id: `${msg.address}-${msg.timestamp}`,
label: msg.address,
sublabel: msg.protocol,
timestamp: msg.timestamp || Date.now(),
type: 'pager',
status: msgEl.dataset.status || 'new'
});
}
// Update filter counts
SignalCards.updateCounts(output);
// Auto-scroll to top (newest messages)
if (autoScroll) {
output.scrollTop = 0;
}
// Limit messages displayed (keep placeholder/empty-state)
const cards = output.querySelectorAll('.signal-card');
while (cards.length > 100) {
output.removeChild(cards[cards.length - 1]);
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeAttr(text) {
// Escape for use in HTML attributes (especially onclick handlers)
if (text === null || text === undefined) return '';
var s = String(text);
s = s.replace(/&/g, '&amp;');
s = s.replace(/'/g, '&#39;');
s = s.replace(/"/g, '&quot;');
s = s.replace(/</g, '&lt;');
s = s.replace(/>/g, '&gt;');
return s;
}
function isValidMac(mac) {
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
}
function isValidChannel(ch) {
// Validate WiFi channel (1-200 covers all bands)
const num = parseInt(ch, 10);
return !isNaN(num) && num >= 1 && num <= 200;
}
function showInfo(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #888; word-break: break-all;';
infoEl.textContent = text;
output.insertBefore(infoEl, output.firstChild);
}
function showError(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const errorEl = document.createElement('div');
errorEl.className = 'error-msg';
errorEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #1a0a0a; border: 1px solid #2a1a1a; border-left: 2px solid #ff3366; font-family: "JetBrains Mono", monospace; font-size: 11px; color: #ff6688; word-break: break-all;';
errorEl.textContent = '⚠ ' + text;
output.insertBefore(errorEl, output.firstChild);
}
function clearMessages() {
document.getElementById('output').innerHTML = `
<div class="placeholder" style="color: #888; text-align: center; padding: 50px;">
Messages cleared. ${isRunning || isSensorRunning ? 'Waiting for new messages...' : 'Start decoding to receive messages.'}
</div>
`;
msgCount = 0;
pocsagCount = 0;
flexCount = 0;
sensorCount = 0;
filteredCount = 0;
allMessages = [];
uniqueDevices.clear();
document.getElementById('msgCount').textContent = '0';
document.getElementById('pocsagCount').textContent = '0';
document.getElementById('flexCount').textContent = '0';
document.getElementById('sensorCount').textContent = '0';
document.getElementById('deviceCount').textContent = '0';
// Clear meter aggregator data
if (typeof MeterAggregator !== 'undefined') {
MeterAggregator.clear();
}
// 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 false if not set
let reconEnabled = localStorage.getItem('reconEnabled') === 'true';
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.protocol === 'Meter') {
// Utility meter (rtlamr)
return 'METER_' + (data.meterId || data.address || 'UNK');
} 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);
};
// Hook rtlamr readings into device intelligence
const originalAddRtlamrReading = addRtlamrReading;
addRtlamrReading = function (data) {
originalAddRtlamrReading(data);
// Transform rtlamr data for device tracking
const msgData = data.Message || {};
const meterInfo = getMeterTypeInfo(msgData.EndpointType, data.Type);
trackDevice({
protocol: 'Meter',
meterId: String(msgData.ID || 'Unknown'),
address: String(msgData.ID || 'Unknown'),
message: `${meterInfo.utility} - ${(msgData.Consumption || 0).toLocaleString()} units`,
model: meterInfo.manufacturer || data.Type || 'Unknown',
meterType: data.Type,
endpointType: msgData.EndpointType,
utility: meterInfo.utility,
manufacturer: meterInfo.manufacturer,
consumption: msgData.Consumption
});
};
// Meter type/manufacturer lookup based on ERT endpoint types and message formats
function getMeterTypeInfo(endpointType, msgType) {
// Common ERT endpoint type mappings (varies by utility)
const endpointInfo = {
// Electric meter types (0-7 common)
0: { utility: 'Electric', manufacturer: 'Generic' },
1: { utility: 'Electric', manufacturer: 'Generic' },
2: { utility: 'Electric', manufacturer: 'Itron' },
3: { utility: 'Electric', manufacturer: 'Itron' },
4: { utility: 'Electric', manufacturer: 'Landis+Gyr' },
5: { utility: 'Electric', manufacturer: 'Landis+Gyr' },
6: { utility: 'Electric', manufacturer: 'Elster' },
7: { utility: 'Electric', manufacturer: 'Elster' },
// Gas meter types (8-15)
8: { utility: 'Gas', manufacturer: 'Itron' },
9: { utility: 'Gas', manufacturer: 'Itron' },
10: { utility: 'Gas', manufacturer: 'Sensus' },
11: { utility: 'Gas', manufacturer: 'Sensus' },
12: { utility: 'Gas', manufacturer: 'Badger' },
13: { utility: 'Gas', manufacturer: 'Neptune' },
// Water meter types (16-23)
16: { utility: 'Water', manufacturer: 'Badger' },
17: { utility: 'Water', manufacturer: 'Badger' },
18: { utility: 'Water', manufacturer: 'Neptune' },
19: { utility: 'Water', manufacturer: 'Neptune' },
20: { utility: 'Water', manufacturer: 'Sensus' },
21: { utility: 'Water', manufacturer: 'Sensus' },
22: { utility: 'Water', manufacturer: 'Master Meter' },
23: { utility: 'Water', manufacturer: 'Mueller' },
// Extended types
156: { utility: 'Electric', manufacturer: 'Itron OpenWay' },
157: { utility: 'Electric', manufacturer: 'Itron OpenWay' },
180: { utility: 'Gas', manufacturer: 'Itron ERT' },
188: { utility: 'Water', manufacturer: 'Badger ORION' },
220: { utility: 'Electric', manufacturer: 'Landis+Gyr Focus' }
};
// Message type hints
const msgTypeInfo = {
'SCM': { utility: 'Electric', manufacturer: 'Standard ERT' },
'SCM+': { utility: 'Electric', manufacturer: 'Enhanced ERT' },
'IDM': { utility: 'Electric', manufacturer: 'Interval Data' },
'NetIDM': { utility: 'Electric', manufacturer: 'Network IDM' },
'R900': { utility: 'Water', manufacturer: 'Neptune R900' },
'R900BCD': { utility: 'Water', manufacturer: 'Neptune R900' }
};
// Try endpoint type first
if (endpointType !== undefined && endpointInfo[endpointType]) {
return endpointInfo[endpointType];
}
// Fall back to message type
if (msgType && msgTypeInfo[msgType]) {
return msgTypeInfo[msgType];
}
// Default based on endpoint range
if (endpointType !== undefined) {
if (endpointType < 8) return { utility: 'Electric', manufacturer: 'Unknown' };
if (endpointType < 16) return { utility: 'Gas', manufacturer: 'Unknown' };
if (endpointType < 24) return { utility: 'Water', manufacturer: 'Unknown' };
}
return { utility: 'Unknown', manufacturer: 'Unknown' };
}
// 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 (with null checks for v2 layout)
const rec24El = document.getElementById('rec24Channel');
const rec24ReasonEl = document.getElementById('rec24Reason');
const rec5El = document.getElementById('rec5Channel');
const rec5ReasonEl = document.getElementById('rec5Reason');
if (rec24El) rec24El.textContent = best24;
if (totalNetworks === 0) {
if (rec24ReasonEl) rec24ReasonEl.textContent = '(no networks detected)';
} else {
const usage = channelUsage24.map(c => `CH${c.channel}:${Math.round(c.count)}`).join(', ');
if (rec24ReasonEl) rec24ReasonEl.textContent =
minCount24 === 0 ? '(clear)' : `(${Math.round(minCount24)} interference) [${usage}]`;
}
if (rec5El) rec5El.textContent = best5;
if (totalNetworks === 0) {
if (rec5ReasonEl) rec5ReasonEl.textContent = '(no networks detected)';
} else {
if (rec5ReasonEl) rec5ReasonEl.textContent =
minCount5 === 0 ? `(clear, ${channels5g.length - used5g} unused)` : `(${minCount5} networks)`;
}
}
// Device Correlation (WiFi <-> Bluetooth)
let deviceCorrelations = [];
let correlationFetchPending = false;
function correlateDevices() {
// Use server-side correlation API for better analysis
if (correlationFetchPending) return;
correlationFetchPending = true;
fetch('/correlation?min_confidence=0.4')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
deviceCorrelations = data.correlations || [];
updateCorrelationDisplay();
}
})
.catch(err => {
console.warn('Correlation fetch failed:', err);
// Fallback to local OUI matching
correlateDevicesLocal();
})
.finally(() => {
correlationFetchPending = false;
});
}
function correlateDevicesLocal() {
// Fallback: simple OUI-based correlation
deviceCorrelations = [];
const wifiMacs = Object.keys(wifiNetworks).concat(Object.keys(wifiClients));
const btMacs = Object.keys(btDevices || {});
wifiMacs.forEach(wifiMac => {
const wifiOui = wifiMac.substring(0, 8).toUpperCase();
btMacs.forEach(btMac => {
const btOui = btMac.substring(0, 8).toUpperCase();
if (wifiOui === btOui) {
const wifiDev = wifiNetworks[wifiMac] || wifiClients[wifiMac];
const btDev = btDevices[btMac];
deviceCorrelations.push({
wifi_mac: wifiMac,
bt_mac: btMac,
wifi_name: wifiDev?.essid || wifiDev?.mac || wifiMac,
bt_name: btDev?.name || btMac,
confidence: 0.5,
reason: 'same OUI'
});
}
});
});
updateCorrelationDisplay();
}
function updateCorrelationDisplay() {
const list = document.getElementById('correlationList');
if (!list) return;
if (deviceCorrelations.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">No correlated devices found yet</div>';
return;
}
list.innerHTML = deviceCorrelations.slice(0, 10).map(c => {
const confidence = Math.round((c.confidence || 0.5) * 100);
const confidenceColor = confidence >= 70 ? 'var(--accent-green)' :
confidence >= 50 ? 'var(--accent-orange)' : 'var(--text-dim)';
return `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--accent-cyan);">${c.wifi_name || c.wifi_mac}</span>
<span class="correlation-badge" style="background: ${confidenceColor};">${confidence}%</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: #6495ED;">${c.bt_name || c.bt_mac}</span>
<span style="font-size: 9px; color: var(--text-dim);">${c.reason || ''}</span>
</div>
</div>
`;
}).join('');
}
// Hidden SSID Revealer
let revealedSsids = {}; // {bssid: ssid}
function revealHiddenSsid(bssid, ssid) {
if (ssid && ssid !== '' && ssid !== 'Hidden' && ssid !== '[Hidden]') {
if (!revealedSsids[bssid]) {
revealedSsids[bssid] = ssid;
updateHiddenSsidDisplay();
showNotification('Hidden SSID Revealed', `"${ssid}" on ${bssid}`);
}
}
}
function updateHiddenSsidDisplay() {
const list = document.getElementById('hiddenSsidList');
if (!list) return;
const entries = Object.entries(revealedSsids);
const hiddenCount = Object.keys(hiddenNetworks).length;
if (entries.length === 0) {
if (hiddenCount > 0) {
list.innerHTML = `<div style="color: var(--text-dim);">Monitoring ${hiddenCount} hidden network${hiddenCount > 1 ? 's' : ''}...</div>`;
} else {
list.innerHTML = '<div style="color: var(--text-dim);">No hidden networks detected</div>';
}
return;
}
let html = entries.map(([bssid, ssid]) => `
<div style="padding: 4px 0; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--accent-green);">✓ "${escapeHtml(ssid)}"</span>
<span style="color: var(--text-dim); font-size: 9px;"> (${bssid})</span>
</div>
`).join('');
if (hiddenCount > 0) {
html += `<div style="color: var(--text-dim); margin-top: 4px; font-size: 10px;">+ ${hiddenCount} hidden still monitoring</div>`;
}
list.innerHTML = html;
}
// NOTE: Browser Notifications code moved to static/js/core/audio.js
// Sync legacy WiFi data to v2 channel chart
function syncLegacyToChannelChart() {
if (typeof ChannelChart === 'undefined') return;
const networksList = Object.values(wifiNetworks);
if (networksList.length === 0) return;
// Calculate channel stats from legacy networks
const stats = {};
// Initialize 2.4 GHz channels
for (let ch = 1; ch <= 11; ch++) {
stats[ch] = { channel: ch, band: '2.4GHz', ap_count: 0, utilization_score: 0 };
}
// Initialize 5 GHz channels
[36, 40, 44, 48, 149, 153, 157, 161, 165].forEach(ch => {
stats[ch] = { channel: ch, band: '5GHz', ap_count: 0, utilization_score: 0 };
});
// Count APs per channel
networksList.forEach(net => {
const ch = parseInt(net.channel);
if (stats[ch]) {
stats[ch].ap_count++;
}
});
// Calculate utilization (0-1)
const maxAPs = Math.max(1, ...Object.values(stats).map(s => s.ap_count));
Object.values(stats).forEach(s => {
s.utilization_score = s.ap_count / maxAPs;
});
// Get active band from tab
const activeTab = document.querySelector('.channel-band-tab.active');
const band = activeTab ? activeTab.dataset.band : '2.4';
const bandFilter = band === '2.4' ? '2.4GHz' : '5GHz';
const filteredStats = Object.values(stats).filter(s => s.band === bandFilter);
ChannelChart.update(filteredStats, []);
}
// Update visualizations periodically
setInterval(() => {
if (currentMode === 'wifi') {
updateChannelRecommendation();
correlateDevices();
updateHiddenSsidDisplay();
updateProbeAnalysis();
syncLegacyToChannelChart();
}
}, 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 (with null checks for v2 layout)
const probeClientEl = document.getElementById('probeClientCount');
const probeSSIDEl = document.getElementById('probeSSIDCount');
const probePrivacyEl = document.getElementById('probePrivacyCount');
if (probeClientEl) probeClientEl.textContent = clientsWithProbes.length;
if (probeSSIDEl) probeSSIDEl.textContent = allProbes.size;
if (probePrivacyEl) probePrivacyEl.textContent = privacyLeaks;
if (clientsWithProbes.length === 0) {
list.innerHTML = '<div style="color: var(--text-dim);">Waiting for client probe requests...</div>';
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;
// Feed to activity timeline if it's a new network
if (isNew && typeof addTimelineEvent === 'function') {
const normalized = typeof WiFiTimelineAdapter !== 'undefined'
? WiFiTimelineAdapter.normalizeNetwork({
ssid: net.essid,
bssid: net.bssid,
channel: net.channel,
rssi: signalStrength,
security: net.privacy
})
: {
id: net.bssid,
label: net.essid || '[Hidden]',
strength: signalBars || 3,
duration: 1500,
type: 'wifi'
};
addTimelineEvent('wifi', normalized);
}
}
// 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 (legacy - now handled by BluetoothMode.init())
function refreshBtInterfaces() {
// New Bluetooth mode uses /api/bluetooth/capabilities instead
// This function is kept for backwards compatibility but uses new API
if (typeof BluetoothMode !== 'undefined') {
BluetoothMode.checkCapabilities();
return;
}
// Legacy fallback (shouldn't be needed)
const select = document.getElementById('btInterfaceSelect') || document.getElementById('btAdapterSelect');
if (!select) return;
fetch('/bt/interfaces')
.then(r => r.json())
.then(data => {
if (!data.interfaces || 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 (if element exists)
const statusDiv = document.getElementById('btToolStatus');
if (statusDiv) {
statusDiv.innerHTML = `
<span>hcitool:</span><span class="tool-status ${data.tools.hcitool ? 'ok' : 'missing'}">${data.tools.hcitool ? 'OK' : 'Missing'}</span>
<span>bluetoothctl:</span><span class="tool-status ${data.tools.bluetoothctl ? 'ok' : 'missing'}">${data.tools.bluetoothctl ? 'OK' : 'Missing'}</span>
`;
}
})
.catch(err => console.warn('Legacy BT interface check failed:', err));
}
// Start Bluetooth scan
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' };
}
// Check name patterns
if (patterns.names && patterns.names.some(n => name.includes(n.toLowerCase()))) {
return { network: network, type: 'Find My Network' };
}
}
// 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' };
}
}
}
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) {
// Skip if new BluetoothMode is handling devices
if (typeof BluetoothMode !== 'undefined') {
return;
}
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++;
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 deviceType = d.device_type || d.type || 'device';
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>${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 deviceType = (device.device_type || device.type || 'unknown').toUpperCase();
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);">${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 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};">${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) {
// Skip if new BluetoothMode is handling rendering
if (typeof BluetoothMode !== 'undefined' && BluetoothMode.isScanning()) {
return;
}
// Add to new device list panel
const deviceList = document.getElementById('btDeviceListContent');
if (deviceList) {
// 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 deviceType = devType.toUpperCase();
// 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);">${escapeHtml(device.name || 'Unknown')}${findMyBadge}${trackerBadge}</span>
<span style="font-size: 10px; color: var(--text-dim);">${deviceType}</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();
// Feed to activity timeline if it's a new detection
if (isNew && typeof addTimelineEvent === 'function') {
const normalized = typeof BluetoothTimelineAdapter !== 'undefined'
? BluetoothTimelineAdapter.normalizeDevice(device)
: {
id: device.mac,
label: device.name || device.mac.substring(0, 8) + '...',
strength: device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3,
duration: 1500,
type: 'bluetooth'
};
addTimelineEvent('bluetooth', normalized);
}
}
// 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>${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();
}
// ============================================
// APRS Functions
// ============================================
let aprsMap = null;
let aprsMarkers = {};
let aprsEventSource = null;
let isAprsRunning = false;
let aprsPacketCount = 0;
let aprsStationCount = 0;
let aprsMeterLastUpdate = 0;
let aprsMeterCheckInterval = null;
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
// APRS user location (from GPS)
let aprsUserLocation = { lat: null, lon: null };
let aprsUserMarker = null;
// Calculate distance in miles using Haversine formula
function aprsCalculateDistanceMi(lat1, lon1, lat2, lon2) {
const R = 3958.8; // Earth's radius in 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 APRS user location from GPS
function updateAprsUserLocation(position) {
if (!position || !position.latitude || !position.longitude) return;
aprsUserLocation.lat = position.latitude;
aprsUserLocation.lon = position.longitude;
// Update user marker on map
if (aprsMap) {
if (aprsUserMarker) {
aprsUserMarker.setLatLng([position.latitude, position.longitude]);
} else {
aprsUserMarker = L.marker([position.latitude, position.longitude], {
icon: L.divIcon({
className: 'aprs-user-marker',
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7]
}),
zIndexOffset: 1000
}).bindPopup('Your Location (GPS)').addTo(aprsMap);
}
// Center map on first GPS fix
if (!aprsMap._gpsInitialized) {
aprsMap.setView([position.latitude, position.longitude], 8);
aprsMap._gpsInitialized = true;
}
}
// Show GPS indicator
const indicator = document.getElementById('aprsGpsIndicator');
if (indicator) indicator.style.display = 'inline-flex';
// Update distances in existing station list
updateAprsStationDistances();
}
// Update distances for all stations in the list
function updateAprsStationDistances() {
if (!aprsUserLocation.lat || !aprsUserLocation.lon) return;
// Update station list items
const listEl = document.getElementById('aprsStationList');
if (listEl) {
listEl.querySelectorAll('[data-callsign]').forEach(stationEl => {
const lat = parseFloat(stationEl.dataset.lat);
const lon = parseFloat(stationEl.dataset.lon);
if (!isNaN(lat) && !isNaN(lon)) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
const distSpan = stationEl.querySelector('.aprs-distance');
if (distSpan) {
distSpan.textContent = dist.toFixed(1) + ' mi';
}
}
});
}
}
function checkAprsTools() {
fetch('/aprs/tools')
.then(r => r.json())
.then(data => {
// Update function bar tool indicators
const direwolfEl = document.getElementById('aprsStripDirewolf');
const multimonEl = document.getElementById('aprsStripMultimon');
if (direwolfEl) {
direwolfEl.className = 'strip-tool' + (data.direwolf ? ' ok' : '');
direwolfEl.title = 'direwolf: ' + (data.direwolf ? 'OK' : 'Missing');
}
if (multimonEl) {
multimonEl.className = 'strip-tool' + (data.multimon_ng ? ' ok' : '');
multimonEl.title = 'multimon-ng: ' + (data.multimon_ng ? 'OK' : 'Missing');
}
})
.catch(() => {
const direwolfEl = document.getElementById('aprsStripDirewolf');
const multimonEl = document.getElementById('aprsStripMultimon');
if (direwolfEl) {
direwolfEl.className = 'strip-tool';
direwolfEl.title = 'direwolf: Error';
}
if (multimonEl) {
multimonEl.className = 'strip-tool';
multimonEl.title = 'multimon-ng: Error';
}
});
}
function initAprsMap() {
if (aprsMap) return;
const mapContainer = document.getElementById('aprsMap');
if (!mapContainer) return;
// Use GPS location if available, otherwise default to center of US
const initialLat = aprsUserLocation.lat || gpsLastPosition?.latitude || 39.8283;
const initialLon = aprsUserLocation.lon || gpsLastPosition?.longitude || -98.5795;
const initialZoom = (aprsUserLocation.lat || gpsLastPosition?.latitude) ? 8 : 4;
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap;
// Use settings manager for tile layer (allows runtime changes)
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(aprsMap);
Settings.registerMap(aprsMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).addTo(aprsMap);
}
// Add user marker if GPS position is already available
if (gpsConnected && gpsLastPosition && gpsLastPosition.latitude && gpsLastPosition.longitude) {
updateAprsUserLocation(gpsLastPosition);
aprsMap._gpsInitialized = true;
}
// Update time display (both map header and function bar)
setInterval(() => {
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
const timeEl = document.getElementById('aprsMapTime');
if (timeEl) timeEl.textContent = timeStr;
const stripTimeEl = document.getElementById('aprsStripTime');
if (stripTimeEl) stripTimeEl.textContent = utcStr;
}, 1000);
}
function updateAprsStatus(state, freq) {
// Update function bar status
const stripDot = document.getElementById('aprsStripDot');
const stripStatus = document.getElementById('aprsStripStatus');
const stripFreq = document.getElementById('aprsStripFreq');
if (stripDot) {
stripDot.className = 'status-dot ' + state;
}
if (stripStatus) {
stripStatus.textContent = state.toUpperCase();
if (state === 'listening') {
stripStatus.style.color = 'var(--accent-cyan)';
} else if (state === 'tracking') {
stripStatus.style.color = 'var(--accent-green)';
} else if (state === 'error') {
stripStatus.style.color = 'var(--accent-red)';
} else {
stripStatus.style.color = '';
}
}
if (freq && stripFreq) {
stripFreq.textContent = freq;
}
}
// APRS mode polling timer for agent mode
let aprsPollTimer = null;
let aprsCurrentAgent = null;
function startAprs() {
// Get values from function bar controls
const region = document.getElementById('aprsStripRegion').value;
const device = getSelectedDevice();
const gain = document.getElementById('aprsStripGain').value;
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
aprsCurrentAgent = isAgentMode ? currentAgent : null;
// Build request body
const requestBody = {
region,
device: parseInt(device),
gain: parseInt(gain)
};
// Add custom frequency if selected
if (region === 'custom') {
const customFreq = document.getElementById('aprsStripCustomFreq').value;
if (!customFreq) {
alert('Please enter a custom frequency');
return;
}
requestBody.frequency = customFreq;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/aprs/start`
: '/aprs/start';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
isAprsRunning = true;
aprsPacketCount = 0;
aprsStationCount = 0;
// Initialize APRS filter bar and clear history
const filterContainer = document.getElementById('aprsFilterBarContainer');
const stationList = document.getElementById('aprsStationList');
if (filterContainer && !document.getElementById('aprsFilterBar')) {
const filterBar = SignalCards.createAprsFilterBar(stationList);
filterContainer.appendChild(filterBar);
}
SignalCards.clearAddressHistory('aprs');
// Clear existing station cards
stationList.innerHTML = '<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">Waiting for stations...</div>';
// Update function bar buttons
document.getElementById('aprsStripStartBtn').style.display = 'none';
document.getElementById('aprsStripStopBtn').style.display = 'inline-block';
// Update map status
document.getElementById('aprsMapStatus').textContent = 'TRACKING';
document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)';
// Update function bar status
updateAprsStatus('listening', scanResult.frequency);
// Reset function bar stats
document.getElementById('aprsStripStations').textContent = '0';
document.getElementById('aprsStripPackets').textContent = '0';
document.getElementById('aprsStripSignal').textContent = '--';
// Disable controls while running
document.getElementById('aprsStripRegion').disabled = true;
document.getElementById('aprsStripGain').disabled = true;
const customFreqInput = document.getElementById('aprsStripCustomFreq');
if (customFreqInput) customFreqInput.disabled = true;
startAprsMeterCheck();
startAprsStream(isAgentMode);
} else {
alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
updateAprsStatus('error');
}
})
.catch(err => {
alert('APRS Error: ' + err);
updateAprsStatus('error');
});
}
function stopAprs() {
const isAgentMode = aprsCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
: '/aprs/stop';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(data => {
isAprsRunning = false;
aprsCurrentAgent = null;
// Update function bar buttons
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
document.getElementById('aprsStripStopBtn').style.display = 'none';
// Update map status
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
document.getElementById('aprsMapStatus').style.color = '';
// Reset function bar status
updateAprsStatus('standby');
document.getElementById('aprsStripFreq').textContent = '--';
document.getElementById('aprsStripSignal').textContent = '--';
// Re-enable controls
document.getElementById('aprsStripRegion').disabled = false;
document.getElementById('aprsStripGain').disabled = false;
const customFreqInput = document.getElementById('aprsStripCustomFreq');
if (customFreqInput) customFreqInput.disabled = false;
// Remove signal quality class
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalStat) {
signalStat.classList.remove('good', 'warning', 'poor');
}
// Stop meter check interval
stopAprsMeterCheck();
if (aprsEventSource) {
aprsEventSource.close();
aprsEventSource = null;
}
// Clear polling timer
if (aprsPollTimer) {
clearInterval(aprsPollTimer);
aprsPollTimer = null;
}
});
}
function startAprsStream(isAgentMode = false) {
if (aprsEventSource) aprsEventSource.close();
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream';
aprsEventSource = new EventSource(streamUrl);
aprsEventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
if (isAgentMode) {
// Handle multi-agent stream format
if (data.scan_type === 'aprs' && data.payload) {
const payload = data.payload;
if (payload.type === 'aprs') {
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
// Add agent info
payload.agent_name = data.agent_name;
processAprsPacket(payload);
} else if (payload.type === 'meter') {
updateAprsMeter(payload.level);
}
}
} else {
// Local stream format
if (data.type === 'aprs') {
aprsPacketCount++;
// Update map footer and function bar
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
// Switch to tracking state on first packet
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
processAprsPacket(data);
} else if (data.type === 'meter') {
// Update signal indicator in function bar
updateAprsMeter(data.level);
}
}
};
aprsEventSource.onerror = function () {
console.error('APRS stream error');
updateAprsStatus('error');
};
// Start polling fallback for agent mode
if (isAgentMode) {
startAprsPolling();
}
}
// Track last station count for polling
let lastAprsStationCount = 0;
function startAprsPolling() {
if (aprsPollTimer) return;
lastAprsStationCount = 0;
const pollInterval = 2000;
aprsPollTimer = setInterval(async () => {
if (!isAprsRunning || !aprsCurrentAgent) {
clearInterval(aprsPollTimer);
aprsPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const stations = result.data || [];
// Process new stations
if (stations.length > lastAprsStationCount) {
const newStations = stations.slice(lastAprsStationCount);
newStations.forEach(station => {
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
// Convert to expected packet format
const packet = {
type: 'aprs',
...station,
agent_name: result.agent_name || 'Remote Agent'
};
processAprsPacket(packet);
});
lastAprsStationCount = stations.length;
}
} catch (err) {
console.error('APRS polling error:', err);
}
}, pollInterval);
}
// Signal Meter Functions
function resetAprsMeter() {
aprsMeterLastUpdate = 0;
// Reset function bar signal indicator
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) signalEl.textContent = '--';
if (signalStat) signalStat.classList.remove('good', 'warning', 'poor');
}
function updateAprsMeter(level) {
aprsMeterLastUpdate = Date.now();
// Update function bar signal indicator
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) {
// Show signal level as bars
if (level >= 60) {
signalEl.textContent = '●●●';
} else if (level >= 30) {
signalEl.textContent = '●●○';
} else if (level >= 10) {
signalEl.textContent = '●○○';
} else {
signalEl.textContent = '○○○';
}
}
if (signalStat) {
signalStat.classList.remove('good', 'warning', 'poor');
if (level >= 60) {
signalStat.classList.add('good');
} else if (level >= 30) {
signalStat.classList.add('warning');
} else {
signalStat.classList.add('poor');
}
}
}
function startAprsMeterCheck() {
// Check for no-signal state every second
aprsMeterCheckInterval = setInterval(function () {
if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) {
// No meter updates for 5 seconds - show no-signal state
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) signalEl.textContent = '○○○';
if (signalStat) {
signalStat.classList.remove('good', 'warning');
signalStat.classList.add('poor');
}
}
}, 1000);
}
function stopAprsMeterCheck() {
if (aprsMeterCheckInterval) {
clearInterval(aprsMeterCheckInterval);
aprsMeterCheckInterval = null;
}
}
// Handle region selection changes to show/hide custom frequency input
document.addEventListener('DOMContentLoaded', function() {
const regionSelect = document.getElementById('aprsStripRegion');
const customFreqControl = document.getElementById('aprsStripCustomFreqControl');
if (regionSelect && customFreqControl) {
regionSelect.addEventListener('change', function() {
if (this.value === 'custom') {
customFreqControl.style.display = 'flex';
} else {
customFreqControl.style.display = 'none';
}
});
}
});
function processAprsPacket(packet) {
// Update packet log
const logEl = document.getElementById('aprsPacketLog');
const logEntry = document.createElement('div');
logEntry.style.cssText = 'padding: 3px 0; border-bottom: 1px solid var(--border-color);';
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const callsign = packet.callsign || 'UNKNOWN';
const packetType = packet.packet_type || 'unknown';
logEntry.innerHTML = `<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span> <span style="color: var(--accent-green);">[${packetType}]</span>`;
// Remove placeholder if present
const placeholder = logEl.querySelector('div[style*="color: var(--text-muted)"]');
if (placeholder && placeholder.textContent.includes('Waiting')) {
placeholder.remove();
}
logEl.insertBefore(logEntry, logEl.firstChild);
// Keep log manageable
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
}
// Update map if position data
if (packet.lat && packet.lon && aprsMap) {
updateAprsMarker(packet);
}
// Update station list
updateAprsStationList(packet);
}
function updateAprsMarker(packet) {
const callsign = packet.callsign;
// Calculate distance if user location available
let distStr = '';
if (aprsUserLocation.lat && aprsUserLocation.lon) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
}
if (aprsMarkers[callsign]) {
// Update existing marker position and popup
aprsMarkers[callsign].setLatLng([packet.lat, packet.lon]);
aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
${packet.course ? `Course: ${packet.course}°<br>` : ''}
</div>
`);
} else {
// Create new marker
aprsStationCount++;
// Update map footer and function bar
document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount;
const icon = L.divIcon({
className: 'aprs-marker',
html: `<div style="background: var(--accent-cyan); color: #000; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; white-space: nowrap;">${callsign}</div>`,
iconSize: [80, 20],
iconAnchor: [40, 10]
});
const marker = L.marker([packet.lat, packet.lon], { icon: icon }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
${packet.course ? `Course: ${packet.course}°<br>` : ''}
</div>
`);
aprsMarkers[callsign] = marker;
}
}
function updateAprsStationList(packet) {
const listEl = document.getElementById('aprsStationList');
const callsign = packet.callsign;
// Remove placeholder if present
const placeholder = listEl.querySelector('.signal-cards-placeholder');
if (placeholder) {
placeholder.remove();
}
// Calculate distance if user location available
let distance = null;
const hasPos = packet.lat && packet.lon;
if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon);
}
// Check if station already exists
let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`);
const isExisting = !!stationEl;
// Prepare message object for card creation
const msg = {
callsign: callsign,
packet_type: packet.packet_type || 'unknown',
latitude: packet.lat,
longitude: packet.lon,
altitude: packet.altitude,
speed: packet.speed,
course: packet.course,
comment: packet.comment,
symbol: packet.symbol,
path: packet.path,
raw: packet.raw,
timestamp: new Date().toISOString(),
distance: distance
};
// Create or update the card
const newCard = SignalCards.createAprsCard(msg, { compact: true });
newCard.dataset.callsign = callsign;
// Store position for distance updates
if (hasPos) {
newCard.dataset.lat = packet.lat;
newCard.dataset.lon = packet.lon;
}
// Add click handler to focus map
newCard.style.cursor = 'pointer';
newCard.addEventListener('click', (e) => {
// Don't trigger if clicking on buttons
if (e.target.closest('button')) return;
if (aprsMarkers[callsign] && aprsMap) {
aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10);
aprsMarkers[callsign].openPopup();
}
});
if (isExisting) {
// Replace existing card
stationEl.replaceWith(newCard);
} else {
// Insert new card at top
listEl.insertBefore(newCard, listEl.firstChild);
}
// Keep list manageable
const cards = listEl.querySelectorAll('.signal-card');
while (cards.length > 50) {
listEl.removeChild(listEl.lastChild);
}
// Update filter counts if filter bar exists
SignalCards.updateCounts(listEl);
}
// ============================================
// 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 observerLocation
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
// Update APRS user location
updateAprsUserLocation(position);
}
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
['satGpsIndicator', 'aprsGpsIndicator'].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);
}
}
// Satellite mode agent state
let satelliteCurrentAgent = null;
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;
}
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
satelliteCurrentAgent = isAgentMode ? currentAgent : null;
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/satellite/predict`
: '/satellite/predict';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, hours, minEl, satellites })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const result = isAgentMode && data.result ? data.result : data;
if (result.status === 'success') {
satellitePasses = result.passes;
renderPassList();
document.getElementById('passCount').textContent = result.passes.length;
if (result.passes.length > 0) {
selectPass(0);
document.getElementById('satelliteCountdown').style.display = 'block';
updateSatelliteCountdown();
startCountdownTimer();
} else {
document.getElementById('satelliteCountdown').style.display = 'none';
}
} else {
alert('Error: ' + (result.message || result.error || 'Failed to predict passes'));
}
});
}
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
});
window.groundTrackMap = groundTrackMap;
// Use settings manager for tile layer (allows runtime changes)
if (typeof Settings !== 'undefined' && Settings.createTileLayer) {
Settings.createTileLayer().addTo(groundTrackMap);
Settings.registerMap(groundTrackMap);
} else {
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd'
}).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);
// Check if using agent mode
const isAgentMode = satelliteCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${satelliteCurrentAgent}/satellite/position`
: '/satellite/position';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, satellites, includeTrack: true })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const result = isAgentMode && data.result ? data.result : data;
if (result.status === 'success' && result.positions) {
updateRealTimeIndicators(result.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('satTrackingList');
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 => {
const noradStr = String(sat.norad);
if (!trackedSatellites.find(s => s.norad === noradStr)) {
trackedSatellites.push({
id: sat.name.replace(/[^a-zA-Z0-9-]/g, '-'),
name: sat.name,
norad: noradStr,
builtin: false,
checked: false, // Don't auto-select
tle: [sat.name, sat.tle1, sat.tle2]
});
added++;
}
});
renderSatelliteList();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${added} satellites (${data.satellites.length} total in category)</span>`;
} else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
}
})
.catch(err => {
status.innerHTML = `<span style="color: var(--accent-red);">Network error</span>`;
});
}
// Initialize satellite list when satellite mode is loaded
function initSatelliteList() {
renderSatelliteList();
}
// Utility function
function showInfo(message) {
// Simple notification - could be enhanced
const existing = document.querySelector('.info-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'info-toast';
toast.textContent = message;
toast.style.cssText = 'position: fixed; bottom: 20px; right: 20px; background: var(--accent-cyan); color: #000; padding: 10px 20px; border-radius: 4px; z-index: 10001; font-size: 12px;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Theme toggle functions
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
if (newTheme === 'dark') {
html.removeAttribute('data-theme');
} else {
html.setAttribute('data-theme', newTheme);
}
// Save to localStorage for instant load on next visit
localStorage.setItem('intercept-theme', newTheme);
// Persist to server for cross-device sync
fetch('/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: newTheme })
}).catch(err => console.warn('Failed to save theme to server:', err));
}
// Animation toggle functions
function toggleAnimations() {
const html = document.documentElement;
const currentState = html.getAttribute('data-animations');
const newState = currentState === 'off' ? 'on' : 'off';
if (newState === 'on') {
html.removeAttribute('data-animations');
} else {
html.setAttribute('data-animations', newState);
}
// Save to localStorage for persistence
localStorage.setItem('intercept-animations', newState);
}
// Load saved theme and animations 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');
}
// Apply animations preference
const localAnimations = localStorage.getItem('intercept-animations');
if (localAnimations === 'off') {
document.documentElement.setAttribute('data-animations', 'off');
}
// Then fetch from server to sync (in case changed on another device)
fetch('/settings/theme')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.value) {
const serverTheme = data.value;
if (serverTheme !== localTheme) {
// Server has different theme, apply it
if (serverTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('intercept-theme', serverTheme);
}
}
})
.catch(() => { }); // Ignore errors, localStorage is fallback
})();
// Help modal functions
function showHelp() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function hideHelp() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
}
function switchHelpTab(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
document.querySelector(`.help-tab[data-tab="${tab}"]`).classList.add('active');
document.getElementById(`help-${tab}`).classList.add('active');
}
// Keyboard shortcuts for help
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
if ((e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !document.getElementById('helpModal').classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
// NOTE: Scanner and Audio Receiver code moved to static/js/modes/listening-post.js
// ============================================
// TSCM (Counter-Surveillance) Functions
// ============================================
let isTscmRunning = false;
let tscmEventSource = null;
let tscmThreats = [];
let tscmWifiDevices = [];
let tscmBtDevices = [];
let isRecordingBaseline = false;
let tscmSweepStartTime = null;
let tscmSweepEndTime = null;
async function refreshTscmDevices() {
// Fetch available interfaces for TSCM scanning
// Check if agent is selected and route accordingly
try {
let response;
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
// Fetch devices from agent capabilities
response = await fetch(`/controller/agents/${currentAgent}?refresh=true`);
} else {
response = await fetch('/tscm/devices');
}
const data = await response.json();
// Handle both local (/tscm/devices) and agent response formats
let devices;
const isAgentResponse = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentResponse && data.agent) {
// Agent response format - extract from capabilities/interfaces
const agentInterfaces = data.agent.interfaces || {};
const agentCapabilities = data.agent.capabilities || {};
devices = {
wifi_interfaces: agentInterfaces.wifi_interfaces || [],
bt_adapters: agentInterfaces.bt_adapters || [],
sdr_devices: agentCapabilities.devices || agentInterfaces.sdr_devices || []
};
} else {
devices = data.devices || {};
}
// Populate WiFi interfaces
const wifiSelect = document.getElementById('tscmWifiInterface');
wifiSelect.innerHTML = '<option value="">Select WiFi interface...</option>';
if (devices.wifi_interfaces && devices.wifi_interfaces.length > 0) {
devices.wifi_interfaces.forEach(iface => {
const opt = document.createElement('option');
opt.value = iface.name;
opt.textContent = iface.display_name || iface.name;
wifiSelect.appendChild(opt);
});
// Auto-select first interface
if (devices.wifi_interfaces.length > 0) {
wifiSelect.value = devices.wifi_interfaces[0].name;
}
} else {
if (isAgentResponse) {
wifiSelect.innerHTML = '<option value="">Agent manages WiFi</option>';
} else {
wifiSelect.innerHTML = '<option value="">No WiFi interfaces found</option>';
}
}
// Populate Bluetooth adapters
const btSelect = document.getElementById('tscmBtInterface');
btSelect.innerHTML = '<option value="">Select Bluetooth adapter...</option>';
if (devices.bt_adapters && devices.bt_adapters.length > 0) {
devices.bt_adapters.forEach(adapter => {
const opt = document.createElement('option');
opt.value = adapter.name;
opt.textContent = adapter.display_name || adapter.name;
btSelect.appendChild(opt);
});
// Auto-select first adapter
if (devices.bt_adapters.length > 0) {
btSelect.value = devices.bt_adapters[0].name;
}
} else {
if (isAgentResponse) {
btSelect.innerHTML = '<option value="">Agent manages Bluetooth</option>';
} else {
btSelect.innerHTML = '<option value="">No Bluetooth adapters found</option>';
}
}
// Populate SDR devices
const sdrSelect = document.getElementById('tscmSdrDevice');
sdrSelect.innerHTML = '<option value="">Select SDR device...</option>';
if (devices.sdr_devices && devices.sdr_devices.length > 0) {
devices.sdr_devices.forEach(dev => {
const opt = document.createElement('option');
opt.value = dev.index !== undefined ? dev.index : 0;
opt.textContent = dev.display_name || dev.name || 'SDR Device';
sdrSelect.appendChild(opt);
});
// Auto-select first SDR if available
if (devices.sdr_devices.length > 0) {
sdrSelect.value = devices.sdr_devices[0].index !== undefined ? devices.sdr_devices[0].index : 0;
}
} else {
if (isAgentResponse) {
sdrSelect.innerHTML = '<option value="">Agent manages SDR</option>';
} else {
sdrSelect.innerHTML = '<option value="">No SDR devices found</option>';
}
}
// Show warnings (e.g., not running as root)
const warningsDiv = document.getElementById('tscmDeviceWarnings');
if (data.warnings && data.warnings.length > 0) {
warningsDiv.innerHTML = data.warnings.map(w =>
`<div class="tscm-privilege-warning">
<span class="warning-icon">⚠️</span>
<div>
<strong>${escapeHtml(w.message)}</strong>
${w.action ? `<div class="warning-action">${escapeHtml(w.action)}</div>` : ''}
</div>
</div>`
).join('');
warningsDiv.style.display = 'block';
} else {
warningsDiv.style.display = 'none';
}
} catch (e) {
console.error('Failed to refresh TSCM devices:', e);
}
}
async function loadTscmBaselines() {
try {
const response = await fetch('/tscm/baselines');
const data = await response.json();
const select = document.getElementById('tscmBaselineSelect');
select.innerHTML = '<option value="">No Baseline</option>';
if (data.baselines) {
data.baselines.forEach(b => {
const opt = document.createElement('option');
opt.value = b.id;
opt.textContent = b.name + (b.is_active ? ' (Active)' : '');
select.appendChild(opt);
});
}
} catch (e) {
console.error('Failed to load baselines:', e);
}
}
async function startTscmSweep() {
const sweepType = document.getElementById('tscmSweepType').value;
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
const btEnabled = document.getElementById('tscmBtEnabled').checked;
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
const wifiInterface = document.getElementById('tscmWifiInterface').value;
const btInterface = document.getElementById('tscmBtInterface').value;
const sdrDevice = document.getElementById('tscmSdrDevice').value;
const verboseResults = document.getElementById('tscmVerboseResults').checked;
// Clear any previous warnings
document.getElementById('tscmDeviceWarnings').style.display = 'none';
document.getElementById('tscmDeviceWarnings').innerHTML = '';
// Check for agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
// Check for conflicts if using agent
if (isAgentMode && typeof checkAgentModeConflict === 'function') {
if (!checkAgentModeConflict('tscm')) {
return; // Conflict detected, user cancelled
}
}
try {
// Route to agent or local based on selection
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/tscm/start`
: '/tscm/sweep/start';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sweep_type: sweepType,
baseline_id: baselineId ? parseInt(baselineId) : null,
wifi: wifiEnabled,
bluetooth: btEnabled,
rf: rfEnabled,
wifi_interface: wifiInterface,
bt_interface: btInterface,
sdr_device: sdrDevice ? parseInt(sdrDevice) : null,
verbose_results: verboseResults
})
});
const data = await response.json();
// Handle controller proxy response (agent response is nested in 'result')
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'success' || scanResult.status === 'started') {
isTscmRunning = true;
tscmSweepStartTime = new Date();
tscmSweepEndTime = null;
document.getElementById('startTscmBtn').style.display = 'none';
document.getElementById('stopTscmBtn').style.display = 'block';
document.getElementById('tscmProgress').style.display = 'flex';
// Clear and reset the signal timeline for new sweep
SignalTimeline.clear();
document.getElementById('tscmReportBtn').style.display = 'none';
// Show warnings if any devices unavailable
if (scanResult.warnings && scanResult.warnings.length > 0) {
const warningsDiv = document.getElementById('tscmDeviceWarnings');
warningsDiv.innerHTML = scanResult.warnings.map(w =>
`<div style="color: #ff9933; font-size: 10px; margin-bottom: 2px;">⚠ ${w}</div>`
).join('');
warningsDiv.style.display = 'block';
}
// Update device indicators
updateTscmDeviceIndicators(scanResult.devices);
// Reset displays
tscmThreats = [];
tscmWifiDevices = [];
tscmBtDevices = [];
tscmRfSignals = [];
tscmRfStatusMessage = null;
updateTscmDisplays();
// Start SSE stream
startTscmStream();
} else {
// Show error with details
let errorMsg = scanResult.message || 'Failed to start sweep';
if (scanResult.details && scanResult.details.length > 0) {
errorMsg += '\n\n' + scanResult.details.join('\n');
}
alert(errorMsg);
}
} catch (e) {
console.error('Failed to start TSCM sweep:', e);
alert('Failed to start sweep: Network error');
}
}
function updateTscmDeviceIndicators(devices) {
const wifiIndicator = document.getElementById('tscmWifiIndicator');
const btIndicator = document.getElementById('tscmBtIndicator');
const rfIndicator = document.getElementById('tscmRfIndicator');
// Safety check for agent mode which may not return devices
if (!devices) {
// Just mark all as active if we don't have device info
if (wifiIndicator) wifiIndicator.classList.add('active');
if (btIndicator) btIndicator.classList.add('active');
if (rfIndicator) rfIndicator.classList.add('active');
return;
}
if (wifiIndicator) {
wifiIndicator.classList.toggle('active', devices.wifi);
wifiIndicator.classList.toggle('inactive', !devices.wifi);
}
if (btIndicator) {
btIndicator.classList.toggle('active', devices.bluetooth);
btIndicator.classList.toggle('inactive', !devices.bluetooth);
}
if (rfIndicator) {
rfIndicator.classList.toggle('active', devices.rf);
rfIndicator.classList.toggle('inactive', !devices.rf);
}
}
async function stopTscmSweep() {
try {
// Route to agent or local based on selection
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/tscm/stop`
: '/tscm/sweep/stop';
await fetch(endpoint, { method: 'POST' });
} catch (e) {
console.error('Error stopping sweep:', e);
}
isTscmRunning = false;
tscmSweepEndTime = new Date();
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
if (typeof tscmAgentPollInterval !== 'undefined' && tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
document.getElementById('startTscmBtn').style.display = 'block';
document.getElementById('stopTscmBtn').style.display = 'none';
document.getElementById('tscmProgress').style.display = 'none';
// Show report button if we have any data
const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0;
document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none';
}
function generateTscmReport() {
// Calculate sweep duration
const startTime = tscmSweepStartTime || new Date();
const endTime = tscmSweepEndTime || new Date();
const durationMs = endTime - startTime;
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
// Categorize devices by classification
const allDevices = [
...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })),
...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })),
...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' }))
];
const highInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6);
const needsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6));
const informational = allDevices.filter(d => d.classification === 'informational' || d.score < 3);
// Determine overall assessment
let assessment = 'LOW CONCERN';
let assessmentClass = 'informational';
if (highInterest.length > 0) {
assessment = `ELEVATED CONCERN: ${highInterest.length} high-interest item(s) detected requiring immediate attention`;
assessmentClass = 'high-interest';
} else if (needsReview.length > 0) {
assessment = `MODERATE CONCERN: ${needsReview.length} item(s) requiring further review`;
assessmentClass = 'needs-review';
} else {
assessment = 'LOW CONCERN: No anomalies flagged by automated scan. Manual inspection recommended for comprehensive assessment.';
}
// Helper function to render device row
const renderDevice = (device) => {
const scoreClass = device.score >= 6 ? 'high' : (device.score >= 3 ? 'medium' : 'low');
const indicators = (device.indicators || []).map(i =>
`<span class="indicator">${i.type}: ${i.desc}</span>`
).join('');
const reasons = (device.reasons || []).map(r => `<li>${r}</li>`).join('');
let identifier = device.bssid || device.mac || (device.frequency ? `${device.frequency} MHz` : 'Unknown');
let name = device.essid || device.name || device.band || 'Unknown';
return `
<tr class="device-row ${device.classification || ''}">
<td><span class="protocol-badge ${device.protocol.toLowerCase()}">${device.protocol}</span></td>
<td><strong>${name}</strong><br><small class="identifier">${identifier}</small></td>
<td><span class="score-badge ${scoreClass}">${device.score || 0}</span></td>
<td>${device.classification || 'unknown'}</td>
<td>${device.signal || device.rssi || device.power || 'N/A'} dBm</td>
<td>
${indicators ? `<div class="indicators">${indicators}</div>` : ''}
${reasons ? `<ul class="reasons">${reasons}</ul>` : ''}
</td>
<td>${device.recommended_action || 'monitor'}</td>
</tr>
`;
};
// Generate HTML report
const reportHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TSCM Sweep Report - ${startTime.toLocaleDateString()}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
background: #1a1a2e;
color: #e8eaed;
padding: 40px;
line-height: 1.6;
}
.report-container {
max-width: 1200px;
margin: 0 auto;
background: #0f1218;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.report-header {
background: linear-gradient(135deg, #1a1a2e 0%, #0f1218 100%);
padding: 40px;
border-bottom: 1px solid #2a2a4a;
}
.report-title {
font-size: 28px;
font-weight: 700;
color: #4a9eff;
margin-bottom: 8px;
}
.report-subtitle {
font-size: 14px;
color: #9ca3af;
letter-spacing: 1px;
text-transform: uppercase;
}
.report-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
padding: 20px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
}
.meta-item {
text-align: center;
}
.meta-value {
font-size: 24px;
font-weight: 700;
color: #fff;
}
.meta-label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
}
.section {
padding: 30px 40px;
border-bottom: 1px solid #2a2a4a;
}
.section:last-child {
border-bottom: none;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #4a9eff;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.assessment {
padding: 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
}
.assessment.high-interest {
background: rgba(255, 51, 51, 0.15);
border: 1px solid #ff3333;
color: #ff6666;
}
.assessment.needs-review {
background: rgba(255, 204, 0, 0.15);
border: 1px solid #ffcc00;
color: #ffdd44;
}
.assessment.informational {
background: rgba(0, 204, 0, 0.15);
border: 1px solid #00cc00;
color: #44dd44;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.summary-card {
background: rgba(0,0,0,0.3);
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #2a2a4a;
}
.summary-card.high-interest { border-color: #ff3333; }
.summary-card.needs-review { border-color: #ffcc00; }
.summary-card.informational { border-color: #00cc00; }
.summary-card .count {
font-size: 32px;
font-weight: 700;
}
.summary-card.high-interest .count { color: #ff3333; }
.summary-card.needs-review .count { color: #ffcc00; }
.summary-card.informational .count { color: #00cc00; }
.summary-card .label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 12px;
background: rgba(0,0,0,0.4);
color: #9ca3af;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #2a2a4a;
}
td {
padding: 12px;
border-bottom: 1px solid #2a2a4a;
vertical-align: top;
}
.device-row.high_interest { background: rgba(255, 51, 51, 0.08); }
.device-row.review { background: rgba(255, 204, 0, 0.08); }
.protocol-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.protocol-badge.wifi { background: #4a9eff; color: #000; }
.protocol-badge.bluetooth { background: #8b5cf6; color: #fff; }
.protocol-badge.rf { background: #f59e0b; color: #000; }
.score-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-weight: 600;
font-size: 12px;
}
.score-badge.high { background: rgba(255,51,51,0.2); color: #ff3333; }
.score-badge.medium { background: rgba(255,204,0,0.2); color: #ffcc00; }
.score-badge.low { background: rgba(0,204,0,0.2); color: #00cc00; }
.identifier {
color: #6b7280;
font-family: monospace;
font-size: 11px;
}
.indicators {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.indicator {
display: inline-block;
padding: 2px 6px;
background: rgba(255,153,51,0.2);
color: #ff9933;
border-radius: 3px;
font-size: 10px;
}
.reasons {
margin: 0;
padding-left: 16px;
font-size: 11px;
color: #9ca3af;
}
.reasons li {
margin-bottom: 2px;
}
.category-section {
margin-bottom: 30px;
}
.category-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 6px;
}
.category-title.high-interest { background: rgba(255,51,51,0.15); color: #ff6666; }
.category-title.needs-review { background: rgba(255,204,0,0.15); color: #ffdd44; }
.category-title.informational { background: rgba(0,204,0,0.15); color: #44dd44; }
.empty-state {
text-align: center;
padding: 40px;
color: #6b7280;
}
.disclaimer {
padding: 20px;
background: rgba(74, 158, 255, 0.1);
border-radius: 8px;
font-size: 12px;
color: #9ca3af;
}
.disclaimer h4 {
color: #4a9eff;
margin-bottom: 10px;
font-size: 13px;
}
.recommendations {
margin-top: 20px;
}
.recommendations ul {
padding-left: 20px;
}
.recommendations li {
margin-bottom: 8px;
}
.report-actions {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
}
.report-btn {
padding: 12px 24px;
background: #4a9eff;
color: #000;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.report-btn:hover {
background: #6bb3ff;
}
.report-btn.save {
background: #22c55e;
}
.report-btn.save:hover {
background: #2ecc71;
}
@media print {
body { background: #fff; color: #000; padding: 20px; }
.report-container { box-shadow: none; }
.report-header { background: #f8f9fa; }
.report-title { color: #1a1a2e; }
.section { border-color: #ddd; }
.section-title { color: #1a1a2e; }
th { background: #f0f0f0; color: #333; }
td { border-color: #ddd; }
.report-actions { display: none; }
.device-row.high_interest { background: rgba(255, 51, 51, 0.1); }
.device-row.review { background: rgba(255, 204, 0, 0.1); }
}
</style>
</head>
<body>
<div class="report-actions">
<button class="report-btn save" onclick="saveReport()">Save Report</button>
<button class="report-btn" onclick="window.print()">Print Report</button>
</div>
<div class="report-container">
<div class="report-header">
<div class="report-title">TSCM Sweep Report</div>
<div class="report-subtitle">Technical Surveillance Counter-Measures Analysis</div>
<div class="report-meta">
<div class="meta-item">
<div class="meta-value">${startTime.toLocaleDateString()}</div>
<div class="meta-label">Date</div>
</div>
<div class="meta-item">
<div class="meta-value">${startTime.toLocaleTimeString()} - ${endTime.toLocaleTimeString()}</div>
<div class="meta-label">Time Range</div>
</div>
<div class="meta-item">
<div class="meta-value">${durationMin}m ${durationSec}s</div>
<div class="meta-label">Duration</div>
</div>
<div class="meta-item">
<div class="meta-value">${allDevices.length}</div>
<div class="meta-label">Total Devices</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Executive Summary</div>
<div class="summary-grid">
<div class="summary-card high-interest">
<div class="count">${highInterest.length}</div>
<div class="label">High Interest</div>
</div>
<div class="summary-card needs-review">
<div class="count">${needsReview.length}</div>
<div class="label">Needs Review</div>
</div>
<div class="summary-card informational">
<div class="count">${informational.length}</div>
<div class="label">Informational</div>
</div>
<div class="summary-card">
<div class="count" style="color: #4a9eff;">${tscmWifiDevices.length}/${tscmBtDevices.length}/${tscmRfSignals.length}</div>
<div class="label">WiFi/BT/RF</div>
</div>
</div>
<div class="assessment ${assessmentClass}">
<strong>Assessment:</strong> ${assessment}
</div>
</div>
${highInterest.length > 0 ? `
<div class="section">
<div class="section-title">High Interest Items</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${highInterest.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${needsReview.length > 0 ? `
<div class="section">
<div class="section-title">Items Requiring Review</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${needsReview.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${informational.length > 0 ? `
<div class="section">
<div class="section-title">Informational Items</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${informational.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${allDevices.length === 0 ? `
<div class="section">
<div class="empty-state">
<p>No devices were detected during this sweep.</p>
</div>
</div>
` : ''}
<div class="section">
<div class="section-title">Recommendations</div>
<div class="recommendations">
<ul>
${highInterest.length > 0 ? `
<li><strong>Immediate Action Required:</strong> ${highInterest.length} high-interest item(s) detected. These devices exhibit characteristics commonly associated with surveillance equipment and should be physically located and investigated.</li>
` : ''}
${needsReview.length > 0 ? `
<li><strong>Further Investigation Recommended:</strong> ${needsReview.length} item(s) require additional review to determine their purpose and legitimacy.</li>
` : ''}
${allDevices.filter(d => d.is_new).length > 0 ? `
<li><strong>New Devices Detected:</strong> ${allDevices.filter(d => d.is_new).length} device(s) were not present in the baseline. Verify these are authorized.</li>
` : ''}
<li><strong>Regular Monitoring:</strong> Consider establishing a baseline of normal wireless activity and conducting periodic sweeps to detect changes.</li>
<li><strong>Physical Inspection:</strong> For any high-interest items, conduct a thorough physical inspection of the area to locate potential surveillance devices.</li>
</ul>
</div>
</div>
<div class="section">
<div class="section-title">Disclaimer</div>
<div class="disclaimer">
<h4>Important Notice</h4>
<p>This report is generated by automated wireless spectrum analysis software. The findings presented are <strong>indicators only</strong> and do not constitute confirmation of surveillance activity. Many legitimate devices may trigger alerts due to their wireless characteristics.</p>
<p style="margin-top: 10px;">Professional TSCM services involve specialized equipment and expertise beyond wireless spectrum analysis, including: non-linear junction detection, thermal imaging, physical inspection, and RF spectrum analysis with calibrated equipment.</p>
<p style="margin-top: 10px;"><strong>No content was intercepted or decoded during this analysis.</strong> This tool only detects the presence and characteristics of wireless transmissions.</p>
</div>
</div>
</div>
<scr` + `ipt>
function saveReport() {
const html = document.documentElement.outerHTML;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'TSCM_Report_${startTime.toISOString().split('T')[0]}.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</scr` + `ipt>
</body>
</html>
`;
// Open report in new window
const reportWindow = window.open('', '_blank');
reportWindow.document.write(reportHtml);
reportWindow.document.close();
}
let tscmAgentPollInterval = null;
function startTscmStream() {
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
// Check if using agent
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentMode) {
// For agent mode, poll the agent for TSCM data since push may not be enabled
console.log('[TSCM] Starting agent polling mode');
pollAgentTscmData(); // Initial poll
tscmAgentPollInterval = setInterval(pollAgentTscmData, 2000); // Poll every 2 seconds
} else {
// For local mode, use SSE stream
const streamUrl = '/tscm/sweep/stream';
tscmEventSource = new EventSource(streamUrl);
tscmEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
handleTscmEvent(data);
} catch (e) {
console.error('TSCM SSE parse error:', e);
}
};
tscmEventSource.onerror = function () {
console.warn('TSCM SSE connection error');
};
}
}
async function pollAgentTscmData() {
if (!isTscmRunning) {
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/tscm/data`);
const result = await response.json();
if (result.status === 'success' && result.data) {
// Agent data is nested: result.data.data (controller wraps agent response)
const data = result.data.data || result.data;
// Process WiFi devices
if (data.wifi_devices) {
data.wifi_devices.forEach(device => {
if (!tscmWifiDevices.find(d => d.bssid === device.bssid)) {
handleTscmEvent({ type: 'wifi_device', ...device });
}
});
}
// Process Bluetooth devices
if (data.bt_devices) {
data.bt_devices.forEach(device => {
if (!tscmBtDevices.find(d => d.address === device.address)) {
handleTscmEvent({ type: 'bt_device', ...device });
}
});
}
// Process anomalies/threats
// Agent now uses same ThreatDetector as local mode, so format matches:
// threat_type, severity, source, identifier, name, signal_strength
if (data.anomalies) {
data.anomalies.forEach(threat => {
handleTscmEvent({
type: 'threat_detected',
...threat
});
});
}
// Process RF signals
if (data.rf_signals) {
data.rf_signals.forEach(signal => {
handleTscmEvent({ type: 'rf_signal', ...signal });
});
}
// Update progress (simple time-based estimate)
if (tscmSweepStartTime) {
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
const durations = { quick: 120, standard: 300, full: 900 };
const maxDuration = durations[sweepType] || 300;
const progress = Math.min(95, (elapsed / maxDuration) * 100);
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
}
}
} catch (e) {
console.error('[TSCM] Agent poll error:', e);
}
}
let tscmCorrelations = [];
function handleTscmEvent(data) {
switch (data.type) {
case 'sweep_progress':
updateTscmProgress(data);
break;
case 'wifi_device':
addTscmWifiDevice(data);
break;
case 'bt_device':
addTscmBtDevice(data);
break;
case 'rf_signal':
addTscmRfSignal(data);
break;
case 'rf_status':
handleRfStatus(data);
break;
case 'threat_detected':
addTscmThreat(data);
break;
case 'correlation_findings':
handleCorrelationFindings(data);
break;
case 'sweep_completed':
completeTscmSweep(data);
break;
case 'sweep_stopped':
case 'sweep_error':
stopTscmSweep();
break;
}
}
function handleCorrelationFindings(data) {
tscmCorrelations = data.correlations || [];
updateCorrelationsDisplay();
}
function addTscmWifiDevice(device) {
// Check if already exists
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
if (!exists) {
tscmWifiDevices.push(device);
updateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (device.score >= 3) {
addHighInterestDevice(device, 'wifi');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/wifi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(device)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
const freq = device.channel <= 14 ? '2400' : '5000';
const strength = Math.min(5, Math.max(1, Math.ceil((device.signal + 100) / 20)));
SignalTimeline.addEvent(freq, strength, 2000, device.ssid || 'Hidden WiFi');
}
}
function addTscmBtDevice(device) {
// Check if already exists
const exists = tscmBtDevices.some(d => d.mac === device.mac);
if (!exists) {
tscmBtDevices.push(device);
updateTscmDisplays();
updateTscmThreatCounts();
// Add to threats panel if score >= 3 (review level or higher)
if (device.score >= 3) {
addHighInterestDevice(device, 'bluetooth');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/bluetooth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(device)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
const strength = device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3;
SignalTimeline.addEvent('2450', strength, 1500, device.name || 'Bluetooth Device');
}
}
let tscmRfSignals = [];
let tscmRfStatusMessage = null;
function addTscmRfSignal(signal) {
// Clear any error message since we're receiving signals
tscmRfStatusMessage = null;
// Check if already exists (within 0.1 MHz)
const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1);
if (!exists) {
tscmRfSignals.push(signal);
updateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (signal.score >= 3) {
addHighInterestDevice(signal, 'rf');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/rf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signal)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
const strength = signal.power_dbm ? Math.min(5, Math.max(1, Math.ceil((signal.power_dbm + 60) / 15))) : 3;
SignalTimeline.addEvent(String(signal.frequency), strength, 1000, signal.classification || 'RF Signal');
} else {
// Update existing signal on timeline (show recurring transmission)
const strength = signal.power_dbm ? Math.min(5, Math.max(1, Math.ceil((signal.power_dbm + 60) / 15))) : 3;
SignalTimeline.addEvent(String(signal.frequency), strength, 500, signal.classification || 'RF Signal');
}
}
function handleRfStatus(data) {
// Store status message to display in RF panel
tscmRfStatusMessage = data.message;
updateRfDisplay();
}
function updateRfDisplay() {
const rfList = document.getElementById('tscmRfList');
if (!rfList) return;
if (tscmRfSignals.length === 0) {
if (tscmRfStatusMessage) {
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
} else {
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
}
}
// If there are signals, updateTscmDisplays() will handle the display
}
// Track high-interest devices for the threats panel
let tscmHighInterestDevices = [];
function addHighInterestDevice(device, protocol) {
const id = device.mac || device.bssid || device.frequency;
const exists = tscmHighInterestDevices.some(d => d.id === id);
if (!exists) {
tscmHighInterestDevices.push({
id: id,
protocol: protocol,
name: device.name || device.essid || device.ssid || (device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device'),
score: device.score,
classification: device.classification,
indicators: device.indicators || [],
recommended_action: device.recommended_action,
device: device
});
updateHighInterestPanel();
}
}
function updateHighInterestPanel() {
const panel = document.getElementById('tscmThreatList');
if (tscmHighInterestDevices.length === 0) {
panel.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
} else {
// Sort by score (highest first)
const sorted = [...tscmHighInterestDevices].sort((a, b) => b.score - a.score);
panel.innerHTML = '<div class="tscm-threat-list">' + sorted.map(d => {
const severityClass = d.score >= 6 ? 'critical' : d.score >= 4 ? 'high' : 'medium';
return `
<div class="tscm-threat-item ${severityClass}" onclick="showDeviceDetails('${d.id}', '${d.protocol}')">
<div class="tscm-threat-header">
<span class="tscm-threat-type">${d.protocol.toUpperCase()}</span>
<span class="tscm-threat-severity">Score: ${d.score}</span>
</div>
<div class="tscm-threat-details">
<strong>${escapeHtml(d.name)}</strong><br>
<span style="font-size: 10px; color: var(--text-muted);">
${d.indicators && d.indicators.length > 0 ? d.indicators.slice(0, 2).map(i => i.desc || i.type).join(' | ') : 'Review recommended'}
</span>
</div>
<div class="tscm-threat-action">${d.recommended_action || 'review'}</div>
</div>
`;
}).join('') + '</div>';
}
}
function updateTscmProgress(data) {
// Update percentage text
document.getElementById('tscmProgressPercent').textContent = data.progress + '%';
// Update SVG circle progress (circumference = 2 * PI * 45 = ~283)
const circumference = 283;
const offset = circumference - (data.progress / 100) * circumference;
const circle = document.getElementById('tscmScannerCircle');
if (circle) {
circle.style.strokeDashoffset = offset;
}
// Update status text
let statusText = 'SCANNING...';
if (data.threats_found > 0) {
statusText = `THREATS: ${data.threats_found}`;
} else if (data.status) {
statusText = data.status;
} else {
const parts = [];
if (data.wifi_count > 0) parts.push(`${data.wifi_count} WiFi`);
if (data.bt_count > 0) parts.push(`${data.bt_count} BT`);
if (data.rf_count > 0) parts.push(`${data.rf_count} RF`);
statusText = parts.length > 0 ? parts.join(' | ') : 'SCANNING...';
}
document.getElementById('tscmProgressLabel').textContent = statusText;
}
function addTscmThreat(threat) {
tscmThreats.unshift(threat);
// Update dashboard counts
updateTscmThreatCounts();
updateTscmDisplays();
}
function updateTscmThreatCounts() {
// Count devices by new scoring model classification
const counts = { high_interest: 0, review: 0, informational: 0 };
// Count from all device lists
[...tscmWifiDevices, ...tscmBtDevices, ...tscmRfSignals].forEach(d => {
const classification = d.classification || 'review';
if (classification === 'high_interest') counts.high_interest++;
else if (classification === 'review') counts.review++;
else counts.informational++;
});
document.getElementById('tscmHighInterestCount').textContent = counts.high_interest;
document.getElementById('tscmNeedsReviewCount').textContent = counts.review;
document.getElementById('tscmInformationalCount').textContent = counts.informational;
document.getElementById('tscmCorrelationsCount').textContent = tscmCorrelations.length;
document.getElementById('tscmHighInterestCard').classList.toggle('active', counts.high_interest > 0);
document.getElementById('tscmNeedsReviewCard').classList.toggle('active', counts.review > 0);
document.getElementById('tscmInformationalCard').classList.toggle('active', counts.informational > 0);
document.getElementById('tscmCorrelationsCard').classList.toggle('active', tscmCorrelations.length > 0);
// Update threat panel count (shows high interest items only)
document.getElementById('tscmThreatCount').textContent = counts.high_interest;
}
function getClassificationClass(classification) {
// Map classification to CSS class
switch (classification) {
case 'high_interest': return 'classification-red';
case 'review': return 'classification-yellow';
case 'informational': return 'classification-green';
default: return 'classification-yellow';
}
}
function getClassificationIcon(classification) {
// Returns CSS class name for colored dot styling instead of emojis
switch (classification) {
case 'high_interest': return '<span class="classification-dot high"></span>';
case 'review': return '<span class="classification-dot review"></span>';
case 'informational': return '<span class="classification-dot info"></span>';
default: return '<span class="classification-dot review"></span>';
}
}
function formatIndicators(indicators) {
if (!indicators || indicators.length === 0) return '';
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
}
function getScoreBadge(score) {
if (score === undefined || score === null) return '';
let scoreClass = 'score-low';
if (score >= 6) scoreClass = 'score-high';
else if (score >= 3) scoreClass = 'score-medium';
return `<span class="score-badge ${scoreClass}">Score: ${score}</span>`;
}
// Store all devices for lookup
function getAllTscmDevices() {
const devices = {};
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
return devices;
}
function showDeviceDetails(id, protocol) {
const devices = getAllTscmDevices();
const key = `${protocol}:${id}`;
const device = devices[key];
if (!device) {
console.warn('Device not found:', key);
return;
}
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
// Build detailed view
let html = `
<div class="device-detail-header ${getClassificationClass(device.classification)}">
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.essid || device.ssid || device.mac || device.bssid || (device.frequency ? device.frequency.toFixed(3) + ' MHz' : 'Unknown'))}</h3>
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
</div>
<div class="device-detail-score">
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
<span class="score-value">${device.score || 0}</span>
<span class="score-label">SCORE</span>
</div>
<div class="score-breakdown">
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
</div>
</div>
<div class="device-detail-section">
<h4>Device Information</h4>
<table class="device-detail-table">
`;
// Add device-specific fields
if (protocol === 'wifi') {
html += `
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
`;
} else if (protocol === 'bluetooth') {
html += `
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
`;
} else if (protocol === 'rf') {
html += `
<tr><td>Frequency</td><td>${device.frequency?.toFixed(3) || 'Unknown'} MHz</td></tr>
<tr><td>Band</td><td>${device.band || 'Unknown'}</td></tr>
<tr><td>Power</td><td>${device.power?.toFixed(1) || '--'} dBm</td></tr>
<tr><td>Signal Strength</td><td>+${(device.signal_strength || 0).toFixed(1)} dB above noise</td></tr>
`;
}
html += `</table></div>`;
// Actions section - always show with "Add to Known Devices"
const deviceIdentifier = device.bssid || device.mac || (device.frequency ? device.frequency.toString() : id);
const deviceName = device.name || device.ssid || device.essid || (device.frequency ? device.frequency + ' MHz' : 'Unknown');
html += `
<div class="device-detail-section">
<h4>Actions</h4>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
`;
// Add "Listen" button for RF signals
if (protocol === 'rf' && device.frequency) {
const freq = device.frequency;
html += `
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'fm')">
Listen (FM)
</button>
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'am')">
Listen (AM)
</button>
`;
}
// Add "Add to Known Devices" button for all device types
html += `
<button class="tscm-action-btn" style="background: var(--accent-cyan);" onclick="tscmAddToKnownDevices('${escapeHtml(deviceIdentifier)}', '${escapeHtml(deviceName)}', '${protocol}')">
Add to Known Devices
</button>
</div>
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
${protocol === 'rf' ? 'Listen buttons open Listening Post. ' : ''}Known devices are excluded from threat scoring in future sweeps.
</div>
</div>
`;
// Add indicators section
if (device.indicators && device.indicators.length > 0) {
html += `
<div class="device-detail-section">
<h4>Risk Indicators (Why This Score)</h4>
<div class="indicator-list">
${device.indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${i.type}</span>
<span class="indicator-desc">${escapeHtml(i.desc || '')}</span>
</div>
`).join('')}
</div>
</div>
`;
}
// Add reasons section
if (device.reasons && device.reasons.length > 0) {
html += `
<div class="device-detail-section">
<h4>Detection Notes</h4>
<ul class="device-reasons-list">
${device.reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
</ul>
</div>
`;
}
// Add disclaimer
html += `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> This analysis identifies indicators and anomalies.
It does NOT confirm surveillance activity. Professional verification required.
</div>
`;
content.innerHTML = html;
modal.style.display = 'flex';
}
function closeTscmDeviceModal() {
document.getElementById('tscmDeviceModal').style.display = 'none';
}
function listenToRfSignal(frequency, modulation) {
// Close the modal
closeTscmDeviceModal();
// Switch to listening post mode
switchMode('listening');
// Wait a moment for the mode to switch, then tune to the frequency
setTimeout(() => {
if (typeof tuneToFrequency === 'function') {
tuneToFrequency(frequency, modulation);
} else {
// Fallback: manually update the frequency input
const freqInput = document.getElementById('radioScanStart');
if (freqInput) {
freqInput.value = frequency.toFixed(1);
}
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
}
}, 300);
}
function showDevicesByCategory(category) {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
let devices = [];
let title = '';
let titleClass = '';
if (category === 'correlations') {
// Show correlations
title = 'Cross-Protocol Correlations';
titleClass = 'classification-yellow';
if (tscmCorrelations.length === 0) {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title}</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No correlations detected yet.</p>
</div>
`;
} else {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title} (${tscmCorrelations.length})</h3>
</div>
<div class="device-detail-section">
${tscmCorrelations.map(c => `
<div class="correlation-detail-item">
<strong>${escapeHtml(c.description || 'Cross-protocol match')}</strong>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Protocols: ${(c.protocols || []).join(', ')}<br>
Devices: ${(c.devices || []).join(', ')}
</div>
</div>
`).join('')}
</div>
`;
}
modal.style.display = 'flex';
return;
}
// Filter devices by classification
const allDevices = [
...tscmWifiDevices.map(d => ({ ...d, protocol: 'wifi', id: d.bssid })),
...tscmBtDevices.map(d => ({ ...d, protocol: 'bluetooth', id: d.mac })),
...tscmRfSignals.map(d => ({ ...d, protocol: 'rf', id: d.frequency }))
];
if (category === 'high_interest') {
devices = allDevices.filter(d => d.classification === 'high_interest');
title = 'High Interest Devices';
titleClass = 'classification-red';
} else if (category === 'review') {
devices = allDevices.filter(d => d.classification === 'review');
title = 'Devices Needing Review';
titleClass = 'classification-yellow';
} else if (category === 'informational') {
devices = allDevices.filter(d => d.classification === 'informational');
title = 'Informational Devices';
titleClass = 'classification-green';
}
// Sort by score descending
devices.sort((a, b) => (b.score || 0) - (a.score || 0));
if (devices.length === 0) {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title}</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No devices in this category.</p>
</div>
`;
} else {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title} (${devices.length})</h3>
</div>
<div class="category-device-list">
${devices.map(d => `
<div class="category-device-item" onclick="event.stopPropagation(); showDeviceDetails('${d.id}', '${d.protocol}')">
<div class="category-device-header">
<span class="category-device-name">
${getClassificationIcon(d.classification)}
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || (d.frequency ? d.frequency.toFixed(3) + ' MHz' : 'Unknown'))}
</span>
<span class="category-device-score">${d.score || 0}</span>
</div>
<div class="category-device-meta">
<span class="protocol-badge">${d.protocol}</span>
${d.indicators ? d.indicators.slice(0, 2).map(i => `<span class="indicator-mini">${i.type}</span>`).join('') : ''}
</div>
</div>
`).join('')}
</div>
`;
}
modal.style.display = 'flex';
}
function updateTscmDisplays() {
// Update WiFi list
const wifiList = document.getElementById('tscmWifiList');
if (tscmWifiDevices.length === 0) {
wifiList.innerHTML = '<div class="tscm-empty">No WiFi networks detected</div>';
} else {
// Sort by score (highest first)
const sorted = [...tscmWifiDevices].sort((a, b) => (b.score || 0) - (a.score || 0));
wifiList.innerHTML = sorted.map(d => `
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.bssid}', 'wifi')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
${escapeHtml(d.ssid || d.bssid || 'Hidden')}
</div>
${getScoreBadge(d.score)}
</div>
<div class="tscm-device-meta">
<span>${d.bssid}</span>
<span>${d.signal || '--'} dBm</span>
<span>${d.security || 'Open'}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmWifiCount').textContent = tscmWifiDevices.length;
// Update BT list
const btList = document.getElementById('tscmBtList');
if (tscmBtDevices.length === 0) {
btList.innerHTML = '<div class="tscm-empty">No Bluetooth devices detected</div>';
} else {
// Sort by score (highest first)
const sorted = [...tscmBtDevices].sort((a, b) => (b.score || 0) - (a.score || 0));
btList.innerHTML = sorted.map(d => `
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.mac}', 'bluetooth')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
${escapeHtml(d.name || 'Unknown')}
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
</div>
${getScoreBadge(d.score)}
</div>
<div class="tscm-device-meta">
<span>${d.mac}</span>
<span>${d.rssi || '--'} dBm</span>
<span>${d.device_type || 'Unknown'}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmBtCount').textContent = tscmBtDevices.length;
// Update RF list
const rfList = document.getElementById('tscmRfList');
if (tscmRfSignals.length === 0) {
if (tscmRfStatusMessage) {
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
} else {
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
}
} else {
// Sort by score (highest first)
const sorted = [...tscmRfSignals].sort((a, b) => (b.score || 0) - (a.score || 0));
rfList.innerHTML = sorted.map(s => `
<div class="tscm-device-item ${getClassificationClass(s.classification)}" onclick="showDeviceDetails('${s.frequency}', 'rf')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(s.classification)}</span>
${s.frequency.toFixed(3)} MHz
</div>
${getScoreBadge(s.score)}
</div>
<div class="tscm-device-meta">
<span>${s.band}</span>
<span>${s.power.toFixed(1)} dBm</span>
<span>+${(s.signal_strength || 0).toFixed(1)} dB above noise</span>
</div>
${s.indicators && s.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(s.indicators)}</div>` : ''}
${s.recommended_action && s.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${s.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmRfCount').textContent = tscmRfSignals.length;
// Update threats list
const threatList = document.getElementById('tscmThreatList');
if (tscmThreats.length === 0) {
threatList.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
} else {
threatList.innerHTML = '<div class="tscm-threat-list">' + tscmThreats.map(t => `
<div class="tscm-threat-item ${t.severity}">
<div class="tscm-threat-header">
<span class="tscm-threat-type">${escapeHtml(t.threat_type || 'Unknown')}</span>
<span class="tscm-threat-severity">${t.severity}</span>
</div>
<div class="tscm-threat-details">
<strong>${escapeHtml(t.name || t.identifier)}</strong><br>
Source: ${t.source} | Signal: ${t.signal_strength || '--'} dBm
</div>
</div>
`).join('') + '</div>';
}
}
function updateCorrelationsDisplay() {
const container = document.getElementById('tscmCorrelationsContainer');
if (!container) return;
if (tscmCorrelations.length === 0) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
container.style.display = 'block';
container.innerHTML = `
<div class="tscm-correlations">
<h4>Cross-Protocol Correlations (${tscmCorrelations.length})</h4>
${tscmCorrelations.map(c => `
<div class="correlation-item">
<strong>${escapeHtml(c.description)}</strong>
<div class="correlation-devices">
Devices: ${c.devices.join(', ')} | Protocols: ${c.protocols.join(', ')}
</div>
</div>
`).join('')}
</div>
`;
}
function completeTscmSweep(data) {
isTscmRunning = false;
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
document.getElementById('startTscmBtn').style.display = 'block';
document.getElementById('stopTscmBtn').style.display = 'none';
document.getElementById('tscmProgress').style.display = 'none';
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
document.getElementById('tscmProgressPercent').textContent = '100%';
document.getElementById('tscmProgressBar').style.width = '100%';
// Final update of counts
updateTscmThreatCounts();
// Display sweep summary with correlation results
const summaryContainer = document.getElementById('tscmSweepSummary');
if (summaryContainer && data) {
const highInterest = data.high_interest_devices || 0;
const needsReview = data.needs_review_devices || 0;
const correlations = data.correlations_found || 0;
let assessment = 'BASELINE ENVIRONMENT';
let assessmentClass = 'informational';
if (highInterest > 0 || correlations > 0) {
assessment = 'ELEVATED CONCERN';
assessmentClass = 'high-interest';
} else if (needsReview > 3) {
assessment = 'MODERATE CONCERN';
assessmentClass = 'needs-review';
} else if (needsReview > 0) {
assessment = 'LOW CONCERN';
assessmentClass = 'needs-review';
}
summaryContainer.innerHTML = `
<div class="tscm-summary-box">
<div class="summary-stat high-interest">
<div class="count">${highInterest}</div>
<div class="label">High Interest</div>
</div>
<div class="summary-stat needs-review">
<div class="count">${needsReview}</div>
<div class="label">Needs Review</div>
</div>
<div class="summary-stat">
<div class="count">${correlations}</div>
<div class="label">Correlations</div>
</div>
</div>
<div class="tscm-assessment ${assessmentClass}">
<strong>Assessment:</strong> ${assessment}
</div>
<div class="tscm-disclaimer">
This screening identifies wireless/RF anomalies, NOT confirmed surveillance devices.
Findings require professional verification.
</div>
`;
summaryContainer.style.display = 'block';
}
// Update correlations display
updateCorrelationsDisplay();
}
async function tscmRecordBaseline() {
const name = document.getElementById('tscmBaselineName').value ||
`Baseline ${new Date().toLocaleString()}`;
try {
const response = await fetch('/tscm/baseline/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
const data = await response.json();
if (data.status === 'success') {
isRecordingBaseline = true;
document.getElementById('tscmRecordBaselineBtn').style.display = 'none';
document.getElementById('tscmStopBaselineBtn').style.display = 'block';
document.getElementById('tscmBaselineStatus').textContent = 'Recording baseline...';
document.getElementById('tscmBaselineStatus').style.color = '#ff9933';
} else {
alert(data.message || 'Failed to start baseline recording');
}
} catch (e) {
console.error('Failed to start baseline:', e);
alert('Failed to start baseline recording');
}
}
async function tscmStopBaseline() {
try {
const response = await fetch('/tscm/baseline/stop', { method: 'POST' });
const data = await response.json();
isRecordingBaseline = false;
document.getElementById('tscmRecordBaselineBtn').style.display = 'block';
document.getElementById('tscmStopBaselineBtn').style.display = 'none';
if (data.status === 'success') {
document.getElementById('tscmBaselineStatus').textContent =
`Baseline saved: ${data.wifi_count} WiFi, ${data.bt_count} BT, ${data.rf_count} RF`;
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
loadTscmBaselines();
} else {
document.getElementById('tscmBaselineStatus').textContent = data.message || 'Recording stopped';
document.getElementById('tscmBaselineStatus').style.color = 'var(--text-muted)';
}
} catch (e) {
console.error('Failed to stop baseline:', e);
document.getElementById('tscmBaselineStatus').textContent = 'Error stopping baseline';
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ========== TSCM Advanced Features ==========
// Meeting Window Management
let tscmActiveMeetingId = null;
let tscmMeetingStartTime = null;
async function tscmStartMeeting() {
const meetingName = document.getElementById('tscmMeetingName').value ||
`Meeting ${new Date().toLocaleString()}`;
try {
const response = await fetch('/tscm/meeting/start-tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: meetingName })
});
const data = await response.json();
if (data.status === 'success') {
tscmActiveMeetingId = data.meeting_id;
tscmMeetingStartTime = new Date();
// Update UI
document.getElementById('tscmStartMeetingBtn').style.display = 'none';
document.getElementById('tscmEndMeetingBtn').style.display = 'block';
document.getElementById('tscmMeetingStatus').innerHTML =
`<span style="color: #ff9933;">Meeting active: ${escapeHtml(meetingName)}</span>`;
// Show meeting banner
const banner = document.getElementById('tscmMeetingBanner');
if (banner) {
banner.style.display = 'flex';
const nameSpan = document.getElementById('tscmMeetingName_display');
if (nameSpan) nameSpan.textContent = meetingName;
}
} else {
alert(data.message || 'Failed to start meeting window');
}
} catch (e) {
console.error('Failed to start meeting:', e);
alert('Failed to start meeting window');
}
}
async function tscmEndMeeting() {
if (!tscmActiveMeetingId) return;
try {
const response = await fetch(`/tscm/meeting/${tscmActiveMeetingId}/end`, {
method: 'POST'
});
const data = await response.json();
// Update UI
document.getElementById('tscmStartMeetingBtn').style.display = 'block';
document.getElementById('tscmEndMeetingBtn').style.display = 'none';
// Hide meeting banner
const banner = document.getElementById('tscmMeetingBanner');
if (banner) banner.style.display = 'none';
if (data.status === 'success') {
const duration = tscmMeetingStartTime ?
Math.round((new Date() - tscmMeetingStartTime) / 60000) : 0;
document.getElementById('tscmMeetingStatus').innerHTML =
`<span style="color: #00ff88;">Meeting ended (${duration} min) - ${data.devices_flagged || 0} devices flagged</span>`;
// Show export section if devices were flagged
if (data.devices_flagged > 0) {
document.getElementById('tscmExportSection').style.display = 'block';
}
} else {
document.getElementById('tscmMeetingStatus').textContent = 'Meeting ended';
}
tscmActiveMeetingId = null;
tscmMeetingStartTime = null;
} catch (e) {
console.error('Failed to end meeting:', e);
}
}
// Capabilities Display
async function tscmShowCapabilities() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading capabilities...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/capabilities');
const data = await response.json();
if (data.status === 'success') {
const caps = data.capabilities;
// Determine availability from nested structure
const wifiAvailable = caps.wifi && caps.wifi.mode !== 'unavailable';
const btAvailable = caps.bluetooth && caps.bluetooth.mode !== 'unavailable';
const rfAvailable = caps.rf && caps.rf.available;
// Build can/cannot detect lists based on capabilities
const canDetect = [];
const cannotDetect = [];
if (wifiAvailable) {
canDetect.push('WiFi access points and networks');
canDetect.push('Hidden SSIDs (presence only)');
if (caps.wifi.monitor_capable) {
canDetect.push('WiFi client devices (probe requests)');
canDetect.push('Deauthentication attacks');
}
} else {
cannotDetect.push('WiFi networks - no adapter available');
}
if (btAvailable) {
canDetect.push('Bluetooth Classic devices');
canDetect.push('BLE beacons and trackers');
canDetect.push('Audio-capable Bluetooth devices');
} else {
cannotDetect.push('Bluetooth devices - no adapter available');
}
if (rfAvailable) {
const minFreq = caps.rf.frequency_range_mhz?.min || 0;
const maxFreq = caps.rf.frequency_range_mhz?.max || 0;
canDetect.push(`RF signals (${minFreq}-${maxFreq} MHz)`);
canDetect.push('Unknown transmitters in frequency range');
} else {
cannotDetect.push('RF signals - no SDR device available');
}
// Always cannot detect
cannotDetect.push('Wired surveillance devices');
cannotDetect.push('Passive listening devices (no transmitter)');
cannotDetect.push('Devices that are powered off');
cannotDetect.push('Burst/store-and-forward transmitters (when idle)');
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>Sweep Capabilities</h3>
</div>
<div class="device-detail-section">
<h4>System Information</h4>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
OS: ${escapeHtml(caps.system?.os || 'Unknown')} ${escapeHtml(caps.system?.os_version || '')} |
Root: ${caps.system?.is_root ? 'Yes' : 'No'}
</div>
<h4>Available Detection Methods</h4>
<div class="capabilities-grid">
<div class="cap-detail-item ${wifiAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="cap-name">WiFi Scanning</span>
<span class="cap-status">${wifiAvailable ? caps.wifi.mode : 'Not Available'}</span>
${caps.wifi?.interface ? `<span class="cap-detail">${escapeHtml(caps.wifi.interface)}</span>` : ''}
</div>
<div class="cap-detail-item ${btAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
<span class="cap-name">Bluetooth Scanning</span>
<span class="cap-status">${btAvailable ? caps.bluetooth.mode : 'Not Available'}</span>
${caps.bluetooth?.adapter ? `<span class="cap-detail">${escapeHtml(caps.bluetooth.adapter)}</span>` : ''}
</div>
<div class="cap-detail-item ${rfAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
<span class="cap-name">RF/SDR Scanning</span>
<span class="cap-status">${rfAvailable ? 'Available' : 'Not Available'}</span>
${caps.rf?.device_type ? `<span class="cap-detail">${escapeHtml(caps.rf.device_type)}</span>` : ''}
</div>
</div>
</div>
<div class="device-detail-section">
<h4>What This Sweep CAN Detect</h4>
<ul class="cap-can-list">
${canDetect.map(item => `<li>✅ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
<div class="device-detail-section">
<h4>What This Sweep CANNOT Detect</h4>
<ul class="cap-cannot-list">
${cannotDetect.map(item => `<li>❌ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
${caps.all_limitations && caps.all_limitations.length > 0 ? `
<div class="device-detail-section">
<h4>Current Limitations</h4>
<ul class="cap-cannot-list">
${caps.all_limitations.map(item => `<li>⚠️ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
` : ''}
<div class="device-detail-disclaimer">
<strong>Important:</strong> ${escapeHtml(caps.disclaimer || 'This tool detects wireless RF emissions only. Professional TSCM requires physical inspection, NLJD, thermal imaging, and spectrum analysis equipment.')}
</div>
`;
} else {
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load capabilities: ${data.message || 'Unknown error'}</div>`;
}
} catch (e) {
console.error('Failed to load capabilities:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load capabilities</div>';
}
}
// Known Devices Management
async function tscmShowKnownDevices() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading known devices...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/known-devices');
const data = await response.json();
const devices = data.devices || [];
content.innerHTML = `
<div class="device-detail-header classification-green">
<h3>✅ Known/Approved Devices (${devices.length})</h3>
</div>
<div class="device-detail-section">
<div style="margin-bottom: 12px;">
<button class="preset-btn" onclick="tscmAddKnownDevice()" style="font-size: 11px;">
+ Add Device
</button>
</div>
${devices.length === 0 ?
'<p style="color: var(--text-muted);">No known devices registered. Devices you mark as "known" will be excluded from threat scoring.</p>' :
`<div class="known-devices-list">
${devices.map(d => `
<div class="known-device-item">
<div class="known-device-info">
<strong>${escapeHtml(d.name || d.identifier)}</strong>
<span class="known-device-id">${escapeHtml(d.identifier)}</span>
<span class="known-device-type">${d.device_type}</span>
</div>
<div class="known-device-actions">
<button class="preset-btn" onclick="tscmRemoveKnownDevice(${d.id})" style="font-size: 10px; background: #ff4444;">
Remove
</button>
</div>
</div>
`).join('')}
</div>`
}
</div>
`;
} catch (e) {
console.error('Failed to load known devices:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load known devices</div>';
}
}
async function tscmAddKnownDevice() {
const identifier = prompt('Enter device identifier (MAC address, BSSID, or frequency):');
if (!identifier) return;
const name = prompt('Enter friendly name for this device:');
const protocol = prompt('Enter protocol type (wifi/bluetooth/rf):') || 'wifi';
try {
const response = await fetch('/tscm/known-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: identifier,
protocol: protocol,
name: name || identifier
})
});
const data = await response.json();
if (data.status === 'success') {
tscmShowKnownDevices(); // Refresh list
} else {
alert(data.message || 'Failed to add device');
}
} catch (e) {
console.error('Failed to add known device:', e);
alert('Failed to add device');
}
}
async function tscmRemoveKnownDevice(deviceId) {
if (!confirm('Remove this device from known devices list?')) return;
try {
const response = await fetch(`/tscm/known-devices/${deviceId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.status === 'success') {
tscmShowKnownDevices(); // Refresh list
} else {
alert(data.message || 'Failed to remove device');
}
} catch (e) {
console.error('Failed to remove known device:', e);
}
}
async function tscmAddToKnownDevices(identifier, name, protocol) {
// Ask for optional custom name
const customName = prompt(`Add "${name}" to known devices.\n\nEnter a friendly name (or leave blank to use default):`, name);
if (customName === null) return; // User cancelled
try {
const response = await fetch('/tscm/known-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: identifier,
protocol: protocol,
name: customName || name
})
});
const data = await response.json();
if (data.status === 'success') {
// Show success message
alert(`"${customName || name}" added to known devices.\n\nThis device will be excluded from threat scoring in future sweeps.`);
// Close the device modal
closeTscmDeviceModal();
} else {
alert(data.message || 'Failed to add device');
}
} catch (e) {
console.error('Failed to add to known devices:', e);
alert('Failed to add device to known list');
}
}
// Cases Management
async function tscmShowCases() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading cases...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/cases');
const data = await response.json();
const cases = data.cases || [];
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>TSCM Cases (${cases.length})</h3>
</div>
<div class="device-detail-section">
<div style="margin-bottom: 12px;">
<button class="preset-btn" onclick="tscmCreateCase()" style="font-size: 11px;">
+ New Case
</button>
</div>
${cases.length === 0 ?
'<p style="color: var(--text-muted);">No cases created. Cases help you organize sweeps and findings for specific locations or clients.</p>' :
`<div class="cases-list">
${cases.map(c => `
<div class="case-item" onclick="tscmViewCase(${c.id})">
<div class="case-header">
<strong>${escapeHtml(c.name)}</strong>
<span class="case-status ${c.status}">${c.status}</span>
</div>
<div class="case-meta">
${c.client_name ? `Client: ${escapeHtml(c.client_name)} | ` : ''}
${c.location ? `Location: ${escapeHtml(c.location)} | ` : ''}
Sweeps: ${c.sweep_count || 0} | Threats: ${c.threat_count || 0}
</div>
<div class="case-date">
Created: ${new Date(c.created_at).toLocaleDateString()}
</div>
</div>
`).join('')}
</div>`
}
</div>
`;
} catch (e) {
console.error('Failed to load cases:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load cases</div>';
}
}
async function tscmCreateCase() {
const name = prompt('Enter case name:');
if (!name) return;
const clientName = prompt('Enter client name (optional):');
const location = prompt('Enter location (optional):');
try {
const response = await fetch('/tscm/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
client_name: clientName || null,
location: location || null
})
});
const data = await response.json();
if (data.status === 'success') {
tscmShowCases(); // Refresh list
} else {
alert(data.message || 'Failed to create case');
}
} catch (e) {
console.error('Failed to create case:', e);
alert('Failed to create case');
}
}
async function tscmViewCase(caseId) {
try {
const response = await fetch(`/tscm/cases/${caseId}`);
const data = await response.json();
if (data.status === 'success') {
const c = data.case;
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>${escapeHtml(c.name)}</h3>
<span class="case-status ${c.status}">${c.status}</span>
</div>
<div class="device-detail-section">
<h4>Case Details</h4>
<table class="device-detail-table">
<tr><td>Client</td><td>${escapeHtml(c.client_name || 'N/A')}</td></tr>
<tr><td>Location</td><td>${escapeHtml(c.location || 'N/A')}</td></tr>
<tr><td>Created</td><td>${new Date(c.created_at).toLocaleString()}</td></tr>
<tr><td>Status</td><td>${c.status}</td></tr>
</table>
</div>
<div class="device-detail-section">
<h4>Linked Sweeps (${(c.sweeps || []).length})</h4>
${(c.sweeps || []).length === 0 ?
'<p style="color: var(--text-muted);">No sweeps linked to this case yet.</p>' :
`<ul>${(c.sweeps || []).map(s => `<li>Sweep ${s.id} - ${new Date(s.timestamp).toLocaleString()}</li>`).join('')}</ul>`
}
</div>
<div class="device-detail-section">
<h4>Flagged Threats (${(c.threats || []).length})</h4>
${(c.threats || []).length === 0 ?
'<p style="color: var(--text-muted);">No threats flagged in this case.</p>' :
`<ul>${(c.threats || []).map(t => `<li>${escapeHtml(t.identifier)} - ${t.threat_type}</li>`).join('')}</ul>`
}
</div>
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowCases()">← Back to Cases</button>
</div>
`;
}
} catch (e) {
console.error('Failed to view case:', e);
}
}
// Playbooks Display
async function tscmShowPlaybooks() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading playbooks...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/playbooks');
const data = await response.json();
const playbooks = data.playbooks || [];
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>Operator Playbooks</h3>
</div>
<div class="device-detail-section">
<p style="color: var(--text-muted); margin-bottom: 16px;">
Playbooks provide step-by-step guidance for investigating specific types of findings.
</p>
<div class="playbooks-list">
${playbooks.map(p => `
<div class="playbook-item" onclick="tscmViewPlaybook('${p.id}')">
<div class="playbook-header">
<strong>${escapeHtml(p.name || p.title)}</strong>
<span class="playbook-category">${escapeHtml(p.risk_level || p.category || 'General')}</span>
</div>
<div class="playbook-desc">
${escapeHtml(p.description || 'No description')}
</div>
<div class="playbook-meta">
${p.steps?.length || 0} steps
</div>
</div>
`).join('')}
</div>
</div>
`;
} catch (e) {
console.error('Failed to load playbooks:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load playbooks</div>';
}
}
async function tscmViewPlaybook(playbookId) {
try {
const response = await fetch(`/tscm/playbooks/${playbookId}`);
const data = await response.json();
if (data.status === 'success') {
const p = data.playbook;
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>${escapeHtml(p.title || p.name || 'Playbook')}</h3>
</div>
<div class="device-detail-section">
<p>${escapeHtml(p.description || '')}</p>
</div>
<div class="device-detail-section">
<h4>Steps</h4>
<ol class="playbook-steps">
${(p.steps || []).map((step, i) => `
<li class="playbook-step">
<strong>${escapeHtml(step.action || step.title || `Step ${step.step || i + 1}`)}</strong>
<p>${escapeHtml(step.details || step.description || '')}</p>
${step.safety_note ? `<div class="playbook-warning">${escapeHtml(step.safety_note)}</div>` : ''}
</li>
`).join('')}
</ol>
</div>
${p.when_to_escalate ? `
<div class="device-detail-section">
<h4>When to Escalate</h4>
<p>${escapeHtml(p.when_to_escalate)}</p>
</div>
` : ''}
${p.documentation_required && p.documentation_required.length > 0 ? `
<div class="device-detail-section">
<h4>Documentation Required</h4>
<ul>
${p.documentation_required.map(d => `<li>${escapeHtml(d)}</li>`).join('')}
</ul>
</div>
` : ''}
${p.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
</div>
` : ''}
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowPlaybooks()">← Back to Playbooks</button>
</div>
`;
}
} catch (e) {
console.error('Failed to view playbook:', e);
}
}
// Report Downloads
async function tscmDownloadPdf() {
try {
const response = await fetch('/tscm/report/pdf');
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TSCM_Report_${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const data = await response.json();
alert(data.message || 'Failed to generate PDF');
}
} catch (e) {
console.error('Failed to download PDF:', e);
alert('Failed to download PDF report');
}
}
async function tscmDownloadAnnex(format) {
try {
const response = await fetch(`/tscm/report/annex?format=${format}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TSCM_Annex_${new Date().toISOString().split('T')[0]}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const data = await response.json();
alert(data.message || 'Failed to generate annex');
}
} catch (e) {
console.error('Failed to download annex:', e);
alert('Failed to download technical annex');
}
}
// Update capabilities bar on sweep start
async function updateTscmCapabilitiesBar() {
try {
const response = await fetch('/tscm/capabilities');
const data = await response.json();
if (data.status === 'success') {
const caps = data.capabilities;
const bar = document.getElementById('tscmCapabilitiesBar');
if (bar) {
document.getElementById('capWifiStatus').textContent = caps.wifi_available ? 'ON' : 'OFF';
document.getElementById('capWifi').classList.toggle('active', caps.wifi_available);
document.getElementById('capBtStatus').textContent = caps.bluetooth_available ? 'ON' : 'OFF';
document.getElementById('capBt').classList.toggle('active', caps.bluetooth_available);
document.getElementById('capSdrStatus').textContent = caps.sdr_available ? 'ON' : 'OFF';
document.getElementById('capSdr').classList.toggle('active', caps.sdr_available);
bar.style.display = 'flex';
}
}
} catch (e) {
console.error('Failed to update capabilities bar:', e);
}
}
// Update baseline health indicator
async function updateTscmBaselineHealth(baselineId) {
if (!baselineId) {
const healthDiv = document.getElementById('tscmBaselineHealth');
if (healthDiv) healthDiv.style.display = 'none';
return;
}
try {
const response = await fetch(`/tscm/baseline/${baselineId}/health`);
const data = await response.json();
if (data.status === 'success') {
const healthDiv = document.getElementById('tscmBaselineHealth');
const badge = document.getElementById('baselineHealthBadge');
if (healthDiv && badge) {
badge.textContent = data.health_status || 'Unknown';
badge.className = `health-badge health-${(data.health_status || 'unknown').toLowerCase()}`;
healthDiv.style.display = 'block';
}
}
} catch (e) {
console.error('Failed to update baseline health:', e);
}
}
// Listen for baseline selection changes
document.addEventListener('DOMContentLoaded', function () {
const baselineSelect = document.getElementById('tscmBaselineSelect');
if (baselineSelect) {
baselineSelect.addEventListener('change', function () {
updateTscmBaselineHealth(this.value);
});
}
});
</script>
<!-- Scanner/Audio code moved to static/js/modes/listening-post.js -->
<!-- Help Modal -->
<div id="helpModal" class="help-modal" onclick="if(event.target === this) hideHelp()">
<div class="help-content">
<button class="help-close" onclick="hideHelp()">×</button>
<h2>iNTERCEPT Help</h2>
<div class="help-tabs">
<button class="help-tab active" data-tab="icons" onclick="switchHelpTab('icons')">Icons</button>
<button class="help-tab" data-tab="modes" onclick="switchHelpTab('modes')">Modes</button>
<button class="help-tab" data-tab="wifi" onclick="switchHelpTab('wifi')">WiFi</button>
<button class="help-tab" data-tab="tips" onclick="switchHelpTab('tips')">Tips</button>
</div>
<!-- Icons Section -->
<div id="help-icons" class="help-section active">
<h3>Stats Bar Icons</h3>
<div class="icon-grid">
<div class="icon-item"><span class="icon">📟</span><span class="desc">POCSAG messages decoded</span>
</div>
<div class="icon-item"><span class="icon">📠</span><span class="desc">FLEX messages decoded</span>
</div>
<div class="icon-item"><span class="icon">📨</span><span class="desc">Total messages received</span>
</div>
<div class="icon-item"><span class="icon">🌡️</span><span class="desc">Unique sensors
detected</span></div>
<div class="icon-item"><span class="icon">📊</span><span class="desc">Device types found</span>
</div>
<div class="icon-item"><span class="icon">🛰️</span><span class="desc">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 / APRS
stations</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">Meters - Utility meter decoder</span>
</div>
<div class="icon-item"><span class="icon">✈️</span><span class="desc">Aircraft - ADS-B tracking &amp; history</span></div>
<div class="icon-item"><span class="icon">🚢</span><span class="desc">Vessels - AIS &amp; VHF DSC distress</span></div>
<div class="icon-item"><span class="icon">📻</span><span class="desc">Spy Stations - Number stations database</span></div>
<div class="icon-item"><span class="icon">📍</span><span class="desc">APRS - Amateur radio
tracking</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 class="icon-item"><span class="icon">📻</span><span class="desc">Listening Post - SDR
scanner</span></div>
<div class="icon-item"><span class="icon">🔍</span><span class="desc">TSCM -
Counter-surveillance</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>Utility Meter Mode</h3>
<ul class="tip-list">
<li>Decodes utility meter transmissions (water, gas, electric) using rtlamr</li>
<li>Supports ERT protocol on 912 MHz (North America) or 868 MHz (Europe)</li>
<li>Displays meter IDs and consumption data in real-time</li>
<li>Supports SCM, SCM+, IDM, NetIDM, and R900 message types</li>
</ul>
<h3>Aircraft (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the dedicated ADS-B Dashboard for aircraft tracking</li>
<li>Features radar scope, map view, airband audio, and ACARS decoding</li>
<li>Optional history mode persists data to Postgres for long-term analysis</li>
<li>Access history dashboard at <code>/adsb/history</code></li>
</ul>
<h3>Vessels (Dashboard)</h3>
<ul class="tip-list">
<li>Opens the AIS Dashboard for maritime vessel tracking</li>
<li>Displays vessel name, MMSI, callsign, destination, and navigation data</li>
<li><strong>VHF DSC Channel 70:</strong> Monitors maritime distress frequency (156.525 MHz)</li>
<li>Decodes DSC messages: Distress, Urgency, Safety, and Routine calls</li>
<li>MMSI country identification via Maritime Identification Digits (MID)</li>
<li>Visual alerts for DISTRESS and URGENCY messages with map markers</li>
</ul>
<h3>Spy Stations</h3>
<ul class="tip-list">
<li>Database of number stations and diplomatic HF networks</li>
<li>Browse stations from priyom.org with frequencies and schedules</li>
<li>Filter by type (number/diplomatic), country, and mode</li>
<li>Famous stations: UVB-76 "The Buzzer", Cuban HM01, Israeli E17z</li>
<li>Click "Tune" to listen via Listening Post mode</li>
</ul>
<h3>APRS Mode</h3>
<ul class="tip-list">
<li>Decodes APRS (Automatic Packet Reporting System) on VHF</li>
<li>Tracks amateur radio operators transmitting position data</li>
<li>Regional frequencies: 144.390 MHz (N. America), 144.800 MHz (Europe)</li>
<li>Uses Direwolf or multimon-ng for packet decoding</li>
<li>Interactive map shows station positions in real-time</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>
<h3>Listening Post Mode</h3>
<ul class="tip-list">
<li>Wideband SDR scanner with spectrum visualization</li>
<li>Tune to any frequency supported by your SDR hardware</li>
<li>AM/FM/USB/LSB demodulation modes</li>
<li>Bookmark frequencies for quick recall</li>
<li>Quick tune presets for emergency and marine channels</li>
</ul>
<h3>TSCM Mode</h3>
<ul class="tip-list">
<li>Technical Surveillance Countermeasures sweep</li>
<li>Scans for unknown RF transmitters, WiFi devices, Bluetooth</li>
<li>Baseline comparison to detect new/anomalous devices</li>
<li>Threat classification: Critical, High, Medium, Low</li>
<li>Useful for security audits and bug sweeps</li>
<li><em style="color: var(--text-muted);">Note: This feature is in early development</em></li>
</ul>
<h3>Meshtastic Mode</h3>
<ul class="tip-list">
<li>Integrates with Meshtastic LoRa mesh network devices</li>
<li>Connect Heltec, T-Beam, RAK, or other compatible devices via USB</li>
<li>Real-time message streaming with RSSI and SNR metrics</li>
<li>Configure channels with encryption keys</li>
<li>View connected nodes and message history</li>
<li>Requires: Meshtastic device + <code>pip install meshtastic</code></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 the theme button in header to switch dark/light mode</li>
<li><strong>Settings:</strong> Click the gear icon in the header to access settings</li>
<li><strong>Offline mode:</strong> Enable in Settings to use local assets without internet</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:</strong> RTL-SDR, rtl_fm, multimon-ng</li>
<li><strong>433MHz Sensors:</strong> RTL-SDR, rtl_433</li>
<li><strong>Utility Meters:</strong> RTL-SDR, rtl_tcp, rtlamr</li>
<li><strong>Aircraft (ADS-B):</strong> RTL-SDR, dump1090 or rtl_adsb</li>
<li><strong>Aircraft (ACARS):</strong> Second RTL-SDR, acarsdec</li>
<li><strong>Vessels (AIS):</strong> RTL-SDR, AIS-catcher</li>
<li><strong>APRS:</strong> RTL-SDR, direwolf or multimon-ng</li>
<li><strong>Satellite:</strong> Internet for Celestrak (optional), skyfield</li>
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
<li><strong>Listening Post:</strong> RTL-SDR or SoapySDR-compatible hardware</li>
<li><strong>TSCM:</strong> WiFi adapter, Bluetooth adapter, RTL-SDR (all optional)</li>
<li>Run as root/sudo for full hardware access</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>
<!-- Satellite Add Modal -->
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
<div class="help-content" style="max-width: 600px;">
<button class="help-close" onclick="closeSatModal()">×</button>
<h2>Add Satellites</h2>
<!-- Tabs -->
<div
style="display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
<button class="sat-modal-tab preset-btn active" onclick="switchSatModalTab('tle')"
style="flex: 1;">Manual TLE</button>
<button class="sat-modal-tab preset-btn" onclick="switchSatModalTab('celestrak')"
style="flex: 1;">Celestrak</button>
</div>
<!-- TLE Section -->
<div id="tleSection" class="sat-modal-section active">
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
Paste TLE (Two-Line Element) data. Format: Name on first line, followed by TLE lines 1 and 2.
</p>
<textarea id="tleInput" placeholder="ISS (ZARYA)
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
style="width: 100%; height: 150px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; font-family: 'JetBrains Mono', monospace; font-size: 11px; resize: vertical;"></textarea>
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
Satellite</button>
</div>
<!-- Celestrak Section -->
<div id="celestrakSection" class="sat-modal-section">
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
Fetch satellite TLE data from CelesTrak by category.
</p>
<div id="celestrakStatus" style="margin-bottom: 10px; font-size: 11px; min-height: 20px;"></div>
<div
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('noaa')">NOAA</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('starlink')">Starlink</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('oneweb')">OneWeb</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('iridium-NEXT')">Iridium NEXT</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('visual')">Bright/Visual</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('geo')">Geostationary</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('resource')">Earth Resources</button>
</div>
<p style="color: var(--text-muted); font-size: 10px; margin-top: 10px;">
Data from <a href="https://celestrak.org" target="_blank"
style="color: var(--accent-cyan);">celestrak.org</a>.
Note: Some categories (Starlink, OneWeb) contain many satellites.
</p>
</div>
</div>
</div>
<script>
// 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);
}
});
}
}
// 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;">
Missing Dependencies
</h3>
<button onclick="closeStartupDeps()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 18px; padding: 0; line-height: 1;">&times;</button>
</div>
<p style="color: var(--text-secondary); margin: 0 0 15px 0; font-size: 13px; line-height: 1.4;">
<strong style="color: var(--accent-orange);">${modeCount} mode(s)</strong> require tools that aren't installed.
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="action-btn" onclick="closeStartupDeps(); showSettings(); switchSettingsTab('tools');" 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();
}
}
function logout(event) {
// We use event.currentTarget to ensure we select the button even if the icon was clicked
const btn = event.currentTarget;
// 1. Visual Feedback: Change color to red and add a "glow"
btn.style.color = "#ff4d4d";
btn.style.borderColor = "#ff4d4d";
btn.style.textShadow = "0 0 10px #ff4d4d"; // Glow effect
btn.style.transform = "scale(0.95)"; // Slight press effect
// 2. Disable the button to prevent double clicks
btn.style.pointerEvents = "none";
// 3. Logic execution
setTimeout(() => {
localStorage.removeItem('user_session');
window.location.href = '/login';
}, 600); // 600ms is enough for the user to perceive the color change
}
</script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Toast Container -->
<div id="toastContainer"></div>
<!-- Settings Manager -->
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
<!-- Updater -->
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
</body>
</html>