mirror of
https://github.com/smittix/intercept.git
synced 2026-06-19 19:06:15 -07:00
f889c53d92
- _waitForPlayback now only succeeds on playing/timeupdate events, not loadeddata/canplay which fire from just the WAV header before real audio arrives - stopMonitor() pauses audio and updates UI immediately instead of blocking on the backend stop request (1+ second delay) - Reduced backend audio stop sleep from 1.0s to 0.15s; the start retry loop already handles USB contention Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15309 lines
777 KiB
HTML
15309 lines
777 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
|
||
|
||
<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">
|
||
<link rel="manifest" href="/static/manifest.json">
|
||
<meta name="theme-color" content="#0b1118">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||
<!-- 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;
|
||
}
|
||
// If navigating with a mode param (e.g. /?mode=waterfall), hide welcome immediately
|
||
// to prevent flash of welcome screen before JS applies the mode
|
||
else if (new URLSearchParams(window.location.search).get('mode')) {
|
||
document.write('<style id="mode-gate">.welcome-overlay{display:none !important}</style>');
|
||
}
|
||
</script>
|
||
<script>
|
||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||
</script>
|
||
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></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=Roboto+Condensed:wght@300;400;500;600;700&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>
|
||
<script src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.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>
|
||
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></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 %}
|
||
<!-- Chart.js date adapter for time-scale axes -->
|
||
<script src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.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/settings.css') }}?v={{ version }}&r=maptheme17">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/ux-platform.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
|
||
<script>
|
||
window.INTERCEPT_MODE_STYLE_MAP = {
|
||
aprs: "{{ url_for('static', filename='css/modes/aprs.css') }}",
|
||
tscm: "{{ url_for('static', filename='css/modes/tscm.css') }}",
|
||
spystations: "{{ url_for('static', filename='css/modes/spy-stations.css') }}",
|
||
meshtastic: "{{ url_for('static', filename='css/modes/meshtastic.css') }}",
|
||
sstv: "{{ url_for('static', filename='css/modes/sstv.css') }}",
|
||
weathersat: "{{ url_for('static', filename='css/modes/weather-satellite.css') }}",
|
||
sstv_general: "{{ url_for('static', filename='css/modes/sstv-general.css') }}",
|
||
gps: "{{ url_for('static', filename='css/modes/gps.css') }}",
|
||
subghz: "{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9",
|
||
bt_locate: "{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4",
|
||
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}"
|
||
};
|
||
window.INTERCEPT_MODE_STYLE_LOADED = {};
|
||
window.ensureModeStyles = function(mode) {
|
||
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
|
||
if (!href) return;
|
||
const absHref = new URL(href, window.location.href).href;
|
||
const existing = Array.from(document.querySelectorAll('link[data-mode-style]'))
|
||
.find((link) => link.href === absHref);
|
||
if (existing) {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
return;
|
||
}
|
||
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loading') return;
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
|
||
const link = document.createElement('link');
|
||
link.rel = 'stylesheet';
|
||
link.href = href;
|
||
link.dataset.modeStyle = mode;
|
||
link.onload = () => {
|
||
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
|
||
};
|
||
link.onerror = () => {
|
||
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
|
||
try {
|
||
link.remove();
|
||
} catch (_) {}
|
||
};
|
||
document.head.appendChild(link);
|
||
};
|
||
</script>
|
||
</head>
|
||
|
||
<body data-mode="pager">
|
||
<!-- 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>
|
||
|
||
<!-- Signals -->
|
||
<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" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12h4l3-8 3 16 3-8h4"/><path d="M22 12h-1"/><path d="M1 12h1"/></svg></span> Signals</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>
|
||
<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"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></span>
|
||
<span class="mode-name">Meters</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('subghz')">
|
||
<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="M2 12h6l3-9 3 18 3-9h5"/></svg></span>
|
||
<span class="mode-name">SubGHz</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('waterfall')">
|
||
<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="M2 12h4l3-8 3 16 3-8h4"/><path d="M2 18h20" opacity="0.5"/><path d="M2 21h20" opacity="0.3"/></svg></span>
|
||
<span class="mode-name">Waterfall</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tracking -->
|
||
<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" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg></span> Tracking</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<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('gps')">
|
||
<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="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
|
||
<span class="mode-name">GPS</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Space -->
|
||
<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" 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> Space</h3>
|
||
<div class="mode-grid mode-grid-compact">
|
||
<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"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
||
<span class="mode-name">Satellite</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
||
<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"/><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="mode-name">ISS SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
|
||
<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"/><path d="M2 12h20"/><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>
|
||
<span class="mode-name">Weather Sat</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv_general')">
|
||
<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"/><circle cx="12" cy="12" r="3"/><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">HF SSTV</span>
|
||
</button>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||
<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="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>
|
||
<span class="mode-name">Space Wx</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" 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> 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>
|
||
<button class="mode-card mode-card-sm" onclick="selectMode('bt_locate')">
|
||
<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="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/><path d="M9.5 8.5l3 3 2-4-2 4-3 3"/></svg></span>
|
||
<span class="mode-name">BT Locate</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>
|
||
|
||
<!-- Intel -->
|
||
<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" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span> Intel</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>
|
||
<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('websdr')">
|
||
<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"/><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>
|
||
<span class="mode-name">WebSDR</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()">×</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: var(--font-mono); 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>
|
||
|
||
<div id="runStateStrip" class="run-state-strip" aria-live="polite">
|
||
<div class="run-state-left">
|
||
<span class="run-state-label">Run State</span>
|
||
<div id="runStateChips"></div>
|
||
</div>
|
||
<div class="run-state-right">
|
||
<span id="runStateSummary" class="run-state-value">Loading...</span>
|
||
<button id="runStateRefreshBtn" class="run-state-btn" type="button">Refresh</button>
|
||
<button id="runStateSettingsBtn" class="run-state-btn" type="button">Tools</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode Navigation Bar -->
|
||
{% set is_index_page = true %}
|
||
{% set active_mode = 'pager' %}
|
||
{% include 'partials/nav.html' with context %}
|
||
|
||
<!-- Mobile Drawer Overlay -->
|
||
<div class="drawer-overlay" id="drawerOverlay"></div>
|
||
|
||
<div class="container">
|
||
<div class="main-content">
|
||
<div class="sidebar mobile-drawer" id="mainSidebar">
|
||
<button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleMainSidebarCollapse()" title="Collapse sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></span>
|
||
Collapse Sidebar
|
||
</button>
|
||
<!-- 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="sdrplay">SDRplay</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 }}{% if device.serial and device.serial != 'N/A' and device.serial != 'Unknown' %} (SN: {{ device.serial }}){% endif %}</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>
|
||
|
||
<!-- SDR Device Status -->
|
||
<div id="sdrStatusPanel" style="margin-top: 10px; border: 1px solid var(--border-color); border-radius: 4px;">
|
||
<div id="sdrStatusList" style="max-height: 150px; overflow-y: auto;"></div>
|
||
<div style="padding: 6px 8px; background: var(--bg-tertiary); border-top: 1px solid var(--border-color); font-size: 10px; color: #666;">
|
||
Auto-refreshes every 5s
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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/weather-satellite.html' %}
|
||
|
||
{% include 'partials/modes/sstv-general.html' %}
|
||
|
||
{% include 'partials/modes/gps.html' %}
|
||
|
||
{% include 'partials/modes/space-weather.html' %}
|
||
|
||
{% include 'partials/modes/tscm.html' %}
|
||
|
||
{% include 'partials/modes/ais.html' %}
|
||
|
||
{% include 'partials/modes/spy-stations.html' %}
|
||
|
||
{% include 'partials/modes/meshtastic.html' %}
|
||
|
||
{% include 'partials/modes/websdr.html' %}
|
||
|
||
{% include 'partials/modes/subghz.html' %}
|
||
|
||
{% include 'partials/modes/bt_locate.html' %}
|
||
{% include 'partials/modes/waterfall.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">
|
||
<button class="sidebar-expand-handle" id="sidebarExpandHandle" onclick="toggleMainSidebarCollapse()" title="Expand sidebar">
|
||
<span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>
|
||
</button>
|
||
<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()">×</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 <span class="wifi-client-count-badge" id="wifiClientCountBadge"></span></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">
|
||
<span id="btDetailAddress">00:00:00:00:00:00</span>
|
||
<span class="bt-mac-cluster-badge" id="btDetailMacCluster" style="display:none;"></span>
|
||
</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-tracker-analysis" id="btDetailTrackerAnalysis" style="display: none;"></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 class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">TX Power</span>
|
||
<span class="bt-detail-stat-value" id="btDetailTxPower">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Seen Rate</span>
|
||
<span class="bt-detail-stat-value" id="btDetailSeenRate">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Stability</span>
|
||
<span class="bt-detail-stat-value" id="btDetailStability">--</span>
|
||
</div>
|
||
<div class="bt-detail-stat">
|
||
<span class="bt-detail-stat-label">Distance</span>
|
||
<span class="bt-detail-stat-value" id="btDetailDistance">--</span>
|
||
</div>
|
||
</div>
|
||
<!-- Service Data Inspector (collapsible) -->
|
||
<div class="bt-detail-service-inspector" id="btDetailServiceInspector" style="display:none;">
|
||
<div class="bt-inspector-toggle" onclick="BluetoothMode.toggleServiceInspector()">
|
||
<span class="bt-inspector-arrow" id="btInspectorArrow">▸</span> Raw Data
|
||
</div>
|
||
<div class="bt-inspector-content" id="btInspectorContent" style="display:none;">
|
||
</div>
|
||
</div>
|
||
<div class="bt-detail-bottom-row">
|
||
<div class="bt-detail-irk" id="btDetailIrk" style="display: none;">
|
||
<span class="bt-irk-badge">IRK</span>
|
||
<span class="bt-detail-irk-value" id="btDetailIrkValue" style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono);margin-left:6px;word-break:break-all;"></span>
|
||
</div>
|
||
<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" id="btDetailWatchBtn" onclick="BluetoothMode.toggleWatchlist()">Watchlist</button>
|
||
<button class="bt-detail-btn" id="btDetailCopyBtn" onclick="BluetoothMode.copyAddress()">Copy</button>
|
||
<button class="bt-detail-btn bt-locate-btn" id="btDetailLocateBtn" onclick="BluetoothMode.locateDevice()">Locate</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 bt-tracker-panel">
|
||
<h5>Tracker Detection</h5>
|
||
<div id="btTrackerList" class="bt-tracker-list">
|
||
<div class="app-collection-state is-empty">Monitoring for AirTags, Tiles...</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">New Only</button>
|
||
<button data-filter="strongest" class="bt-radar-filter-btn">Strongest</button>
|
||
<button data-filter="unapproved" class="bt-radar-filter-btn">Unapproved</button>
|
||
<button id="btRadarPauseBtn">Pause</button>
|
||
</div>
|
||
<div id="btZoneSummary" class="bt-zone-summary">
|
||
<div class="bt-zone-card immediate">
|
||
<span id="btZoneImmediate" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Immediate</div>
|
||
</div>
|
||
<div class="bt-zone-card near">
|
||
<span id="btZoneNear" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">Near</div>
|
||
</div>
|
||
<div class="bt-zone-card far">
|
||
<span id="btZoneFar" class="bt-zone-value">0</span>
|
||
<div class="bt-zone-label">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-list-summary" id="btListSummary">
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Total</span>
|
||
<span class="bt-summary-value" id="btSummaryTotal">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">New</span>
|
||
<span class="bt-summary-value" id="btSummaryNew">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Trackers</span>
|
||
<span class="bt-summary-value" id="btSummaryTrackers">0</span>
|
||
</div>
|
||
<div class="bt-summary-item">
|
||
<span class="bt-summary-label">Strongest</span>
|
||
<span class="bt-summary-value" id="btSummaryStrongest">--</span>
|
||
</div>
|
||
</div>
|
||
<div class="bt-list-signal-strip">
|
||
<div class="bt-list-signal-title">Signal Distribution</div>
|
||
<div class="bt-signal-dist bt-signal-dist-compact" id="btSignalDist">
|
||
<div class="signal-range"><span>Strong</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</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</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 class="bt-device-toolbar">
|
||
<input type="search" id="btDeviceSearch" class="bt-device-search" placeholder="Filter by name, MAC, manufacturer...">
|
||
</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>
|
||
<button class="bt-filter-btn" data-filter="trackers">Trackers</button>
|
||
</div>
|
||
<div class="wifi-device-list-content" id="btDeviceListContent">
|
||
<div class="app-collection-state is-empty">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()">×</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; overflow: hidden; min-height: 0;">
|
||
<!-- 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="iss">ISS (145.825)</option>
|
||
<option value="sonate2">SONATE-2 (145.825)</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; min-height: 0; overflow: hidden;">
|
||
<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; min-height: 0; overflow: hidden;">
|
||
<h5
|
||
style="color: var(--accent-green); text-shadow: 0 0 10px var(--accent-green); margin-bottom: 8px; flex-shrink: 0;">
|
||
STATION LIST</h5>
|
||
<div id="aprsFilterBarContainer" style="flex-shrink: 0;"></div>
|
||
<div id="aprsStationList" class="signal-cards-container" style="flex: 1; overflow-y: auto; font-size: 11px; gap: 8px; min-height: 0;">
|
||
<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; flex-shrink: 0;">
|
||
<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: var(--font-mono); 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>
|
||
|
||
<!-- GPS Receiver Dashboard -->
|
||
<div id="gpsVisuals" class="gps-visuals-container" style="display: none;">
|
||
<div class="gps-visuals-top">
|
||
<!-- Sky View Polar Plot -->
|
||
<div class="gps-skyview-panel">
|
||
<h4>Satellite Sky View</h4>
|
||
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
|
||
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky globe"></canvas>
|
||
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
|
||
</div>
|
||
<div class="gps-sky-hint">Drag to orbit | Scroll to zoom</div>
|
||
<div class="gps-legend">
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> GPS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00ff88;"></span> GLONASS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff8800;"></span> Galileo</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ff4466;"></span> BeiDou</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffdd00;"></span> SBAS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#cc66ff;"></span> QZSS</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#00d4ff;"></span> Used (filled)</div>
|
||
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:transparent; border:1.5px solid #00d4ff;"></span> Unused (hollow)</div>
|
||
</div>
|
||
</div>
|
||
<!-- Position Info -->
|
||
<div class="gps-position-panel">
|
||
<h4>Position</h4>
|
||
<div class="gps-pos-big">
|
||
<div id="gpsVisPosLat">---</div>
|
||
<div id="gpsVisPosLon">---</div>
|
||
</div>
|
||
<div style="margin-top: 4px;">
|
||
<span class="gps-fix-badge no-fix" id="gpsVisFixBadge">NO FIX</span>
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Altitude</span>
|
||
<span class="gps-pos-value" id="gpsVisPosAlt">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Speed</span>
|
||
<span class="gps-pos-value" id="gpsVisPosSpeed">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Heading</span>
|
||
<span class="gps-pos-value" id="gpsVisPosHeading">---</span>
|
||
</div>
|
||
<div class="gps-pos-row">
|
||
<span class="gps-pos-label">Climb</span>
|
||
<span class="gps-pos-value" id="gpsVisPosClimb">---</span>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--border-color);">
|
||
<div class="gps-pos-label">GPS TIME</div>
|
||
<div class="gps-pos-value" id="gpsVisTime" style="font-size: 14px; color: var(--accent-cyan);">---</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Signal Strength Bars -->
|
||
<div class="gps-signal-panel">
|
||
<h4>Signal Strength (SNR dB-Hz)</h4>
|
||
<div class="gps-signal-bars" id="gpsSignalBars"></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 class="meeting-actions">
|
||
<button class="preset-btn" onclick="tscmShowMeetingSummary()" style="font-size: 10px; padding: 6px 8px;">
|
||
Summary
|
||
</button>
|
||
</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>
|
||
|
||
<!-- Filters -->
|
||
<div class="tscm-filter-bar">
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterProtocol">Protocol</label>
|
||
<select id="tscmFilterProtocol">
|
||
<option value="all">All</option>
|
||
<option value="wifi">WiFi</option>
|
||
<option value="bluetooth">Bluetooth</option>
|
||
<option value="rf">RF</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterRisk">Risk</label>
|
||
<select id="tscmFilterRisk">
|
||
<option value="all">All</option>
|
||
<option value="high_interest">High Interest</option>
|
||
<option value="review">Needs Review</option>
|
||
<option value="informational">Informational</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterStatus">Status</label>
|
||
<select id="tscmFilterStatus">
|
||
<option value="all">All</option>
|
||
<option value="new">New</option>
|
||
<option value="baseline">Baseline</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-group">
|
||
<label for="tscmFilterKnown">Known</label>
|
||
<select id="tscmFilterKnown">
|
||
<option value="all">All</option>
|
||
<option value="known">Known</option>
|
||
<option value="unknown">Unknown</option>
|
||
</select>
|
||
</div>
|
||
<div class="tscm-filter-status" id="tscmFilterStatusText">Filters: none</div>
|
||
</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 class="threat-card medium clickable" id="tscmIdentityCard"
|
||
onclick="showDevicesByCategory('identity')" title="Click to view identity clusters">
|
||
<span class="count" id="tscmIdentityCount">0</span>
|
||
<span class="label">Identity Clusters</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" id="tscmWifiPanel">
|
||
<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>
|
||
|
||
<!-- WiFi Clients Panel -->
|
||
<div class="tscm-panel" id="tscmWifiClientPanel">
|
||
<div class="tscm-panel-header">
|
||
WiFi Clients
|
||
<span class="badge" id="tscmWifiClientCount">0</span>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmWifiClientList">
|
||
<div class="tscm-empty">Start a sweep to scan for WiFi clients</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bluetooth Panel -->
|
||
<div class="tscm-panel" id="tscmBtPanel">
|
||
<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" id="tscmRfPanel">
|
||
<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" id="tscmThreatPanel">
|
||
<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>
|
||
|
||
<!-- Device Timelines Overview -->
|
||
<div class="tscm-panel" id="tscmDeviceTimelinesPanel" style="margin-top: 12px;">
|
||
<div class="tscm-panel-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||
Device Timelines
|
||
<button class="preset-btn" onclick="loadDeviceTimelines()" style="font-size: 9px; padding: 3px 8px;">Refresh</button>
|
||
</div>
|
||
<div class="tscm-panel-content" id="tscmDeviceTimelinesList">
|
||
<div class="tscm-empty">Run a sweep to see device timelines</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SubGHz Transceiver Dashboard -->
|
||
<div id="subghzVisuals" class="subghz-visuals-container" style="display: none;">
|
||
|
||
<!-- Stats Strip -->
|
||
<div class="subghz-stats-strip">
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-device-badge" id="subghzStripDevice">
|
||
<span class="subghz-strip-device-dot" id="subghzStripDeviceDot"></span>
|
||
HackRF
|
||
</span>
|
||
<div class="subghz-strip-status">
|
||
<span class="subghz-strip-dot" id="subghzStripDot"></span>
|
||
<span class="subghz-strip-status-text" id="subghzStripStatus">Idle</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-cyan" id="subghzStripFreq">--</span>
|
||
<span class="subghz-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value" id="subghzStripMode">--</span>
|
||
<span class="subghz-strip-label">MODE</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-green" id="subghzStripSignals">0</span>
|
||
<span class="subghz-strip-label">SIGNALS</span>
|
||
</div>
|
||
<div class="subghz-strip-stat">
|
||
<span class="subghz-strip-value accent-orange" id="subghzStripCaptures">0</span>
|
||
<span class="subghz-strip-label">CAPTURES</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-strip-divider"></div>
|
||
<div class="subghz-strip-group">
|
||
<span class="subghz-strip-timer" id="subghzStripTimer"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Console (collapsible) -->
|
||
<div class="subghz-signal-console" id="subghzConsole" style="display: none;">
|
||
<div class="subghz-console-header" onclick="SubGhz.toggleConsole()">
|
||
<div class="subghz-phase-strip">
|
||
<span class="subghz-phase-step" id="subghzPhaseTuning">TUNING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
|
||
<span class="subghz-phase-arrow">▸</span>
|
||
<span class="subghz-phase-step" id="subghzPhaseDecoding">DECODING</span>
|
||
</div>
|
||
<div class="subghz-burst-indicator" id="subghzBurstIndicator" title="Live burst detector">
|
||
<span class="subghz-burst-dot"></span>
|
||
<span class="subghz-burst-text" id="subghzBurstText">NO BURST</span>
|
||
</div>
|
||
<button class="subghz-console-toggle" id="subghzConsoleToggleBtn">▼</button>
|
||
</div>
|
||
<div class="subghz-console-body" id="subghzConsoleBody">
|
||
<div class="subghz-console-log" id="subghzConsoleLog"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Hub (idle state — 2x2 Flipper-style cards) -->
|
||
<div class="subghz-action-hub" id="subghzActionHub">
|
||
<div class="subghz-hub-header">
|
||
<div class="subghz-hub-header-title">HackRF One</div>
|
||
<div class="subghz-hub-header-sub">SubGHz Transceiver — 1 MHz - 6 GHz</div>
|
||
</div>
|
||
<div class="subghz-hub-grid">
|
||
<div class="subghz-hub-card subghz-hub-card--green" onclick="SubGhz.hubAction('rx')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4"/><path d="M5.6 5.6l2.85 2.85m7.1 7.1l2.85 2.85M5.6 18.4l2.85-2.85m7.1-7.1l2.85-2.85"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Read RAW</div>
|
||
<div class="subghz-hub-desc">Capture raw IQ via hackrf_transfer</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--red" onclick="SubGhz.hubAction('txselect')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M5 19h14"/><path d="M12 5v11"/><path d="M8 9l4-4 4 4"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Transmit</div>
|
||
<div class="subghz-hub-desc">Replay a saved capture</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--orange" onclick="SubGhz.hubAction('sweep')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M3 20h18"/><path d="M3 17l3-7 3 4 3-9 3 6 3-3 3 9"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Freq Analyzer</div>
|
||
<div class="subghz-hub-desc">Wideband sweep via hackrf_sweep</div>
|
||
</div>
|
||
<div class="subghz-hub-card subghz-hub-card--purple" onclick="SubGhz.hubAction('saved')">
|
||
<div class="subghz-hub-icon">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" width="32" height="32"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||
</div>
|
||
<div class="subghz-hub-title">Saved</div>
|
||
<div class="subghz-hub-desc">Signal library & replay</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operation Panels (one visible at a time, replaces hub) -->
|
||
|
||
<!-- RX (Raw Capture) Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelRx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Read RAW — Signal Capture</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzRxStartBtnPanel" onclick="SubGhz.startRx()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzRxStopBtnPanel" onclick="SubGhz.stopRx()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-display">
|
||
<div class="subghz-rx-recording" id="subghzRxRecording" style="display: none;">
|
||
<span class="subghz-rx-rec-dot"></span>
|
||
<span>RECORDING</span>
|
||
</div>
|
||
<div class="subghz-rx-info-grid">
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FREQUENCY</span>
|
||
<span class="subghz-rx-info-value accent-cyan" id="subghzRxFreq">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">LNA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxLna">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">VGA GAIN</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxVga">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">SAMPLE RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxSampleRate">--</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">FILE SIZE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxFileSize">0 KB</span>
|
||
</div>
|
||
<div class="subghz-rx-info-item">
|
||
<span class="subghz-rx-info-label">DATA RATE</span>
|
||
<span class="subghz-rx-info-value" id="subghzRxRate">0 KB/s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-level-wrapper">
|
||
<span class="subghz-rx-level-label">SIGNAL</span>
|
||
<span class="subghz-rx-burst-pill" id="subghzRxBurstPill">IDLE</span>
|
||
<div class="subghz-rx-level-bar">
|
||
<div class="subghz-rx-level-fill" id="subghzRxLevel" style="width: 0%;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-hint" id="subghzRxHint">
|
||
<span class="subghz-rx-hint-label">ANALYSIS</span>
|
||
<span class="subghz-rx-hint-text" id="subghzRxHintText">No modulation hint yet</span>
|
||
<span class="subghz-rx-hint-confidence" id="subghzRxHintConfidence">--</span>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<span class="subghz-rx-scope-label">WAVEFORM</span>
|
||
<div class="subghz-rx-scope">
|
||
<canvas id="subghzRxScope"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-scope-wrap">
|
||
<div class="subghz-rx-waterfall-header">
|
||
<span class="subghz-rx-scope-label">WATERFALL</span>
|
||
<div class="subghz-rx-waterfall-controls">
|
||
<div class="subghz-wf-control">
|
||
<span>FLOOR</span>
|
||
<input type="range" id="subghzWfFloor" min="0" max="200" value="20" oninput="SubGhz.setWaterfallFloor(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfFloorVal">20</span>
|
||
</div>
|
||
<div class="subghz-wf-control">
|
||
<span>RANGE</span>
|
||
<input type="range" id="subghzWfRange" min="16" max="255" value="180" oninput="SubGhz.setWaterfallRange(this.value)">
|
||
<span class="subghz-wf-value" id="subghzWfRangeVal">180</span>
|
||
</div>
|
||
<button class="subghz-wf-pause-btn" id="subghzWfPauseBtn" onclick="SubGhz.toggleWaterfall()">PAUSE</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-rx-waterfall">
|
||
<canvas id="subghzRxWaterfall"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sweep Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSweep" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Frequency Analyzer</span>
|
||
<div class="subghz-op-panel-actions">
|
||
<button class="subghz-btn start" id="subghzSweepStartBtnPanel" onclick="SubGhz.startSweep()">Start</button>
|
||
<button class="subghz-btn stop" id="subghzSweepStopBtnPanel" onclick="SubGhz.stopSweep()" disabled>Stop</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-sweep-layout">
|
||
<div class="subghz-sweep-chart-wrapper" id="subghzSweepChartWrapper">
|
||
<canvas id="subghzSweepCanvas"></canvas>
|
||
</div>
|
||
<div class="subghz-sweep-peaks-sidebar" id="subghzSweepPeaksSidebar">
|
||
<div class="subghz-sweep-peaks-title">PEAKS</div>
|
||
<div class="subghz-peak-list" id="subghzSweepPeakList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelTx" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Transmit</span>
|
||
</div>
|
||
<div class="subghz-tx-display" id="subghzTxDisplay">
|
||
<div class="subghz-tx-pulse-ring">
|
||
<div class="subghz-tx-pulse-dot"></div>
|
||
</div>
|
||
<div class="subghz-tx-label" id="subghzTxStateLabel">READY</div>
|
||
<div class="subghz-tx-info-grid">
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">FREQUENCY</span>
|
||
<span class="subghz-tx-info-value accent-red" id="subghzTxFreqDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">TX GAIN</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxGainDisplay">--</span>
|
||
</div>
|
||
<div class="subghz-tx-info-item">
|
||
<span class="subghz-tx-info-label">ELAPSED</span>
|
||
<span class="subghz-tx-info-value" id="subghzTxElapsed">0s</span>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-btn-row" style="max-width: 420px; margin: 16px auto 0;">
|
||
<button class="subghz-btn" id="subghzTxChooseCaptureBtn" onclick="SubGhz.showPanel('saved')">Choose Capture</button>
|
||
<button class="subghz-btn stop" id="subghzTxStopBtn" onclick="SubGhz.stopTx()">Stop Transmission</button>
|
||
<button class="subghz-btn start" id="subghzTxReplayLastBtn" onclick="SubGhz.replayLastTx()" style="display: none;">Replay Last</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Saved Panel -->
|
||
<div class="subghz-op-panel" id="subghzPanelSaved" style="display: none;">
|
||
<div class="subghz-op-panel-header">
|
||
<button class="subghz-op-back-btn" onclick="SubGhz.backToHub()" title="Back to hub">◀ Back</button>
|
||
<span class="subghz-op-panel-title">Saved Captures</span>
|
||
<div class="subghz-op-panel-actions subghz-saved-actions">
|
||
<span class="subghz-saved-selection-count" id="subghzSavedSelectionCount" style="display: none;">0 selected</span>
|
||
<button class="subghz-btn" id="subghzSavedSelectBtn" onclick="SubGhz.toggleCaptureSelectMode()">Select</button>
|
||
<button class="subghz-btn" id="subghzSavedSelectAllBtn" onclick="SubGhz.selectAllCaptures()" style="display: none;">Select All</button>
|
||
<button class="subghz-btn stop" id="subghzSavedDeleteSelectedBtn" onclick="SubGhz.deleteSelectedCaptures()" style="display: none;" disabled>Delete Selected</button>
|
||
</div>
|
||
</div>
|
||
<div class="subghz-captures-list subghz-captures-list-main" id="subghzCapturesList" style="flex: 1; min-height: 0; max-height: none; overflow-y: auto;">
|
||
<div class="subghz-empty" id="subghzCapturesEmpty">No captures yet</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TX Confirmation Modal -->
|
||
<div id="subghzTxModalOverlay" class="subghz-tx-modal-overlay">
|
||
<div class="subghz-tx-modal">
|
||
<h3>Confirm Transmission</h3>
|
||
<p>You are about to transmit a radio signal on:</p>
|
||
<p class="tx-freq" id="subghzTxModalFreq">--- MHz</p>
|
||
<p class="tx-duration">Capture duration: <span id="subghzTxModalDuration">--</span></p>
|
||
<div class="subghz-tx-segment-box">
|
||
<label class="subghz-tx-segment-toggle">
|
||
<input type="checkbox" id="subghzTxSegmentEnabled" onchange="SubGhz.syncTxSegmentSelection()">
|
||
Transmit selected segment only
|
||
</label>
|
||
<div class="subghz-tx-segment-grid">
|
||
<label>Start (s)</label>
|
||
<input type="number" id="subghzTxSegmentStart" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('start')">
|
||
<label>End (s)</label>
|
||
<input type="number" id="subghzTxSegmentEnd" min="0" step="0.01" value="0" disabled oninput="SubGhz.syncTxSegmentSelection('end')">
|
||
</div>
|
||
<p class="subghz-tx-segment-summary" id="subghzTxSegmentSummary">Full capture</p>
|
||
</div>
|
||
<div class="subghz-tx-burst-assist" id="subghzTxBurstAssist" style="display: none;">
|
||
<div class="subghz-tx-burst-title">Detected Bursts</div>
|
||
<div class="subghz-tx-burst-timeline" id="subghzTxBurstTimeline"></div>
|
||
<div class="subghz-tx-burst-range" id="subghzTxBurstRange">Drag on timeline to select TX segment</div>
|
||
<div class="subghz-tx-burst-list" id="subghzTxBurstList"></div>
|
||
</div>
|
||
<p>Ensure you have proper authorization to transmit on this frequency.</p>
|
||
<div class="subghz-tx-modal-actions">
|
||
<button class="subghz-tx-cancel-btn" onclick="SubGhz.cancelTx()">Cancel</button>
|
||
<button class="subghz-tx-trim-btn" id="subghzTxTrimBtn" onclick="SubGhz.trimCaptureSelection()">Trim + Save</button>
|
||
<button class="subghz-tx-confirm-btn" onclick="SubGhz.confirmTx()">Transmit</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- BT Locate SAR Dashboard -->
|
||
<div id="btLocateVisuals" class="btl-visuals-container" style="display: none; flex-direction: column; gap: 8px; flex: 1; min-height: 0; overflow: hidden; padding: 8px;">
|
||
<!-- Proximity HUD -->
|
||
<div class="btl-hud" id="btLocateHud" style="display: none;">
|
||
<div class="btl-hud-top">
|
||
<div class="btl-hud-band" id="btLocateBand">---</div>
|
||
<div class="btl-hud-metrics">
|
||
<div class="btl-hud-metric btl-hud-metric-lg">
|
||
<span class="btl-hud-value" id="btLocateDistance">--</span>
|
||
<span class="btl-hud-unit">m</span>
|
||
<span class="btl-hud-label">Est. Distance</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssi">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateRssiEma">--</span>
|
||
<span class="btl-hud-unit">dBm</span>
|
||
<span class="btl-hud-label">RSSI avg</span>
|
||
</div>
|
||
<div class="btl-hud-separator"></div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateDetectionCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Detections</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateGpsCount">0</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">GPS pts</span>
|
||
</div>
|
||
<div class="btl-hud-metric">
|
||
<span class="btl-hud-value" id="btLocateSessionTime">0:00</span>
|
||
<span class="btl-hud-unit"> </span>
|
||
<span class="btl-hud-label">Duration</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-controls">
|
||
<label class="btl-hud-audio-toggle">
|
||
<input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()">
|
||
<span>Audio</span>
|
||
</label>
|
||
<div class="btl-hud-export-row">
|
||
<select id="btLocateExportFormat" class="btl-hud-export-format">
|
||
<option value="csv">CSV</option>
|
||
<option value="gpx">GPX</option>
|
||
<option value="kml">KML</option>
|
||
</select>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.exportTrail()">Export</button>
|
||
</div>
|
||
<button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button>
|
||
</div>
|
||
</div>
|
||
<div class="btl-hud-bottom">
|
||
<div class="btl-hud-info">
|
||
<span class="btl-hud-info-item" id="btLocateTargetInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateConfidenceInfo">Confidence: --</span>
|
||
<span class="btl-hud-info-sep">·</span>
|
||
<span class="btl-hud-info-item" id="btLocateBestSignal">Best: --</span>
|
||
</div>
|
||
<div id="btLocateDiag" class="btl-hud-diag"></div>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-container" style="flex: 1; min-height: 250px; position: relative; overflow: hidden;">
|
||
<div id="btLocateMap" style="position: absolute; inset: 0;"></div>
|
||
<div id="btLocateCrosshairOverlay" class="btl-crosshair-overlay" aria-hidden="true">
|
||
<div class="btl-crosshair-line btl-crosshair-vertical"></div>
|
||
<div class="btl-crosshair-line btl-crosshair-horizontal"></div>
|
||
</div>
|
||
<div class="btl-map-overlay-controls">
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
|
||
<span>Heatmap</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateMovementEnable" onchange="BtLocate.toggleMovement()">
|
||
<span>Movement</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateFollowEnable" onchange="BtLocate.toggleFollow()">
|
||
<span>Auto follow</span>
|
||
</label>
|
||
<label class="btl-map-overlay-toggle">
|
||
<input type="checkbox" id="btLocateSmoothEnable" onchange="BtLocate.toggleSmoothing()">
|
||
<span>Smooth path</span>
|
||
</label>
|
||
</div>
|
||
<div class="btl-map-heat-legend" id="btLocateHeatLegend">
|
||
<span class="btl-map-heat-label">Signal Heat</span>
|
||
<div class="btl-map-heat-bar"></div>
|
||
<div class="btl-map-heat-scale">
|
||
<span>Weak</span>
|
||
<span>Strong</span>
|
||
</div>
|
||
</div>
|
||
<div class="btl-map-track-stats" id="btLocateTrackStats">Track: 0 m | 0 pts</div>
|
||
</div>
|
||
<div class="btl-rssi-chart-container">
|
||
<span class="btl-chart-label">RSSI History</span>
|
||
<canvas id="btLocateRssiChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- WebSDR Dashboard -->
|
||
<div id="websdrVisuals" style="display: none; padding: 12px; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Audio Control Bar (hidden until connected) -->
|
||
<div id="kiwiAudioControls" class="radio-module-box" style="display: none; padding: 8px 12px; flex-shrink: 0;">
|
||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||
<!-- Live indicator -->
|
||
<div style="display: flex; align-items: center; gap: 5px;">
|
||
<div id="kiwiLiveIndicator" style="width: 8px; height: 8px; border-radius: 50%; background: var(--accent-green); animation: pulse 1.5s infinite;"></div>
|
||
<span style="font-size: 10px; color: var(--text-muted); text-transform: uppercase;">LIVE</span>
|
||
</div>
|
||
<!-- Receiver name -->
|
||
<span id="kiwiBarReceiverName" style="font-size: 11px; color: var(--accent-cyan); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
|
||
<!-- Frequency input -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<input type="number" id="kiwiBarFrequency" step="1" style="width: 80px; font-size: 12px; font-family: var(--font-mono); padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<span style="font-size: 10px; color: var(--text-muted);">kHz</span>
|
||
</div>
|
||
<!-- Mode selector -->
|
||
<select id="kiwiBarMode" style="font-size: 11px; padding: 2px 6px; background: rgba(0,0,0,0.3); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
<option value="cw">CW</option>
|
||
</select>
|
||
<!-- Tune button -->
|
||
<button class="preset-btn" onclick="tuneFromBar()" style="font-size: 10px; padding: 3px 10px;">Tune</button>
|
||
<!-- Volume -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="font-size: 10px; color: var(--text-muted);">VOL</span>
|
||
<input type="range" id="kiwiBarVolume" min="0" max="100" value="80" style="width: 60px;" oninput="setKiwiVolume(this.value)">
|
||
</div>
|
||
<!-- S-meter mini -->
|
||
<div style="display: flex; align-items: center; gap: 4px;">
|
||
<div style="width: 50px; height: 6px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden;">
|
||
<div id="kiwiBarSmeter" style="height: 100%; width: 0%; background: linear-gradient(to right, var(--accent-green), var(--accent-orange)); transition: width 0.2s; border-radius: 3px;"></div>
|
||
</div>
|
||
<span id="kiwiBarSmeterValue" style="font-size: 9px; color: var(--text-muted); font-family: var(--font-mono); min-width: 20px;">S0</span>
|
||
</div>
|
||
<!-- Disconnect -->
|
||
<button class="stop-btn" onclick="disconnectFromReceiver()" style="font-size: 10px; padding: 3px 10px; margin-left: auto;">Disconnect</button>
|
||
</div>
|
||
</div>
|
||
<!-- Map and receiver list side by side -->
|
||
<div style="display: grid; grid-template-columns: 3fr 1fr; gap: 12px; flex: 1; min-height: 0; overflow: hidden;">
|
||
<!-- Map -->
|
||
<div class="radio-module-box" style="padding: 0; overflow: hidden; position: relative; min-height: 0;">
|
||
<div id="websdrMap" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0;"></div>
|
||
</div>
|
||
<!-- Receiver List -->
|
||
<div style="display: flex; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; overflow: hidden;">
|
||
<div class="radio-module-box" style="padding: 10px; flex: 1; overflow-y: auto; min-height: 0;">
|
||
<div class="module-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 10px;">
|
||
<span>RECEIVERS</span>
|
||
<span id="websdrReceiverCount" style="color: var(--accent-cyan);">0 found</span>
|
||
</div>
|
||
<div id="websdrReceiverList" style="font-size: 11px;">
|
||
<div style="color: var(--text-muted); text-align: center; padding: 20px;">Click "Find Receivers" to search</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="meshStripConnType" class="mesh-strip-select" title="Connection Type" onchange="Meshtastic.onConnectionTypeChange()" style="width: 70px;">
|
||
<option value="serial">Serial</option>
|
||
<option value="tcp">TCP</option>
|
||
</select>
|
||
<select id="meshStripDevice" class="mesh-strip-select" title="Device">
|
||
<option value="">Auto-detect</option>
|
||
</select>
|
||
<input type="text" id="meshStripHostname" class="mesh-strip-input" placeholder="IP address" title="Hostname/IP for TCP" style="display: none; width: 120px;">
|
||
<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 class="sstv-strip-divider"></div>
|
||
<!-- Location Controls -->
|
||
<div class="sstv-strip-group">
|
||
<div class="sstv-strip-location">
|
||
<span class="sstv-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="sstvObsLat" class="sstv-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="sstvObsLon" class="sstv-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="sstv-strip-btn gps" onclick="SSTV.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/>
|
||
<line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/>
|
||
<line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>
|
||
</svg>
|
||
GPS
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-strip-divider"></div>
|
||
<!-- TLE Update -->
|
||
<div class="sstv-strip-group">
|
||
<button class="sstv-strip-btn update-tle" onclick="SSTV.updateTLE(this)" title="Update satellite orbital data from CelesTrak">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 12px; height: 12px;">
|
||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||
<path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||
<path d="M16 21h5v-5"/>
|
||
</svg>
|
||
Update TLE
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ISS Tracking Map + Countdown Row -->
|
||
<div class="sstv-top-row">
|
||
<!-- ISS Map -->
|
||
<div class="sstv-map-row">
|
||
<div class="sstv-map-container">
|
||
<div id="sstvIssMap" class="sstv-iss-map"></div>
|
||
<div class="sstv-map-overlay">
|
||
<div class="sstv-map-info">
|
||
<span class="sstv-map-label">ISS</span>
|
||
<span class="sstv-map-coords"><span id="sstvIssLat">--.-</span>°, <span id="sstvIssLon">--.-</span>°</span>
|
||
<span class="sstv-map-alt">Alt: <span id="sstvIssAlt">---</span> km</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Next Pass Countdown Panel -->
|
||
<div class="sstv-countdown-panel">
|
||
<div class="sstv-countdown-header">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<polyline points="12 6 12 12 16 14"/>
|
||
</svg>
|
||
<span>Next Pass</span>
|
||
</div>
|
||
<div class="sstv-countdown-body">
|
||
<div class="sstv-countdown-timer" id="sstvCountdownTimer">
|
||
<span class="sstv-countdown-value" id="sstvCountdownValue">--:--:--</span>
|
||
<span class="sstv-countdown-label" id="sstvCountdownLabel">Set location</span>
|
||
</div>
|
||
<div class="sstv-countdown-details">
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Start</span>
|
||
<span class="sstv-detail-value" id="sstvPassStart">--:--</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Max El</span>
|
||
<span class="sstv-detail-value" id="sstvPassMaxEl">--°</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Duration</span>
|
||
<span class="sstv-detail-value" id="sstvPassDuration">-- min</span>
|
||
</div>
|
||
<div class="sstv-countdown-detail">
|
||
<span class="sstv-detail-label">Direction</span>
|
||
<span class="sstv-detail-value" id="sstvPassDirection">--</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-countdown-status" id="sstvCountdownStatus">
|
||
<span class="sstv-status-dot"></span>
|
||
<span>Waiting for pass data...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</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>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-gallery-count" id="sstvImageCount">0</span>
|
||
<button class="sstv-gallery-clear-btn" onclick="SSTV.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
|
||
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
|
||
<!-- Stats strip -->
|
||
<div class="wxsat-stats-strip">
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-status">
|
||
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
|
||
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
|
||
</div>
|
||
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
|
||
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
|
||
<span class="wxsat-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
|
||
<span class="wxsat-strip-label">MODE</span>
|
||
</div>
|
||
<div class="wxsat-strip-stat">
|
||
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
|
||
<span class="wxsat-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<div class="wxsat-strip-location">
|
||
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
|
||
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-strip-divider"></div>
|
||
<div class="wxsat-strip-group">
|
||
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
|
||
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
|
||
<span class="wxsat-toggle-label">AUTO</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Countdown + Timeline -->
|
||
<div class="wxsat-countdown-bar">
|
||
<div class="wxsat-countdown-next">
|
||
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
|
||
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
|
||
</div>
|
||
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
|
||
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
|
||
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
|
||
</div>
|
||
</div>
|
||
<div class="wxsat-timeline" id="wxsatTimeline">
|
||
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
|
||
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
|
||
<div class="wxsat-timeline-labels">
|
||
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Capture progress -->
|
||
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||
<div class="wxsat-capture-info">
|
||
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
|
||
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
|
||
</div>
|
||
<div class="wxsat-progress-bar">
|
||
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Decoder Console -->
|
||
<div class="wxsat-signal-console" id="wxsatSignalConsole">
|
||
<div class="wxsat-console-header">
|
||
<div class="wxsat-console-title-group">
|
||
<span class="wxsat-console-title">DECODER CONSOLE</span>
|
||
<div class="wxsat-phase-indicator" id="wxsatPhaseIndicator">
|
||
<span class="wxsat-phase-step" data-phase="tuning">TUNING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
|
||
<span class="wxsat-phase-arrow">▸</span>
|
||
<span class="wxsat-phase-step" data-phase="complete">COMPLETE</span>
|
||
</div>
|
||
</div>
|
||
<button class="wxsat-strip-btn" id="wxsatConsoleToggle"
|
||
onclick="WeatherSat.toggleConsole()" title="Toggle console">▼</button>
|
||
</div>
|
||
<div class="wxsat-console-body" id="wxsatConsoleBody">
|
||
<div class="wxsat-console-log" id="wxsatConsoleLog">
|
||
<div class="wxsat-console-entry wxsat-log-info">Waiting for capture...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main content: 3-column layout -->
|
||
<div class="wxsat-content">
|
||
<!-- Left: Pass predictions -->
|
||
<div class="wxsat-passes-panel">
|
||
<div class="wxsat-passes-header">
|
||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||
</div>
|
||
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||
<div class="wxsat-gallery-empty">
|
||
<p>Set location to see pass predictions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Center: Polar plot + Ground track map -->
|
||
<div class="wxsat-center-panel">
|
||
<div class="wxsat-polar-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Polar Plot</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
|
||
</div>
|
||
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
|
||
</div>
|
||
<div class="wxsat-map-container">
|
||
<div class="wxsat-panel-header">
|
||
<span class="wxsat-panel-title">Global Projection</span>
|
||
<span class="wxsat-panel-subtitle" id="wxsatMapInfo">--</span>
|
||
</div>
|
||
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right: Image gallery -->
|
||
<div class="wxsat-gallery-panel">
|
||
<div class="wxsat-gallery-header">
|
||
<span class="wxsat-gallery-title">Decoded Images</span>
|
||
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
|
||
<button class="wxsat-gallery-clear-btn" onclick="WeatherSat.deleteAllImages()" title="Delete all images">
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="wxsat-gallery-grid" id="wxsatGallery">
|
||
<div class="wxsat-gallery-empty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<circle cx="12" cy="12" r="10"/>
|
||
<path d="M2 12h20"/>
|
||
<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>
|
||
<p>No images decoded yet</p>
|
||
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSTV General Decoder Dashboard -->
|
||
<div id="sstvGeneralVisuals" class="sstv-general-visuals-container" style="display: none;">
|
||
<!-- Status Strip -->
|
||
<div class="sstv-general-stats-strip">
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-status">
|
||
<span class="sstv-general-strip-dot idle" id="sstvGeneralStripDot"></span>
|
||
<span class="sstv-general-strip-status-text" id="sstvGeneralStripStatus">Idle</span>
|
||
</div>
|
||
<button class="sstv-general-strip-btn start" id="sstvGeneralStartBtn" onclick="SSTVGeneral.start()">Start</button>
|
||
<button class="sstv-general-strip-btn stop" id="sstvGeneralStopBtn" onclick="SSTVGeneral.stop()" style="display: none;">Stop</button>
|
||
</div>
|
||
<div class="sstv-general-strip-divider"></div>
|
||
<div class="sstv-general-strip-group">
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value accent-cyan" id="sstvGeneralStripFreq">14.230</span>
|
||
<span class="sstv-general-strip-label">MHZ</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripMod">FM</span>
|
||
<span class="sstv-general-strip-label">MOD</span>
|
||
</div>
|
||
<div class="sstv-general-strip-stat">
|
||
<span class="sstv-general-strip-value" id="sstvGeneralStripImageCount">0</span>
|
||
<span class="sstv-general-strip-label">IMAGES</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Signal Scope -->
|
||
<div id="sstvGeneralScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1e1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="sstvGeneralScopeRmsLabel" style="color: #c080ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="sstvGeneralScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="sstvGeneralScopeToneLabel" style="color: #444;">QUIET</span>
|
||
<span id="sstvGeneralScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sstvGeneralScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Row (Live + Gallery) -->
|
||
<div class="sstv-general-main-row">
|
||
<!-- Live Decode Section -->
|
||
<div class="sstv-general-live-section">
|
||
<div class="sstv-general-live-header">
|
||
<div class="sstv-general-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-general-live-content" id="sstvGeneralLiveContent">
|
||
<div class="sstv-general-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>SSTV Decoder</h4>
|
||
<p>Select a frequency and click Start to listen for SSTV transmissions</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Gallery Section -->
|
||
<div class="sstv-general-gallery-section">
|
||
<div class="sstv-general-gallery-header">
|
||
<div class="sstv-general-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>
|
||
<div style="display:flex;align-items:center;gap:4px;">
|
||
<span class="sstv-general-gallery-count" id="sstvGeneralImageCount">0</span>
|
||
<button class="sstv-general-gallery-clear-btn" onclick="SSTVGeneral.deleteAllImages()" title="Delete all images">Clear All</button>
|
||
</div>
|
||
</div>
|
||
<div class="sstv-general-gallery-grid" id="sstvGeneralGallery">
|
||
<div class="sstv-general-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>
|
||
|
||
<!-- Space Weather Dashboard -->
|
||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||
<!-- Header metrics strip -->
|
||
<div class="sw-header-strip">
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||
<span class="sw-header-label">SFI</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripKp">--</span>
|
||
<span class="sw-header-label">Kp</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripA">--</span>
|
||
<span class="sw-header-label">A-Index</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripSsn">--</span>
|
||
<span class="sw-header-label">SSN</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripWind">--</span>
|
||
<span class="sw-header-label">Wind</span>
|
||
</div>
|
||
<div class="sw-header-stat">
|
||
<span class="sw-header-value" id="swStripBz">--</span>
|
||
<span class="sw-header-label">Bz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- NOAA G/S/R Scales -->
|
||
<div class="sw-scales-row">
|
||
<div class="sw-scale-card" id="swScaleG">
|
||
<div class="sw-scale-label">Geomagnetic</div>
|
||
<div class="sw-scale-value sw-scale-0">G0</div>
|
||
<div class="sw-scale-desc">Quiet</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleS">
|
||
<div class="sw-scale-label">Solar Radiation</div>
|
||
<div class="sw-scale-value sw-scale-0">S0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
<div class="sw-scale-card" id="swScaleR">
|
||
<div class="sw-scale-label">Radio Blackouts</div>
|
||
<div class="sw-scale-value sw-scale-0">R0</div>
|
||
<div class="sw-scale-desc">None</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- HF Band Conditions -->
|
||
<div class="sw-band-panel">
|
||
<div class="sw-band-title">HF Band Conditions</div>
|
||
<div class="sw-band-grid" id="swBandGrid">
|
||
<div class="sw-loading">Loading band data</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Charts row -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Solar Wind</div>
|
||
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||
</div>
|
||
<div class="sw-chart-card">
|
||
<div class="sw-chart-title">Flare Probability</div>
|
||
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Solar imagery gallery -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||
<div class="sw-image-tabs">
|
||
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swSolarImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||
<div class="sw-image-frame" id="swAuroraFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- D-RAP absorption map -->
|
||
<div class="sw-image-panel">
|
||
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||
<div class="sw-drap-freqs">
|
||
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||
</div>
|
||
<div class="sw-image-frame" id="swDrapImageFrame">
|
||
<div class="sw-loading">Loading</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alerts & Active Regions -->
|
||
<div class="sw-dashboard-grid">
|
||
<div class="sw-alerts-panel">
|
||
<div class="sw-chart-title">Active Alerts</div>
|
||
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||
</div>
|
||
<div class="sw-regions-panel">
|
||
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||
<table class="sw-regions-table">
|
||
<thead>
|
||
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||
</thead>
|
||
<tbody id="swRegionsBody">
|
||
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Waterfall Visuals -->
|
||
<div id="waterfallVisuals" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden;">
|
||
<div class="wf-container">
|
||
<div class="wf-headline">
|
||
<div class="wf-headline-left">
|
||
<span class="wf-headline-tag">SPECTRUM RECEIVER</span>
|
||
<span class="wf-headline-sub">Local SDR</span>
|
||
</div>
|
||
<div class="wf-headline-right">
|
||
<span class="wf-range-text" id="wfRangeDisplay">98.8000 - 101.2000 MHz</span>
|
||
<span class="wf-tune-text" id="wfTuneDisplay">Tune 100.0000 MHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-monitor-strip">
|
||
<div class="wf-rx-vfo">
|
||
<div class="wf-rx-vfo-top">
|
||
<span class="wf-rx-vfo-name">VFO-A</span>
|
||
<span class="wf-rx-vfo-status" id="wfVisualStatus">IDLE</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-readout">
|
||
<span id="wfRxFreqReadout">100.0000</span>
|
||
<span class="wf-rx-vfo-unit">MHz</span>
|
||
</div>
|
||
<div class="wf-rx-vfo-bottom">
|
||
<span id="wfRxModeReadout">WFM</span>
|
||
<span id="wfRxStepReadout">STEP 100 kHz</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-modebank" id="wfModeBank">
|
||
<button class="wf-mode-btn is-active" data-mode="wfm">WFM</button>
|
||
<button class="wf-mode-btn" data-mode="fm">NFM</button>
|
||
<button class="wf-mode-btn" data-mode="am">AM</button>
|
||
<button class="wf-mode-btn" data-mode="usb">USB</button>
|
||
<button class="wf-mode-btn" data-mode="lsb">LSB</button>
|
||
<select id="wfMonitorMode" class="wf-monitor-select wf-monitor-select-hidden">
|
||
<option value="wfm" selected>WFM</option>
|
||
<option value="fm">NFM</option>
|
||
<option value="am">AM</option>
|
||
<option value="usb">USB</option>
|
||
<option value="lsb">LSB</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="wf-rx-levels">
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Squelch</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorSquelch" min="0" max="100" value="0">
|
||
<span id="wfMonitorSquelchValue" class="wf-monitor-value">0</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Gain</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorGain" min="0" max="60" value="40">
|
||
<span id="wfMonitorGainValue" class="wf-monitor-value">40</span>
|
||
</div>
|
||
</div>
|
||
<div class="wf-monitor-group">
|
||
<span class="wf-monitor-label">Volume</span>
|
||
<div class="wf-monitor-slider-wrap">
|
||
<input type="range" id="wfMonitorVolume" min="0" max="100" value="82">
|
||
<span id="wfMonitorVolumeValue" class="wf-monitor-value">82</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-meter-wrap">
|
||
<span class="wf-monitor-label">S-Meter</span>
|
||
<div class="wf-rx-smeter">
|
||
<div class="wf-rx-smeter-fill" id="wfSmeterBar"></div>
|
||
</div>
|
||
<div class="wf-rx-smeter-text" id="wfSmeterText">S0</div>
|
||
</div>
|
||
|
||
<div class="wf-rx-actions">
|
||
<div class="wf-rx-action-row">
|
||
<button class="wf-monitor-btn" id="wfMonitorBtn" onclick="Waterfall.toggleMonitor()">Monitor</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-secondary" id="wfMuteBtn" onclick="Waterfall.toggleMute()">Mute</button>
|
||
<button class="wf-monitor-btn wf-monitor-btn-unlock" id="wfAudioUnlockBtn" onclick="Waterfall.unlockAudio()" style="display:none;">Unlock Audio</button>
|
||
</div>
|
||
<div class="wf-monitor-state" id="wfMonitorState">No audio monitor</div>
|
||
</div>
|
||
<audio id="wfAudioPlayer" autoplay playsinline></audio>
|
||
</div>
|
||
|
||
<!-- Frequency control bar -->
|
||
<div class="wf-freq-bar">
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-10)" title="Step down ×10">«</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(-1)" title="Step down">‹</button>
|
||
<div class="wf-freq-display-wrap">
|
||
<span class="wf-freq-bar-label">CENTER</span>
|
||
<input type="text" id="wfFreqCenterDisplay" class="wf-freq-center-input" value="100.0000" inputmode="decimal" autocomplete="off" spellcheck="false">
|
||
<span class="wf-freq-bar-unit">MHz</span>
|
||
</div>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(1)" title="Step up">›</button>
|
||
<button class="wf-step-btn" onclick="Waterfall.stepFreq && Waterfall.stepFreq(10)" title="Step up ×10">»</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">ZOOM</span>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomOut && Waterfall.zoomOut()" title="Zoom out (wider span)">-</button>
|
||
<button class="wf-step-btn wf-zoom-btn" onclick="Waterfall.zoomIn && Waterfall.zoomIn()" title="Zoom in (narrower span)">+</button>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">STEP</span>
|
||
<select id="wfStepSize" class="wf-step-select">
|
||
<option value="0.001">1 kHz</option>
|
||
<option value="0.005">5 kHz</option>
|
||
<option value="0.01">10 kHz</option>
|
||
<option value="0.025">25 kHz</option>
|
||
<option value="0.05">50 kHz</option>
|
||
<option value="0.1" selected>100 kHz</option>
|
||
<option value="0.5">500 kHz</option>
|
||
<option value="1">1 MHz</option>
|
||
<option value="5">5 MHz</option>
|
||
</select>
|
||
<div class="wf-freq-bar-sep"></div>
|
||
<span class="wf-freq-bar-label">SPAN</span>
|
||
<span id="wfSpanDisplay" class="wf-span-display">2.4 MHz</span>
|
||
</div>
|
||
|
||
<!-- Spectrum canvas -->
|
||
<div class="wf-spectrum-canvas-wrap">
|
||
<canvas id="wfSpectrumCanvas"></canvas>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineSpec"></div>
|
||
</div>
|
||
|
||
<div class="wf-band-strip" id="wfBandStrip"></div>
|
||
|
||
<!-- Drag handle to resize spectrum vs waterfall -->
|
||
<div class="wf-resize-handle" id="wfResizeHandle">
|
||
<div class="wf-resize-grip"></div>
|
||
</div>
|
||
|
||
<!-- Waterfall canvas -->
|
||
<div class="wf-waterfall-canvas-wrap">
|
||
<canvas id="wfWaterfallCanvas"></canvas>
|
||
<div class="wf-tooltip" id="wfTooltip"></div>
|
||
<div class="wf-center-line"></div>
|
||
<div class="wf-tune-line" id="wfTuneLineWf"></div>
|
||
</div>
|
||
|
||
<div class="wf-freq-axis" id="wfFreqAxis"></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>
|
||
|
||
<!-- Pager Signal Scope -->
|
||
<div id="pagerScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a1a2e; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RMS: <span id="scopeRmsLabel" style="color: #0ff; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span>PEAK: <span id="scopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
|
||
<span id="scopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="pagerScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mode-specific Timeline Containers -->
|
||
<div id="pagerTimelineContainer" style="display: none; margin-bottom: 12px;"></div>
|
||
|
||
<!-- Sensor Signal Scope -->
|
||
<div id="sensorScopePanel" style="display: none; margin-bottom: 12px;">
|
||
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1px;">
|
||
<span>Audio Waveform</span>
|
||
<div style="display: flex; gap: 14px;">
|
||
<span>RSSI: <span id="sensorScopeRssiLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span>SNR: <span id="sensorScopeSnrLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span><span style="color: #444;"> dB</span></span>
|
||
<span id="sensorScopeStatusLabel" style="color: #444;">IDLE</span>
|
||
</div>
|
||
</div>
|
||
<canvas id="sensorScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
|
||
</div>
|
||
</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') }}?v={{ version }}&r=btlocate1"></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/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 src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/sstv-general.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/gps.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=voicefix2"></script>
|
||
<script src="{{ url_for('static', filename='js/core/keyboard-shortcuts.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/core/cheat-sheets.js') }}"></script>
|
||
<script src="{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"></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
|
||
}
|
||
},
|
||
'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
|
||
const savedDefaultMode = localStorage.getItem('intercept.default_mode');
|
||
let selectedStartMode = savedDefaultMode === 'listening' ? 'waterfall' : (savedDefaultMode || 'pager');
|
||
if (savedDefaultMode === 'listening') {
|
||
localStorage.setItem('intercept.default_mode', 'waterfall');
|
||
}
|
||
|
||
// 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, { updateUrl: true });
|
||
}, 400);
|
||
}
|
||
|
||
// Disclaimer handling - show on page load if not accepted
|
||
function showDisclaimer() {
|
||
document.getElementById('disclaimerModal').style.display = 'flex';
|
||
}
|
||
|
||
// Mode from query string (e.g., /?mode=wifi)
|
||
let pendingStartMode = null;
|
||
const modeCatalog = {
|
||
pager: { label: 'Pager', indicator: 'PAGER', outputTitle: 'Pager Decoder', group: 'signals' },
|
||
sensor: { label: '433MHz', indicator: '433MHZ', outputTitle: '433MHz Sensor Monitor', group: 'signals' },
|
||
rtlamr: { label: 'Meters', indicator: 'METERS', outputTitle: 'Utility Meter Monitor', group: 'signals' },
|
||
subghz: { label: 'SubGHz', indicator: 'SUBGHZ', outputTitle: 'SubGHz Transceiver', group: 'signals' },
|
||
aprs: { label: 'APRS', indicator: 'APRS', outputTitle: 'APRS Tracker', group: 'tracking' },
|
||
gps: { label: 'GPS', indicator: 'GPS', outputTitle: 'GPS Receiver', group: 'tracking' },
|
||
satellite: { label: 'Satellite', indicator: 'SATELLITE', outputTitle: 'Satellite Monitor', group: 'space' },
|
||
sstv: { label: 'ISS SSTV', indicator: 'ISS SSTV', outputTitle: 'ISS SSTV Decoder', group: 'space' },
|
||
weathersat: { label: 'Weather Sat', indicator: 'WEATHER SAT', outputTitle: 'Weather Satellite Decoder', group: 'space' },
|
||
sstv_general: { label: 'HF SSTV', indicator: 'HF SSTV', outputTitle: 'HF SSTV Decoder', group: 'space' },
|
||
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
|
||
wifi: { label: 'WiFi', indicator: 'WIFI', outputTitle: 'WiFi Scanner', group: 'wireless' },
|
||
bluetooth: { label: 'Bluetooth', indicator: 'BLUETOOTH', outputTitle: 'Bluetooth Scanner', group: 'wireless' },
|
||
bt_locate: { label: 'BT Locate', indicator: 'BT LOCATE', outputTitle: 'BT Locate — SAR Tracker', group: 'wireless' },
|
||
meshtastic: { label: 'Meshtastic', indicator: 'MESHTASTIC', outputTitle: 'Meshtastic Mesh Monitor', group: 'wireless' },
|
||
tscm: { label: 'TSCM', indicator: 'TSCM', outputTitle: 'TSCM Counter-Surveillance', group: 'intel' },
|
||
spystations: { label: 'Spy Stations', indicator: 'SPY STATIONS', outputTitle: 'Spy Stations', group: 'intel' },
|
||
websdr: { label: 'WebSDR', indicator: 'WEBSDR', outputTitle: 'HF/Shortwave WebSDR', group: 'intel' },
|
||
waterfall: { label: 'Waterfall', indicator: 'WATERFALL', outputTitle: 'Spectrum Waterfall', group: 'signals' },
|
||
};
|
||
const validModes = new Set(Object.keys(modeCatalog));
|
||
window.interceptModeCatalog = Object.assign({}, modeCatalog);
|
||
|
||
function getModeFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const requestedMode = params.get('mode');
|
||
const mode = requestedMode === 'listening' ? 'waterfall' : requestedMode;
|
||
if (!mode || !validModes.has(mode)) return null;
|
||
return mode;
|
||
}
|
||
|
||
function applyModeFromQuery() {
|
||
const mode = getModeFromQuery();
|
||
if (!mode) return;
|
||
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||
if (accepted) {
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = 'none';
|
||
// Remove mode-gate style injected to prevent welcome flash
|
||
const modeGate = document.getElementById('mode-gate');
|
||
if (modeGate) modeGate.remove();
|
||
switchMode(mode, { updateUrl: false });
|
||
updateModeUrl(mode, true);
|
||
} else {
|
||
pendingStartMode = mode;
|
||
}
|
||
}
|
||
|
||
function applySettingsFromQuery() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (params.get('settings') === '1') {
|
||
// Remove settings param from URL to avoid reopening on refresh
|
||
params.delete('settings');
|
||
const newUrl = params.toString()
|
||
? window.location.pathname + '?' + params.toString()
|
||
: window.location.pathname;
|
||
window.history.replaceState({}, '', newUrl);
|
||
// Open settings modal after a brief delay to ensure page is ready
|
||
setTimeout(() => {
|
||
if (typeof showSettings === 'function') {
|
||
showSettings();
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
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
|
||
const welcome = document.getElementById('welcomePage');
|
||
if (welcome) welcome.style.display = '';
|
||
if (pendingStartMode) {
|
||
// Bypass welcome and jump to requested mode
|
||
welcome.style.display = 'none';
|
||
switchMode(pendingStartMode, { updateUrl: true });
|
||
pendingStartMode = null;
|
||
}
|
||
}, 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;
|
||
}
|
||
|
||
function setActiveModeIndicator(label) {
|
||
const indicator = document.getElementById('activeModeIndicator');
|
||
if (!indicator) return;
|
||
|
||
indicator.textContent = '';
|
||
const dot = document.createElement('span');
|
||
dot.className = 'pulse-dot';
|
||
indicator.appendChild(dot);
|
||
indicator.appendChild(document.createTextNode(String(label || '')));
|
||
}
|
||
|
||
function applyKeyboardAccessibility(root = document) {
|
||
const interactive = root.querySelectorAll('[onclick]:not(button):not(a):not(input):not(select):not(textarea)');
|
||
interactive.forEach((el) => {
|
||
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
|
||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
|
||
el.setAttribute('data-keyboard-activate', 'true');
|
||
});
|
||
}
|
||
|
||
if (!window._keyboardActivationBound) {
|
||
window._keyboardActivationBound = true;
|
||
document.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
const target = event.target && event.target.closest ? event.target.closest('[data-keyboard-activate="true"]') : null;
|
||
if (!target) return;
|
||
event.preventDefault();
|
||
target.click();
|
||
});
|
||
}
|
||
|
||
// Update clock every second
|
||
setInterval(updateHeaderClock, 1000);
|
||
updateHeaderClock(); // Initial call
|
||
applyKeyboardAccessibility();
|
||
|
||
// 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 () {
|
||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||
return ObserverLocation.getForModule('observerLocation');
|
||
}
|
||
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;
|
||
}
|
||
|
||
const SIDEBAR_COLLAPSE_KEY = 'mainSidebarCollapsed';
|
||
|
||
function setMainSidebarCollapsed(collapsed) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
const collapseBtn = document.getElementById('sidebarCollapseBtn');
|
||
if (!mainContent) return;
|
||
|
||
mainContent.classList.toggle('sidebar-collapsed', collapsed);
|
||
if (collapseBtn) {
|
||
collapseBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||
}
|
||
localStorage.setItem(SIDEBAR_COLLAPSE_KEY, collapsed ? 'true' : 'false');
|
||
}
|
||
|
||
function toggleMainSidebarCollapse(forceState = null) {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent || window.innerWidth < 1024) return;
|
||
const collapsed = mainContent.classList.contains('sidebar-collapsed');
|
||
const nextState = forceState === null ? !collapsed : !!forceState;
|
||
setMainSidebarCollapsed(nextState);
|
||
}
|
||
|
||
function applySidebarCollapsePreference() {
|
||
const mainContent = document.querySelector('.main-content');
|
||
if (!mainContent) return;
|
||
if (window.innerWidth < 1024) {
|
||
mainContent.classList.remove('sidebar-collapsed');
|
||
return;
|
||
}
|
||
const savedCollapsed = localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true';
|
||
setMainSidebarCollapsed(savedCollapsed);
|
||
}
|
||
|
||
window.addEventListener('resize', applySidebarCollapsePreference);
|
||
|
||
// Make sections collapsible
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
document.querySelectorAll('.section h3').forEach(h3 => {
|
||
h3.addEventListener('click', function () {
|
||
this.parentElement.classList.toggle('collapsed');
|
||
});
|
||
});
|
||
|
||
// Collapse sidebar menu sections by default, but skip headerless utility blocks.
|
||
document.querySelectorAll('.sidebar .section').forEach((section) => {
|
||
if (section.querySelector('h3')) {
|
||
section.classList.add('collapsed');
|
||
} else {
|
||
section.classList.remove('collapsed');
|
||
}
|
||
});
|
||
|
||
applySidebarCollapsePreference();
|
||
|
||
// Load bias-T setting from localStorage
|
||
loadBiasTSetting();
|
||
|
||
// Initialize device list from server-provided data
|
||
// This ensures currentDeviceList is populated on page load (fixes #99)
|
||
if (typeof deviceList !== 'undefined' && deviceList.length > 0) {
|
||
currentDeviceList = deviceList;
|
||
const firstType = deviceList[0].sdr_type || 'rtlsdr';
|
||
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
|
||
if (sdrTypeSelect) {
|
||
sdrTypeSelect.value = firstType;
|
||
}
|
||
// Defer onSDRTypeChanged to ensure DOM is ready
|
||
setTimeout(onSDRTypeChanged, 0);
|
||
}
|
||
|
||
// 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();
|
||
|
||
// Start SDR device status polling
|
||
startSdrStatusPolling();
|
||
|
||
// Apply mode from URL query (e.g., /?mode=wifi)
|
||
applyModeFromQuery();
|
||
|
||
// Check for settings=1 query param (from dashboard settings button)
|
||
applySettingsFromQuery();
|
||
});
|
||
|
||
// 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() {
|
||
// 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 = modeCatalog[currentMode] ? modeCatalog[currentMode].group : null;
|
||
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();
|
||
}
|
||
});
|
||
|
||
function updateModeUrl(mode, replace = false) {
|
||
if (!validModes.has(mode)) return;
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set('mode', mode);
|
||
if (replace) {
|
||
window.history.replaceState({ mode }, '', url);
|
||
} else {
|
||
window.history.pushState({ mode }, '', url);
|
||
}
|
||
}
|
||
|
||
const LOCAL_STOP_TIMEOUT_MS = 2200;
|
||
const REMOTE_STOP_TIMEOUT_MS = 8000;
|
||
const DASHBOARD_NAV_PATHS = new Set([
|
||
'/adsb/dashboard',
|
||
'/ais/dashboard',
|
||
'/satellite/dashboard',
|
||
]);
|
||
|
||
function getActiveScanSummary() {
|
||
return {
|
||
pager: Boolean(isRunning),
|
||
sensor: Boolean(isSensorRunning),
|
||
wifi: Boolean(
|
||
((typeof WiFiMode !== 'undefined' && typeof WiFiMode.isScanning === 'function' && WiFiMode.isScanning()) || isWifiRunning)
|
||
),
|
||
bluetooth: Boolean(
|
||
((typeof BluetoothMode !== 'undefined' && typeof BluetoothMode.isScanning === 'function' && BluetoothMode.isScanning()) || isBtRunning)
|
||
),
|
||
aprs: Boolean(typeof isAprsRunning !== 'undefined' && isAprsRunning),
|
||
tscm: Boolean(typeof isTscmRunning !== 'undefined' && isTscmRunning),
|
||
};
|
||
}
|
||
|
||
function stopActiveLocalScansForNavigation() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
if (isAgentMode) return;
|
||
|
||
if (isRunning && typeof stopDecoding === 'function') {
|
||
Promise.resolve(stopDecoding()).catch(() => { });
|
||
}
|
||
if (isSensorRunning && typeof stopSensorDecoding === 'function') {
|
||
Promise.resolve(stopSensorDecoding()).catch(() => { });
|
||
}
|
||
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
if (wifiScanActive && typeof stopWifiScan === 'function') {
|
||
Promise.resolve(stopWifiScan()).catch(() => { });
|
||
}
|
||
|
||
const btScanActive = (
|
||
typeof BluetoothMode !== 'undefined'
|
||
&& typeof BluetoothMode.isScanning === 'function'
|
||
&& BluetoothMode.isScanning()
|
||
) || isBtRunning;
|
||
if (btScanActive && typeof stopBtScan === 'function') {
|
||
Promise.resolve(stopBtScan()).catch(() => { });
|
||
}
|
||
|
||
if (typeof isAprsRunning !== 'undefined' && isAprsRunning && typeof stopAprs === 'function') {
|
||
Promise.resolve(stopAprs()).catch(() => { });
|
||
}
|
||
if (typeof isTscmRunning !== 'undefined' && isTscmRunning && typeof stopTscmSweep === 'function') {
|
||
Promise.resolve(stopTscmSweep()).catch(() => { });
|
||
}
|
||
}
|
||
|
||
if (!window._dashboardNavigationStopHookBound) {
|
||
window._dashboardNavigationStopHookBound = true;
|
||
document.addEventListener('click', (event) => {
|
||
if (event.defaultPrevented || event.button !== 0) return;
|
||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||
|
||
const link = event.target && event.target.closest
|
||
? event.target.closest('a[href]')
|
||
: null;
|
||
if (!link || link.target === '_blank') return;
|
||
|
||
try {
|
||
const href = new URL(link.href, window.location.href);
|
||
if (href.origin !== window.location.origin) return;
|
||
if (!DASHBOARD_NAV_PATHS.has(href.pathname)) return;
|
||
if (window.InterceptNavPerf && typeof window.InterceptNavPerf.markStart === 'function') {
|
||
window.InterceptNavPerf.markStart({
|
||
targetPath: href.pathname,
|
||
trigger: 'index-link',
|
||
sourceMode: currentMode,
|
||
activeScans: getActiveScanSummary(),
|
||
});
|
||
}
|
||
stopActiveLocalScansForNavigation();
|
||
} catch (_) {
|
||
// Ignore malformed hrefs.
|
||
}
|
||
});
|
||
}
|
||
|
||
function postStopRequest(url, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
|
||
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||
const started = performance.now();
|
||
return fetch(url, {
|
||
method: 'POST',
|
||
...(controller ? { signal: controller.signal } : {}),
|
||
})
|
||
.then((response) => response.json().catch(() => ({ status: response.ok ? 'ok' : 'error' })))
|
||
.catch((err) => {
|
||
if (err && err.name === 'AbortError') {
|
||
console.warn(`[Stop] ${url} timed out after ${timeoutMs}ms`);
|
||
return { status: 'timeout', timed_out: true };
|
||
}
|
||
console.warn(`[Stop] ${url} failed: ${err?.message || err}`);
|
||
return { status: 'error', message: err?.message || String(err) };
|
||
})
|
||
.finally(() => {
|
||
if (timeoutId) clearTimeout(timeoutId);
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[Stop] ${url} finished in ${elapsedMs}ms`);
|
||
});
|
||
}
|
||
|
||
async function awaitStopAction(name, action, timeoutMs = LOCAL_STOP_TIMEOUT_MS) {
|
||
const started = performance.now();
|
||
try {
|
||
const result = action();
|
||
const promise = (result && typeof result.then === 'function')
|
||
? result
|
||
: Promise.resolve(result);
|
||
await Promise.race([
|
||
promise,
|
||
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
||
]);
|
||
} catch (err) {
|
||
console.warn(`[ModeSwitch] stop ${name} failed: ${err?.message || err}`);
|
||
} finally {
|
||
const elapsedMs = Math.round(performance.now() - started);
|
||
console.debug(`[ModeSwitch] stop ${name} finished in ${elapsedMs}ms`);
|
||
}
|
||
}
|
||
|
||
// Mode switching
|
||
async function switchMode(mode, options = {}) {
|
||
const { updateUrl = true } = options;
|
||
const switchStartMs = performance.now();
|
||
const previousMode = currentMode;
|
||
if (mode === 'listening') mode = 'waterfall';
|
||
if (!validModes.has(mode)) mode = 'pager';
|
||
// Only stop local scans if in local mode (not agent mode)
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const stopPhaseStartMs = performance.now();
|
||
let stopTaskCount = 0;
|
||
if (!isAgentMode) {
|
||
const stopTasks = [];
|
||
|
||
if (isRunning) {
|
||
stopTasks.push(awaitStopAction('pager', () => stopDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isSensorRunning) {
|
||
stopTasks.push(awaitStopAction('sensor', () => stopSensorDecoding(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const wifiScanActive = (
|
||
typeof WiFiMode !== 'undefined'
|
||
&& typeof WiFiMode.isScanning === 'function'
|
||
&& WiFiMode.isScanning()
|
||
) || isWifiRunning;
|
||
if (wifiScanActive) {
|
||
stopTasks.push(awaitStopAction('wifi', () => stopWifiScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
const btScanActive = (typeof BluetoothMode !== 'undefined' &&
|
||
typeof BluetoothMode.isScanning === 'function' &&
|
||
BluetoothMode.isScanning()) || isBtRunning;
|
||
const isBtModeTransition =
|
||
(currentMode === 'bluetooth' && mode === 'bt_locate') ||
|
||
(currentMode === 'bt_locate' && mode === 'bluetooth');
|
||
if (btScanActive && !isBtModeTransition && typeof stopBtScan === 'function') {
|
||
stopTasks.push(awaitStopAction('bluetooth', () => stopBtScan(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isAprsRunning) {
|
||
stopTasks.push(awaitStopAction('aprs', () => stopAprs(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
if (isTscmRunning) {
|
||
stopTasks.push(awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS));
|
||
}
|
||
|
||
if (stopTasks.length) {
|
||
await Promise.allSettled(stopTasks);
|
||
}
|
||
stopTaskCount = stopTasks.length;
|
||
}
|
||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||
|
||
// Clean up SubGHz SSE connection when leaving the mode
|
||
if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
|
||
SubGhz.destroy();
|
||
}
|
||
|
||
currentMode = mode;
|
||
document.body.setAttribute('data-mode', mode);
|
||
if (updateUrl) {
|
||
updateModeUrl(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();
|
||
|
||
if (typeof window.ensureModeStyles === 'function') {
|
||
window.ensureModeStyles(mode);
|
||
}
|
||
|
||
// Remove active from all nav buttons, then add to the correct one
|
||
document.querySelectorAll('.mode-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
document.querySelectorAll('.mobile-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||
});
|
||
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('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||
document.getElementById('sstvGeneralMode')?.classList.toggle('active', mode === 'sstv_general');
|
||
document.getElementById('gpsMode')?.classList.toggle('active', mode === 'gps');
|
||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||
document.getElementById('btLocateMode')?.classList.toggle('active', mode === 'bt_locate');
|
||
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');
|
||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||
document.getElementById('waterfallMode')?.classList.toggle('active', mode === 'waterfall');
|
||
|
||
|
||
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 modeMeta = modeCatalog[mode] || {};
|
||
setActiveModeIndicator(modeMeta.indicator || mode.toUpperCase());
|
||
const wifiLayoutContainer = document.getElementById('wifiLayoutContainer');
|
||
const btLayoutContainer = document.getElementById('btLayoutContainer');
|
||
const satelliteVisuals = document.getElementById('satelliteVisuals');
|
||
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');
|
||
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||
const sstvGeneralVisuals = document.getElementById('sstvGeneralVisuals');
|
||
const gpsVisuals = document.getElementById('gpsVisuals');
|
||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||
const waterfallVisuals = document.getElementById('waterfallVisuals');
|
||
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 (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';
|
||
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||
if (sstvGeneralVisuals) sstvGeneralVisuals.style.display = mode === 'sstv_general' ? 'flex' : 'none';
|
||
if (gpsVisuals) gpsVisuals.style.display = mode === 'gps' ? 'flex' : 'none';
|
||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
|
||
|
||
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
|
||
BtLocate.setActiveMode(mode === 'bt_locate');
|
||
}
|
||
|
||
// 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 outputTitle = document.getElementById('outputTitle');
|
||
if (outputTitle) outputTitle.textContent = modeMeta.outputTitle || 'Signal Monitor';
|
||
|
||
// Initialize mode-specific timelines
|
||
initializeModeTimeline(mode);
|
||
|
||
// Initialize TSCM mode when selected
|
||
if (mode === 'tscm') {
|
||
loadTscmBaselines();
|
||
refreshTscmDevices();
|
||
}
|
||
|
||
// Initialize/destroy Space Weather mode
|
||
if (mode !== 'spaceweather') {
|
||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||
}
|
||
|
||
// Suspend Weather Satellite background timers/streams when leaving the mode
|
||
if (mode !== 'weathersat') {
|
||
if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
|
||
}
|
||
|
||
// 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 === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') {
|
||
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', 'aprs', 'wifi', 'bluetooth', 'aircraft', 'tscm', 'ais'];
|
||
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 === 'aprs' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general') ? '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 === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block';
|
||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? '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 === '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();
|
||
setTimeout(() => {
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||
}, 120);
|
||
} else if (mode === 'weathersat') {
|
||
WeatherSat.init();
|
||
setTimeout(() => {
|
||
WeatherSat.invalidateMap();
|
||
}, 100);
|
||
} else if (mode === 'sstv_general') {
|
||
SSTVGeneral.init();
|
||
} else if (mode === 'gps') {
|
||
GPS.init();
|
||
} else if (mode === 'websdr') {
|
||
if (typeof initWebSDR === 'function') initWebSDR();
|
||
} else if (mode === 'subghz') {
|
||
SubGhz.init();
|
||
} else if (mode === 'bt_locate') {
|
||
BtLocate.init();
|
||
setTimeout(() => {
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||
}, 100);
|
||
setTimeout(() => {
|
||
if (typeof BtLocate !== 'undefined' && BtLocate.invalidateMap) BtLocate.invalidateMap();
|
||
}, 320);
|
||
} else if (mode === 'spaceweather') {
|
||
SpaceWeather.init();
|
||
} else if (mode === 'waterfall') {
|
||
if (typeof Waterfall !== 'undefined') Waterfall.init();
|
||
}
|
||
|
||
// Destroy Waterfall WebSocket when leaving SDR receiver modes
|
||
if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
|
||
Promise.resolve(Waterfall.destroy()).catch(() => {});
|
||
}
|
||
|
||
const totalMs = Math.round(performance.now() - switchStartMs);
|
||
console.info(
|
||
`[Perf] switchMode ${previousMode} -> ${mode}: stop=${stopPhaseMs}ms tasks=${stopTaskCount} total=${totalMs}ms`,
|
||
{
|
||
updateUrl,
|
||
agentMode: isAgentMode,
|
||
}
|
||
);
|
||
requestAnimationFrame(() => {
|
||
const firstFrameMs = Math.round(performance.now() - switchStartMs);
|
||
console.info(`[Perf] switchMode ${previousMode} -> ${mode}: first-frame=${firstFrameMs}ms`);
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
if (typeof BtLocate !== 'undefined') BtLocate.invalidateMap();
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.invalidateMap();
|
||
});
|
||
|
||
window.addEventListener('popstate', function () {
|
||
const mode = getModeFromQuery();
|
||
if (mode && mode !== currentMode) {
|
||
switchMode(mode, { updateUrl: false });
|
||
}
|
||
});
|
||
|
||
// Also handle orientation changes explicitly for mobile
|
||
window.addEventListener('orientationchange', function () {
|
||
setTimeout(() => {
|
||
if (aprsMap) aprsMap.invalidateSize();
|
||
if (typeof Meshtastic !== 'undefined') Meshtastic.invalidateMap();
|
||
if (typeof SSTV !== 'undefined' && SSTV.invalidateMap) SSTV.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));
|
||
}
|
||
}
|
||
|
||
// --- Sensor Signal Scope ---
|
||
let sensorScopeCtx = null;
|
||
let sensorScopeAnim = null;
|
||
let sensorScopeHistory = [];
|
||
let sensorScopeWaveBuffer = [];
|
||
let sensorScopeDisplayWave = [];
|
||
const SENSOR_SCOPE_LEN = 200;
|
||
const SENSOR_SCOPE_WAVE_BUFFER_LEN = 2048;
|
||
const SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||
const SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||
const SENSOR_SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||
let sensorScopeRssi = 0;
|
||
let sensorScopeSnr = 0;
|
||
let sensorScopeTargetRssi = 0;
|
||
let sensorScopeTargetSnr = 0;
|
||
let sensorScopeMsgBurst = 0;
|
||
let sensorScopeLastWaveAt = 0;
|
||
let sensorScopeLastInputSample = 0;
|
||
|
||
function resizeSensorScopeCanvas(canvas) {
|
||
if (!canvas) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
}
|
||
|
||
function buildSensorWaveformFallback(rssi, snr, noise, points = 160) {
|
||
const rssiNorm = Math.min(Math.max(Math.abs(rssi) / 40, 0), 1);
|
||
const snrNorm = Math.min(Math.max((snr + 5) / 35, 0), 1);
|
||
const noiseNorm = Math.min(Math.max(Math.abs(noise) / 40, 0), 1);
|
||
|
||
const amplitude = Math.max(0.06, Math.min(1.0, (0.6 * rssiNorm + 0.4 * snrNorm) - (0.22 * noiseNorm)));
|
||
const cycles = 3 + (snrNorm * 8);
|
||
const harmonic = 0.25 + (0.35 * snrNorm);
|
||
const hiss = 0.08 + (0.18 * noiseNorm);
|
||
const phase = (performance.now() * 0.002 * (1.4 + (snrNorm * 2.2))) % (Math.PI * 2);
|
||
|
||
const waveform = [];
|
||
for (let i = 0; i < points; i++) {
|
||
const t = points > 1 ? (i / (points - 1)) : 0;
|
||
const base = Math.sin((Math.PI * 2 * cycles * t) + phase);
|
||
const overtone = Math.sin((Math.PI * 2 * (cycles * 2.4) * t) + (phase * 0.7));
|
||
const noiseWobble = Math.sin((Math.PI * 2 * (cycles * 7.0) * t) + (phase * 2.1));
|
||
let sample = amplitude * (base + (harmonic * overtone) + (hiss * noiseWobble));
|
||
sample /= (1 + harmonic + hiss);
|
||
waveform.push(Math.round(Math.max(-1, Math.min(1, sample)) * 127));
|
||
}
|
||
return waveform;
|
||
}
|
||
|
||
function appendSensorWaveformSamples(waveform) {
|
||
if (!Array.isArray(waveform) || waveform.length === 0) return;
|
||
|
||
for (const packedSample of waveform) {
|
||
const sample = Number(packedSample);
|
||
if (!Number.isFinite(sample)) continue;
|
||
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||
sensorScopeLastInputSample += (normalized - sensorScopeLastInputSample) * SENSOR_SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||
sensorScopeWaveBuffer.push(sensorScopeLastInputSample);
|
||
}
|
||
|
||
if (sensorScopeWaveBuffer.length > SENSOR_SCOPE_WAVE_BUFFER_LEN) {
|
||
sensorScopeWaveBuffer.splice(0, sensorScopeWaveBuffer.length - SENSOR_SCOPE_WAVE_BUFFER_LEN);
|
||
}
|
||
sensorScopeLastWaveAt = performance.now();
|
||
}
|
||
|
||
function applySensorScopeData(scopeData) {
|
||
if (!scopeData || typeof scopeData !== 'object') return;
|
||
|
||
const parsedRssi = Number(scopeData.rssi);
|
||
const parsedSnr = Number(scopeData.snr);
|
||
const parsedNoise = Number(scopeData.noise);
|
||
|
||
const rssi = Number.isFinite(parsedRssi) ? parsedRssi : 0;
|
||
const snr = Number.isFinite(parsedSnr) ? parsedSnr : 0;
|
||
const noise = Number.isFinite(parsedNoise) ? parsedNoise : 0;
|
||
|
||
sensorScopeTargetRssi = rssi;
|
||
sensorScopeTargetSnr = snr;
|
||
|
||
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||
appendSensorWaveformSamples(scopeData.waveform);
|
||
} else {
|
||
appendSensorWaveformSamples(buildSensorWaveformFallback(rssi, snr, noise));
|
||
}
|
||
}
|
||
|
||
function initSensorScope() {
|
||
const canvas = document.getElementById('sensorScopeCanvas');
|
||
if (!canvas) return;
|
||
|
||
if (sensorScopeAnim) {
|
||
cancelAnimationFrame(sensorScopeAnim);
|
||
sensorScopeAnim = null;
|
||
}
|
||
|
||
resizeSensorScopeCanvas(canvas);
|
||
sensorScopeCtx = canvas.getContext('2d');
|
||
sensorScopeHistory = new Array(SENSOR_SCOPE_LEN).fill(0);
|
||
sensorScopeWaveBuffer = [];
|
||
sensorScopeDisplayWave = [];
|
||
sensorScopeRssi = 0;
|
||
sensorScopeSnr = 0;
|
||
sensorScopeTargetRssi = 0;
|
||
sensorScopeTargetSnr = 0;
|
||
sensorScopeMsgBurst = 0;
|
||
sensorScopeLastWaveAt = 0;
|
||
sensorScopeLastInputSample = 0;
|
||
drawSensorScope();
|
||
}
|
||
|
||
function drawSensorScope() {
|
||
const ctx = sensorScopeCtx;
|
||
if (!ctx) return;
|
||
|
||
resizeSensorScopeCanvas(ctx.canvas);
|
||
const W = ctx.canvas.width;
|
||
const H = ctx.canvas.height;
|
||
const midY = H / 2;
|
||
|
||
// Phosphor persistence
|
||
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Smooth towards targets
|
||
sensorScopeRssi += (sensorScopeTargetRssi - sensorScopeRssi) * 0.25;
|
||
sensorScopeSnr += (sensorScopeTargetSnr - sensorScopeSnr) * 0.15;
|
||
|
||
// Decay targets back to idle between updates
|
||
sensorScopeTargetRssi *= 0.97;
|
||
sensorScopeTargetSnr *= 0.97;
|
||
|
||
// Keep amplitude envelope for context
|
||
const rssiNorm = Math.min(Math.max(Math.abs(sensorScopeRssi) / 40, 0), 1.0);
|
||
sensorScopeHistory.push(rssiNorm);
|
||
if (sensorScopeHistory.length > SENSOR_SCOPE_LEN) {
|
||
sensorScopeHistory.shift();
|
||
}
|
||
|
||
// Grid lines (horizontal + vertical)
|
||
ctx.strokeStyle = 'rgba(40, 80, 40, 0.4)';
|
||
ctx.lineWidth = 0.8;
|
||
for (let i = 1; i < 8; i++) {
|
||
const gx = (W / 8) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(gx, 0);
|
||
ctx.lineTo(gx, H);
|
||
ctx.stroke();
|
||
}
|
||
for (let g = 0.25; g < 1; g += 0.25) {
|
||
const gy = midY - g * midY;
|
||
const gy2 = midY + g * midY;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Center baseline
|
||
ctx.strokeStyle = 'rgba(60, 100, 60, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, midY);
|
||
ctx.lineTo(W, midY);
|
||
ctx.stroke();
|
||
|
||
// Slow envelope as context around baseline
|
||
const envStepX = W / (SENSOR_SCOPE_LEN - 1);
|
||
ctx.strokeStyle = 'rgba(86, 230, 120, 0.45)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||
const y = midY - amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
for (let i = 0; i < sensorScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = sensorScopeHistory[i] * midY * 0.85;
|
||
const y = midY + amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Actual waveform trace
|
||
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||
if (sensorScopeWaveBuffer.length > 1) {
|
||
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||
const sourceLen = sensorScopeWaveBuffer.length;
|
||
const sourceWindow = Math.min(sourceLen, 1536);
|
||
const sourceStart = sourceLen - sourceWindow;
|
||
|
||
if (sensorScopeDisplayWave.length !== waveformPointCount) {
|
||
sensorScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||
}
|
||
|
||
for (let i = 0; i < waveformPointCount; i++) {
|
||
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let j = start; j < end; j++) {
|
||
sum += sensorScopeWaveBuffer[j];
|
||
count++;
|
||
}
|
||
const targetSample = count > 0 ? (sum / count) : 0;
|
||
sensorScopeDisplayWave[i] += (targetSample - sensorScopeDisplayWave[i]) * SENSOR_SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||
}
|
||
|
||
ctx.strokeStyle = waveIsFresh ? '#40ff7a' : 'rgba(64, 255, 122, 0.45)';
|
||
ctx.lineWidth = 1.7;
|
||
ctx.shadowColor = '#40ff7a';
|
||
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||
|
||
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||
ctx.beginPath();
|
||
const firstY = midY - (sensorScopeDisplayWave[0] * midY * 0.9);
|
||
ctx.moveTo(0, firstY);
|
||
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||
const x = i * stepX;
|
||
const y = midY - (sensorScopeDisplayWave[i] * midY * 0.9);
|
||
const nx = (i + 1) * stepX;
|
||
const ny = midY - (sensorScopeDisplayWave[i + 1] * midY * 0.9);
|
||
const cx = (x + nx) / 2;
|
||
const cy = (y + ny) / 2;
|
||
ctx.quadraticCurveTo(x, y, cx, cy);
|
||
}
|
||
const lastX = (waveformPointCount - 1) * stepX;
|
||
const lastY = midY - (sensorScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||
ctx.lineTo(lastX, lastY);
|
||
ctx.stroke();
|
||
|
||
if (!waveIsFresh) {
|
||
for (let i = 0; i < sensorScopeDisplayWave.length; i++) {
|
||
sensorScopeDisplayWave[i] *= SENSOR_SCOPE_WAVE_IDLE_DECAY;
|
||
}
|
||
}
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
|
||
// SNR indicator (amber dashed line)
|
||
const snrNorm = Math.min(Math.max(Math.abs(sensorScopeSnr) / 40, 0), 1.0);
|
||
if (snrNorm > 0.01) {
|
||
const snrY = midY - snrNorm * midY * 0.9;
|
||
ctx.strokeStyle = 'rgba(255, 170, 0, 0.6)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, snrY);
|
||
ctx.lineTo(W, snrY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// Sensor decode flash
|
||
if (sensorScopeMsgBurst > 0.01) {
|
||
ctx.fillStyle = `rgba(0, 255, 100, ${sensorScopeMsgBurst * 0.15})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
sensorScopeMsgBurst *= 0.88;
|
||
}
|
||
|
||
// Update labels
|
||
const rssiLabel = document.getElementById('sensorScopeRssiLabel');
|
||
const snrLabel = document.getElementById('sensorScopeSnrLabel');
|
||
const statusLabel = document.getElementById('sensorScopeStatusLabel');
|
||
if (rssiLabel) rssiLabel.textContent = sensorScopeRssi < -0.5 ? sensorScopeRssi.toFixed(1) : '--';
|
||
if (snrLabel) snrLabel.textContent = sensorScopeSnr > 0.5 ? sensorScopeSnr.toFixed(1) : '--';
|
||
if (statusLabel) {
|
||
const waveIsFresh = (performance.now() - sensorScopeLastWaveAt) < 1000;
|
||
if (Math.abs(sensorScopeRssi) > 1.2 && waveIsFresh) {
|
||
statusLabel.textContent = 'DEMODULATING';
|
||
statusLabel.style.color = '#40ff7a';
|
||
} else if (Math.abs(sensorScopeRssi) > 0.6) {
|
||
statusLabel.textContent = 'CARRIER';
|
||
statusLabel.style.color = '#78ff9a';
|
||
} else {
|
||
statusLabel.textContent = 'QUIET';
|
||
statusLabel.style.color = '#555';
|
||
}
|
||
}
|
||
|
||
sensorScopeAnim = requestAnimationFrame(drawSensorScope);
|
||
}
|
||
|
||
function stopSensorScope() {
|
||
if (sensorScopeAnim) {
|
||
cancelAnimationFrame(sensorScopeAnim);
|
||
sensorScopeAnim = null;
|
||
}
|
||
sensorScopeCtx = null;
|
||
sensorScopeWaveBuffer = [];
|
||
sensorScopeDisplayWave = [];
|
||
sensorScopeHistory = [];
|
||
sensorScopeLastWaveAt = 0;
|
||
sensorScopeLastInputSample = 0;
|
||
}
|
||
|
||
// 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() {
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/sensor/stop`
|
||
: '/stop_sensor';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
setSensorRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (agentPollInterval) {
|
||
clearInterval(agentPollInterval);
|
||
agentPollInterval = null;
|
||
}
|
||
if (!isAgentMode) {
|
||
releaseDevice('sensor');
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs).then((data) => {
|
||
if (isAgentMode && data && data.status !== 'error' && data.status !== 'timeout') {
|
||
showInfo('Sensor stopped on remote agent');
|
||
}
|
||
return data;
|
||
});
|
||
}
|
||
|
||
// 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';
|
||
|
||
// Agent polling may only return decoded packets, so derive scope updates from packet levels.
|
||
if (msg && (msg.rssi !== undefined || msg.snr !== undefined || msg.noise !== undefined)) {
|
||
applySensorScopeData(msg);
|
||
}
|
||
|
||
// 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';
|
||
|
||
// Signal scope
|
||
const scopePanel = document.getElementById('sensorScopePanel');
|
||
if (scopePanel) {
|
||
if (running) {
|
||
scopePanel.style.display = 'block';
|
||
initSensorScope();
|
||
} else {
|
||
stopSensorScope();
|
||
scopePanel.style.display = '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 === 'scope') {
|
||
applySensorScopeData(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();
|
||
|
||
// Flash sensor scope green on decode
|
||
sensorScopeMsgBurst = 1.0;
|
||
|
||
// Fallback when no dedicated scope packet has arrived recently.
|
||
if ((data.rssi !== undefined || data.snr !== undefined || data.noise !== undefined)
|
||
&& ((performance.now() - sensorScopeLastWaveAt) > 250)) {
|
||
applySensorScopeData(data);
|
||
}
|
||
|
||
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';
|
||
} else if (data.pressure_PSI !== undefined) {
|
||
msg.pressure = data.pressure_PSI;
|
||
msg.pressure_unit = 'PSI';
|
||
} else if (data.pressure_kPa !== undefined) {
|
||
msg.pressure = data.pressure_kPa;
|
||
msg.pressure_unit = 'kPa';
|
||
} else if (data.tire_pressure_kPa !== undefined) {
|
||
msg.pressure = data.tire_pressure_kPa;
|
||
msg.pressure_unit = 'kPa';
|
||
}
|
||
if (data.flags !== undefined) msg.state = data.flags;
|
||
else if (data.state !== undefined) msg.state = data.state;
|
||
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';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
rtlamrCurrentAgent = null;
|
||
setRtlamrRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (rtlamrPollTimer) {
|
||
clearInterval(rtlamrPollTimer);
|
||
rtlamrPollTimer = null;
|
||
}
|
||
if (!isAgentMode) {
|
||
releaseDevice('rtlamr');
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs);
|
||
}
|
||
|
||
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;
|
||
setActiveModeIndicator('METERS @ ' + freq + ' MHz');
|
||
} else {
|
||
setActiveModeIndicator('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 },
|
||
'sdrplay': { name: 'SDRplay', freq_min: 0.001, freq_max: 2000, gain_min: 0, gain_max: 59 },
|
||
'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 },
|
||
'airspy': { name: 'Airspy', freq_min: 24, freq_max: 1800, gain_min: 0, gain_max: 21 }
|
||
};
|
||
|
||
// 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 => {
|
||
const serialSuffix = d.serial && d.serial !== 'N/A' && d.serial !== 'Unknown' ? ` (SN: ${d.serial})` : '';
|
||
return `<option value="${d.index}" data-sdr-type="${d.sdr_type || 'rtlsdr'}">${d.index}: ${d.name}${serialSuffix}</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();
|
||
|
||
// Also refresh SDR status panel
|
||
fetchSdrStatus();
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to refresh devices:', err);
|
||
const select = document.getElementById('deviceSelect');
|
||
select.innerHTML = '<option value="0">Error loading devices</option>';
|
||
});
|
||
}
|
||
|
||
// SDR Device Status Panel
|
||
let sdrStatusPollingInterval = null;
|
||
|
||
function renderSdrStatus(devices) {
|
||
const container = document.getElementById('sdrStatusList');
|
||
if (!container) return;
|
||
|
||
if (!devices || devices.length === 0) {
|
||
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">No SDR devices detected</div>';
|
||
return;
|
||
}
|
||
|
||
const html = devices.map(d => {
|
||
const isActive = d.in_use;
|
||
const statusDot = isActive
|
||
? '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #00ff88; box-shadow: 0 0 6px #00ff88; margin-right: 6px;"></span>'
|
||
: '<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #555; margin-right: 6px;"></span>';
|
||
const modeName = d.used_by ? d.used_by.toUpperCase() : 'IDLE';
|
||
const modeColor = isActive ? '#00ff88' : '#666';
|
||
const sdrType = (d.sdr_type || 'RTL').toUpperCase().replace('RTLSDR', 'RTL');
|
||
|
||
return `<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; border-bottom: 1px solid var(--border-color);">
|
||
<div style="display: flex; align-items: center;">
|
||
${statusDot}
|
||
<span style="font-size: 11px;">#${d.index} ${d.name || 'Unknown'}${d.serial && d.serial !== 'N/A' && d.serial !== 'Unknown' ? ` (${d.serial})` : ''}</span>
|
||
</div>
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<span style="font-size: 10px; color: ${modeColor}; font-weight: bold;">${modeName}</span>
|
||
<span style="font-size: 9px; padding: 1px 4px; background: var(--bg-tertiary); border-radius: 3px; color: #888;">${sdrType}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function fetchSdrStatus() {
|
||
// Avoid probing SDR inventory while HackRF SubGHz mode is active.
|
||
// Device discovery runs hackrf_info and can disrupt active HackRF streams.
|
||
if (typeof currentMode !== 'undefined' && currentMode === 'subghz') {
|
||
return;
|
||
}
|
||
fetch('/devices/status')
|
||
.then(r => r.json())
|
||
.then(devices => {
|
||
renderSdrStatus(devices);
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to fetch SDR status:', err);
|
||
const container = document.getElementById('sdrStatusList');
|
||
if (container) {
|
||
container.innerHTML = '<div style="padding: 8px; color: #ff6666; font-size: 11px; text-align: center;">Error loading status</div>';
|
||
}
|
||
});
|
||
}
|
||
|
||
function startSdrStatusPolling() {
|
||
// Initial fetch
|
||
fetchSdrStatus();
|
||
// Poll every 5 seconds
|
||
sdrStatusPollingInterval = setInterval(fetchSdrStatus, 5000);
|
||
}
|
||
|
||
function stopSdrStatusPolling() {
|
||
if (sdrStatusPollingInterval) {
|
||
clearInterval(sdrStatusPollingInterval);
|
||
sdrStatusPollingInterval = null;
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// --- Pager Signal Scope ---
|
||
let pagerScopeCtx = null;
|
||
let pagerScopeAnim = null;
|
||
let pagerScopeHistory = [];
|
||
let pagerScopeWaveBuffer = [];
|
||
let pagerScopeDisplayWave = [];
|
||
const SCOPE_HISTORY_LEN = 200;
|
||
const SCOPE_WAVE_BUFFER_LEN = 2048;
|
||
const SCOPE_WAVE_INPUT_SMOOTH_ALPHA = 0.55;
|
||
const SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA = 0.22;
|
||
const SCOPE_WAVE_IDLE_DECAY = 0.96;
|
||
let pagerScopeRms = 0;
|
||
let pagerScopePeak = 0;
|
||
let pagerScopeTargetRms = 0;
|
||
let pagerScopeTargetPeak = 0;
|
||
let pagerScopeMsgBurst = 0;
|
||
let pagerScopeLastWaveAt = 0;
|
||
let pagerScopeLastInputSample = 0;
|
||
|
||
function resizePagerScopeCanvas(canvas) {
|
||
if (!canvas) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const width = Math.max(1, Math.floor(rect.width * dpr));
|
||
const height = Math.max(1, Math.floor(rect.height * dpr));
|
||
if (canvas.width !== width || canvas.height !== height) {
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
}
|
||
}
|
||
|
||
function applyPagerScopeData(scopeData) {
|
||
if (!scopeData || typeof scopeData !== 'object') return;
|
||
|
||
pagerScopeTargetRms = Number(scopeData.rms) || 0;
|
||
pagerScopeTargetPeak = Number(scopeData.peak) || 0;
|
||
|
||
if (Array.isArray(scopeData.waveform) && scopeData.waveform.length) {
|
||
for (const packedSample of scopeData.waveform) {
|
||
const sample = Number(packedSample);
|
||
if (!Number.isFinite(sample)) continue;
|
||
const normalized = Math.max(-127, Math.min(127, sample)) / 127;
|
||
pagerScopeLastInputSample += (normalized - pagerScopeLastInputSample) * SCOPE_WAVE_INPUT_SMOOTH_ALPHA;
|
||
pagerScopeWaveBuffer.push(pagerScopeLastInputSample);
|
||
}
|
||
if (pagerScopeWaveBuffer.length > SCOPE_WAVE_BUFFER_LEN) {
|
||
pagerScopeWaveBuffer.splice(0, pagerScopeWaveBuffer.length - SCOPE_WAVE_BUFFER_LEN);
|
||
}
|
||
pagerScopeLastWaveAt = performance.now();
|
||
}
|
||
}
|
||
|
||
function initPagerScope() {
|
||
const canvas = document.getElementById('pagerScopeCanvas');
|
||
if (!canvas) return;
|
||
|
||
if (pagerScopeAnim) {
|
||
cancelAnimationFrame(pagerScopeAnim);
|
||
pagerScopeAnim = null;
|
||
}
|
||
|
||
resizePagerScopeCanvas(canvas);
|
||
pagerScopeCtx = canvas.getContext('2d');
|
||
pagerScopeHistory = new Array(SCOPE_HISTORY_LEN).fill(0);
|
||
pagerScopeWaveBuffer = [];
|
||
pagerScopeDisplayWave = [];
|
||
pagerScopeRms = 0;
|
||
pagerScopePeak = 0;
|
||
pagerScopeTargetRms = 0;
|
||
pagerScopeTargetPeak = 0;
|
||
pagerScopeMsgBurst = 0;
|
||
pagerScopeLastWaveAt = 0;
|
||
pagerScopeLastInputSample = 0;
|
||
drawPagerScope();
|
||
}
|
||
|
||
function drawPagerScope() {
|
||
const ctx = pagerScopeCtx;
|
||
if (!ctx) return;
|
||
|
||
resizePagerScopeCanvas(ctx.canvas);
|
||
const W = ctx.canvas.width;
|
||
const H = ctx.canvas.height;
|
||
const midY = H / 2;
|
||
|
||
// Phosphor persistence
|
||
ctx.fillStyle = 'rgba(5, 5, 16, 0.26)';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Smooth towards target values
|
||
pagerScopeRms += (pagerScopeTargetRms - pagerScopeRms) * 0.25;
|
||
pagerScopePeak += (pagerScopeTargetPeak - pagerScopePeak) * 0.15;
|
||
|
||
// Keep a slow amplitude envelope for readability
|
||
pagerScopeHistory.push(Math.min(pagerScopeRms / 32768, 1.0));
|
||
if (pagerScopeHistory.length > SCOPE_HISTORY_LEN) {
|
||
pagerScopeHistory.shift();
|
||
}
|
||
|
||
// Grid lines (horizontal + vertical)
|
||
ctx.strokeStyle = 'rgba(40, 40, 80, 0.4)';
|
||
ctx.lineWidth = 0.8;
|
||
for (let i = 1; i < 8; i++) {
|
||
const gx = (W / 8) * i;
|
||
ctx.beginPath();
|
||
ctx.moveTo(gx, 0);
|
||
ctx.lineTo(gx, H);
|
||
ctx.stroke();
|
||
}
|
||
for (let g = 0.25; g < 1; g += 0.25) {
|
||
const gy = midY - g * midY;
|
||
const gy2 = midY + g * midY;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, gy); ctx.lineTo(W, gy);
|
||
ctx.moveTo(0, gy2); ctx.lineTo(W, gy2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// Center baseline
|
||
ctx.strokeStyle = 'rgba(60, 60, 100, 0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, midY);
|
||
ctx.lineTo(W, midY);
|
||
ctx.stroke();
|
||
|
||
// Slow envelope as context around baseline
|
||
const envStepX = W / (SCOPE_HISTORY_LEN - 1);
|
||
ctx.strokeStyle = 'rgba(90, 180, 255, 0.45)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||
const y = midY - amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
for (let i = 0; i < pagerScopeHistory.length; i++) {
|
||
const x = i * envStepX;
|
||
const amp = pagerScopeHistory[i] * midY * 0.85;
|
||
const y = midY + amp;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Actual waveform from real incoming audio samples
|
||
const waveformPointCount = Math.min(Math.max(120, Math.floor(W / 3.2)), 420);
|
||
if (pagerScopeWaveBuffer.length > 1) {
|
||
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||
const sourceLen = pagerScopeWaveBuffer.length;
|
||
const sourceWindow = Math.min(sourceLen, 1536);
|
||
const sourceStart = sourceLen - sourceWindow;
|
||
|
||
if (pagerScopeDisplayWave.length !== waveformPointCount) {
|
||
pagerScopeDisplayWave = new Array(waveformPointCount).fill(0);
|
||
}
|
||
|
||
for (let i = 0; i < waveformPointCount; i++) {
|
||
const a = sourceStart + Math.floor((i / waveformPointCount) * sourceWindow);
|
||
const b = sourceStart + Math.floor(((i + 1) / waveformPointCount) * sourceWindow);
|
||
const start = Math.max(sourceStart, Math.min(sourceLen - 1, a));
|
||
const end = Math.max(start + 1, Math.min(sourceLen, b));
|
||
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let j = start; j < end; j++) {
|
||
sum += pagerScopeWaveBuffer[j];
|
||
count++;
|
||
}
|
||
const targetSample = count > 0 ? (sum / count) : 0;
|
||
pagerScopeDisplayWave[i] += (targetSample - pagerScopeDisplayWave[i]) * SCOPE_WAVE_DISPLAY_SMOOTH_ALPHA;
|
||
}
|
||
|
||
ctx.strokeStyle = waveIsFresh ? '#2efbff' : 'rgba(46, 251, 255, 0.45)';
|
||
ctx.lineWidth = 1.7;
|
||
ctx.shadowColor = '#2efbff';
|
||
ctx.shadowBlur = waveIsFresh ? 6 : 2;
|
||
|
||
const stepX = waveformPointCount > 1 ? (W / (waveformPointCount - 1)) : W;
|
||
ctx.beginPath();
|
||
const firstY = midY - (pagerScopeDisplayWave[0] * midY * 0.9);
|
||
ctx.moveTo(0, firstY);
|
||
for (let i = 1; i < waveformPointCount - 1; i++) {
|
||
const x = i * stepX;
|
||
const y = midY - (pagerScopeDisplayWave[i] * midY * 0.9);
|
||
const nx = (i + 1) * stepX;
|
||
const ny = midY - (pagerScopeDisplayWave[i + 1] * midY * 0.9);
|
||
const cx = (x + nx) / 2;
|
||
const cy = (y + ny) / 2;
|
||
ctx.quadraticCurveTo(x, y, cx, cy);
|
||
}
|
||
const lastX = (waveformPointCount - 1) * stepX;
|
||
const lastY = midY - (pagerScopeDisplayWave[waveformPointCount - 1] * midY * 0.9);
|
||
ctx.lineTo(lastX, lastY);
|
||
ctx.stroke();
|
||
|
||
if (!waveIsFresh) {
|
||
for (let i = 0; i < pagerScopeDisplayWave.length; i++) {
|
||
pagerScopeDisplayWave[i] *= SCOPE_WAVE_IDLE_DECAY;
|
||
}
|
||
}
|
||
}
|
||
ctx.shadowBlur = 0;
|
||
|
||
// Peak indicator (dashed red line)
|
||
const peakNorm = Math.min(pagerScopePeak / 32768, 1.0);
|
||
if (peakNorm > 0.01) {
|
||
const peakY = midY - peakNorm * midY * 0.9;
|
||
ctx.strokeStyle = 'rgba(255, 68, 68, 0.6)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, peakY);
|
||
ctx.lineTo(W, peakY);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
// Message decode flash (green overlay)
|
||
if (pagerScopeMsgBurst > 0.01) {
|
||
ctx.fillStyle = `rgba(0, 255, 100, ${pagerScopeMsgBurst * 0.15})`;
|
||
ctx.fillRect(0, 0, W, H);
|
||
pagerScopeMsgBurst *= 0.88;
|
||
}
|
||
|
||
// Update labels
|
||
const rmsLabel = document.getElementById('scopeRmsLabel');
|
||
const peakLabel = document.getElementById('scopePeakLabel');
|
||
const statusLabel = document.getElementById('scopeStatusLabel');
|
||
if (rmsLabel) rmsLabel.textContent = Math.round(pagerScopeRms);
|
||
if (peakLabel) peakLabel.textContent = Math.round(pagerScopePeak);
|
||
if (statusLabel) {
|
||
const waveIsFresh = (performance.now() - pagerScopeLastWaveAt) < 700;
|
||
if (pagerScopeRms > 1300 && waveIsFresh) {
|
||
statusLabel.textContent = 'DEMODULATING';
|
||
statusLabel.style.color = '#00ff88';
|
||
} else if (pagerScopeRms > 500) {
|
||
statusLabel.textContent = 'CARRIER';
|
||
statusLabel.style.color = '#2efbff';
|
||
} else {
|
||
statusLabel.textContent = 'QUIET';
|
||
statusLabel.style.color = '#555';
|
||
}
|
||
}
|
||
|
||
pagerScopeAnim = requestAnimationFrame(drawPagerScope);
|
||
}
|
||
|
||
function stopPagerScope() {
|
||
if (pagerScopeAnim) {
|
||
cancelAnimationFrame(pagerScopeAnim);
|
||
pagerScopeAnim = null;
|
||
}
|
||
pagerScopeCtx = null;
|
||
pagerScopeWaveBuffer = [];
|
||
pagerScopeDisplayWave = [];
|
||
pagerScopeHistory = [];
|
||
pagerScopeLastWaveAt = 0;
|
||
pagerScopeLastInputSample = 0;
|
||
}
|
||
|
||
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';
|
||
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
|
||
|
||
if (!isAgentMode) {
|
||
releaseDevice('pager');
|
||
}
|
||
setRunning(false);
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (pagerPollTimer) {
|
||
clearInterval(pagerPollTimer);
|
||
pagerPollTimer = null;
|
||
}
|
||
|
||
return postStopRequest(endpoint, timeoutMs);
|
||
}
|
||
|
||
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('All processes stopped' + (data.processes.length ? ` (${data.processes.length} killed)` : ' (none were 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';
|
||
|
||
// Signal scope
|
||
const scopePanel = document.getElementById('pagerScopePanel');
|
||
if (scopePanel) {
|
||
if (running) {
|
||
scopePanel.style.display = 'block';
|
||
initPagerScope();
|
||
} else {
|
||
stopPagerScope();
|
||
scopePanel.style.display = '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 (payload.type === 'scope') {
|
||
applyPagerScopeData(payload);
|
||
}
|
||
} 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);
|
||
} else if (data.type === 'scope') {
|
||
applyPagerScopeData(data);
|
||
}
|
||
}
|
||
};
|
||
|
||
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();
|
||
|
||
// Flash signal scope green on decode
|
||
pagerScopeMsgBurst = 1.0;
|
||
|
||
// 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, '&');
|
||
s = s.replace(/'/g, ''');
|
||
s = s.replace(/"/g, '"');
|
||
s = s.replace(/</g, '<');
|
||
s = s.replace(/>/g, '>');
|
||
return s;
|
||
}
|
||
|
||
function isValidMac(mac) {
|
||
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
|
||
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
|
||
}
|
||
|
||
function isValidChannel(ch) {
|
||
// Validate WiFi channel (1-200 covers all bands)
|
||
const num = parseInt(ch, 10);
|
||
return !isNaN(num) && num >= 1 && num <= 200;
|
||
}
|
||
|
||
function showInfo(text) {
|
||
const output = document.getElementById('output');
|
||
|
||
// Clear placeholder only (has the 'placeholder' class)
|
||
const placeholder = output.querySelector('.placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
const infoEl = document.createElement('div');
|
||
infoEl.className = 'info-msg';
|
||
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "Roboto Condensed", "Arial Narrow", sans-serif; 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: "Roboto Condensed", "Arial Narrow", sans-serif; 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 btDeviceMap = getBluetoothDevicesSnapshot();
|
||
const btMacs = Object.keys(btDeviceMap);
|
||
|
||
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 = btDeviceMap[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 getBluetoothDevicesSnapshot() {
|
||
const snapshot = {};
|
||
if (typeof BluetoothMode === 'undefined' || typeof BluetoothMode.getDevices !== 'function') {
|
||
return snapshot;
|
||
}
|
||
|
||
const devices = BluetoothMode.getDevices();
|
||
devices.forEach(device => {
|
||
const address = String(device.address || device.mac || '').toUpperCase();
|
||
// Correlation fallback is OUI-based, so only include MAC-form addresses.
|
||
if (!/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/.test(address)) return;
|
||
snapshot[address] = device;
|
||
});
|
||
|
||
return snapshot;
|
||
}
|
||
|
||
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>';
|
||
|
||
// Check if we're in agent mode
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
if (isAgentMode) {
|
||
// Fetch from agent via controller
|
||
fetch(`/controller/agents/${currentAgent}?refresh=true`)
|
||
.then(r => {
|
||
if (!r.ok) throw new Error('Failed to fetch agent interfaces');
|
||
return r.json();
|
||
})
|
||
.then(data => {
|
||
const interfaces = data.agent?.interfaces?.wifi_interfaces || [];
|
||
if (interfaces.length === 0) {
|
||
select.innerHTML = '<option value="">No WiFi interfaces on agent</option>';
|
||
showNotification('WiFi', 'No WiFi interfaces found on remote agent.');
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
} else {
|
||
select.innerHTML = interfaces.map(i => {
|
||
let label = i.name || i;
|
||
if (i.display_name) label = i.display_name;
|
||
else if (i.type) label += ` (${i.type})`;
|
||
if (i.monitor_capable) label += ' [Monitor OK]';
|
||
return `<option value="${i.name || i}" data-type="${i.type || 'managed'}">${label}</option>`;
|
||
}).join('');
|
||
showNotification('WiFi', `Found ${interfaces.length} interface(s) on agent`);
|
||
|
||
// Check if any interface is already in monitor mode
|
||
const monitorIface = interfaces.find(i => i.type === 'monitor');
|
||
if (monitorIface) {
|
||
monitorInterface = monitorIface.name;
|
||
updateMonitorStatus(true);
|
||
select.value = monitorIface.name;
|
||
} else {
|
||
monitorInterface = null;
|
||
updateMonitorStatus(false);
|
||
}
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Failed to refresh agent interfaces:', err);
|
||
select.innerHTML = '<option value="">Error loading agent interfaces</option>';
|
||
showNotification('WiFi', 'Failed to load agent interfaces');
|
||
});
|
||
return;
|
||
}
|
||
|
||
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;
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
// Show loading state
|
||
const btn = document.getElementById('monitorStartBtn');
|
||
const originalText = btn.textContent;
|
||
btn.textContent = 'Enabling...';
|
||
btn.disabled = true;
|
||
|
||
// Use agent endpoint if in agent mode
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/wifi/monitor`
|
||
: '/wifi/monitor';
|
||
|
||
fetch(endpoint, {
|
||
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);
|
||
const location = isAgentMode ? ' on remote agent' : '';
|
||
showInfo('Monitor mode enabled on ' + monitorInterface + location + ' - Ready to scan!');
|
||
|
||
// Refresh interface list and auto-select the monitor interface
|
||
refreshWifiInterfaces();
|
||
} else {
|
||
alert('Error: ' + (data.message || 'Unknown error'));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
btn.textContent = originalText;
|
||
btn.disabled = false;
|
||
alert('Error: ' + err.message);
|
||
});
|
||
}
|
||
|
||
// Disable monitor mode
|
||
function disableMonitorMode() {
|
||
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
|
||
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
|
||
|
||
const endpoint = isAgentMode
|
||
? `/controller/agents/${currentAgent}/wifi/monitor`
|
||
: '/wifi/monitor';
|
||
|
||
fetch(endpoint, {
|
||
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 || 'Unknown error'));
|
||
}
|
||
});
|
||
}
|
||
|
||
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>';
|
||
}
|
||
|
||
function getWifiChannelPresetList(preset) {
|
||
switch (preset) {
|
||
case '2.4-common':
|
||
return '1,6,11';
|
||
case '2.4-all':
|
||
return '1,2,3,4,5,6,7,8,9,10,11,12,13';
|
||
case '5-low':
|
||
return '36,40,44,48';
|
||
case '5-mid':
|
||
return '52,56,60,64';
|
||
case '5-high':
|
||
return '149,153,157,161,165';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function buildWifiChannelConfig() {
|
||
const preset = document.getElementById('wifiChannelPreset')?.value || '';
|
||
const listInput = document.getElementById('wifiChannelList')?.value || '';
|
||
const singleInput = document.getElementById('wifiChannel')?.value || '';
|
||
|
||
const listValue = listInput.trim();
|
||
const presetValue = getWifiChannelPresetList(preset);
|
||
const channels = listValue || presetValue || '';
|
||
const channel = channels ? null : (singleInput.trim() ? parseInt(singleInput.trim()) : null);
|
||
|
||
return {
|
||
channels: channels || null,
|
||
channel: Number.isFinite(channel) ? channel : null,
|
||
};
|
||
}
|
||
|
||
// Start WiFi scan - auto-enables monitor mode if needed
|
||
async function startWifiScan() {
|
||
console.log('startWifiScan called');
|
||
const band = document.getElementById('wifiBand').value;
|
||
const channelConfig = buildWifiChannelConfig();
|
||
|
||
// 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: channelConfig.channel,
|
||
channels: channelConfig.channels,
|
||
})
|
||
});
|
||
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() {
|
||
setWifiRunning(false);
|
||
if (wifiEventSource) {
|
||
wifiEventSource.close();
|
||
wifiEventSource = null;
|
||
}
|
||
|
||
if (typeof WiFiMode !== 'undefined' && typeof WiFiMode.stopScan === 'function') {
|
||
return Promise.resolve(WiFiMode.stopScan()).catch((err) => {
|
||
console.warn('[WiFi] stop via WiFiMode failed:', err);
|
||
});
|
||
}
|
||
|
||
return postStopRequest('/wifi/scan/stop', LOCAL_STOP_TIMEOUT_MS);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
name" href=" |