Files
intercept/templates/index.html
James Smith dc84e933c1 Fix setup.sh hanging on Python 3.14/macOS and add satellite enhancements
- Add --no-cache-dir and --timeout 120 to all pip calls to prevent hanging
  on corrupt/stale pip HTTP cache (cachecontrol .pyc issue)
- Replace silent python -c import verification with pip show to avoid
  import-time side effects hanging the installer
- Switch optional packages to --only-binary :all: to skip source compilation
  on Python versions without pre-built wheels (prevents gevent/numpy hangs)
- Warn early when Python 3.13+ is detected that some packages may be skipped
- Add ground track caching with 30-minute TTL to satellite route
- Add live satellite position tracker background thread via SSE fanout
- Add satellite_predict, satellite_telemetry, and satnogs utilities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:09:00 +00:00

16390 lines
846 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" 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">
<!-- Preconnect hints for CDN domains -->
{% if offline_settings.assets_source != 'local' %}
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
{% endif %}
{% if offline_settings.fonts_source != 'local' %}
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
{% endif %}
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
<!-- 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>
<!-- 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 CSS -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
{% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
{% endif %}
<!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.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/components/signal-waveform.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/waterfall.css') }}?v={{ version }}&r=wfdeck19">
<!-- Deferred scripts - Leaflet, Chart.js, observer-location -->
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
{% else %}
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script defer src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
{% endif %}
{% if offline_settings.assets_source == 'local' %}
<script defer src="{{ url_for('static', filename='vendor/chartjs/chart.umd.min.js') }}"></script>
{% else %}
<script defer src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
{% endif %}
<script defer src="{{ url_for('static', filename='vendor/chartjs/chartjs-adapter-date-fns.bundle.min.js') }}"></script>
<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",
wifi_locate: "{{ url_for('static', filename='css/modes/wifi_locate.css') }}?v={{ version }}&r=wflocate1",
spaceweather: "{{ url_for('static', filename='css/modes/space-weather.css') }}",
wefax: "{{ url_for('static', filename='css/modes/wefax.css') }}",
morse: "{{ url_for('static', filename='css/modes/morse.css') }}",
radiosonde: "{{ url_for('static', filename='css/modes/radiosonde.css') }}",
meteor: "{{ url_for('static', filename='css/modes/meteor.css') }}",
system: "{{ url_for('static', filename='css/modes/system.css') }}",
ook: "{{ url_for('static', filename='css/modes/ook.css') }}"
};
window.INTERCEPT_MODE_STYLE_LOADED = {};
window.INTERCEPT_MODE_STYLE_PROMISES = {};
window.ensureModeStyles = function(mode) {
const href = window.INTERCEPT_MODE_STYLE_MAP ? window.INTERCEPT_MODE_STYLE_MAP[mode] : null;
if (!href) return Promise.resolve();
if (window.INTERCEPT_MODE_STYLE_LOADED[href] === 'loaded') {
return Promise.resolve();
}
if (window.INTERCEPT_MODE_STYLE_PROMISES[href]) {
return window.INTERCEPT_MODE_STYLE_PROMISES[href];
}
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 && existing.sheet) {
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
return Promise.resolve();
}
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loading';
const link = existing || document.createElement('link');
if (!existing) {
link.rel = 'stylesheet';
link.href = href;
link.dataset.modeStyle = mode;
}
const promise = new Promise((resolve, reject) => {
const onLoad = () => {
window.INTERCEPT_MODE_STYLE_LOADED[href] = 'loaded';
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
resolve();
};
const onError = () => {
delete window.INTERCEPT_MODE_STYLE_LOADED[href];
delete window.INTERCEPT_MODE_STYLE_PROMISES[href];
try {
link.remove();
} catch (_) {}
reject(new Error(`failed to load mode stylesheet: ${mode}`));
};
link.addEventListener('load', onLoad, { once: true });
link.addEventListener('error', onError, { once: true });
if (existing) {
// Existing links may have finished loading before listeners attached.
if (existing.sheet) onLoad();
} else {
document.head.appendChild(link);
}
});
window.INTERCEPT_MODE_STYLE_PROMISES[href] = promise;
return promise;
};
// Start loading a deep-linked mode stylesheet as early as possible.
(function preloadQueryModeStyles() {
const queryMode = new URLSearchParams(window.location.search).get('mode');
const mode = queryMode === 'listening' ? 'waterfall' : queryMode;
if (!mode) return;
window.ensureModeStyles(mode).catch(() => {});
})();
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
(function warmModeStylesInBackground() {
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
const queryMode = new URLSearchParams(window.location.search).get('mode');
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
if (!modes.length) return;
const warm = function () {
modes.forEach(function (mode, index) {
setTimeout(function () {
window.ensureModeStyles(mode).catch(() => {});
}, index * 40);
});
};
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(warm, { timeout: 2000 });
} else {
setTimeout(warm, 600);
}
})();
</script>
<script>
window.INTERCEPT_MODE_SCRIPT_MAP = {
bluetooth: "{{ url_for('static', filename='js/modes/bluetooth.js') }}?v={{ version }}&r=btlocate2",
wifi: "{{ url_for('static', filename='js/modes/wifi.js') }}",
spystations: "{{ url_for('static', filename='js/modes/spy-stations.js') }}",
meshtastic: "{{ url_for('static', filename='js/modes/meshtastic.js') }}",
sstv: "{{ url_for('static', filename='js/modes/sstv.js') }}",
weathersat: "{{ url_for('static', filename='js/modes/weather-satellite.js') }}",
sstv_general: "{{ url_for('static', filename='js/modes/sstv-general.js') }}",
gps: "{{ url_for('static', filename='js/modes/gps.js') }}",
websdr: "{{ url_for('static', filename='js/modes/websdr.js') }}",
subghz: "{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9",
bt_locate: "{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4",
wifi_locate: "{{ url_for('static', filename='js/modes/wifi_locate.js') }}?v={{ version }}&r=wflocate1",
wefax: "{{ url_for('static', filename='js/modes/wefax.js') }}",
morse: "{{ url_for('static', filename='js/modes/morse.js') }}?v={{ version }}&r=morse_iq12",
ook: "{{ url_for('static', filename='js/modes/ook.js') }}?v={{ version }}&r=ook2",
spaceweather: "{{ url_for('static', filename='js/modes/space-weather.js') }}",
system: "{{ url_for('static', filename='js/modes/system.js') }}",
meteor: "{{ url_for('static', filename='js/modes/meteor.js') }}",
waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21"
};
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
window.ensureModeScript = function(mode) {
var src = window.INTERCEPT_MODE_SCRIPT_MAP ? window.INTERCEPT_MODE_SCRIPT_MAP[mode] : null;
if (!src) return Promise.resolve();
if (window.INTERCEPT_MODE_SCRIPT_LOADED[src]) return Promise.resolve();
if (window.INTERCEPT_MODE_SCRIPT_PROMISES[src]) return window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
var promise = new Promise(function(resolve, reject) {
var script = document.createElement('script');
script.src = src;
script.dataset.modeScript = mode;
script.onload = function() {
window.INTERCEPT_MODE_SCRIPT_LOADED[src] = true;
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
resolve();
};
script.onerror = function() {
delete window.INTERCEPT_MODE_SCRIPT_PROMISES[src];
reject(new Error('failed to load mode script: ' + mode));
};
(document.body || document.head).appendChild(script);
});
window.INTERCEPT_MODE_SCRIPT_PROMISES[src] = promise;
return promise;
};
// Preload script for deep-linked mode
(function preloadQueryModeScript() {
var queryMode = new URLSearchParams(window.location.search).get('mode');
var mode = queryMode === 'listening' ? 'waterfall' : queryMode;
if (!mode) return;
window.ensureModeScript(mode).catch(function() {});
})();
</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="60" height="60" 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>
<h1 class="welcome-title"><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT</h1>
<p class="welcome-tagline">// See the Invisible</p>
<span class="welcome-version">v{{ version }}</span>
<button type="button" class="welcome-settings-btn" onclick="showSettings()" title="Settings" aria-label="Open settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</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>
<button class="mode-card mode-card-sm" onclick="selectMode('morse')">
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="12" x2="5" y2="12"/><line x1="7" y1="12" x2="13" y2="12"/><line x1="15" y1="12" x2="18" y2="12"/><line x1="20" y1="12" x2="22" y2="12"/></svg></span>
<span class="mode-name">Morse</span>
</button>
<button class="mode-card mode-card-sm" onclick="selectMode('ook')">
<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 12h3"/><path d="M19 12h3"/><rect x="5" y="8" width="4" height="8" rx="1"/><rect x="10" y="9" width="4" height="6" rx="1"/><rect x="15" y="7" width="4" height="10" rx="1"/></svg></span>
<span class="mode-name">OOK Decoder</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>
<button class="mode-card mode-card-sm" onclick="selectMode('radiosonde')">
<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="M12 2v6"/><circle cx="12" cy="12" r="4"/><path d="M12 16v6"/><path d="M4.93 4.93l4.24 4.24"/><path d="M14.83 14.83l4.24 4.24"/></svg></span>
<span class="mode-name">Radiosonde</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('wefax')">
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
<span class="mode-name">WeFax</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('wifi_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"><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" stroke="none"/><circle cx="12" cy="10" r="2"/><path d="M12 14v-2"/></svg></span>
<span class="mode-name">WF 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>
<a href="https://www.smittix.net" target="_blank" rel="noopener noreferrer" class="welcome-footer-credit">By Smittix</a>
</div>
</div>
<div class="welcome-scanline"></div>
</div>
<!-- Disclaimer Modal -->
<div class="disclaimer-overlay" id="disclaimerModal" style="display: none;">
<div class="disclaimer-modal">
<div class="warning-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>
<h2>DISCLAIMER</h2>
<p>
<strong>iNTERCEPT</strong> is a signal intelligence tool designed for <strong>educational purposes
only</strong>.
</p>
<p>By using this software, you acknowledge and agree that:</p>
<ul>
<li>This tool is intended for use by <strong>cyber security professionals</strong> and researchers only
</li>
<li>You will only use this software in a <strong>controlled environment</strong> with proper
authorization</li>
<li>Intercepting communications without consent may be <strong>illegal</strong> in your jurisdiction
</li>
<li>You are solely responsible for ensuring compliance with all applicable laws and regulations</li>
<li>The developers assume no liability for misuse of this software</li>
</ul>
<p style="color: var(--accent-red); font-weight: bold;">
Only proceed if you understand and accept these terms.
</p>
<div style="display: flex; gap: 15px; justify-content: center; margin-top: 20px;">
<button class="accept-btn" onclick="acceptDisclaimer()">I UNDERSTAND & ACCEPT</button>
<button class="accept-btn" onclick="declineDisclaimer()"
style="background: transparent; border: 1px solid var(--accent-red); color: var(--accent-red);">DECLINE</button>
</div>
</div>
</div>
<!-- TSCM Device Details Modal -->
<div class="tscm-modal-overlay" id="tscmDeviceModal" style="display: none;"
onclick="if(event.target === this) closeTscmDeviceModal()">
<div class="tscm-modal">
<button class="tscm-modal-close" onclick="closeTscmDeviceModal()">&times;</button>
<div id="tscmDeviceModalContent"></div>
</div>
</div>
<!-- Rejection Page -->
<div class="disclaimer-overlay disclaimer-hidden" id="rejectionPage">
<div class="disclaimer-modal" style="max-width: 600px;">
<pre
style="color: var(--accent-red); font-size: 9px; line-height: 1.1; margin-bottom: 20px; text-align: center;">
█████╗ ██████╗ ██████╗███████╗███████╗███████╗
██╔══██╗██╔════╝██╔════╝██╔════╝██╔════╝██╔════╝
███████║██║ ██║ █████╗ ███████╗███████╗
██╔══██║██║ ██║ ██╔══╝ ╚════██║╚════██║
██║ ██║╚██████╗╚██████╗███████╗███████║███████║
╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚══════╝╚══════╝
██████╗ ███████╗███╗ ██╗██╗███████╗██████╗
██╔══██╗██╔════╝████╗ ██║██║██╔════╝██╔══██╗
██║ ██║█████╗ ██╔██╗ ██║██║█████╗ ██║ ██║
██║ ██║██╔══╝ ██║╚██╗██║██║██╔══╝ ██║ ██║
██████╔╝███████╗██║ ╚████║██║███████╗██████╔╝
╚═════╝ ╚══════╝╚═╝ ╚═══╝╚═╝╚══════╝╚═════╝</pre>
<div style="margin: 25px 0; padding: 15px; background: #0a0a0a; border-left: 3px solid var(--accent-red);">
<p
style="font-family: 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>
<div class="header-left">
<!-- 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><span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT <span class="tagline">// See the Invisible</span></h1>
</div>
<div class="header-right">
<span class="active-mode-indicator" id="activeModeIndicator"><span class="pulse-dot"></span>PAGER</span>
<span class="version-badge">v{{ version }}</span>
</div>
</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">
<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">
<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/wefax.html' %}
{% include 'partials/modes/morse.html' %}
{% include 'partials/modes/ook.html' %}
{% include 'partials/modes/space-weather.html' %}
{% include 'partials/modes/tscm.html' %}
{% include 'partials/modes/ais.html' %}
{% include 'partials/modes/radiosonde.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/wifi_locate.html' %}
{% include 'partials/modes/waterfall.html' %}
{% include 'partials/modes/meteor.html' %}
{% include 'partials/modes/system.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">
<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">
<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">
<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">
<!-- 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="wfl-locate-btn" onclick="(function(){ var p={bssid: document.getElementById('wifiDetailBssid')?.textContent, ssid: document.getElementById('wifiDetailEssid')?.textContent}; if(typeof WiFiLocate!=='undefined'){WiFiLocate.handoff(p);return;} if(typeof switchMode==='function'){switchMode('wifi_locate').then(function(){if(typeof WiFiLocate!=='undefined')WiFiLocate.handoff(p);});} })()" title="Locate this AP">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
Locate
</button>
<button class="wifi-detail-close" onclick="WiFiMode.closeDetail()">&times;</button>
</div>
<div class="wifi-detail-content" id="wifiDetailContent">
<div class="wifi-detail-grid">
<div class="wifi-detail-stat">
<span class="label">Signal</span>
<span class="value" id="wifiDetailRssi">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Channel</span>
<span class="value" id="wifiDetailChannel">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Band</span>
<span class="value" id="wifiDetailBand">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Security</span>
<span class="value" id="wifiDetailSecurity">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Cipher</span>
<span class="value" id="wifiDetailCipher">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Vendor</span>
<span class="value" id="wifiDetailVendor">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">Clients</span>
<span class="value" id="wifiDetailClients">--</span>
</div>
<div class="wifi-detail-stat">
<span class="label">First Seen</span>
<span class="value" id="wifiDetailFirstSeen">--</span>
</div>
</div>
<div class="wifi-detail-clients" id="wifiDetailClientList" style="display: none;">
<h6>Connected Clients <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">
<!-- 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">&#9656;</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()">&times;</button>
</div>
<div class="bt-modal-body" id="btModalBody">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<!-- APRS Visualizations -->
<div id="aprsVisuals" style="display: none; flex-direction: column; gap: 10px; flex: 1; padding: 10px; 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 Globe -->
<div class="gps-skyview-panel">
<h4>Satellite Globe View</h4>
<div class="gps-skyview-canvas-wrap" id="gpsSkyViewWrap">
<div id="gpsSkyGlobe" class="gps-sky-globe" aria-label="GPS satellite globe"></div>
<canvas id="gpsSkyCanvas" width="400" height="400" aria-label="GPS satellite sky fallback globe"></canvas>
<div class="gps-sky-overlay" id="gpsSkyOverlay" aria-hidden="true"></div>
</div>
<div class="gps-sky-hint">Drag to orbit globe | Scroll to zoom | Hover satellites for details</div>
<div class="gps-legend">
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:#ffffff;"></span> Observer</div>
<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 (bright)</div>
<div class="gps-legend-item"><span class="gps-legend-dot" style="background:rgba(0,212,255,0.45);"></span> Unused (dim)</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">&#9656;</span>
<span class="subghz-phase-step" id="subghzPhaseListening">LISTENING</span>
<span class="subghz-phase-arrow">&#9656;</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">&#9660;</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 &mdash; 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">&#9664; 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">&#9664; 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">&#9664; 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">&#9664; 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">&nbsp;</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">&nbsp;</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">&nbsp;</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">&middot;</span>
<span class="btl-hud-info-item" id="btLocateEnvInfo">--</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateConfidenceInfo">Confidence: --</span>
<span class="btl-hud-info-sep">&middot;</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>
<!-- WiFi Locate Dashboard -->
<div id="wflVisuals" class="wfl-visuals-container" style="display: none;">
<div class="wfl-hud" id="wflHud" style="display: none;">
<div class="wfl-hud-header">
<div class="wfl-hud-target">
<span class="wfl-target-ssid" id="wflTargetSsid">--</span>
<span class="wfl-target-bssid" id="wflTargetBssid">--</span>
</div>
<label class="wfl-hud-audio-toggle">
<input type="checkbox" id="wflAudioEnable" onchange="WiFiLocate.toggleAudio()"> Audio
</label>
<button class="wfl-hud-stop-btn" onclick="WiFiLocate.stop()">Stop Tracking</button>
</div>
<div class="wfl-rssi-display" id="wflRssiValue">--</div>
<div class="wfl-distance" id="wflDistance">--</div>
<div class="wfl-bar-container" id="wflBarContainer"></div>
<div class="wfl-rssi-chart-container">
<span class="wfl-chart-label">RSSI History</span>
<canvas id="wflRssiChart"></canvas>
</div>
<div class="wfl-stats">
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatCurrent">--</span><span class="wfl-stat-label">Current</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMin">--</span><span class="wfl-stat-label">Min</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatMax">--</span><span class="wfl-stat-label">Max</span></div>
<div class="wfl-stat"><span class="wfl-stat-value" id="wflStatAvg">--</span><span class="wfl-stat-label">Avg</span></div>
</div>
<div class="wfl-signal-lost" id="wflSignalLost" style="display: none;">SIGNAL LOST</div>
</div>
<div class="wfl-waiting" id="wflWaiting">
<p>Enter a target BSSID and click Start Locate</p>
</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">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="listening">LISTENING</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="signal_detected">SIGNAL</span>
<span class="wxsat-phase-arrow">&#x25B8;</span>
<span class="wxsat-phase-step" data-phase="decoding">DECODING</span>
<span class="wxsat-phase-arrow">&#x25B8;</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">&#x25BC;</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>
<!-- WeFax Decoder Dashboard -->
<div id="wefaxVisuals" class="wefax-visuals-container" style="display: none;">
<!-- Stats Strip -->
<div class="wefax-stats-strip">
<div class="wefax-strip-group">
<div class="wefax-strip-status">
<span class="wefax-strip-dot idle" id="wefaxStripDot"></span>
<span class="wefax-strip-status-text" id="wefaxStripStatus">Idle</span>
</div>
<button class="wefax-strip-btn start" id="wefaxStartBtn" onclick="WeFax.start()">Start</button>
<button class="wefax-strip-btn stop" id="wefaxStopBtn" onclick="WeFax.stop()" style="display: none;">Stop</button>
<label class="wefax-schedule-toggle" title="Auto-capture broadcasts">
<input type="checkbox" id="wefaxStripAutoSchedule"
onchange="WeFax.toggleScheduler(this)">
<span>Auto</span>
</label>
</div>
<div class="wefax-strip-divider"></div>
<div class="wefax-strip-group">
<div class="wefax-strip-stat">
<span class="wefax-strip-value accent-amber" id="wefaxStripFreq">---</span>
<span class="wefax-strip-label">KHZ</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripLines">0</span>
<span class="wefax-strip-label">LINES</span>
</div>
<div class="wefax-strip-stat">
<span class="wefax-strip-value" id="wefaxStripImageCount">0</span>
<span class="wefax-strip-label">IMAGES</span>
</div>
</div>
</div>
<!-- Countdown + Timeline -->
<div class="wefax-countdown-bar" id="wefaxCountdownBar" style="display: none;">
<div class="wefax-countdown-next">
<div class="wefax-countdown-boxes" id="wefaxCountdownBoxes">
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdHours">--</span><span class="wefax-cd-unit">HRS</span></div>
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdMins">--</span><span class="wefax-cd-unit">MIN</span></div>
<div class="wefax-countdown-box"><span class="wefax-cd-value" id="wefaxCdSecs">--</span><span class="wefax-cd-unit">SEC</span></div>
</div>
<div class="wefax-countdown-info" id="wefaxCountdownInfo">
<span class="wefax-countdown-content" id="wefaxCountdownContent">--</span>
<span class="wefax-countdown-detail" id="wefaxCountdownDetail">Select a station</span>
</div>
</div>
<div class="wefax-timeline" id="wefaxTimeline">
<div class="wefax-timeline-track" id="wefaxTimelineTrack"></div>
<div class="wefax-timeline-cursor" id="wefaxTimelineCursor"></div>
<div class="wefax-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>
<!-- Audio Waveform Scope -->
<div id="wefaxScopePanel" style="display: none;">
<div style="background: #0a0a0a; border: 1px solid #2e2a1a; 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="wefaxScopeRmsLabel" style="color: #ffaa00; font-variant-numeric: tabular-nums;">0</span></span>
<span>PEAK: <span id="wefaxScopePeakLabel" style="color: #f44; font-variant-numeric: tabular-nums;">0</span></span>
<span id="wefaxScopeStatusLabel" style="color: #444;">IDLE</span>
</div>
</div>
<canvas id="wefaxScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
</div>
</div>
<!-- Schedule Timeline -->
<div class="wefax-schedule-panel">
<div class="wefax-schedule-header">
<span class="wefax-schedule-title">Broadcast Schedule</span>
<span id="wefaxStatusText" style="font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></span>
</div>
<div id="wefaxScheduleTimeline">
<div class="wefax-schedule-empty">Select a station to see broadcast schedule</div>
</div>
</div>
<!-- Main Content: Live Preview + Gallery -->
<div class="wefax-main-row">
<div class="wefax-live-section">
<div class="wefax-live-header">
<div class="wefax-live-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
Live Decode
</div>
</div>
<div class="wefax-live-content" id="wefaxLiveContent">
<div class="wefax-idle-state" id="wefaxIdleState">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<h4>WeFax Decoder</h4>
<p>Select a station and click Start to decode weather fax transmissions</p>
</div>
<img id="wefaxLivePreview" class="wefax-live-preview" style="display: none;" alt="WeFax decode in progress">
</div>
</div>
<div class="wefax-gallery-section">
<div class="wefax-gallery-header">
<div class="wefax-gallery-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="vertical-align: -2px; margin-right: 4px; color: #ffaa00;">
<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 class="wefax-gallery-controls">
<span class="wefax-gallery-count" id="wefaxImageCount">0</span>
<button class="wefax-gallery-clear-btn" onclick="WeFax.deleteAllImages()" title="Delete all images">Clear All</button>
</div>
</div>
<div class="wefax-gallery-grid" id="wefaxGallery">
<div class="wefax-gallery-empty">No images decoded yet</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"></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&#197;</button>
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304&#197;</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>
<!-- Morse Signal Scope -->
<div id="morseScopePanel" 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>CW Tone Detection</span>
<div style="display: flex; gap: 14px;">
<span>TONE: <span id="morseScopeToneLabel" style="color: #0f0; font-variant-numeric: tabular-nums;">--</span></span>
<span>THRESH: <span id="morseScopeThreshLabel" style="color: #fa0; font-variant-numeric: tabular-nums;">--</span></span>
<span id="morseScopeStatusLabel" style="color: #444;">IDLE</span>
</div>
</div>
<canvas id="morseScopeCanvas" style="width: 100%; height: 80px; display: block; border-radius: 3px; background: #050510;"></canvas>
</div>
</div>
<div id="morseDiagLog" style="display: none; margin-bottom: 8px; max-height: 60px; overflow-y: auto;
background: #080812; border: 1px solid #1a1a2e; border-radius: 4px; padding: 4px 8px;
font-family: var(--font-mono); font-size: 10px; color: #556677; line-height: 1.6;">
</div>
<!-- Morse Decoded Output -->
<div id="morseOutputPanel" style="display: none; margin-bottom: 12px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px;">
<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>Decoded Text</span>
<div style="display: flex; gap: 6px;">
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportTxt()">TXT</button>
<button class="btn btn-sm btn-ghost" onclick="MorseMode.exportCsv()">CSV</button>
<button class="btn btn-sm btn-ghost" id="morseCopyBtn" onclick="MorseMode.copyToClipboard()">Copy</button>
<button class="btn btn-sm btn-ghost" onclick="MorseMode.clearText()">Clear</button>
</div>
</div>
<div id="morseDecodedText" class="morse-decoded-panel"></div>
<div id="morseRawPanel" class="morse-raw-panel" style="display: none;">
<div class="morse-raw-label">Raw Elements</div>
<div id="morseRawText" class="morse-raw-text"></div>
</div>
<div id="morseMetricsPanel" class="morse-metrics-panel">
<span id="morseMetricState">STATE idle</span>
<span id="morseMetricTone">TONE -- Hz</span>
<span id="morseMetricLevel">LEVEL --</span>
<span id="morseMetricThreshold">THRESH --</span>
<span id="morseMetricNoise">NOISE --</span>
<span id="morseMetricStopMs">STOP -- ms</span>
</div>
<div class="morse-status-bar">
<span class="status-item" id="morseStatusBarState">IDLE</span>
<span class="status-item" id="morseStatusBarWpm">-- WPM</span>
<span class="status-item" id="morseStatusBarTone">700 Hz</span>
<span class="status-item" id="morseStatusBarChars">0 chars decoded</span>
</div>
</div>
</div>
<!-- Radiosonde Visuals -->
<div id="radiosondeVisuals" class="radiosonde-visuals-container" style="display: none;">
<div class="radiosonde-strip">
<div class="radiosonde-strip-inner">
<div class="strip-status">
<div class="status-dot" id="radiosondeStripDot"></div>
<span id="radiosondeStripStatus">STANDBY</span>
</div>
<div class="strip-divider"></div>
<div class="strip-stat">
<span class="strip-value" id="radiosondeStripBalloons">0</span>
<span class="strip-label">BALLOONS</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="radiosondeStripUpdate">--:--:--</span>
<span class="strip-label">LAST UPDATE</span>
</div>
<div class="strip-divider"></div>
<div class="strip-waveform" id="radiosondeStripWaveform"></div>
</div>
</div>
<div id="radiosondeMapContainer" style="flex: 1; min-height: 300px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--bg-primary);"></div>
<div id="radiosondeCardContainer" class="radiosonde-card-container"></div>
</div>
<!-- Meteor Scatter Visuals -->
<div id="meteorVisuals" class="meteor-visuals-container" style="display: none;">
<div class="ms-headline">
<div class="ms-headline-left">
<span id="meteorStatusChip" class="ms-headline-tag idle">IDLE</span>
<span class="ms-headline-sub" id="meteorFreqLabel">143.050 MHz</span>
</div>
<div class="ms-headline-right">
<span id="meteorStateTag" class="ms-headline-tag idle">IDLE</span>
<button id="meteorStartBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;">Start</button>
<button id="meteorStopBtn" class="preset-btn" style="font-size: 10px; padding: 2px 10px;" disabled>Stop</button>
</div>
</div>
<div class="ms-stats-strip">
<div class="ms-stat-cell">
<span class="ms-stat-label">Total Pings</span>
<span class="ms-stat-value highlight" id="meteorStatPingsTotal">0</span>
</div>
<div class="ms-stat-cell">
<span class="ms-stat-label">Last 10 Min</span>
<span class="ms-stat-value" id="meteorStatPings10min">0</span>
</div>
<div class="ms-stat-cell">
<span class="ms-stat-label">Strongest SNR</span>
<span class="ms-stat-value" id="meteorStatStrongest">0.0 dB</span>
</div>
<div class="ms-stat-cell">
<span class="ms-stat-label">Noise Floor</span>
<span class="ms-stat-value" id="meteorStatNoiseFloor">-100.0 dB</span>
</div>
<div class="ms-stat-cell">
<span class="ms-stat-label">Uptime</span>
<span class="ms-stat-value" id="meteorStatUptime">0:00</span>
</div>
</div>
<div class="ms-spectrum-wrap">
<canvas id="meteorSpectrumCanvas"></canvas>
</div>
<div class="ms-waterfall-wrap">
<canvas id="meteorWaterfallCanvas"></canvas>
<div class="ms-empty-state" id="meteorEmptyState">
<div class="ms-empty-icon">&#9732;</div>
<div class="ms-empty-text">
Configure frequency and press Start to begin meteor scatter monitoring.
</div>
</div>
</div>
<div class="ms-timeline-wrap">
<canvas id="meteorTimelineCanvas"></canvas>
</div>
<div class="ms-events-panel">
<div class="ms-events-header">
<span class="ms-events-title">Detected Pings</span>
<span class="ms-events-count" id="meteorEventsCount">0 events</span>
</div>
<div class="ms-events-scroll">
<table class="ms-events-table">
<thead>
<tr>
<th>Time</th>
<th>Duration</th>
<th>SNR</th>
<th>Offset</th>
<th>Conf</th>
<th>Tags</th>
</tr>
</thead>
<tbody id="meteorEventsBody"></tbody>
</table>
</div>
</div>
</div>
<!-- System Health Visuals -->
<div id="systemVisuals" class="sys-visuals-container" style="display: none;">
<div class="sys-dashboard">
<!-- Row 1: COMPUTE -->
<div class="sys-group-header">Compute</div>
<div class="sys-card" id="sysCardCpu">
<div class="sys-card-header">CPU</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardMemory">
<div class="sys-card-header">Memory</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardTemp">
<div class="sys-card-header">Temperature &amp; Power</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 2: NETWORK & LOCATION -->
<div class="sys-group-header">Network &amp; Location</div>
<div class="sys-card" id="sysCardNetwork">
<div class="sys-card-header">Network</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardLocation">
<div class="sys-card-header">Location &amp; Weather</div>
<div class="sys-card-body"><span class="sys-metric-na">Loading&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardInfo">
<div class="sys-card-header">System Info</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<!-- Row 3: EQUIPMENT & OPERATIONS -->
<div class="sys-group-header">Equipment &amp; Operations</div>
<div class="sys-card" id="sysCardDisk">
<div class="sys-card-header">Disk &amp; Storage</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardSdr">
<div class="sys-card-header">SDR Devices</div>
<div class="sys-card-body"><span class="sys-metric-na">Scanning&hellip;</span></div>
</div>
<div class="sys-card" id="sysCardProcesses">
<div class="sys-card-header">Active Processes</div>
<div class="sys-card-body"><span class="sys-metric-na">Connecting&hellip;</span></div>
</div>
</div>
</div>
<!-- OOK Decoder Output Panel -->
<div id="ookOutputPanel" style="display: none; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; padding: 10px;">
<div style="background: #0a0a0a; border: 1px solid #1a2e1a; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; height: 100%; min-height: 0;">
<!-- Toolbar row 1: bit order -->
<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>Decoded Frames <span id="ookStatusBarFrames" style="color: var(--text-dim);">0 frames</span></span>
<div style="display: flex; gap: 6px; align-items: center;">
<span style="color: var(--text-dim); font-size: 10px;">Bit order:</span>
<button class="btn btn-sm btn-ghost" id="ookBitMSB"
onclick="OokMode.setBitOrder('msb')"
style="background: var(--accent); color: #000;">MSB</button>
<button class="btn btn-sm btn-ghost" id="ookBitLSB"
onclick="OokMode.setBitOrder('lsb')">LSB</button>
<button class="btn btn-sm btn-ghost" onclick="OokMode.suggestBitOrder()"
title="Auto-detect best bit order from printable character count">
Suggest <span id="ookSuggestLabel" style="font-size:9px; margin-left:2px;"></span>
</button>
</div>
</div>
<!-- Toolbar row 2: pattern filter -->
<div style="margin-bottom: 6px;">
<input type="text" id="ookPatternFilter"
placeholder="Filter hex or ASCII..."
oninput="OokMode.filterFrames(this.value)"
style="width: 100%; background: #111; border: 1px solid #222; border-radius: 3px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; padding: 3px 6px; box-sizing: border-box;">
</div>
<div id="ookOutput" style="flex: 1; min-height: 0; overflow-y: auto; font-family: var(--font-mono); font-size: 10px; color: var(--text-dim);"></div>
</div>
</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/components/signal-waveform.js') }}"></script>
<!-- Mode scripts are lazy-loaded via ensureModeScript() in switchMode() -->
<!-- WiFi v2 components (eagerly loaded — shared component) -->
<script src="{{ url_for('static', filename='js/components/channel-chart.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>
// ============================================
// 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' },
radiosonde: { label: 'Radiosonde', indicator: 'SONDE', outputTitle: 'Radiosonde Decoder', 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' },
wefax: { label: 'WeFax', indicator: 'WEFAX', outputTitle: 'Weather Fax Decoder', group: 'space' },
spaceweather: { label: 'Space Weather', indicator: 'SPACE WX', outputTitle: 'Space Weather Monitor', group: 'space' },
meteor: { label: 'Meteor', indicator: 'METEOR', outputTitle: 'Meteor Scatter 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' },
wifi_locate: { label: 'WiFi Locate', indicator: 'WF LOCATE', outputTitle: 'WiFi Locate', 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' },
morse: { label: 'Morse', indicator: 'MORSE', outputTitle: 'CW/Morse Decoder', group: 'signals' },
system: { label: 'System', indicator: 'SYSTEM', outputTitle: 'System Health Monitor', group: 'system' },
ook: { label: 'OOK Decoder', indicator: 'OOK', outputTitle: 'OOK Signal Decoder', 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: false,
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 {
const parsed = JSON.parse(saved);
// Only persist keywords across sessions.
// hideToneOnly defaults to false every session so users
// always see the full traffic stream unless they opt-in.
if (Array.isArray(parsed.keywords)) pagerFilters.keywords = parsed.keywords;
} 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);
const lat = Number(parsed.lat);
const lon = Number(parsed.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
return { lat, lon };
}
} 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();
if (typeof SignalCards !== 'undefined') SignalCards.updateMutedIndicator();
// 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',
]);
// Shared module destroy map — closes SSE EventSources, timers, etc.
// Used by both switchMode() and dashboard navigation cleanup.
function getModuleDestroyFn(mode) {
const moduleDestroyMap = {
pager: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
sensor: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
rtlamr: () => { if (eventSource) { eventSource.close(); eventSource = null; } },
subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.destroy?.(),
wefax: () => typeof WeFax !== 'undefined' && WeFax.destroy?.(),
system: () => typeof SystemHealth !== 'undefined' && SystemHealth.destroy?.(),
waterfall: () => typeof Waterfall !== 'undefined' && Waterfall.destroy?.(),
gps: () => typeof GPS !== 'undefined' && GPS.destroy?.(),
meshtastic: () => typeof Meshtastic !== 'undefined' && Meshtastic.destroy?.(),
bluetooth: () => typeof BluetoothMode !== 'undefined' && BluetoothMode.destroy?.(),
wifi: () => typeof WiFiMode !== 'undefined' && WiFiMode.destroy?.(),
bt_locate: () => typeof BtLocate !== 'undefined' && BtLocate.destroy?.(),
wifi_locate: () => typeof WiFiLocate !== 'undefined' && WiFiLocate.destroy?.(),
sstv: () => typeof SSTV !== 'undefined' && SSTV.destroy?.(),
sstv_general: () => typeof SSTVGeneral !== 'undefined' && SSTVGeneral.destroy?.(),
websdr: () => typeof WebSDR !== 'undefined' && WebSDR.destroy?.(),
spystations: () => typeof SpyStations !== 'undefined' && SpyStations.destroy?.(),
ais: () => { if (aisEventSource) { aisEventSource.close(); aisEventSource = null; } },
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
aprs: () => { if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; } },
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
};
return moduleDestroyMap[mode] || null;
}
function destroyCurrentMode() {
if (!currentMode) return;
const destroyFn = getModuleDestroyFn(currentMode);
if (destroyFn) {
try { destroyFn(); } catch(e) { console.warn(`[destroyCurrentMode] destroy ${currentMode} failed:`, e); }
}
}
function getActiveScanSummary() {
return {
pager: Boolean(isRunning),
sensor: Boolean(isSensorRunning),
morse: Boolean(
typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive()
),
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 morseActive = typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive();
if (morseActive && typeof MorseMode.stop === 'function') {
Promise.resolve(MorseMode.stop()).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(() => { });
}
// Additional modes with server-side processes that need stopping
if (typeof WeFax !== 'undefined' && typeof WeFax.stop === 'function') {
Promise.resolve(WeFax.stop()).catch(() => { });
}
if (typeof WeatherSat !== 'undefined' && typeof WeatherSat.stop === 'function') {
Promise.resolve(WeatherSat.stop()).catch(() => { });
}
if (typeof SSTV !== 'undefined' && typeof SSTV.stop === 'function') {
Promise.resolve(SSTV.stop()).catch(() => { });
}
if (typeof SSTVGeneral !== 'undefined' && typeof SSTVGeneral.stop === 'function') {
Promise.resolve(SSTVGeneral.stop()).catch(() => { });
}
if (typeof SubGhz !== 'undefined' && typeof SubGhz.stop === 'function') {
Promise.resolve(SubGhz.stop()).catch(() => { });
}
if (typeof Meshtastic !== 'undefined' && typeof Meshtastic.stop === 'function') {
Promise.resolve(Meshtastic.stop()).catch(() => { });
}
if (typeof GPS !== 'undefined' && typeof GPS.stop === 'function') {
Promise.resolve(GPS.stop()).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(),
});
}
destroyCurrentMode();
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';
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
console.warn(`[ModeSwitch] style load failed for ${mode}: ${err?.message || err}`);
})
: Promise.resolve();
const scriptReadyPromise = (typeof window.ensureModeScript === 'function')
? Promise.resolve(window.ensureModeScript(mode)).catch((err) => {
console.warn(`[ModeSwitch] script load failed for ${mode}: ${err?.message || err}`);
})
: Promise.resolve();
// 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 morseActive = typeof MorseMode !== 'undefined'
&& typeof MorseMode.isActive === 'function'
&& MorseMode.isActive();
if (morseActive && typeof MorseMode.stop === 'function') {
stopTasks.push(awaitStopAction('morse', () => MorseMode.stop(), LOCAL_STOP_TIMEOUT_MS));
}
const wifiScanActive = (
typeof WiFiMode !== 'undefined'
&& typeof WiFiMode.isScanning === 'function'
&& WiFiMode.isScanning()
) || isWifiRunning;
const isWifiModeTransition =
(currentMode === 'wifi' && mode === 'wifi_locate') ||
(currentMode === 'wifi_locate' && mode === 'wifi');
if (wifiScanActive && !isWifiModeTransition) {
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);
await styleReadyPromise;
await scriptReadyPromise;
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
if (previousMode && previousMode !== mode) {
const destroyFn = getModuleDestroyFn(previousMode);
if (destroyFn) {
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
}
}
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();
// 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);
});
const activeMobileBtn = document.querySelector('.mobile-nav-btn.active');
if (activeMobileBtn) {
activeMobileBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}
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('wefaxMode')?.classList.toggle('active', mode === 'wefax');
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('wflMode')?.classList.toggle('active', mode === 'wifi_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('radiosondeMode')?.classList.toggle('active', mode === 'radiosonde');
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');
document.getElementById('morseMode')?.classList.toggle('active', mode === 'morse');
document.getElementById('meteorMode')?.classList.toggle('active', mode === 'meteor');
document.getElementById('systemMode')?.classList.toggle('active', mode === 'system');
document.getElementById('ookMode')?.classList.toggle('active', mode === 'ook');
document.getElementById('pagerStats')?.classList.toggle('active', mode === 'pager');
document.getElementById('sensorStats')?.classList.toggle('active', mode === 'sensor');
document.getElementById('satelliteStats')?.classList.toggle('active', mode === 'satellite');
document.getElementById('wifiStats')?.classList.toggle('active', mode === 'wifi');
// 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 wflVisuals = document.getElementById('wflVisuals');
const wefaxVisuals = document.getElementById('wefaxVisuals');
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
const waterfallVisuals = document.getElementById('waterfallVisuals');
const radiosondeVisuals = document.getElementById('radiosondeVisuals');
const meteorVisuals = document.getElementById('meteorVisuals');
const systemVisuals = document.getElementById('systemVisuals');
if (wifiLayoutContainer) wifiLayoutContainer.classList.toggle('active', mode === 'wifi');
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
const satFrame = document.getElementById('satelliteDashboardFrame');
if (satFrame && satFrame.contentWindow) {
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
}
// Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner
if (mode !== 'satellite' && mode !== 'weathersat') {
const existing = document.getElementById('weatherSatHandoffBanner');
if (existing) existing.remove();
}
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 (wflVisuals) wflVisuals.style.display = mode === 'wifi_locate' ? 'flex' : 'none';
if (wefaxVisuals) wefaxVisuals.style.display = mode === 'wefax' ? 'flex' : 'none';
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
if (waterfallVisuals) waterfallVisuals.style.display = mode === 'waterfall' ? 'flex' : 'none';
if (radiosondeVisuals) radiosondeVisuals.style.display = mode === 'radiosonde' ? 'flex' : 'none';
if (meteorVisuals) meteorVisuals.style.display = mode === 'meteor' ? 'flex' : 'none';
if (systemVisuals) systemVisuals.style.display = mode === 'system' ? 'flex' : 'none';
// Hide the signal feed output for modes that have their own visuals
const outputEl = document.getElementById('output');
const modesWithVisuals = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'aprs', 'wifi', 'bluetooth', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'bt_locate', 'wifi_locate', 'waterfall', 'morse', 'meteor', 'system', 'ook', 'radiosonde', 'gps'];
if (outputEl) outputEl.style.display = modesWithVisuals.includes(mode) ? 'none' : 'block';
// Prevent Leaflet heatmap redraws on hidden BT Locate map containers.
if (typeof BtLocate !== 'undefined' && BtLocate.setActiveMode) {
BtLocate.setActiveMode(mode === 'bt_locate');
}
if (typeof WiFiLocate !== 'undefined' && WiFiLocate.setActiveMode) {
WiFiLocate.setActiveMode(mode === 'wifi_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';
const pagerScopePanel = document.getElementById('pagerScopePanel');
if (pagerScopePanel && mode !== 'pager') pagerScopePanel.style.display = 'none';
const sensorScopePanel = document.getElementById('sensorScopePanel');
if (sensorScopePanel && mode !== 'sensor') sensorScopePanel.style.display = 'none';
const morseScopePanel = document.getElementById('morseScopePanel');
const morseOutputPanel = document.getElementById('morseOutputPanel');
if (morseScopePanel && mode !== 'morse') morseScopePanel.style.display = 'none';
if (morseOutputPanel && mode !== 'morse') morseOutputPanel.style.display = 'none';
const morseDiagLog = document.getElementById('morseDiagLog');
if (morseDiagLog && mode !== 'morse') morseDiagLog.style.display = 'none';
const ookOutputPanel = document.getElementById('ookOutputPanel');
if (ookOutputPanel && mode !== 'ook') ookOutputPanel.style.display = '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();
}
// Module destroy is now handled by moduleDestroyMap above.
// 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');
const hideRecon = ['satellite', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'gps', 'aprs', 'tscm', 'spystations', 'meshtastic', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'meteor', 'system'].includes(mode);
if (reconPanel) reconPanel.style.display = (!hideRecon && reconEnabled) ? 'block' : 'none';
if (reconBtn) reconBtn.style.display = hideRecon ? 'none' : 'inline-block';
if (intelBtn) intelBtn.style.display = hideRecon ? 'none' : 'inline-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) {
const showRtl = ['pager', 'sensor', 'rtlamr', 'aprs', 'sstv', 'weathersat', 'sstv_general', 'wefax', 'morse', 'radiosonde', 'meteor', 'ook'].includes(mode);
rtlDeviceSection.classList.toggle('active', showRtl);
// Save original sidebar position of SDR device section (once)
if (!rtlDeviceSection._origParent) {
rtlDeviceSection._origParent = rtlDeviceSection.parentNode;
rtlDeviceSection._origNext = rtlDeviceSection.nextElementSibling;
}
// For morse/radiosonde/meteor/ook modes, move SDR device section inside the panel after the title
const morsePanel = document.getElementById('morseMode');
const radiosondePanel = document.getElementById('radiosondeMode');
const meteorPanel = document.getElementById('meteorMode');
const ookPanel = document.getElementById('ookMode');
if (mode === 'morse' && morsePanel) {
const firstSection = morsePanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection);
} else if (mode === 'radiosonde' && radiosondePanel) {
const firstSection = radiosondePanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection);
} else if (mode === 'meteor' && meteorPanel) {
const firstSection = meteorPanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection);
} else if (mode === 'ook' && ookPanel) {
const firstSection = ookPanel.querySelector('.section');
if (firstSection) firstSection.after(rtlDeviceSection);
} else if (rtlDeviceSection._origParent && rtlDeviceSection.parentNode !== rtlDeviceSection._origParent) {
// Restore to original sidebar position when leaving morse mode
if (rtlDeviceSection._origNext) {
rtlDeviceSection._origNext.before(rtlDeviceSection);
} else {
rtlDeviceSection._origParent.appendChild(rtlDeviceSection);
}
}
}
// Toggle mode-specific tool status displays
document.getElementById('toolStatusPager')?.classList.toggle('active', mode === 'pager');
document.getElementById('toolStatusSensor')?.classList.toggle('active', mode === 'sensor');
// Hide output console for modes with their own visualizations
const hideStatusBar = ['satellite', 'websdr', 'subghz', 'spaceweather', 'waterfall', 'morse', 'meteor', 'system'].includes(mode);
const statusBar = document.querySelector('.status-bar');
if (statusBar) statusBar.style.display = hideStatusBar ? '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 === 'wifi_locate') {
WiFiLocate.init();
} else if (mode === 'wefax') {
WeFax.init();
} else if (mode === 'spaceweather') {
SpaceWeather.init();
} else if (mode === 'waterfall') {
if (typeof Waterfall !== 'undefined') Waterfall.init();
} else if (mode === 'morse') {
MorseMode.init();
} else if (mode === 'radiosonde') {
initRadiosondeWaveform();
initRadiosondeMap();
setTimeout(() => {
if (radiosondeMap) radiosondeMap.invalidateSize();
}, 100);
} else if (mode === 'meteor') {
MeteorScatter.init();
} else if (mode === 'system') {
SystemHealth.init();
} else if (mode === 'ook') {
OokMode.init();
}
// Waterfall destroy is now handled by moduleDestroyMap above.
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
async 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' && !await 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 (!await 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;
async 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 && !await checkDeviceAvailability('rtlamr')) {
return;
}
const config = {
frequency: freq,
gain: gain,
ppm: ppm,
device: device,
sdr_type: getSelectedSDRType(),
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 (currentMode === 'ook') { OokMode.exportLog(); return; }
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 (currentMode === 'ook') { OokMode.exportJSON(); return; }
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 = '';
}
async function removePreset(freq) {
const confirmed = await AppFeedback.confirmAction({
title: 'Remove Preset',
message: 'Remove preset ' + freq + ' MHz?',
confirmLabel: 'Remove',
confirmClass: 'btn-danger'
});
if (confirmed) {
let presets = loadPresets();
presets = presets.filter(p => p !== freq);
savePresets(presets);
renderPresets();
}
}
async function resetPresets() {
const confirmed = await AppFeedback.confirmAction({
title: 'Reset Presets',
message: 'Reset to default presets?',
confirmLabel: 'Reset',
confirmClass: 'btn-danger'
});
if (confirmed) {
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 (keyed by "sdr_type:index")
const sdrDeviceUsage = {
// "sdr_type:index": 'modeName' (e.g., "rtlsdr:0": 'pager', "hackrf:0": 'scanner')
};
function getDeviceInUseBy(deviceIndex, sdrType) {
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
return sdrDeviceUsage[key] || null;
}
function isDeviceInUse(deviceIndex, sdrType) {
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
return sdrDeviceUsage[key] !== undefined;
}
function reserveDevice(deviceIndex, modeName, sdrType) {
const key = `${sdrType || getSelectedSDRType()}:${deviceIndex}`;
sdrDeviceUsage[key] = modeName;
updateDeviceSelectStatus();
}
function releaseDevice(modeName) {
for (const [key, mode] of Object.entries(sdrDeviceUsage)) {
if (mode === modeName) {
delete sdrDeviceUsage[key];
}
}
updateDeviceSelectStatus();
}
function getAvailableDevice() {
// Find first device not in use (within selected SDR type)
const sdrType = getSelectedSDRType();
for (const device of currentDeviceList) {
if ((device.sdr_type || 'rtlsdr') === sdrType && !isDeviceInUse(device.index, sdrType)) {
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 sdrType = getSelectedSDRType();
const options = select.querySelectorAll('option');
options.forEach(opt => {
const idx = parseInt(opt.value);
const usedBy = getDeviceInUseBy(idx, sdrType);
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 = '';
}
});
}
async 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 = await AppFeedback.confirmAction({
title: 'SDR Device In Use',
message: `Device ${selectedDevice} is in use by ${usedBy.toUpperCase()}. Device ${availableDevice} is available. Switch to it?`,
confirmLabel: 'Switch Device',
confirmClass: 'btn-danger'
});
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 => {
const transient = (typeof window.isTransientOrOffline === 'function' && window.isTransientOrOffline(err)) ||
(typeof navigator !== 'undefined' && navigator.onLine === false) ||
/failed to fetch|network io suspended|networkerror|timeout/i.test(String((err && err.message) || err || ''));
if (!transient) {
console.error('Failed to fetch SDR status:', err);
}
const container = document.getElementById('sdrStatusList');
if (container) {
if (transient) {
container.innerHTML = '<div style="padding: 8px; color: #888; font-size: 11px; text-align: center;">Status temporarily unavailable</div>';
} else {
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;
}
async 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 && !await 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, '&amp;');
s = s.replace(/'/g, '&#39;');
s = s.replace(/"/g, '&quot;');
s = s.replace(/</g, '&lt;');
s = s.replace(/>/g, '&gt;');
return s;
}
function isValidMac(mac) {
// Validate MAC address format (XX:XX:XX:XX:XX:XX)
return /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/.test(mac);
}
function isValidChannel(ch) {
// Validate WiFi channel (1-200 covers all bands)
const num = parseInt(ch, 10);
return !isNaN(num) && num >= 1 && num <= 200;
}
function showInfo(text) {
const output = document.getElementById('output');
// Clear placeholder only (has the 'placeholder' class)
const placeholder = output.querySelector('.placeholder');
if (placeholder) {
placeholder.remove();
}
const infoEl = document.createElement('div');
infoEl.className = 'info-msg';
infoEl.style.cssText = 'padding: 12px 15px; margin-bottom: 8px; background: #0a0a0a; border: 1px solid #1a1a1a; border-left: 2px solid #00d4ff; font-family: "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() {
if (currentMode === 'ook') { OokMode.clearOutput(); return; }
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 non-printable characters (outside printable ASCII range)
const hasNonPrintable = /[^\x20-\x7E]/.test(message);
// Check for common encrypted patterns (hex strings)
const hexPattern = /^[0-9A-Fa-f\s]+$/;
if (hasNonPrintable) {
return true; // Contains non-printable chars — likely encrypted or encoded
}
if (hexPattern.test(message.replace(/\s/g, ''))) {
return true; // Pure hex data — likely encoded
}
// All printable ASCII (covers base64, structured data, punctuation, etc.)
return false; // Likely plaintext
}
// 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
document.getElementById('reconPanel').style.display = reconEnabled ? 'block' : 'none';
if (reconEnabled) {
document.getElementById('reconBtn')?.classList.add('active');
}
// 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;
}
// Handle signal strength
let signalStrength = parseInt(client.power);
if (isNaN(signalStrength) || signalStrength === -1) {
signalStrength = null;
}
const signalBars = signalStrength !== null ? Math.max(0, Math.min(5, Math.floor((signalStrength + 100) / 15))) : 0;
const signalDisplay = signalStrength !== null ? `${signalStrength} dBm` : 'N/A';
// Get connected AP info
const connectedAP = client.bssid && wifiNetworks[client.bssid];
const apName = connectedAP ? (connectedAP.essid || '[Hidden]') : (client.bssid || 'Not Associated');
// Format probes
const probes = client.probes ? client.probes.split(',').map(p => p.trim()).filter(p => p) : [];
const probesDisplay = probes.length > 0 ? probes.slice(0, 3).join(', ') + (probes.length > 3 ? ` +${probes.length - 3}` : '') : 'None';
card.innerHTML = `
<div class="header" style="display: flex; justify-content: space-between; margin-bottom: 8px;">
<span class="device-name" style="color: var(--accent-purple);">${escapeHtml(client.vendor || 'Client')}</span>
<span style="font-size: 10px; color: var(--text-dim);">CLIENT</span>
</div>
<div class="sensor-data">
<div class="data-item">
<div class="data-label">MAC</div>
<div class="data-value" style="font-size: 11px;">${escapeHtml(client.mac)}</div>
</div>
<div class="data-item">
<div class="data-label">Connected To</div>
<div class="data-value" style="color: var(--accent-cyan);">${escapeHtml(apName)}</div>
</div>
<div class="data-item">
<div class="data-label">Signal</div>
<div class="data-value">${signalDisplay} ${'█'.repeat(signalBars)}${'░'.repeat(5 - signalBars)}</div>
</div>
<div class="data-item">
<div class="data-label">Probes</div>
<div class="data-value" style="font-size: 10px;">${escapeHtml(probesDisplay)}</div>
</div>
</div>
`;
}
// Target a network for attack
function targetNetwork(bssid, channel) {
document.getElementById('targetBssid').value = bssid;
document.getElementById('wifiChannel').value = channel;
showInfo('Targeted: ' + bssid + ' on channel ' + channel);
}
// Start handshake capture
async function captureHandshake(bssid, channel) {
const confirmed = await AppFeedback.confirmAction({
title: 'Capture Handshake',
message: 'Start handshake capture for ' + bssid + '? This will stop the current scan.',
confirmLabel: 'Start Capture',
confirmClass: 'btn-danger'
});
if (!confirmed) {
return;
}
const iface = monitorInterface || document.getElementById('wifiInterfaceSelect').value;
if (!iface) {
showError('No monitor interface available. Enable monitor mode first.');
return;
}
// Stop any existing scan first
if (isWifiRunning) {
showInfo('Stopping current scan...');
try {
await fetch('/wifi/scan/stop', { method: 'POST' });
if (wifiEventSource) {
wifiEventSource.close();
wifiEventSource = null;
}
setWifiRunning(false);
// Brief delay to ensure process stops
await new Promise(resolve => setTimeout(resolve, 500));
} catch (e) {
console.error('Error stopping scan:', e);
}
}
try {
const response = await fetch('/wifi/handshake/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bssid: bssid, channel: channel, interface: iface })
});
const data = await response.json();
if (data.status === 'started') {
showInfo('Capturing handshakes for ' + bssid);
setWifiRunning(true);
// Update handshake indicator to show active capture
const hsSpan = document.getElementById('handshakeCount');
hsSpan.style.animation = 'pulse 1s infinite';
hsSpan.title = 'Capturing: ' + bssid;
// Show capture status panel
const panel = document.getElementById('captureStatusPanel');
panel.style.display = 'block';
document.getElementById('captureTargetBssid').textContent = bssid;
document.getElementById('captureTargetChannel').textContent = channel;
document.getElementById('captureFilePath').textContent = data.capture_file;
document.getElementById('captureStatus').textContent = 'Waiting for handshake...';
document.getElementById('captureStatus').style.color = 'var(--accent-orange)';
// Store active capture info and start polling
activeCapture = {
bssid: bssid,
channel: channel,
file: data.capture_file,
startTime: Date.now(),
pollInterval: setInterval(checkCaptureStatus, 5000) // Check every 5 seconds
};
} else {
showError('Handshake capture failed: ' + (data.message || 'Unknown error'));
}
} catch (err) {
showError('Handshake capture error: ' + err.message);
console.error('Handshake capture error:', err);
}
}
// Check handshake capture status
function checkCaptureStatus() {
if (!activeCapture) {
showInfo('No active handshake capture');
return;
}
fetch('/wifi/handshake/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: activeCapture.file, bssid: activeCapture.bssid })
}).then(r => r.json())
.then(data => {
const statusSpan = document.getElementById('captureStatus');
const elapsed = Math.round((Date.now() - activeCapture.startTime) / 1000);
const elapsedStr = elapsed < 60 ? elapsed + 's' : Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's';
if (data.handshake_found) {
// Handshake captured!
statusSpan.textContent = '✓ VALID HANDSHAKE CAPTURED!';
statusSpan.style.color = 'var(--accent-green)';
handshakeCount++;
document.getElementById('handshakeCount').textContent = handshakeCount;
playAlert();
showInfo('Handshake captured for ' + activeCapture.bssid + '. File: ' + data.file);
showNotification('Handshake Captured!', `Target: ${activeCapture.bssid}`);
// Stop polling
if (activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
document.getElementById('handshakeCount').style.animation = '';
// Show crack button in the capture panel
const panel = document.getElementById('captureStatusPanel');
const existingCrackBtn = panel.querySelector('.crack-btn');
if (!existingCrackBtn) {
const crackDiv = document.createElement('div');
crackDiv.style.marginTop = '10px';
crackDiv.innerHTML = `
<button class="preset-btn crack-btn" onclick="crackHandshake('${data.file}', '${activeCapture.bssid}')" style="width: 100%; background: var(--accent-green); border-color: var(--accent-green); color: #000; font-weight: bold;">
Crack with Aircrack-ng
</button>
`;
panel.querySelector('.section') ? panel.querySelector('.section').appendChild(crackDiv) : panel.appendChild(crackDiv);
}
// Store the captured file for later use
activeCapture.captured = true;
activeCapture.capturedFile = data.file;
} else if (data.file_exists) {
const sizeKB = (data.file_size / 1024).toFixed(1);
let extra = '';
if (data.handshake_checked && data.handshake_valid === false) {
extra = data.handshake_reason ? ' • ' + data.handshake_reason : ' • No valid handshake yet';
}
statusSpan.textContent = 'Capturing... (' + sizeKB + ' KB, ' + elapsedStr + ')' + extra;
statusSpan.style.color = 'var(--accent-orange)';
} else if (data.status === 'stopped') {
statusSpan.textContent = 'Capture stopped';
statusSpan.style.color = 'var(--text-dim)';
if (activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
} else {
statusSpan.textContent = 'Waiting for data... (' + elapsedStr + ')';
statusSpan.style.color = 'var(--accent-orange)';
}
})
.catch(err => {
console.error('Capture status check failed:', err);
});
}
// Stop handshake capture
function stopHandshakeCapture() {
if (activeCapture && activeCapture.pollInterval) {
clearInterval(activeCapture.pollInterval);
}
// Stop the WiFi scan (which stops airodump-ng)
stopWifiScan();
document.getElementById('captureStatus').textContent = 'Stopped';
document.getElementById('captureStatus').style.color = 'var(--text-dim)';
document.getElementById('handshakeCount').style.animation = '';
// Keep the panel visible so user can see the file path
showInfo('Handshake capture stopped. Check ' + (activeCapture ? activeCapture.file : 'capture file'));
activeCapture = null;
}
// Crack handshake with aircrack-ng
function crackHandshake(captureFile, bssid) {
const wordlist = prompt('Enter path to wordlist file:\n\nCommon locations:\n- /usr/share/wordlists/rockyou.txt\n- /usr/share/john/password.lst', '/usr/share/wordlists/rockyou.txt');
if (!wordlist) {
showInfo('Cracking cancelled');
return;
}
showInfo('Starting aircrack-ng... This may take a while.');
fetch('/wifi/handshake/crack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
capture_file: captureFile,
bssid: bssid,
wordlist: wordlist
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.password) {
showInfo('PASSWORD FOUND: ' + data.password);
showNotification('Password Cracked', data.password);
alert('Password found!\n\n' + data.password + '\n\nThis has been logged.');
} else if (data.status === 'not_found') {
showInfo('Password not found in wordlist. Try a different wordlist.');
alert('Password not found in wordlist.\n\nTry using a larger or different wordlist.');
} else if (data.status === 'running') {
showInfo('Aircrack-ng is running in background. Check terminal for progress.');
} else {
showError('Crack failed: ' + (data.message || 'Unknown error'));
}
})
.catch(err => {
showError('Crack error: ' + err.message);
console.error('Crack error:', err);
});
}
// Beacon Flood Detection
let beaconHistory = [];
let lastBeaconCheck = Date.now();
function checkBeaconFlood(networks) {
const now = Date.now();
const windowMs = 5000; // 5 second window
// Add current networks to history
beaconHistory.push({ time: now, count: Object.keys(networks).length });
// Remove old entries
beaconHistory = beaconHistory.filter(h => now - h.time < windowMs);
// Calculate rate of new networks
if (beaconHistory.length >= 2) {
const oldest = beaconHistory[0];
const newest = beaconHistory[beaconHistory.length - 1];
const timeDiff = (newest.time - oldest.time) / 1000;
const countDiff = newest.count - oldest.count;
if (timeDiff > 0) {
const rate = countDiff / timeDiff;
// Alert if more than 10 new networks per second
if (rate > 10) {
document.getElementById('beaconFloodAlert').style.display = 'block';
document.getElementById('beaconFloodRate').textContent = rate.toFixed(1);
if (!muted) playAlertSound();
} else if (rate < 2) {
document.getElementById('beaconFloodAlert').style.display = 'none';
}
}
}
}
// Send deauth
async function sendDeauth() {
const bssid = document.getElementById('targetBssid').value;
const client = document.getElementById('targetClient').value || 'FF:FF:FF:FF:FF:FF';
const count = document.getElementById('deauthCount').value || '5';
if (!bssid) {
alert('Enter target BSSID');
return;
}
const deauthConfirmed = await AppFeedback.confirmAction({
title: 'Send Deauth Packets',
message: 'Send ' + count + ' deauth packets to ' + bssid + '? Only use on networks you own or have authorization to test.',
confirmLabel: 'Send Deauth',
confirmClass: 'btn-danger'
});
if (!deauthConfirmed) {
return;
}
fetch('/wifi/deauth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bssid: bssid, client: client, count: parseInt(count) })
}).then(r => r.json())
.then(data => {
if (data.status === 'success') {
showInfo(data.message);
} else {
alert('Error: ' + data.message);
}
});
}
// ============== WIFI VISUALIZATIONS ==============
let radarCtx = null;
let radarAngle = 0;
let radarAnimFrame = null;
let radarNetworks = []; // {x, y, strength, ssid, bssid}
let targetBssidForSignal = null;
// Initialize radar canvas
function initRadar() {
const canvas = document.getElementById('radarCanvas');
if (!canvas) return;
radarCtx = canvas.getContext('2d');
canvas.width = 150;
canvas.height = 150;
// Start animation
if (!radarAnimFrame) {
animateRadar();
}
}
// Animate radar sweep
function animateRadar() {
if (!radarCtx) {
radarAnimFrame = null;
return;
}
const canvas = radarCtx.canvas;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 5;
// Clear canvas
radarCtx.fillStyle = 'rgba(0, 10, 10, 0.1)';
radarCtx.fillRect(0, 0, canvas.width, canvas.height);
// Draw grid circles
radarCtx.strokeStyle = 'rgba(0, 212, 255, 0.2)';
radarCtx.lineWidth = 1;
for (let r = radius / 4; r <= radius; r += radius / 4) {
radarCtx.beginPath();
radarCtx.arc(cx, cy, r, 0, Math.PI * 2);
radarCtx.stroke();
}
// Draw crosshairs
radarCtx.beginPath();
radarCtx.moveTo(cx, cy - radius);
radarCtx.lineTo(cx, cy + radius);
radarCtx.moveTo(cx - radius, cy);
radarCtx.lineTo(cx + radius, cy);
radarCtx.stroke();
// Draw sweep line
radarCtx.strokeStyle = 'rgba(0, 255, 136, 0.8)';
radarCtx.lineWidth = 2;
radarCtx.beginPath();
radarCtx.moveTo(cx, cy);
radarCtx.lineTo(
cx + Math.cos(radarAngle) * radius,
cy + Math.sin(radarAngle) * radius
);
radarCtx.stroke();
// Draw sweep gradient
const gradient = radarCtx.createConicalGradient ?
null : // Not supported in all browsers
radarCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
radarCtx.fillStyle = 'rgba(0, 255, 136, 0.05)';
radarCtx.beginPath();
radarCtx.moveTo(cx, cy);
radarCtx.arc(cx, cy, radius, radarAngle - 0.5, radarAngle);
radarCtx.closePath();
radarCtx.fill();
// Draw network blips
radarNetworks.forEach(net => {
const age = Date.now() - net.timestamp;
const alpha = Math.max(0.1, 1 - age / 10000);
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha})`;
radarCtx.beginPath();
radarCtx.arc(net.x, net.y, 4 + (1 - alpha) * 3, 0, Math.PI * 2);
radarCtx.fill();
// Glow effect
radarCtx.fillStyle = `rgba(0, 255, 136, ${alpha * 0.3})`;
radarCtx.beginPath();
radarCtx.arc(net.x, net.y, 8 + (1 - alpha) * 5, 0, Math.PI * 2);
radarCtx.fill();
});
// Update angle
radarAngle += 0.03;
if (radarAngle > Math.PI * 2) radarAngle = 0;
radarAnimFrame = requestAnimationFrame(animateRadar);
}
// Add network to radar
function addNetworkToRadar(net) {
const canvas = document.getElementById('radarCanvas');
if (!canvas) return;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 10;
// Convert signal strength to distance (stronger = closer)
const power = parseInt(net.power) || -80;
const distance = Math.max(0.1, Math.min(1, (power + 100) / 60));
const r = radius * (1 - distance);
// Random angle based on BSSID hash
let angle = 0;
for (let i = 0; i < net.bssid.length; i++) {
angle += net.bssid.charCodeAt(i);
}
angle = (angle % 360) * Math.PI / 180;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
// Update or add
const existing = radarNetworks.find(n => n.bssid === net.bssid);
if (existing) {
existing.x = x;
existing.y = y;
existing.timestamp = Date.now();
} else {
radarNetworks.push({
x, y,
bssid: net.bssid,
ssid: net.essid,
timestamp: Date.now()
});
}
// Limit to 50 networks
if (radarNetworks.length > 50) {
radarNetworks.shift();
}
}
// Update channel graph
function updateChannelGraph() {
const channels = {};
for (let i = 1; i <= 13; i++) channels[i] = 0;
// Count networks per channel
Object.values(wifiNetworks).forEach(net => {
const ch = parseInt(net.channel);
if (ch >= 1 && ch <= 13) {
channels[ch]++;
}
});
// Find max for scaling
const maxCount = Math.max(1, ...Object.values(channels));
// Update bars
const bars = document.querySelectorAll('#channelGraph .channel-bar');
bars.forEach((bar, i) => {
const ch = i + 1;
const count = channels[ch] || 0;
const height = Math.max(2, (count / maxCount) * 55);
bar.style.height = height + 'px';
bar.classList.remove('active', 'congested', 'very-congested');
if (count > 0) bar.classList.add('active');
if (count >= 3) bar.classList.add('congested');
if (count >= 5) bar.classList.add('very-congested');
});
}
// Update security donut chart
function updateSecurityDonut() {
const canvas = document.getElementById('securityCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 2;
const innerRadius = radius * 0.6;
// Count security types
let wpa3 = 0, wpa2 = 0, wep = 0, open = 0;
Object.values(wifiNetworks).forEach(net => {
const priv = (net.privacy || '').toUpperCase();
if (priv.includes('WPA3')) wpa3++;
else if (priv.includes('WPA')) wpa2++;
else if (priv.includes('WEP')) wep++;
else if (priv === 'OPN' || priv === '' || priv === 'OPEN') open++;
else wpa2++; // Default to WPA2
});
const total = wpa3 + wpa2 + wep + open;
// Update legend
document.getElementById('wpa3Count').textContent = wpa3;
document.getElementById('wpa2Count').textContent = wpa2;
document.getElementById('wepCount').textContent = wep;
document.getElementById('openCount').textContent = open;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (total === 0) {
// Draw empty circle
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = radius - innerRadius;
ctx.beginPath();
ctx.arc(cx, cy, (radius + innerRadius) / 2, 0, Math.PI * 2);
ctx.stroke();
return;
}
// Draw segments
const colors = {
wpa3: '#00ff88',
wpa2: '#ff8800',
wep: '#ff3366',
open: '#00d4ff'
};
const data = [
{ value: wpa3, color: colors.wpa3 },
{ value: wpa2, color: colors.wpa2 },
{ value: wep, color: colors.wep },
{ value: open, color: colors.open }
];
let startAngle = -Math.PI / 2;
data.forEach(segment => {
if (segment.value === 0) return;
const sliceAngle = (segment.value / total) * Math.PI * 2;
ctx.fillStyle = segment.color;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
startAngle += sliceAngle;
});
// Draw inner circle (donut hole)
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2);
ctx.fill();
// Draw total in center
ctx.fillStyle = '#fff';
ctx.font = 'bold 16px Roboto Condensed';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(total, cx, cy);
}
// Update signal strength meter for targeted network
function updateSignalMeter(net) {
if (!net) return;
targetBssidForSignal = net.bssid;
const ssidEl = document.getElementById('targetSsid');
const valueEl = document.getElementById('signalValue');
const barsEl = document.querySelectorAll('.signal-bar-large');
ssidEl.textContent = net.essid || net.bssid;
const power = parseInt(net.power) || -100;
valueEl.textContent = power + ' dBm';
// Determine signal quality
let quality = 'weak';
let activeBars = 1;
if (power >= -50) { quality = 'strong'; activeBars = 5; }
else if (power >= -60) { quality = 'strong'; activeBars = 4; }
else if (power >= -70) { quality = 'medium'; activeBars = 3; }
else if (power >= -80) { quality = 'medium'; activeBars = 2; }
else { quality = 'weak'; activeBars = 1; }
valueEl.className = 'signal-value ' + quality;
barsEl.forEach((bar, i) => {
bar.className = 'signal-bar-large';
if (i < activeBars) {
bar.classList.add('active', quality);
}
});
}
// Hook into handleWifiNetworkImmediate to update visualizations
const originalHandleWifiNetworkImmediate = handleWifiNetworkImmediate;
handleWifiNetworkImmediate = function (net) {
originalHandleWifiNetworkImmediate(net);
// Update radar
addNetworkToRadar(net);
// Update security donut
updateSecurityDonut();
// Update signal meter if this is the targeted network
if (targetBssidForSignal === net.bssid) {
updateSignalMeter(net);
}
// Note: Channel graphs are updated in the batched scheduleWifiUIUpdate
};
// Update targetNetwork to also set signal meter
const originalTargetNetwork = targetNetwork;
targetNetwork = function (bssid, channel) {
originalTargetNetwork(bssid, channel);
const net = wifiNetworks[bssid];
if (net) {
updateSignalMeter(net);
}
};
// ============== BLUETOOTH COMPATIBILITY SHIMS ==============
function getBluetoothModeApi() {
if (typeof BluetoothMode === 'undefined') return null;
return BluetoothMode;
}
function syncBtRunningState() {
const bt = getBluetoothModeApi();
if (!bt || typeof bt.isScanning !== 'function') {
return isBtRunning;
}
isBtRunning = bt.isScanning();
return isBtRunning;
}
function refreshBtInterfaces() {
const bt = getBluetoothModeApi();
if (!bt) return;
if (typeof bt.checkCapabilities === 'function') bt.checkCapabilities();
syncBtRunningState();
}
function startBtScan() {
const bt = getBluetoothModeApi();
if (!bt || typeof bt.startScan !== 'function') return;
bt.startScan();
setTimeout(syncBtRunningState, 0);
}
function stopBtScan() {
const bt = getBluetoothModeApi();
let stopPromise = Promise.resolve();
if (bt && typeof bt.stopScan === 'function') {
stopPromise = Promise.resolve(bt.stopScan()).catch((err) => {
console.warn('[BT] stop failed:', err);
});
}
setTimeout(syncBtRunningState, 0);
return stopPromise;
}
function setBtRunning(running) {
isBtRunning = !!running;
syncBtRunningState();
}
function initBtRadar() {
// Radar lifecycle is handled by BluetoothMode.
syncBtRunningState();
}
function resetBtAdapter() {
// Legacy hook retained for old callers.
if (typeof showInfo === 'function') {
showInfo('Bluetooth adapter reset is handled by the Bluetooth mode backend.');
} else {
console.info('Bluetooth adapter reset is handled by the Bluetooth mode backend.');
}
}
// ============================================
// APRS Functions
// ============================================
let aprsMap = null;
let aprsMarkers = {};
let aprsEventSource = null;
let isAprsRunning = false;
let aprsPacketCount = 0;
let aprsStationCount = 0;
let aprsMeterLastUpdate = 0;
let aprsMeterCheckInterval = null;
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
// APRS user location (from GPS or shared observer location)
let aprsUserLocation = { lat: null, lon: null };
// Seed from configured observer location so the map centres on the
// user's position even without a live GPS fix.
(function _seedAprsLocation() {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared();
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon;
return;
}
}
// Fallback: read the Jinja-injected defaults directly
const lat = Number(window.INTERCEPT_DEFAULT_LAT);
const lon = Number(window.INTERCEPT_DEFAULT_LON);
if (aprsHasValidCoordinates(lat, lon)) {
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
}
})();
// Listen for observer location changes from settings or other sources
window.addEventListener('observer-location-changed', function(e) {
if (e.detail && aprsHasValidCoordinates(e.detail.lat, e.detail.lon)) {
updateAprsUserLocation({ latitude: e.detail.lat, longitude: e.detail.lon });
}
});
let aprsUserMarker = null;
// Calculate distance in miles using Haversine formula
function aprsCalculateDistanceMi(lat1, lon1, lat2, lon2) {
const R = 3958.8; // Earth's radius in miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function aprsHasValidCoordinates(lat, lon) {
return lat != null && lon != null &&
Number.isFinite(Number(lat)) && Number.isFinite(Number(lon));
}
// Update APRS user location from GPS
function updateAprsUserLocation(position) {
const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
if (!aprsHasValidCoordinates(lat, lon)) return;
aprsUserLocation.lat = lat;
aprsUserLocation.lon = lon;
// Update user marker on map
if (aprsMap) {
if (aprsUserMarker) {
aprsUserMarker.setLatLng([lat, lon]);
} else {
aprsUserMarker = L.marker([lat, lon], {
icon: L.divIcon({
className: 'aprs-user-marker',
html: '<div style="width: 14px; height: 14px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7]
}),
zIndexOffset: 1000
}).bindPopup('Your Location (GPS)').addTo(aprsMap);
}
// Center map on first GPS fix
if (!aprsMap._gpsInitialized) {
aprsMap.setView([lat, lon], 8);
aprsMap._gpsInitialized = true;
}
}
// Show GPS indicator
const indicator = document.getElementById('aprsGpsIndicator');
if (indicator) indicator.style.display = 'inline-flex';
// Update distances in existing station list
updateAprsStationDistances();
}
// Update distances for all stations in the list
function updateAprsStationDistances() {
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) return;
// Update station list items
const listEl = document.getElementById('aprsStationList');
if (listEl) {
listEl.querySelectorAll('[data-callsign]').forEach(stationEl => {
const lat = parseFloat(stationEl.dataset.lat);
const lon = parseFloat(stationEl.dataset.lon);
if (!isNaN(lat) && !isNaN(lon)) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
const distSpan = stationEl.querySelector('.aprs-distance');
if (distSpan) {
distSpan.textContent = dist.toFixed(1) + ' mi';
}
}
});
}
}
function checkAprsTools() {
fetch('/aprs/tools')
.then(r => r.json())
.then(data => {
// Update function bar tool indicators
const direwolfEl = document.getElementById('aprsStripDirewolf');
const multimonEl = document.getElementById('aprsStripMultimon');
if (direwolfEl) {
direwolfEl.className = 'strip-tool' + (data.direwolf ? ' ok' : '');
direwolfEl.title = 'direwolf: ' + (data.direwolf ? 'OK' : 'Missing');
}
if (multimonEl) {
multimonEl.className = 'strip-tool' + (data.multimon_ng ? ' ok' : '');
multimonEl.title = 'multimon-ng: ' + (data.multimon_ng ? 'OK' : 'Missing');
}
})
.catch(() => {
const direwolfEl = document.getElementById('aprsStripDirewolf');
const multimonEl = document.getElementById('aprsStripMultimon');
if (direwolfEl) {
direwolfEl.className = 'strip-tool';
direwolfEl.title = 'direwolf: Error';
}
if (multimonEl) {
multimonEl.className = 'strip-tool';
multimonEl.title = 'multimon-ng: Error';
}
});
}
async function initAprsMap() {
if (aprsMap) return;
const mapContainer = document.getElementById('aprsMap');
if (!mapContainer) return;
// Refresh from ObserverLocation in case it changed since page load
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon) ||
(aprsUserLocation.lat === 0 && aprsUserLocation.lon === 0)) {
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
const shared = ObserverLocation.getShared();
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
aprsUserLocation.lat = shared.lat;
aprsUserLocation.lon = shared.lon;
}
}
}
// Use GPS location if available, otherwise default to center of US
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
const hasUserLocation = aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon);
const hasGpsLocation = aprsHasValidCoordinates(gpsLat, gpsLon);
const initialLat = hasUserLocation ? aprsUserLocation.lat : (hasGpsLocation ? gpsLat : 39.8283);
const initialLon = hasUserLocation ? aprsUserLocation.lon : (hasGpsLocation ? gpsLon : -98.5795);
const initialZoom = (hasUserLocation || hasGpsLocation) ? 8 : 4;
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
window.aprsMap = aprsMap;
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(aprsMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
aprsMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(aprsMap);
Settings.registerMap(aprsMap);
} catch (e) {
console.warn('APRS: Settings init failed/timed out, using fallback tiles:', e);
}
}
// Add user marker if GPS position is already available
if (gpsConnected && hasGpsLocation) {
updateAprsUserLocation({ latitude: gpsLat, longitude: gpsLon });
aprsMap._gpsInitialized = true;
}
// Update time display (both map header and function bar)
setInterval(() => {
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
const timeEl = document.getElementById('aprsMapTime');
if (timeEl) timeEl.textContent = timeStr;
const stripTimeEl = document.getElementById('aprsStripTime');
if (stripTimeEl) stripTimeEl.textContent = utcStr;
}, 1000);
}
function updateAprsStatus(state, freq) {
// Update function bar status
const stripDot = document.getElementById('aprsStripDot');
const stripStatus = document.getElementById('aprsStripStatus');
const stripFreq = document.getElementById('aprsStripFreq');
if (stripDot) {
stripDot.className = 'status-dot ' + state;
}
if (stripStatus) {
stripStatus.textContent = state.toUpperCase();
if (state === 'listening') {
stripStatus.style.color = 'var(--accent-cyan)';
} else if (state === 'tracking') {
stripStatus.style.color = 'var(--accent-green)';
} else if (state === 'error') {
stripStatus.style.color = 'var(--accent-red)';
} else {
stripStatus.style.color = '';
}
}
if (freq && stripFreq) {
stripFreq.textContent = freq;
}
}
// APRS mode polling timer for agent mode
let aprsPollTimer = null;
let aprsCurrentAgent = null;
const aprsAgentStationSignatures = new Map();
function resetAprsAgentStationTracking() {
aprsAgentStationSignatures.clear();
}
function extractAprsStationsFromPayload(payload) {
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload.stations)) return payload.stations;
if (Array.isArray(payload.data)) return payload.data;
if (payload.data && Array.isArray(payload.data.stations)) return payload.data.stations;
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data;
if (payload.result && Array.isArray(payload.result.stations)) return payload.result.stations;
if (payload.result && Array.isArray(payload.result.data)) return payload.result.data;
if (payload.data && payload.data.result && Array.isArray(payload.data.result.stations)) {
return payload.data.result.stations;
}
return [];
}
function getAprsStationSignature(station) {
if (!station || typeof station !== 'object') return '';
const receivedAt = station.received_at || station.last_seen || station.timestamp || '';
const lat = station.lat ?? station.latitude ?? '';
const lon = station.lon ?? station.longitude ?? '';
const payloadHint = station.raw || station.comment || station.path || '';
return `${receivedAt}|${lat},${lon}|${payloadHint}`;
}
function processAprsAgentStations(stations, agentName) {
if (!Array.isArray(stations) || stations.length === 0) return;
stations.forEach((station) => {
const callsign = String(station && station.callsign ? station.callsign : '').trim();
if (!callsign) return;
const lat = station.lat ?? station.latitude ?? null;
const lon = station.lon ?? station.longitude ?? null;
const signature = getAprsStationSignature(station);
if (aprsAgentStationSignatures.get(callsign) === signature) return;
aprsAgentStationSignatures.set(callsign, signature);
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
processAprsPacket({
type: 'aprs',
...station,
lat,
lon,
callsign,
agent_name: station.agent_name || agentName || 'Remote Agent'
});
});
}
async function loadAprsStationSnapshot(isAgentMode = false) {
try {
const endpoint = (isAgentMode && aprsCurrentAgent)
? `/controller/agents/${aprsCurrentAgent}/aprs/data`
: '/aprs/stations';
const response = await fetch(endpoint);
if (!response.ok) return;
const payload = await response.json();
const stations = extractAprsStationsFromPayload(payload);
if (!Array.isArray(stations) || stations.length === 0) return;
if (isAgentMode) {
processAprsAgentStations(stations, payload.agent_name);
return;
}
stations.forEach((station) => {
const callsign = String(station && station.callsign ? station.callsign : '').trim();
if (!callsign) return;
const packet = {
type: 'aprs',
...station,
callsign,
lat: station.lat ?? station.latitude ?? null,
lon: station.lon ?? station.longitude ?? null,
packet_type: station.packet_type || 'position',
};
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
updateAprsMarker(packet);
}
updateAprsStationList(packet);
});
} catch (err) {
console.debug('APRS snapshot load failed:', err);
}
}
function startAprs() {
// Get values from function bar controls
const region = document.getElementById('aprsStripRegion').value;
const device = getSelectedDevice();
const gain = document.getElementById('aprsStripGain').value;
const sdrType = (typeof getSelectedSDRType === 'function') ? getSelectedSDRType() : 'rtlsdr';
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
aprsCurrentAgent = isAgentMode ? currentAgent : null;
// Check for remote SDR (only for local mode)
const remoteConfig = isAgentMode ? null : getRemoteSDRConfig();
if (remoteConfig === false) return; // Validation failed
// Build request body
const requestBody = {
region,
device: parseInt(device),
gain: parseInt(gain),
sdr_type: sdrType
};
// Add rtl_tcp params if using remote SDR
if (remoteConfig) {
requestBody.rtl_tcp_host = remoteConfig.host;
requestBody.rtl_tcp_port = remoteConfig.port;
}
// Add custom frequency if selected
if (region === 'custom') {
const customFreq = document.getElementById('aprsStripCustomFreq').value;
if (!customFreq) {
alert('Please enter a custom frequency');
return;
}
requestBody.frequency = customFreq;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/aprs/start`
: '/aprs/start';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
isAprsRunning = true;
aprsPacketCount = 0;
aprsStationCount = 0;
resetAprsAgentStationTracking();
if (aprsMap) {
Object.values(aprsMarkers).forEach((marker) => {
try {
aprsMap.removeLayer(marker);
} catch (_) {}
});
}
aprsMarkers = {};
// Initialize APRS filter bar and clear history
const filterContainer = document.getElementById('aprsFilterBarContainer');
const stationList = document.getElementById('aprsStationList');
if (filterContainer && !document.getElementById('aprsFilterBar')) {
const filterBar = SignalCards.createAprsFilterBar(stationList);
filterContainer.appendChild(filterBar);
}
SignalCards.clearAddressHistory('aprs');
// Clear existing station cards
stationList.innerHTML = '<div class="signal-cards-placeholder" style="padding: 20px; text-align: center; color: var(--text-muted);">Waiting for stations...</div>';
const packetLog = document.getElementById('aprsPacketLog');
if (packetLog) {
packetLog.innerHTML = '<div style="color: var(--text-muted);">Waiting for packets...</div>';
}
document.getElementById('aprsPacketCount').textContent = '0';
document.getElementById('aprsStationCount').textContent = '0';
// Update function bar buttons
document.getElementById('aprsStripStartBtn').style.display = 'none';
document.getElementById('aprsStripStopBtn').style.display = 'inline-block';
// Update map status
document.getElementById('aprsMapStatus').textContent = 'TRACKING';
document.getElementById('aprsMapStatus').style.color = 'var(--accent-green)';
// Update function bar status
updateAprsStatus('listening', scanResult.frequency);
// Reset function bar stats
document.getElementById('aprsStripStations').textContent = '0';
document.getElementById('aprsStripPackets').textContent = '0';
document.getElementById('aprsStripSignal').textContent = '--';
// Disable controls while running
document.getElementById('aprsStripRegion').disabled = true;
document.getElementById('aprsStripGain').disabled = true;
const customFreqInput = document.getElementById('aprsStripCustomFreq');
if (customFreqInput) customFreqInput.disabled = true;
startAprsMeterCheck();
startAprsStream(isAgentMode);
// Backfill current stations in case position packets arrived before
// map initialization or SSE attachment.
loadAprsStationSnapshot(isAgentMode);
} else {
alert('APRS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
updateAprsStatus('error');
}
})
.catch(err => {
alert('APRS Error: ' + err);
updateAprsStatus('error');
});
}
async function stopAprs() {
const isAgentMode = aprsCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${aprsCurrentAgent}/aprs/stop`
: '/aprs/stop';
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
isAprsRunning = false;
aprsCurrentAgent = null;
resetAprsAgentStationTracking();
document.getElementById('aprsStripStopBtn').style.display = 'none';
document.getElementById('aprsMapStatus').textContent = 'STOPPING';
document.getElementById('aprsMapStatus').style.color = '';
updateAprsStatus('standby');
document.getElementById('aprsStripFreq').textContent = '--';
document.getElementById('aprsStripSignal').textContent = '--';
document.getElementById('aprsStripRegion').disabled = false;
document.getElementById('aprsStripGain').disabled = false;
const customFreqInput = document.getElementById('aprsStripCustomFreq');
if (customFreqInput) customFreqInput.disabled = false;
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalStat) {
signalStat.classList.remove('good', 'warning', 'poor');
}
stopAprsMeterCheck();
if (aprsEventSource) {
aprsEventSource.close();
aprsEventSource = null;
}
if (aprsPollTimer) {
clearInterval(aprsPollTimer);
aprsPollTimer = null;
}
await postStopRequest(endpoint, timeoutMs);
document.getElementById('aprsStripStartBtn').style.display = 'inline-block';
document.getElementById('aprsMapStatus').textContent = 'STANDBY';
}
function startAprsStream(isAgentMode = false) {
if (aprsEventSource) aprsEventSource.close();
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/aprs/stream';
aprsEventSource = new EventSource(streamUrl + (streamUrl.includes('?') ? '&' : '?') + 't=' + Date.now());
aprsEventSource.onmessage = function (e) {
const data = JSON.parse(e.data);
if (isAgentMode) {
// Handle multi-agent stream format
if (data.scan_type === 'aprs' && data.payload) {
const payload = data.payload;
if (payload.type === 'aprs') {
aprsPacketCount++;
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
// Add agent info
payload.agent_name = data.agent_name;
processAprsPacket(payload);
} else if (payload.type === 'meter') {
updateAprsMeter(payload.level);
} else {
const stations = extractAprsStationsFromPayload(payload);
processAprsAgentStations(stations, data.agent_name);
}
}
} else {
// Local stream format
if (data.type === 'aprs') {
aprsPacketCount++;
// Update map footer and function bar
document.getElementById('aprsPacketCount').textContent = aprsPacketCount;
document.getElementById('aprsStripPackets').textContent = aprsPacketCount;
// Switch to tracking state on first packet
const dot = document.getElementById('aprsStripDot');
if (dot && !dot.classList.contains('tracking')) {
updateAprsStatus('tracking');
}
processAprsPacket(data);
} else if (data.type === 'meter') {
// Update signal indicator in function bar
updateAprsMeter(data.level);
}
}
};
aprsEventSource.onerror = function () {
console.error('APRS stream error');
updateAprsStatus('error');
};
// Start polling fallback for agent mode
if (isAgentMode) {
startAprsPolling();
}
}
function startAprsPolling() {
if (aprsPollTimer) return;
resetAprsAgentStationTracking();
const pollInterval = 2000;
aprsPollTimer = setInterval(async () => {
if (!isAprsRunning || !aprsCurrentAgent) {
clearInterval(aprsPollTimer);
aprsPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${aprsCurrentAgent}/aprs/data`);
if (!response.ok) return;
const payload = await response.json();
const stations = extractAprsStationsFromPayload(payload);
const agentName = payload.agent_name ||
(payload.data && payload.data.agent_name) ||
'Remote Agent';
processAprsAgentStations(stations, agentName);
} catch (err) {
console.error('APRS polling error:', err);
}
}, pollInterval);
}
// Signal Meter Functions
function resetAprsMeter() {
aprsMeterLastUpdate = 0;
// Reset function bar signal indicator
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) signalEl.textContent = '--';
if (signalStat) signalStat.classList.remove('good', 'warning', 'poor');
}
function updateAprsMeter(level) {
aprsMeterLastUpdate = Date.now();
// Update function bar signal indicator
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) {
// Show signal level as bars
if (level >= 60) {
signalEl.textContent = '●●●';
} else if (level >= 30) {
signalEl.textContent = '●●○';
} else if (level >= 10) {
signalEl.textContent = '●○○';
} else {
signalEl.textContent = '○○○';
}
}
if (signalStat) {
signalStat.classList.remove('good', 'warning', 'poor');
if (level >= 60) {
signalStat.classList.add('good');
} else if (level >= 30) {
signalStat.classList.add('warning');
} else {
signalStat.classList.add('poor');
}
}
}
function startAprsMeterCheck() {
// Check for no-signal state every second
aprsMeterCheckInterval = setInterval(function () {
if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) {
// No meter updates for 5 seconds - show no-signal state
const signalEl = document.getElementById('aprsStripSignal');
const signalStat = document.getElementById('aprsStripSignalStat');
if (signalEl) signalEl.textContent = '○○○';
if (signalStat) {
signalStat.classList.remove('good', 'warning');
signalStat.classList.add('poor');
}
}
}, 1000);
}
function stopAprsMeterCheck() {
if (aprsMeterCheckInterval) {
clearInterval(aprsMeterCheckInterval);
aprsMeterCheckInterval = null;
}
}
// Handle region selection changes to show/hide custom frequency input
document.addEventListener('DOMContentLoaded', function() {
const regionSelect = document.getElementById('aprsStripRegion');
const customFreqControl = document.getElementById('aprsStripCustomFreqControl');
if (regionSelect && customFreqControl) {
regionSelect.addEventListener('change', function() {
if (this.value === 'custom') {
customFreqControl.style.display = 'flex';
} else {
customFreqControl.style.display = 'none';
}
});
}
});
function processAprsPacket(packet) {
// Update packet log
const logEl = document.getElementById('aprsPacketLog');
const logEntry = document.createElement('div');
logEntry.style.cssText = 'padding: 3px 0; border-bottom: 1px solid var(--border-color);';
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const callsign = packet.callsign || 'UNKNOWN';
const packetType = packet.packet_type || 'unknown';
logEntry.innerHTML = `<span style="color: var(--text-muted);">${time}</span> <span style="color: var(--accent-cyan); font-weight: bold;">${callsign}</span> <span style="color: var(--accent-green);">[${packetType}]</span>`;
// Remove placeholder if present
const placeholder = logEl.querySelector('div[style*="color: var(--text-muted)"]');
if (placeholder && placeholder.textContent.includes('Waiting')) {
placeholder.remove();
}
logEl.insertBefore(logEntry, logEl.firstChild);
// Keep log manageable
while (logEl.children.length > 100) {
logEl.removeChild(logEl.lastChild);
}
// Update map if position data
if (aprsHasValidCoordinates(packet.lat, packet.lon) && aprsMap) {
updateAprsMarker(packet);
}
// Update station list
updateAprsStationList(packet);
}
function getAprsMarkerCategory(packet) {
const symbolCode = (packet.symbol && packet.symbol.length > 1) ? packet.symbol[1] : '';
const speed = parseFloat(packet.speed || 0);
const vehicleSymbols = new Set(['>', 'k', 'u', 'v', '[', '<', 's', 'b', 'j']);
if ((Number.isFinite(speed) && speed > 2) || vehicleSymbols.has(symbolCode)) {
return 'vehicle';
}
return 'tower';
}
function getAprsMarkerSvg(category) {
if (category === 'vehicle') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 14l2-5a2 2 0 0 1 2-1h10a2 2 0 0 1 2 1l2 5v4h-2a2 2 0 0 1-4 0H9a2 2 0 0 1-4 0H3v-4z"/><circle cx="7" cy="18" r="1.7"/><circle cx="17" cy="18" r="1.7"/></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l3 7h-2l1 3h-2l1 8h-2l1-8h-2l1-3H9l3-7z"/><path d="M5 21h14" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
}
function buildAprsMarkerIcon(packet) {
const category = getAprsMarkerCategory(packet);
const callsign = packet.callsign || 'UNKNOWN';
const html = `
<div class="aprs-map-marker ${category}">
<span class="aprs-map-marker-icon">${getAprsMarkerSvg(category)}</span>
<span class="aprs-map-marker-label">${callsign}</span>
</div>
`;
return L.divIcon({
className: 'aprs-map-marker-wrap',
html,
iconSize: [110, 24],
iconAnchor: [55, 12]
});
}
function updateAprsMarker(packet) {
const callsign = packet.callsign;
const lat = Number(packet.lat);
const lon = Number(packet.lon);
if (!aprsHasValidCoordinates(lat, lon)) {
return;
}
// Calculate distance if user location available
let distStr = '';
if (aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
distStr = `Distance: ${dist.toFixed(1)} mi<br>`;
}
if (aprsMarkers[callsign]) {
// Update existing marker position and popup
aprsMarkers[callsign].setLatLng([lat, lon]);
aprsMarkers[callsign].setIcon(buildAprsMarkerIcon(packet));
aprsMarkers[callsign].setPopupContent(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
${packet.course ? `Course: ${packet.course}°<br>` : ''}
</div>
`);
} else {
// Create new marker
aprsStationCount++;
// Update map footer and function bar
document.getElementById('aprsStationCount').textContent = aprsStationCount;
document.getElementById('aprsStripStations').textContent = aprsStationCount;
const marker = L.marker([lat, lon], { icon: buildAprsMarkerIcon(packet) }).addTo(aprsMap);
marker.bindPopup(`
<div style="font-family: monospace;">
<strong>${callsign}</strong><br>
Position: ${lat.toFixed(4)}, ${lon.toFixed(4)}<br>
${distStr}
${packet.altitude ? `Altitude: ${packet.altitude} ft<br>` : ''}
${packet.speed ? `Speed: ${packet.speed} kts<br>` : ''}
${packet.course ? `Course: ${packet.course}°<br>` : ''}
</div>
`);
aprsMarkers[callsign] = marker;
}
}
function updateAprsStationList(packet) {
const listEl = document.getElementById('aprsStationList');
const callsign = packet.callsign;
// Remove placeholder if present
const placeholder = listEl.querySelector('.signal-cards-placeholder');
if (placeholder) {
placeholder.remove();
}
// Calculate distance if user location available
let distance = null;
const hasPos = aprsHasValidCoordinates(packet.lat, packet.lon);
const lat = hasPos ? Number(packet.lat) : null;
const lon = hasPos ? Number(packet.lon) : null;
if (hasPos && aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon)) {
distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, lat, lon);
}
// Check if station already exists
let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`);
const isExisting = !!stationEl;
// Prepare message object for card creation
const msg = {
callsign: callsign,
packet_type: packet.packet_type || 'unknown',
latitude: lat,
longitude: lon,
altitude: packet.altitude,
speed: packet.speed,
course: packet.course,
comment: packet.comment,
symbol: packet.symbol,
path: packet.path,
raw: packet.raw,
timestamp: new Date().toISOString(),
distance: distance
};
// Create or update the card
const newCard = SignalCards.createAprsCard(msg, { compact: true });
newCard.dataset.callsign = callsign;
// Store position for distance updates
if (hasPos) {
newCard.dataset.lat = lat;
newCard.dataset.lon = lon;
}
// Add click handler to focus map
newCard.style.cursor = 'pointer';
newCard.addEventListener('click', (e) => {
// Don't trigger if clicking on buttons
if (e.target.closest('button')) return;
if (aprsMarkers[callsign] && aprsMap) {
aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10);
aprsMarkers[callsign].openPopup();
}
});
if (isExisting) {
// Replace existing card
stationEl.replaceWith(newCard);
} else {
// Insert new card at top
listEl.insertBefore(newCard, listEl.firstChild);
}
// Keep list manageable (use live childElementCount, not static NodeList)
const MAX_APRS_STATION_CARDS = 200;
while (listEl.childElementCount > MAX_APRS_STATION_CARDS && listEl.lastElementChild) {
listEl.removeChild(listEl.lastElementChild);
}
// Update filter counts if filter bar exists
SignalCards.updateCounts(listEl);
}
// ============================================
// SATELLITE MODE FUNCTIONS
// ============================================
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
position => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
observerLocation.lat = lat;
observerLocation.lon = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
showInfo('Location updated!');
},
error => {
alert('Could not get location: ' + error.message);
}
);
} else {
alert('Geolocation not supported by browser');
}
}
// ============================================
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
async function autoConnectGps() {
// Automatically try to connect to gpsd on page load
try {
const response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
console.log('GPS: Auto-connect failed -', e.message);
}
}
let gpsReconnectTimeout = null;
// GPS subscriber callbacks - modules can register to receive GPS stream data
const gpsStreamSubscribers = [];
function addGpsStreamSubscriber(fn) {
if (!gpsStreamSubscribers.includes(fn)) {
gpsStreamSubscribers.push(fn);
}
}
function removeGpsStreamSubscriber(fn) {
const idx = gpsStreamSubscribers.indexOf(fn);
if (idx !== -1) gpsStreamSubscribers.splice(idx, 1);
}
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
if (gpsReconnectTimeout) {
clearTimeout(gpsReconnectTimeout);
gpsReconnectTimeout = null;
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'position') {
gpsLastPosition = data;
updateLocationFromGps(data);
}
// Dispatch to all subscribers (e.g. GPS mode UI)
gpsStreamSubscribers.forEach(fn => fn(data));
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
// Don't log every error - connection suspends are normal
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
// Auto-reconnect after 5 seconds if still connected
if (gpsConnected && !gpsReconnectTimeout) {
gpsReconnectTimeout = setTimeout(() => {
gpsReconnectTimeout = null;
if (gpsConnected) {
startGpsStream();
}
}, 5000);
}
};
}
// Reconnect GPS stream when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && gpsConnected && !gpsEventSource) {
startGpsStream();
}
});
function updateLocationFromGps(position) {
const lat = Number(position && position.latitude);
const lon = Number(position && position.longitude);
const fixQuality = Number(position && position.fix_quality);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
return;
}
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
// Update satellite observer location
const satLatInput = document.getElementById('obsLat');
const satLonInput = document.getElementById('obsLon');
if (satLatInput) satLatInput.value = lat.toFixed(4);
if (satLonInput) satLonInput.value = lon.toFixed(4);
// Update observerLocation
observerLocation.lat = lat;
observerLocation.lon = lon;
// Keep live GPS separate from the configured shared observer location.
updateAprsUserLocation({ latitude: lat, longitude: lon });
}
function showGpsIndicator(show) {
// Show/hide all GPS indicators (by class and by ID)
document.querySelectorAll('.gps-indicator').forEach(el => {
el.style.display = show ? 'inline-flex' : 'none';
});
// Also target specific IDs in case class selector doesn't work
['satGpsIndicator', 'aprsGpsIndicator'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = show ? 'inline-flex' : 'none';
});
}
function initPolarPlot() {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const container = canvas.parentElement;
const size = Math.min(container.offsetWidth, 400);
canvas.width = size;
canvas.height = size;
drawPolarPlot();
}
function drawPolarPlot(pass = null) {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = canvas.width;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 30;
// Clear
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, size, size);
// Draw elevation rings
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
ctx.lineWidth = 1;
for (let el = 0; el <= 90; el += 30) {
const r = radius * (90 - el) / 90;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Label
if (el > 0) {
ctx.fillStyle = '#444';
ctx.font = '10px Roboto Condensed';
ctx.textAlign = 'center';
ctx.fillText(el + '°', cx, cy - r + 12);
}
}
// Draw azimuth lines
for (let az = 0; az < 360; az += 45) {
const rad = az * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.sin(rad) * radius, cy - Math.cos(rad) * radius);
ctx.stroke();
}
// Draw cardinal directions
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 14px Rajdhani';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - radius - 8);
ctx.fillStyle = '#888';
ctx.fillText('S', cx, cy + radius + 16);
ctx.fillText('E', cx + radius + 12, cy + 4);
ctx.fillText('W', cx - radius - 12, cy + 4);
// Draw zenith
ctx.fillStyle = '#00ffff';
ctx.beginPath();
ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fill();
// Draw selected pass trajectory
if (pass && pass.trajectory) {
ctx.strokeStyle = pass.color || '#00ff00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
pass.trajectory.forEach((point, i) => {
// Backend returns 'el' and 'az' properties
const el = point.el !== undefined ? point.el : point.elevation;
const az = point.az !== undefined ? point.az : point.azimuth;
const r = radius * (90 - el) / 90;
const rad = az * Math.PI / 180;
const x = cx + Math.sin(rad) * r;
const y = cy - Math.cos(rad) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
// Draw max elevation point
const maxPoint = pass.trajectory.reduce((max, p) => {
const pEl = p.el !== undefined ? p.el : p.elevation;
const maxEl = max.el !== undefined ? max.el : max.elevation;
return pEl > maxEl ? p : max;
}, { el: 0, elevation: 0 });
const maxEl = maxPoint.el !== undefined ? maxPoint.el : maxPoint.elevation;
const maxAz = maxPoint.az !== undefined ? maxPoint.az : maxPoint.azimuth;
const maxR = radius * (90 - maxEl) / 90;
const maxRad = maxAz * Math.PI / 180;
const maxX = cx + Math.sin(maxRad) * maxR;
const maxY = cy - Math.cos(maxRad) * maxR;
ctx.fillStyle = pass.color || '#00ff00';
ctx.beginPath();
ctx.arc(maxX, maxY, 6, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px Roboto Condensed';
ctx.fillText(pass.satellite, maxX + 10, maxY - 5);
}
}
// Satellite mode agent state
let satelliteCurrentAgent = null;
function calculatePasses() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const hours = parseInt(document.getElementById('predictionHours').value);
const minEl = parseInt(document.getElementById('minElevation').value);
const satellites = getSelectedSatellites();
if (satellites.length === 0) {
alert('Please select at least one satellite to track');
return;
}
// Check if using agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
satelliteCurrentAgent = isAgentMode ? currentAgent : null;
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/satellite/predict`
: '/satellite/predict';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, hours, minEl, satellites })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const result = isAgentMode && data.result ? data.result : data;
if (result.status === 'success') {
satellitePasses = result.passes;
renderPassList();
document.getElementById('passCount').textContent = result.passes.length;
if (result.passes.length > 0) {
selectPass(0);
document.getElementById('satelliteCountdown').style.display = 'block';
updateSatelliteCountdown();
startCountdownTimer();
} else {
document.getElementById('satelliteCountdown').style.display = 'none';
}
} else {
alert('Error: ' + (result.message || result.error || 'Failed to predict passes'));
}
});
}
function renderPassList() {
const container = document.getElementById('passList');
container.innerHTML = '';
if (satellitePasses.length === 0) {
container.innerHTML = '<div style="color: #666; text-align: center; padding: 30px;">No passes found for selected criteria.</div>';
return;
}
document.getElementById('passListCount').textContent = satellitePasses.length + ' passes';
satellitePasses.forEach((pass, index) => {
const card = document.createElement('div');
card.className = 'pass-card' + (index === 0 ? ' active' : '');
card.onclick = () => selectPass(index);
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
card.innerHTML = `
<div class="pass-satellite">${pass.satellite}</div>
<div class="pass-time">${pass.startTime}</div>
<div class="pass-details">
<div>Max El: <span>${pass.maxEl}°</span></div>
<div>Duration: <span>${pass.duration}m</span></div>
<div class="pass-quality ${quality}">${quality.toUpperCase()}</div>
</div>
`;
container.appendChild(card);
});
}
function selectPass(index) {
selectedPass = satellitePasses[index];
selectedPassIndex = index;
document.querySelectorAll('.pass-card').forEach((card, i) => {
card.classList.toggle('active', i === index);
});
drawPolarPlot(selectedPass);
updateGroundTrack(selectedPass);
// Update countdown to show selected pass
updateSatelliteCountdown();
// Start real-time position updates for full orbit track
startSatellitePositionUpdates();
// Fetch position immediately
updateRealTimePosition();
}
// Ground Track Map
let groundTrackMap = null;
let groundTrackLine = null;
let satMarker = null;
let observerMarker = null;
let satPositionInterval = null;
async function initGroundTrackMap() {
const mapContainer = document.getElementById('groundTrackMap');
if (!mapContainer || groundTrackMap) return;
groundTrackMap = L.map('groundTrackMap', {
center: [20, 0],
zoom: 1,
zoomControl: true,
attributionControl: false
});
window.groundTrackMap = groundTrackMap;
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).addTo(groundTrackMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
groundTrackMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(groundTrackMap);
Settings.registerMap(groundTrackMap);
} catch (e) {
console.warn('Ground track: Settings init failed/timed out, using fallback tiles:', e);
}
}
// Add observer marker
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
observerMarker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: '#ff6600',
color: '#fff',
weight: 2,
fillOpacity: 1
}).addTo(groundTrackMap).bindPopup('Observer Location');
}
function updateGroundTrack(pass) {
if (!groundTrackMap) initGroundTrackMap();
if (!pass || !pass.groundTrack) return;
// Remove old track and marker
if (groundTrackLine) {
groundTrackMap.removeLayer(groundTrackLine);
groundTrackLine = null;
}
if (satMarker) {
groundTrackMap.removeLayer(satMarker);
satMarker = null;
}
if (orbitTrackLine) {
groundTrackMap.removeLayer(orbitTrackLine);
orbitTrackLine = null;
}
if (pastOrbitLine) {
groundTrackMap.removeLayer(pastOrbitLine);
pastOrbitLine = null;
}
// Split ground track only at true antimeridian crossings (±180° line)
const segments = [];
let currentSegment = [];
for (let i = 0; i < pass.groundTrack.length; i++) {
const p = pass.groundTrack[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// Only split when crossing the antimeridian (one side > 90, other < -90)
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
if (crossesAntimeridian) {
if (currentSegment.length >= 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length >= 1) segments.push(currentSegment);
// Draw ground track segments
groundTrackLine = L.layerGroup();
const allCoords = [];
segments.forEach(seg => {
L.polyline(seg, {
color: pass.color || '#00ff00',
weight: 2,
opacity: 0.8,
dashArray: '5, 5'
}).addTo(groundTrackLine);
allCoords.push(...seg);
});
groundTrackLine.addTo(groundTrackMap);
// Add current position marker
if (pass.currentPosition) {
satMarker = L.marker([pass.currentPosition.lat, pass.currentPosition.lon], {
icon: L.divIcon({
className: 'sat-marker',
html: '<div style="background:#ffff00;width:12px;height:12px;border-radius:50%;border:2px solid #000;box-shadow:0 0 10px #ffff00;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).addTo(groundTrackMap).bindPopup(pass.satellite);
}
// Update observer marker position
const lat = parseFloat(document.getElementById('obsLat').value) || 51.5;
const lon = parseFloat(document.getElementById('obsLon').value) || -0.1;
if (observerMarker) {
observerMarker.setLatLng([lat, lon]);
}
// Fit bounds to show track
if (allCoords.length > 0) {
groundTrackMap.fitBounds(L.latLngBounds(allCoords), { padding: [20, 20] });
}
}
function toggleGroundTrack() {
const show = document.getElementById('showGroundTrack').checked;
document.getElementById('groundTrackMap').style.display = show ? 'block' : 'none';
if (show && groundTrackMap) {
groundTrackMap.invalidateSize();
}
}
function startSatellitePositionUpdates() {
if (satPositionInterval) clearInterval(satPositionInterval);
satPositionInterval = setInterval(() => {
if (selectedPass) {
updateRealTimePosition();
}
}, 5000);
}
function updateRealTimePosition() {
let satellites = getSelectedSatellites();
// Ensure selected pass's satellite is included in the request
if (selectedPass && selectedPass.satellite) {
if (!satellites.includes(selectedPass.satellite)) {
satellites = [selectedPass.satellite, ...satellites];
}
}
if (satellites.length === 0) return;
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
// Check if using agent mode
const isAgentMode = satelliteCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${satelliteCurrentAgent}/satellite/position`
: '/satellite/position';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lat, lon, satellites, includeTrack: true })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const result = isAgentMode && data.result ? data.result : data;
if (result.status === 'success' && result.positions) {
updateRealTimeIndicators(result.positions);
}
});
}
let orbitTrackLine = null;
let pastOrbitLine = null;
function updateRealTimeIndicators(positions) {
// Update ground track map markers
positions.forEach(pos => {
if (selectedPass && pos.satellite === selectedPass.satellite) {
// Update satellite marker position
if (satMarker) {
satMarker.setLatLng([pos.lat, pos.lon]);
satMarker.setPopupContent(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km<br>El: ' + pos.elevation.toFixed(1) + '°');
} else if (groundTrackMap) {
satMarker = L.marker([pos.lat, pos.lon], {
icon: L.divIcon({
className: 'sat-marker',
html: '<div style="background:#ffff00;width:14px;height:14px;border-radius:50%;border:2px solid #000;box-shadow:0 0 15px #ffff00;animation:pulse-sat 1s infinite;"></div>',
iconSize: [14, 14],
iconAnchor: [7, 7]
})
}).addTo(groundTrackMap).bindPopup(pos.satellite + '<br>Alt: ' + pos.altitude.toFixed(0) + ' km');
}
// Draw full orbit track from position endpoint
// Backend returns 'track' property
const orbitData = pos.track || pos.orbitTrack;
if (orbitData && orbitData.length > 0 && groundTrackMap) {
// Split into past and future, handling antimeridian crossings
const pastPoints = orbitData.filter(p => p.past);
const futurePoints = orbitData.filter(p => !p.past);
// Helper to split coords only at true antimeridian crossings (±180° line)
function splitAtAntimeridian(points) {
const segments = [];
let currentSegment = [];
for (let i = 0; i < points.length; i++) {
const p = points[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// Only split when crossing the antimeridian (one side > 90, other < -90)
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
if (crossesAntimeridian) {
if (currentSegment.length >= 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length >= 1) segments.push(currentSegment);
return segments;
}
// Remove old lines
if (orbitTrackLine) groundTrackMap.removeLayer(orbitTrackLine);
if (pastOrbitLine) groundTrackMap.removeLayer(pastOrbitLine);
// Draw past track segments (dimmer)
const pastSegments = splitAtAntimeridian(pastPoints);
if (pastSegments.length > 0) {
pastOrbitLine = L.layerGroup();
pastSegments.forEach(seg => {
L.polyline(seg, {
color: '#666666',
weight: 2,
opacity: 0.5,
dashArray: '3, 6'
}).addTo(pastOrbitLine);
});
pastOrbitLine.addTo(groundTrackMap);
}
// Draw future track segments (brighter)
const futureSegments = splitAtAntimeridian(futurePoints);
if (futureSegments.length > 0) {
orbitTrackLine = L.layerGroup();
futureSegments.forEach(seg => {
L.polyline(seg, {
color: selectedPass.color || '#00ff00',
weight: 3,
opacity: 0.8
}).addTo(orbitTrackLine);
});
orbitTrackLine.addTo(groundTrackMap);
}
}
// Update polar plot with pass trajectory and real-time position
if (selectedPass) {
drawPolarPlot(selectedPass);
// Draw current position on top if satellite is visible
if (pos.elevation > 0) {
drawRealTimePositionOnPolar(pos);
}
}
}
});
}
function drawRealTimePositionOnPolar(pos) {
const canvas = document.getElementById('polarPlotCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const size = canvas.width;
const cx = size / 2;
const cy = size / 2;
const radius = size / 2 - 30;
// Draw pulsing indicator for current position
const r = radius * (90 - pos.elevation) / 90;
const rad = pos.azimuth * Math.PI / 180;
const x = cx + Math.sin(rad) * r;
const y = cy - Math.cos(rad) * r;
ctx.fillStyle = '#ffff00';
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, 12, 0, Math.PI * 2);
ctx.stroke();
}
function updateTLE() {
fetch('/satellite/update-tle', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
showInfo('TLE data updated!');
} else {
alert('Error updating TLE: ' + data.message);
}
});
}
// Satellite management
let trackedSatellites = [];
function renderSatelliteList() {
const list = document.getElementById('satTrackingList');
if (!list) return;
list.innerHTML = trackedSatellites.map((sat, idx) => `
<div class="sat-item ${sat.builtin ? 'builtin' : ''}">
<label>
<input type="checkbox" ${sat.checked ? 'checked' : ''} onchange="toggleSatellite(${idx})">
<span class="sat-name">${sat.name}</span>
<span class="sat-norad">#${sat.norad}</span>
</label>
<button class="sat-remove" onclick="removeSatellite(${idx})" title="Remove">✕</button>
</div>
`).join('');
}
function toggleSatellite(idx) {
const sat = trackedSatellites[idx];
sat.checked = !sat.checked;
fetch(`/satellite/tracked/${sat.norad}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: sat.checked })
}).catch(() => {});
}
function removeSatellite(idx) {
const sat = trackedSatellites[idx];
if (sat.builtin) return;
fetch(`/satellite/tracked/${sat.norad}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
trackedSatellites.splice(idx, 1);
renderSatelliteList();
}
})
.catch(() => {});
}
function getSelectedSatellites() {
return trackedSatellites.filter(s => s.checked).map(s => s.id);
}
function showAddSatelliteModal() {
document.getElementById('satModal').classList.add('active');
}
function closeSatModal() {
document.getElementById('satModal').classList.remove('active');
}
function switchSatModalTab(tab) {
document.querySelectorAll('.sat-modal-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.sat-modal-section').forEach(s => s.classList.remove('active'));
if (tab === 'tle') {
document.querySelector('.sat-modal-tab:first-child').classList.add('active');
document.getElementById('tleSection').classList.add('active');
} else {
document.querySelector('.sat-modal-tab:last-child').classList.add('active');
document.getElementById('celestrakSection').classList.add('active');
}
}
function addFromTLE() {
const tleText = document.getElementById('tleInput').value.trim();
if (!tleText) {
alert('Please paste TLE data');
return;
}
const lines = tleText.split(/\r?\n/).map(l => l.trim()).filter(l => l);
const toAdd = [];
for (let i = 0; i < lines.length; i += 3) {
if (i + 2 < lines.length) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
const norad = line1.substring(2, 7).trim();
if (!trackedSatellites.find(s => s.norad === norad)) {
toAdd.push({ norad_id: norad, name: name, tle1: line1, tle2: line2, enabled: true });
}
}
}
}
if (toAdd.length === 0) {
alert('No valid TLE data found. Format: Name, Line 1, Line 2 (3 lines per satellite)');
return;
}
fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toAdd)
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
_loadSatellitesFromAPI();
document.getElementById('tleInput').value = '';
closeSatModal();
showInfo(`Added ${data.added} satellite(s)`);
}
})
.catch(() => alert('Failed to save satellites'));
}
function fetchCelestrak() {
showAddSatelliteModal();
switchSatModalTab('celestrak');
}
function fetchCelestrakCategory(category) {
const status = document.getElementById('celestrakStatus');
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
fetch('/satellite/celestrak/' + category)
.then(r => r.json())
.then(async data => {
if (data.status === 'success' && data.satellites) {
const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
.map(sat => ({
norad_id: String(sat.norad),
name: sat.name,
tle1: sat.tle1,
tle2: sat.tle2,
enabled: false
}));
if (toAdd.length === 0) {
status.innerHTML = `<span style="color: var(--accent-green);">All ${data.satellites.length} satellites already tracked</span>`;
return;
}
const batchSize = 250;
let addedTotal = 0;
for (let i = 0; i < toAdd.length; i += batchSize) {
const batch = toAdd.slice(i, i + batchSize);
const completed = Math.min(i + batch.length, toAdd.length);
status.innerHTML = `<span style="color: var(--accent-cyan);">Importing ${completed}/${toAdd.length} from ${category}...</span>`;
const resp = await fetch('/satellite/tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(batch)
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || result.status !== 'success') {
throw new Error(result.message || result.error || `HTTP ${resp.status}`);
}
addedTotal += Number(result.added || 0);
}
_loadSatellitesFromAPI();
status.innerHTML = `<span style="color: var(--accent-green);">Added ${addedTotal} satellites (${data.satellites.length} total in category)</span>`;
} else {
status.innerHTML = `<span style="color: var(--accent-red);">Error: ${data.message || 'Failed to fetch'}</span>`;
}
})
.catch((err) => {
const msg = err && err.message ? err.message : 'Network error';
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
});
}
function _loadSatellitesFromAPI() {
fetch('/satellite/tracked')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites) {
trackedSatellites = data.satellites.map(sat => ({
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
name: sat.name,
norad: sat.norad_id,
builtin: sat.builtin,
checked: sat.enabled,
tle: sat.tle_line1 ? [sat.name, sat.tle_line1, sat.tle_line2] : null
}));
renderSatelliteList();
}
})
.catch(() => {
// Fallback to hardcoded defaults if API fails
if (trackedSatellites.length === 0) {
trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
];
renderSatelliteList();
}
});
}
// Initialize satellite list when satellite mode is loaded
function initSatelliteList() {
_loadSatellitesFromAPI();
}
// Utility function
function showInfo(message) {
// Simple notification - could be enhanced
const existing = document.querySelector('.info-toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'info-toast';
toast.textContent = message;
toast.style.cssText = 'position: fixed; bottom: 20px; right: 20px; background: var(--accent-cyan); color: #000; padding: 10px 20px; border-radius: 4px; z-index: 10001; font-size: 12px;';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// Theme toggle functions
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
// Save to localStorage for instant load on next visit
localStorage.setItem('intercept-theme', newTheme);
// Persist to server for cross-device sync
fetch('/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: newTheme })
}).catch(err => console.warn('Failed to save theme to server:', err));
}
// Animation toggle functions
function toggleAnimations() {
const html = document.documentElement;
const currentState = html.getAttribute('data-animations');
const newState = currentState === 'off' ? 'on' : 'off';
if (newState === 'on') {
html.removeAttribute('data-animations');
} else {
html.setAttribute('data-animations', newState);
}
// Save to localStorage for persistence
localStorage.setItem('intercept-animations', newState);
}
// Load saved theme and animations on page load
(function () {
// First apply localStorage theme for instant load (no flash)
const localTheme = localStorage.getItem('intercept-theme') || 'dark';
document.documentElement.setAttribute('data-theme', localTheme);
// Apply animations preference
const localAnimations = localStorage.getItem('intercept-animations');
if (localAnimations === 'off') {
document.documentElement.setAttribute('data-animations', 'off');
}
// Then fetch from server to sync (in case changed on another device)
fetch('/settings/theme')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.value) {
const serverTheme = data.value;
if (serverTheme !== localTheme) {
// Server has different theme, apply it
document.documentElement.setAttribute('data-theme', serverTheme);
localStorage.setItem('intercept-theme', serverTheme);
}
}
})
.catch(() => { }); // Ignore errors, localStorage is fallback
})();
// Help modal functions
function showHelp() {
document.getElementById('helpModal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function hideHelp() {
document.getElementById('helpModal').classList.remove('active');
document.body.style.overflow = '';
}
function switchHelpTab(tab) {
document.querySelectorAll('.help-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.help-section').forEach(s => s.classList.remove('active'));
document.querySelector(`.help-tab[data-tab="${tab}"]`).classList.add('active');
document.getElementById(`help-${tab}`).classList.add('active');
}
// Keyboard shortcuts for help
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') hideHelp();
// Open help with F1 or ? key (when not typing in an input)
if ((e.key === 'F1' || (e.key === '?' && !e.target.matches('input, textarea, select'))) && !document.getElementById('helpModal').classList.contains('active')) {
e.preventDefault();
showHelp();
}
});
// Scanner and receiver logic are handled by Waterfall mode.
// ============================================
// TSCM (Counter-Surveillance) Functions
// ============================================
let isTscmRunning = false;
let tscmEventSource = null;
let tscmThreats = [];
let tscmWifiDevices = [];
let tscmWifiClients = [];
let tscmBtDevices = [];
let tscmBaselineComparison = null;
let tscmIdentityClusters = [];
let tscmIdentitySummary = null;
let tscmCaseLinkContext = null;
let tscmLastSweepId = null;
let tscmLastMeetingId = null;
const tscmFilters = {
protocol: 'all',
risk: 'all',
status: 'all',
known: 'all',
};
let isRecordingBaseline = false;
let tscmSweepStartTime = null;
let tscmSweepEndTime = null;
async function refreshTscmDevices() {
// Fetch available interfaces for TSCM scanning
// Check if agent is selected and route accordingly
try {
let response;
if (typeof currentAgent !== 'undefined' && currentAgent !== 'local') {
// Fetch devices from agent capabilities
response = await fetch(`/controller/agents/${currentAgent}?refresh=true`);
} else {
response = await fetch('/tscm/devices');
}
const data = await response.json();
// Handle both local (/tscm/devices) and agent response formats
let devices;
const isAgentResponse = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentResponse && data.agent) {
// Agent response format - extract from capabilities/interfaces
const agentInterfaces = data.agent.interfaces || {};
const agentCapabilities = data.agent.capabilities || {};
devices = {
wifi_interfaces: agentInterfaces.wifi_interfaces || [],
bt_adapters: agentInterfaces.bt_adapters || [],
sdr_devices: agentCapabilities.devices || agentInterfaces.sdr_devices || []
};
} else {
devices = data.devices || {};
}
// Populate WiFi interfaces
const wifiSelect = document.getElementById('tscmWifiInterface');
wifiSelect.innerHTML = '<option value="">Select WiFi interface...</option>';
if (devices.wifi_interfaces && devices.wifi_interfaces.length > 0) {
devices.wifi_interfaces.forEach(iface => {
const opt = document.createElement('option');
opt.value = iface.name;
opt.textContent = iface.display_name || iface.name;
wifiSelect.appendChild(opt);
});
// Auto-select first interface
if (devices.wifi_interfaces.length > 0) {
wifiSelect.value = devices.wifi_interfaces[0].name;
}
} else {
if (isAgentResponse) {
wifiSelect.innerHTML = '<option value="">Agent manages WiFi</option>';
} else {
wifiSelect.innerHTML = '<option value="">No WiFi interfaces found</option>';
}
}
// Populate Bluetooth adapters
const btSelect = document.getElementById('tscmBtInterface');
btSelect.innerHTML = '<option value="">Select Bluetooth adapter...</option>';
if (devices.bt_adapters && devices.bt_adapters.length > 0) {
devices.bt_adapters.forEach(adapter => {
const opt = document.createElement('option');
opt.value = adapter.name;
opt.textContent = adapter.display_name || adapter.name;
btSelect.appendChild(opt);
});
// Auto-select first adapter
if (devices.bt_adapters.length > 0) {
btSelect.value = devices.bt_adapters[0].name;
}
} else {
if (isAgentResponse) {
btSelect.innerHTML = '<option value="">Agent manages Bluetooth</option>';
} else {
btSelect.innerHTML = '<option value="">No Bluetooth adapters found</option>';
}
}
// Populate SDR devices
const sdrSelect = document.getElementById('tscmSdrDevice');
sdrSelect.innerHTML = '<option value="">Select SDR device...</option>';
if (devices.sdr_devices && devices.sdr_devices.length > 0) {
devices.sdr_devices.forEach(dev => {
const opt = document.createElement('option');
opt.value = dev.index !== undefined ? dev.index : 0;
opt.textContent = dev.display_name || dev.name || 'SDR Device';
sdrSelect.appendChild(opt);
});
// Auto-select first SDR if available
if (devices.sdr_devices.length > 0) {
sdrSelect.value = devices.sdr_devices[0].index !== undefined ? devices.sdr_devices[0].index : 0;
}
} else {
if (isAgentResponse) {
sdrSelect.innerHTML = '<option value="">Agent manages SDR</option>';
} else {
sdrSelect.innerHTML = '<option value="">No SDR devices found</option>';
}
}
// Show warnings (e.g., not running as root)
const warningsDiv = document.getElementById('tscmDeviceWarnings');
if (data.warnings && data.warnings.length > 0) {
warningsDiv.innerHTML = data.warnings.map(w =>
`<div class="tscm-privilege-warning">
<span class="warning-icon">⚠️</span>
<div>
<strong>${escapeHtml(w.message)}</strong>
${w.action ? `<div class="warning-action">${escapeHtml(w.action)}</div>` : ''}
</div>
</div>`
).join('');
warningsDiv.style.display = 'block';
} else {
warningsDiv.style.display = 'none';
}
} catch (e) {
console.error('Failed to refresh TSCM devices:', e);
}
}
async function loadTscmBaselines() {
try {
const response = await fetch('/tscm/baselines');
const data = await response.json();
const select = document.getElementById('tscmBaselineSelect');
select.innerHTML = '<option value="">No Baseline</option>';
if (data.baselines) {
data.baselines.forEach(b => {
const opt = document.createElement('option');
opt.value = b.id;
opt.textContent = b.name + (b.is_active ? ' (Active)' : '');
select.appendChild(opt);
});
}
} catch (e) {
console.error('Failed to load baselines:', e);
}
}
async function startTscmSweep() {
const sweepType = document.getElementById('tscmSweepType').value;
const baselineId = document.getElementById('tscmBaselineSelect').value || null;
const wifiEnabled = document.getElementById('tscmWifiEnabled').checked;
const btEnabled = document.getElementById('tscmBtEnabled').checked;
const rfEnabled = document.getElementById('tscmRfEnabled').checked;
const wifiInterface = document.getElementById('tscmWifiInterface').value;
const btInterface = document.getElementById('tscmBtInterface').value;
const sdrDevice = document.getElementById('tscmSdrDevice').value;
const verboseResults = document.getElementById('tscmVerboseResults').checked;
// Clear any previous warnings
document.getElementById('tscmDeviceWarnings').style.display = 'none';
document.getElementById('tscmDeviceWarnings').innerHTML = '';
// Check for agent mode
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
// Check for conflicts if using agent
if (isAgentMode && typeof checkAgentModeConflict === 'function') {
if (!await checkAgentModeConflict('tscm')) {
return; // Conflict detected, user cancelled
}
}
try {
// Route to agent or local based on selection
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/tscm/start`
: '/tscm/sweep/start';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sweep_type: sweepType,
baseline_id: baselineId ? parseInt(baselineId) : null,
wifi: wifiEnabled,
bluetooth: btEnabled,
rf: rfEnabled,
wifi_interface: wifiInterface,
bt_interface: btInterface,
sdr_device: sdrDevice ? parseInt(sdrDevice) : null,
verbose_results: verboseResults
})
});
const data = await response.json();
// Handle controller proxy response (agent response is nested in 'result')
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'success' || scanResult.status === 'started') {
if (scanResult.sweep_id) {
tscmLastSweepId = scanResult.sweep_id;
}
isTscmRunning = true;
tscmSweepStartTime = new Date();
tscmSweepEndTime = null;
document.getElementById('startTscmBtn').style.display = 'none';
document.getElementById('stopTscmBtn').style.display = 'block';
document.getElementById('tscmProgress').style.display = 'flex';
// Clear and reset the signal timeline for new sweep
SignalTimeline.clear();
document.getElementById('tscmReportBtn').style.display = 'none';
// Show warnings if any devices unavailable
if (scanResult.warnings && scanResult.warnings.length > 0) {
const warningsDiv = document.getElementById('tscmDeviceWarnings');
warningsDiv.innerHTML = scanResult.warnings.map(w =>
`<div style="color: #ff9933; font-size: 10px; margin-bottom: 2px;">⚠ ${w}</div>`
).join('');
warningsDiv.style.display = 'block';
}
// Update device indicators
updateTscmDeviceIndicators(scanResult.devices);
// Reset displays
tscmThreats = [];
tscmWifiDevices = [];
tscmWifiClients = [];
tscmBtDevices = [];
tscmRfSignals = [];
tscmRfStatusMessage = null;
tscmCorrelations = [];
tscmBaselineComparison = null;
tscmIdentityClusters = [];
tscmIdentitySummary = null;
tscmHighInterestDevices = [];
updateTscmDisplays();
updateTscmThreatCounts();
// Update capabilities bar for this sweep
updateTscmCapabilitiesBar(wifiInterface, btInterface);
// Update baseline health indicator if baseline selected
if (baselineId) {
updateTscmBaselineHealth(baselineId);
}
// Start SSE stream
startTscmStream();
} else {
// Show error with details
let errorMsg = scanResult.message || 'Failed to start sweep';
if (scanResult.details && scanResult.details.length > 0) {
errorMsg += '\n\n' + scanResult.details.join('\n');
}
alert(errorMsg);
}
} catch (e) {
console.error('Failed to start TSCM sweep:', e);
alert('Failed to start sweep: Network error');
}
}
function updateTscmDeviceIndicators(devices) {
const wifiIndicator = document.getElementById('tscmWifiIndicator');
const btIndicator = document.getElementById('tscmBtIndicator');
const rfIndicator = document.getElementById('tscmRfIndicator');
// Safety check for agent mode which may not return devices
if (!devices) {
// Just mark all as active if we don't have device info
if (wifiIndicator) wifiIndicator.classList.add('active');
if (btIndicator) btIndicator.classList.add('active');
if (rfIndicator) rfIndicator.classList.add('active');
return;
}
if (wifiIndicator) {
wifiIndicator.classList.toggle('active', devices.wifi);
wifiIndicator.classList.toggle('inactive', !devices.wifi);
}
if (btIndicator) {
btIndicator.classList.toggle('active', devices.bluetooth);
btIndicator.classList.toggle('inactive', !devices.bluetooth);
}
if (rfIndicator) {
rfIndicator.classList.toggle('active', devices.rf);
rfIndicator.classList.toggle('inactive', !devices.rf);
}
}
async function stopTscmSweep() {
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
const endpoint = isAgentMode
? `/controller/agents/${currentAgent}/tscm/stop`
: '/tscm/sweep/stop';
const timeoutMs = isAgentMode ? REMOTE_STOP_TIMEOUT_MS : LOCAL_STOP_TIMEOUT_MS;
isTscmRunning = false;
tscmSweepEndTime = new Date();
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
if (typeof tscmAgentPollInterval !== 'undefined' && tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
document.getElementById('startTscmBtn').style.display = 'block';
document.getElementById('stopTscmBtn').style.display = 'none';
document.getElementById('tscmProgress').style.display = 'none';
// Show report button if we have any data
const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0;
document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none';
return postStopRequest(endpoint, timeoutMs);
}
function generateTscmReport() {
// Calculate sweep duration
const startTime = tscmSweepStartTime || new Date();
const endTime = tscmSweepEndTime || new Date();
const durationMs = endTime - startTime;
const durationMin = Math.floor(durationMs / 60000);
const durationSec = Math.floor((durationMs % 60000) / 1000);
// Categorize devices by classification
const allDevices = [
...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })),
...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })),
...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' }))
];
const highInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6);
const needsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6));
const informational = allDevices.filter(d => d.classification === 'informational' || d.score < 3);
// Determine overall assessment
let assessment = 'LOW CONCERN';
let assessmentClass = 'informational';
if (highInterest.length > 0) {
assessment = `ELEVATED CONCERN: ${highInterest.length} high-interest item(s) detected requiring immediate attention`;
assessmentClass = 'high-interest';
} else if (needsReview.length > 0) {
assessment = `MODERATE CONCERN: ${needsReview.length} item(s) requiring further review`;
assessmentClass = 'needs-review';
} else {
assessment = 'LOW CONCERN: No anomalies flagged by automated scan. Manual inspection recommended for comprehensive assessment.';
}
// Helper function to render device row
const renderDevice = (device) => {
const scoreClass = device.score >= 6 ? 'high' : (device.score >= 3 ? 'medium' : 'low');
const indicators = (device.indicators || []).map(i =>
`<span class="indicator">${i.type}: ${i.desc}</span>`
).join('');
const reasons = (device.reasons || []).map(r => `<li>${r}</li>`).join('');
let identifier = device.bssid || device.mac || (device.frequency ? `${device.frequency} MHz` : 'Unknown');
let name = device.essid || device.name || device.band || 'Unknown';
return `
<tr class="device-row ${device.classification || ''}">
<td><span class="protocol-badge ${device.protocol.toLowerCase()}">${device.protocol}</span></td>
<td><strong>${name}</strong><br><small class="identifier">${identifier}</small></td>
<td><span class="score-badge ${scoreClass}">${device.score || 0}</span></td>
<td>${device.classification || 'unknown'}</td>
<td>${device.signal || device.rssi || device.power || 'N/A'} dBm</td>
<td>
${indicators ? `<div class="indicators">${indicators}</div>` : ''}
${reasons ? `<ul class="reasons">${reasons}</ul>` : ''}
</td>
<td>${device.recommended_action || 'monitor'}</td>
</tr>
`;
};
// Generate HTML report
const reportHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TSCM Sweep Report - ${startTime.toLocaleDateString()}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif;
background: #1a1a2e;
color: #e8eaed;
padding: 40px;
line-height: 1.6;
}
.report-container {
max-width: 1200px;
margin: 0 auto;
background: #0f1218;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.report-header {
background: linear-gradient(135deg, #1a1a2e 0%, #0f1218 100%);
padding: 40px;
border-bottom: 1px solid #2a2a4a;
}
.report-title {
font-size: 28px;
font-weight: 700;
color: #4a9eff;
margin-bottom: 8px;
}
.report-subtitle {
font-size: 14px;
color: #9ca3af;
letter-spacing: 1px;
text-transform: uppercase;
}
.report-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 30px;
padding: 20px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
}
.meta-item {
text-align: center;
}
.meta-value {
font-size: 24px;
font-weight: 700;
color: #fff;
}
.meta-label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 1px;
}
.section {
padding: 30px 40px;
border-bottom: 1px solid #2a2a4a;
}
.section:last-child {
border-bottom: none;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #4a9eff;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.assessment {
padding: 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
}
.assessment.high-interest {
background: rgba(255, 51, 51, 0.15);
border: 1px solid #ff3333;
color: #ff6666;
}
.assessment.needs-review {
background: rgba(255, 204, 0, 0.15);
border: 1px solid #ffcc00;
color: #ffdd44;
}
.assessment.informational {
background: rgba(0, 204, 0, 0.15);
border: 1px solid #00cc00;
color: #44dd44;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 20px;
}
.summary-card {
background: rgba(0,0,0,0.3);
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #2a2a4a;
}
.summary-card.high-interest { border-color: #ff3333; }
.summary-card.needs-review { border-color: #ffcc00; }
.summary-card.informational { border-color: #00cc00; }
.summary-card .count {
font-size: 32px;
font-weight: 700;
}
.summary-card.high-interest .count { color: #ff3333; }
.summary-card.needs-review .count { color: #ffcc00; }
.summary-card.informational .count { color: #00cc00; }
.summary-card .label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
padding: 12px;
background: rgba(0,0,0,0.4);
color: #9ca3af;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #2a2a4a;
}
td {
padding: 12px;
border-bottom: 1px solid #2a2a4a;
vertical-align: top;
}
.device-row.high_interest { background: rgba(255, 51, 51, 0.08); }
.device-row.review { background: rgba(255, 204, 0, 0.08); }
.protocol-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.protocol-badge.wifi { background: #4a9eff; color: #000; }
.protocol-badge.bluetooth { background: #8b5cf6; color: #fff; }
.protocol-badge.rf { background: #f59e0b; color: #000; }
.score-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-weight: 600;
font-size: 12px;
}
.score-badge.high { background: rgba(255,51,51,0.2); color: #ff3333; }
.score-badge.medium { background: rgba(255,204,0,0.2); color: #ffcc00; }
.score-badge.low { background: rgba(0,204,0,0.2); color: #00cc00; }
.identifier {
color: #6b7280;
font-family: monospace;
font-size: 11px;
}
.indicators {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.indicator {
display: inline-block;
padding: 2px 6px;
background: rgba(255,153,51,0.2);
color: #ff9933;
border-radius: 3px;
font-size: 10px;
}
.reasons {
margin: 0;
padding-left: 16px;
font-size: 11px;
color: #9ca3af;
}
.reasons li {
margin-bottom: 2px;
}
.category-section {
margin-bottom: 30px;
}
.category-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
padding: 8px 12px;
border-radius: 6px;
}
.category-title.high-interest { background: rgba(255,51,51,0.15); color: #ff6666; }
.category-title.needs-review { background: rgba(255,204,0,0.15); color: #ffdd44; }
.category-title.informational { background: rgba(0,204,0,0.15); color: #44dd44; }
.empty-state {
text-align: center;
padding: 40px;
color: #6b7280;
}
.disclaimer {
padding: 20px;
background: rgba(74, 158, 255, 0.1);
border-radius: 8px;
font-size: 12px;
color: #9ca3af;
}
.disclaimer h4 {
color: #4a9eff;
margin-bottom: 10px;
font-size: 13px;
}
.recommendations {
margin-top: 20px;
}
.recommendations ul {
padding-left: 20px;
}
.recommendations li {
margin-bottom: 8px;
}
.report-actions {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
}
.report-btn {
padding: 12px 24px;
background: #4a9eff;
color: #000;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.report-btn:hover {
background: #6bb3ff;
}
.report-btn.save {
background: #22c55e;
}
.report-btn.save:hover {
background: #2ecc71;
}
@media print {
body { background: #fff; color: #000; padding: 20px; }
.report-container { box-shadow: none; }
.report-header { background: #f8f9fa; }
.report-title { color: #1a1a2e; }
.section { border-color: #ddd; }
.section-title { color: #1a1a2e; }
th { background: #f0f0f0; color: #333; }
td { border-color: #ddd; }
.report-actions { display: none; }
.device-row.high_interest { background: rgba(255, 51, 51, 0.1); }
.device-row.review { background: rgba(255, 204, 0, 0.1); }
}
</style>
</head>
<body>
<div class="report-actions">
<button class="report-btn save" onclick="saveReport()">Save Report</button>
<button class="report-btn" onclick="window.print()">Print Report</button>
</div>
<div class="report-container">
<div class="report-header">
<div class="report-title">TSCM Sweep Report</div>
<div class="report-subtitle">Technical Surveillance Counter-Measures Analysis</div>
<div class="report-meta">
<div class="meta-item">
<div class="meta-value">${startTime.toLocaleDateString()}</div>
<div class="meta-label">Date</div>
</div>
<div class="meta-item">
<div class="meta-value">${startTime.toLocaleTimeString()} - ${endTime.toLocaleTimeString()}</div>
<div class="meta-label">Time Range</div>
</div>
<div class="meta-item">
<div class="meta-value">${durationMin}m ${durationSec}s</div>
<div class="meta-label">Duration</div>
</div>
<div class="meta-item">
<div class="meta-value">${allDevices.length}</div>
<div class="meta-label">Total Devices</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Executive Summary</div>
<div class="summary-grid">
<div class="summary-card high-interest">
<div class="count">${highInterest.length}</div>
<div class="label">High Interest</div>
</div>
<div class="summary-card needs-review">
<div class="count">${needsReview.length}</div>
<div class="label">Needs Review</div>
</div>
<div class="summary-card informational">
<div class="count">${informational.length}</div>
<div class="label">Informational</div>
</div>
<div class="summary-card">
<div class="count" style="color: #4a9eff;">${tscmWifiDevices.length}/${tscmBtDevices.length}/${tscmRfSignals.length}</div>
<div class="label">WiFi/BT/RF</div>
</div>
</div>
<div class="assessment ${assessmentClass}">
<strong>Assessment:</strong> ${assessment}
</div>
</div>
${highInterest.length > 0 ? `
<div class="section">
<div class="section-title">High Interest Items</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${highInterest.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${needsReview.length > 0 ? `
<div class="section">
<div class="section-title">Items Requiring Review</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${needsReview.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${informational.length > 0 ? `
<div class="section">
<div class="section-title">Informational Items</div>
<div class="category-section">
<table>
<thead>
<tr>
<th>Type</th>
<th>Device</th>
<th>Score</th>
<th>Class</th>
<th>Signal</th>
<th>Indicators / Reasons</th>
<th>Action</th>
</tr>
</thead>
<tbody>
${informational.map(renderDevice).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${allDevices.length === 0 ? `
<div class="section">
<div class="empty-state">
<p>No devices were detected during this sweep.</p>
</div>
</div>
` : ''}
<div class="section">
<div class="section-title">Recommendations</div>
<div class="recommendations">
<ul>
${highInterest.length > 0 ? `
<li><strong>Immediate Action Required:</strong> ${highInterest.length} high-interest item(s) detected. These devices exhibit characteristics commonly associated with surveillance equipment and should be physically located and investigated.</li>
` : ''}
${needsReview.length > 0 ? `
<li><strong>Further Investigation Recommended:</strong> ${needsReview.length} item(s) require additional review to determine their purpose and legitimacy.</li>
` : ''}
${allDevices.filter(d => d.is_new).length > 0 ? `
<li><strong>New Devices Detected:</strong> ${allDevices.filter(d => d.is_new).length} device(s) were not present in the baseline. Verify these are authorized.</li>
` : ''}
<li><strong>Regular Monitoring:</strong> Consider establishing a baseline of normal wireless activity and conducting periodic sweeps to detect changes.</li>
<li><strong>Physical Inspection:</strong> For any high-interest items, conduct a thorough physical inspection of the area to locate potential surveillance devices.</li>
</ul>
</div>
</div>
<div class="section">
<div class="section-title">Disclaimer</div>
<div class="disclaimer">
<h4>Important Notice</h4>
<p>This report is generated by automated wireless spectrum analysis software. The findings presented are <strong>indicators only</strong> and do not constitute confirmation of surveillance activity. Many legitimate devices may trigger alerts due to their wireless characteristics.</p>
<p style="margin-top: 10px;">Professional TSCM services involve specialized equipment and expertise beyond wireless spectrum analysis, including: non-linear junction detection, thermal imaging, physical inspection, and RF spectrum analysis with calibrated equipment.</p>
<p style="margin-top: 10px;"><strong>No content was intercepted or decoded during this analysis.</strong> This tool only detects the presence and characteristics of wireless transmissions.</p>
</div>
</div>
</div>
<scr` + `ipt>
function saveReport() {
const html = document.documentElement.outerHTML;
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'TSCM_Report_${startTime.toISOString().split('T')[0]}.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</scr` + `ipt>
</body>
</html>
`;
// Open report in new window
const reportWindow = window.open('', '_blank');
reportWindow.document.write(reportHtml);
reportWindow.document.close();
}
let tscmAgentPollInterval = null;
function startTscmStream() {
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
// Check if using agent
const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
if (isAgentMode) {
// For agent mode, poll the agent for TSCM data since push may not be enabled
console.log('[TSCM] Starting agent polling mode');
pollAgentTscmData(); // Initial poll
tscmAgentPollInterval = setInterval(pollAgentTscmData, 2000); // Poll every 2 seconds
} else {
// For local mode, use SSE stream
const streamUrl = '/tscm/sweep/stream';
tscmEventSource = new EventSource(streamUrl);
tscmEventSource.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
handleTscmEvent(data);
} catch (e) {
console.error('TSCM SSE parse error:', e);
}
};
tscmEventSource.onerror = function () {
console.warn('TSCM SSE connection error');
};
}
}
async function pollAgentTscmData() {
if (!isTscmRunning) {
if (tscmAgentPollInterval) {
clearInterval(tscmAgentPollInterval);
tscmAgentPollInterval = null;
}
return;
}
try {
const response = await fetch(`/controller/agents/${currentAgent}/tscm/data`);
const result = await response.json();
if (result.status === 'success' && result.data) {
// Agent data is nested: result.data.data (controller wraps agent response)
const data = result.data.data || result.data;
// Process WiFi devices
if (data.wifi_devices) {
data.wifi_devices.forEach(device => {
if (!tscmWifiDevices.find(d => d.bssid === device.bssid)) {
handleTscmEvent({ type: 'wifi_device', ...device });
}
});
}
if (data.wifi_clients) {
data.wifi_clients.forEach(client => {
const clientMac = client.mac || client.address;
if (!tscmWifiClients.find(d => (d.mac || d.address) === clientMac)) {
handleTscmEvent({ type: 'wifi_client', ...client });
}
});
}
// Process Bluetooth devices
if (data.bt_devices) {
data.bt_devices.forEach(device => {
const deviceMac = device.mac || device.address;
if (!tscmBtDevices.find(d => (d.mac || d.address) === deviceMac)) {
handleTscmEvent({ type: 'bt_device', ...device });
}
});
}
// Process anomalies/threats
// Agent now uses same ThreatDetector as local mode, so format matches:
// threat_type, severity, source, identifier, name, signal_strength
if (data.anomalies) {
data.anomalies.forEach(threat => {
handleTscmEvent({
type: 'threat_detected',
...threat
});
});
}
// Process RF signals
if (data.rf_signals) {
data.rf_signals.forEach(signal => {
handleTscmEvent({ type: 'rf_signal', ...signal });
});
}
// Update progress (simple time-based estimate)
if (tscmSweepStartTime) {
const elapsed = (Date.now() - tscmSweepStartTime) / 1000;
const sweepType = document.getElementById('tscmSweepType')?.value || 'standard';
const durations = { quick: 120, standard: 300, full: 900 };
const maxDuration = durations[sweepType] || 300;
const progress = Math.min(95, (elapsed / maxDuration) * 100);
updateTscmProgress({ progress: Math.round(progress), phase: 'Scanning' });
}
}
} catch (e) {
console.error('[TSCM] Agent poll error:', e);
}
}
let tscmCorrelations = [];
function handleTscmEvent(data) {
switch (data.type) {
case 'sweep_progress':
updateTscmProgress(data);
break;
case 'wifi_device':
addTscmWifiDevice(data);
break;
case 'wifi_client':
addTscmWifiClient(data);
break;
case 'bt_device':
addTscmBtDevice(data);
break;
case 'rf_signal':
addTscmRfSignal(data);
break;
case 'rf_status':
handleRfStatus(data);
break;
case 'threat_detected':
addTscmThreat(data);
break;
case 'correlation_findings':
handleCorrelationFindings(data);
break;
case 'baseline_comparison':
handleBaselineComparison(data);
break;
case 'identity_clusters':
handleIdentityClusters(data);
break;
case 'sweep_completed':
completeTscmSweep(data);
break;
case 'sweep_stopped':
case 'sweep_error':
stopTscmSweep();
break;
}
}
function handleCorrelationFindings(data) {
tscmCorrelations = data.correlations || [];
updateCorrelationsDisplay();
updateTscmThreatCounts();
}
function handleBaselineComparison(data) {
tscmBaselineComparison = data || null;
}
function handleIdentityClusters(data) {
tscmIdentitySummary = {
total: data.total_clusters || 0,
high: data.high_risk_count || 0,
medium: data.medium_risk_count || 0,
unique_fingerprints: data.unique_fingerprints || 0,
};
tscmIdentityClusters = data.clusters || [];
updateCorrelationsDisplay();
updateTscmThreatCounts();
}
async function tscmRefreshIdentityClusters() {
try {
const [clusterRes, summaryRes] = await Promise.all([
fetch('/tscm/identity/clusters'),
fetch('/tscm/identity/summary')
]);
const clusterData = await clusterRes.json();
if (clusterData.status === 'success') {
tscmIdentityClusters = clusterData.clusters || [];
}
const summaryData = await summaryRes.json();
if (summaryData.status === 'success' && summaryData.summary) {
const stats = summaryData.summary.statistics || {};
tscmIdentitySummary = {
total: stats.total_clusters || tscmIdentityClusters.length,
high: stats.high_risk_count || 0,
medium: stats.medium_risk_count || 0,
unique_fingerprints: stats.unique_fingerprints || 0,
};
}
updateCorrelationsDisplay();
updateTscmThreatCounts();
} catch (e) {
console.error('Failed to refresh identity clusters:', e);
}
}
function addTscmWifiDevice(device) {
// Check if already exists
const exists = tscmWifiDevices.some(d => d.bssid === device.bssid);
if (!exists) {
tscmWifiDevices.push(device);
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (device.score >= 3) {
addHighInterestDevice(device, 'wifi');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/wifi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(device)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
const freq = device.channel <= 14 ? '2400' : '5000';
const strength = Math.min(5, Math.max(1, Math.ceil((device.signal + 100) / 20)));
SignalTimeline.addEvent(freq, strength, 2000, device.ssid || 'Hidden WiFi');
}
}
function addTscmWifiClient(client) {
const mac = client.mac || client.address || '';
if (!mac) return;
const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac);
if (!exists) {
if (!client.mac) client.mac = mac;
client.is_client = true;
tscmWifiClients.push(client);
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
if (client.score >= 3) {
addHighInterestDevice(client, 'wifi');
}
if (isRecordingBaseline) {
fetch('/tscm/feed/wifi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client)
}).catch(e => console.error('Baseline feed error:', e));
}
}
}
function addTscmBtDevice(device) {
const mac = device.mac || device.address || '';
// Check if already exists
const exists = tscmBtDevices.some(d => (d.mac || d.address) === mac);
if (!exists) {
if (!device.mac && mac) device.mac = mac;
tscmBtDevices.push(device);
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to threats panel if score >= 3 (review level or higher)
if (device.score >= 3) {
addHighInterestDevice(device, 'bluetooth');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/bluetooth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(device)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
const strength = device.rssi ? Math.min(5, Math.max(1, Math.ceil((device.rssi + 100) / 20))) : 3;
SignalTimeline.addEvent('2450', strength, 1500, device.name || 'Bluetooth Device');
}
}
let tscmRfSignals = [];
let tscmRfStatusMessage = null;
function addTscmRfSignal(signal) {
// Clear any error message since we're receiving signals
tscmRfStatusMessage = null;
// Check if already exists (within 0.1 MHz)
const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1);
const powerDbm = signal.power_dbm ?? signal.power ?? signal.level;
const strength = powerDbm !== undefined && powerDbm !== null
? Math.min(5, Math.max(1, Math.ceil((powerDbm + 60) / 15)))
: 3;
if (!exists) {
tscmRfSignals.push(signal);
debouncedUpdateTscmDisplays();
updateTscmThreatCounts();
// Add to findings panel if score >= 3 (review level or higher)
if (signal.score >= 3) {
addHighInterestDevice(signal, 'rf');
}
// Feed to baseline recorder if recording
if (isRecordingBaseline) {
fetch('/tscm/feed/rf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signal)
}).catch(e => console.error('Baseline feed error:', e));
}
// Add to signal timeline
SignalTimeline.addEvent(String(signal.frequency), strength, 1000, signal.classification || 'RF Signal');
} else {
// Update existing signal on timeline (show recurring transmission)
SignalTimeline.addEvent(String(signal.frequency), strength, 500, signal.classification || 'RF Signal');
}
}
function handleRfStatus(data) {
// Store status message to display in RF panel
tscmRfStatusMessage = data.message;
updateRfDisplay();
}
function updateRfDisplay() {
const rfList = document.getElementById('tscmRfList');
if (!rfList) return;
if (tscmRfSignals.length === 0) {
if (tscmRfStatusMessage) {
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
} else {
rfList.innerHTML = '<div class="tscm-empty">No RF signals detected</div>';
}
}
// If there are signals, updateTscmDisplays() will handle the display
}
// Debounced versions of expensive display updates to batch rapid-fire device additions
let _tscmDisplayTimer = null;
function debouncedUpdateTscmDisplays() {
if (_tscmDisplayTimer) clearTimeout(_tscmDisplayTimer);
_tscmDisplayTimer = setTimeout(() => { _tscmDisplayTimer = null; updateTscmDisplays(); }, 250);
}
let _tscmHighInterestTimer = null;
function debouncedUpdateHighInterestPanel() {
if (_tscmHighInterestTimer) clearTimeout(_tscmHighInterestTimer);
_tscmHighInterestTimer = setTimeout(() => { _tscmHighInterestTimer = null; updateHighInterestPanel(); }, 250);
}
// Track high-interest devices for the threats panel
let tscmHighInterestDevices = [];
function addHighInterestDevice(device, protocol) {
const id = device.mac || device.bssid || device.frequency;
const exists = tscmHighInterestDevices.some(d => d.id === id);
if (!exists) {
tscmHighInterestDevices.push({
id: id,
protocol: protocol,
name: device.name || device.essid || device.ssid || (device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device'),
score: device.score,
classification: device.classification,
indicators: device.indicators || [],
recommended_action: device.recommended_action
});
debouncedUpdateHighInterestPanel();
}
}
function updateHighInterestPanel() {
const panel = document.getElementById('tscmThreatList');
if (tscmHighInterestDevices.length === 0) {
panel.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
} else {
// Sort by score (highest first)
const sorted = [...tscmHighInterestDevices].sort((a, b) => b.score - a.score);
panel.innerHTML = '<div class="tscm-threat-list">' + sorted.map(d => {
const severityClass = d.score >= 6 ? 'critical' : d.score >= 4 ? 'high' : 'medium';
return `
<div class="tscm-threat-item ${severityClass}" onclick="showDeviceDetails('${d.id}', '${d.protocol}')">
<div class="tscm-threat-header">
<span class="tscm-threat-type">${d.protocol.toUpperCase()}</span>
<span class="tscm-threat-severity">Score: ${d.score}</span>
</div>
<div class="tscm-threat-details">
<strong>${escapeHtml(d.name)}</strong><br>
<span style="font-size: 10px; color: var(--text-muted);">
${d.indicators && d.indicators.length > 0 ? d.indicators.slice(0, 2).map(i => i.desc || i.type).join(' | ') : 'Review recommended'}
</span>
</div>
<div class="tscm-threat-action">${d.recommended_action || 'review'}</div>
</div>
`;
}).join('') + '</div>';
}
}
function updateTscmProgress(data) {
// Update percentage text
document.getElementById('tscmProgressPercent').textContent = data.progress + '%';
// Update SVG circle progress (circumference = 2 * PI * 45 = ~283)
const circumference = 283;
const offset = circumference - (data.progress / 100) * circumference;
const circle = document.getElementById('tscmScannerCircle');
if (circle) {
circle.style.strokeDashoffset = offset;
}
// Update status text
let statusText = 'SCANNING...';
if (data.threats_found > 0) {
statusText = `THREATS: ${data.threats_found}`;
} else if (data.status) {
statusText = data.status;
} else {
const parts = [];
if (data.wifi_count > 0) parts.push(`${data.wifi_count} WiFi`);
if (data.bt_count > 0) parts.push(`${data.bt_count} BT`);
if (data.rf_count > 0) parts.push(`${data.rf_count} RF`);
statusText = parts.length > 0 ? parts.join(' | ') : 'SCANNING...';
}
document.getElementById('tscmProgressLabel').textContent = statusText;
}
function addTscmThreat(threat) {
tscmThreats.unshift(threat);
// Update dashboard counts
updateTscmThreatCounts();
debouncedUpdateTscmDisplays();
}
function readTscmFilters() {
const protocolSelect = document.getElementById('tscmFilterProtocol');
const riskSelect = document.getElementById('tscmFilterRisk');
const statusSelect = document.getElementById('tscmFilterStatus');
const knownSelect = document.getElementById('tscmFilterKnown');
tscmFilters.protocol = protocolSelect ? protocolSelect.value : 'all';
tscmFilters.risk = riskSelect ? riskSelect.value : 'all';
tscmFilters.status = statusSelect ? statusSelect.value : 'all';
tscmFilters.known = knownSelect ? knownSelect.value : 'all';
}
function updateTscmFilterStatus() {
const statusEl = document.getElementById('tscmFilterStatusText');
if (!statusEl) return;
const parts = [];
if (tscmFilters.protocol !== 'all') parts.push(tscmFilters.protocol.toUpperCase());
if (tscmFilters.risk !== 'all') parts.push(tscmFilters.risk.replace(/_/g, ' ').toUpperCase());
if (tscmFilters.status !== 'all') parts.push(tscmFilters.status.toUpperCase());
if (tscmFilters.known !== 'all') parts.push(tscmFilters.known.toUpperCase());
statusEl.textContent = parts.length > 0 ? `Filters: ${parts.join(' • ')}` : 'Filters: none';
}
function updateTscmPanelVisibility() {
const protocol = tscmFilters.protocol;
const showWifi = protocol === 'all' || protocol === 'wifi';
const showBt = protocol === 'all' || protocol === 'bluetooth';
const showRf = protocol === 'all' || protocol === 'rf';
const wifiPanel = document.getElementById('tscmWifiPanel');
const wifiClientPanel = document.getElementById('tscmWifiClientPanel');
const btPanel = document.getElementById('tscmBtPanel');
const rfPanel = document.getElementById('tscmRfPanel');
if (wifiPanel) wifiPanel.style.display = showWifi ? '' : 'none';
if (wifiClientPanel) wifiClientPanel.style.display = showWifi ? '' : 'none';
if (btPanel) btPanel.style.display = showBt ? '' : 'none';
if (rfPanel) rfPanel.style.display = showRf ? '' : 'none';
}
function matchesTscmFilters(device, protocol, options = {}) {
if (tscmFilters.protocol !== 'all' && protocol !== tscmFilters.protocol) return false;
if (!options.ignoreRisk && tscmFilters.risk !== 'all') {
if ((device.classification || 'review') !== tscmFilters.risk) return false;
}
if (tscmFilters.status === 'new' && device.is_new !== true) return false;
if (tscmFilters.status === 'baseline' && device.is_new !== false) return false;
if (tscmFilters.known === 'known' && !device.known_device) return false;
if (tscmFilters.known === 'unknown' && device.known_device) return false;
return true;
}
function getFilteredDevices(options = {}) {
const wifi = tscmWifiDevices.filter(d => matchesTscmFilters(d, 'wifi', options));
const wifi_clients = tscmWifiClients.filter(d => matchesTscmFilters(d, 'wifi', options));
const bt = tscmBtDevices.filter(d => matchesTscmFilters(d, 'bluetooth', options));
const rf = tscmRfSignals.filter(d => matchesTscmFilters(d, 'rf', options));
return {
wifi,
wifi_clients,
bt,
rf,
all: [...wifi, ...wifi_clients, ...bt, ...rf],
};
}
function applyTscmFilters() {
readTscmFilters();
updateTscmFilterStatus();
updateTscmPanelVisibility();
updateTscmDisplays();
updateTscmThreatCounts();
}
function updateTscmThreatCounts() {
// Count devices by new scoring model classification
const counts = { high_interest: 0, review: 0, informational: 0 };
// Count from all device lists
const filtered = getFilteredDevices();
filtered.all.forEach(d => {
const classification = d.classification || 'review';
if (classification === 'high_interest') counts.high_interest++;
else if (classification === 'review') counts.review++;
else counts.informational++;
});
document.getElementById('tscmHighInterestCount').textContent = counts.high_interest;
document.getElementById('tscmNeedsReviewCount').textContent = counts.review;
document.getElementById('tscmInformationalCount').textContent = counts.informational;
document.getElementById('tscmCorrelationsCount').textContent = tscmCorrelations.length;
document.getElementById('tscmIdentityCount').textContent = tscmIdentityClusters.length;
document.getElementById('tscmHighInterestCard').classList.toggle('active', counts.high_interest > 0);
document.getElementById('tscmNeedsReviewCard').classList.toggle('active', counts.review > 0);
document.getElementById('tscmInformationalCard').classList.toggle('active', counts.informational > 0);
document.getElementById('tscmCorrelationsCard').classList.toggle('active', tscmCorrelations.length > 0);
document.getElementById('tscmIdentityCard').classList.toggle('active', tscmIdentityClusters.length > 0);
// Update threat panel count (shows high interest items only)
document.getElementById('tscmThreatCount').textContent = counts.high_interest;
}
function getClassificationClass(classification) {
// Map classification to CSS class
switch (classification) {
case 'high_interest': return 'classification-red';
case 'review': return 'classification-yellow';
case 'informational': return 'classification-green';
default: return 'classification-yellow';
}
}
function getClassificationIcon(classification) {
// Returns CSS class name for colored dot styling instead of emojis
switch (classification) {
case 'high_interest': return '<span class="classification-dot high"></span>';
case 'review': return '<span class="classification-dot review"></span>';
case 'informational': return '<span class="classification-dot info"></span>';
default: return '<span class="classification-dot review"></span>';
}
}
function formatIndicators(indicators) {
if (!indicators || indicators.length === 0) return '';
return indicators.map(i => `<span class="indicator-tag">${escapeHtml(i.desc || i.type)}</span>`).join(' ');
}
function getTrackerLabel(device) {
if (!device) return null;
return (device.tracker && (device.tracker.name || device.tracker.type)) ||
device.tracker_type || device.tracker_name || null;
}
function formatTrackerBadge(device) {
const label = getTrackerLabel(device);
if (!label) return '';
return `<span class="tracker-badge" title="Tracker">${escapeHtml(label)}</span>`;
}
function getScoreBadge(score) {
if (score === undefined || score === null) return '';
let scoreClass = 'score-low';
if (score >= 6) scoreClass = 'score-high';
else if (score >= 3) scoreClass = 'score-medium';
return `<span class="score-badge ${scoreClass}">Score: ${score}</span>`;
}
// Store all devices for lookup
function getAllTscmDevices() {
const devices = {};
tscmWifiDevices.forEach(d => { devices[`wifi:${d.bssid}`] = { ...d, protocol: 'wifi' }; });
tscmWifiClients.forEach(d => { devices[`wifi:${d.mac}`] = { ...d, protocol: 'wifi' }; });
tscmBtDevices.forEach(d => { devices[`bluetooth:${d.mac}`] = { ...d, protocol: 'bluetooth' }; });
tscmRfSignals.forEach(d => { devices[`rf:${d.frequency}`] = { ...d, protocol: 'rf' }; });
return devices;
}
function showDeviceDetails(id, protocol) {
const devices = getAllTscmDevices();
const key = `${protocol}:${id}`;
const device = devices[key];
if (!device) {
console.warn('Device not found:', key);
return;
}
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
// Build detailed view
let html = `
<div class="device-detail-header ${getClassificationClass(device.classification)}">
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(device.name || device.essid || device.ssid || device.mac || device.bssid || (device.frequency ? device.frequency.toFixed(3) + ' MHz' : 'Unknown'))}</h3>
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
</div>
<div class="device-detail-score">
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
<span class="score-value">${device.score || 0}</span>
<span class="score-label">SCORE</span>
</div>
<div class="score-breakdown">
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
</div>
</div>
<div class="device-detail-section">
<h4>Device Information</h4>
<table class="device-detail-table">
`;
// Add device-specific fields
if (protocol === 'wifi') {
if (device.is_client) {
html += `
<tr><td>Client MAC</td><td>${device.mac || 'Unknown'}</td></tr>
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
<tr><td>Associated BSSID</td><td>${device.associated_bssid || 'Unassociated'}</td></tr>
<tr><td>Probed SSIDs</td><td>${device.probe_count || (device.probed_ssids ? device.probed_ssids.length : 0)}</td></tr>
`;
} else {
html += `
<tr><td>BSSID</td><td>${device.bssid || 'Unknown'}</td></tr>
<tr><td>SSID</td><td>${escapeHtml(device.ssid || '[Hidden]')}</td></tr>
<tr><td>Vendor</td><td>${escapeHtml(device.vendor || 'Unknown')}</td></tr>
<tr><td>Channel</td><td>${device.channel || 'Unknown'}</td></tr>
<tr><td>Signal</td><td>${device.signal || '--'} dBm</td></tr>
<tr><td>Security</td><td>${device.security || 'Unknown'}</td></tr>
`;
}
} else if (protocol === 'bluetooth') {
const trackerLabel = getTrackerLabel(device);
html += `
<tr><td>MAC Address</td><td>${device.mac || 'Unknown'}</td></tr>
<tr><td>Name</td><td>${escapeHtml(device.name || 'Unknown')}</td></tr>
<tr><td>Type</td><td>${device.device_type || 'Unknown'}</td></tr>
<tr><td>Manufacturer</td><td>${escapeHtml(device.manufacturer || 'Unknown')}</td></tr>
<tr><td>Tracker</td><td>${trackerLabel ? escapeHtml(trackerLabel) : 'No'}</td></tr>
<tr><td>RSSI</td><td>${device.rssi || '--'} dBm</td></tr>
<tr><td>Audio Capable</td><td>${device.is_audio_capable ? 'Yes' : 'No'}</td></tr>
`;
} else if (protocol === 'rf') {
html += `
<tr><td>Frequency</td><td>${device.frequency?.toFixed(3) || 'Unknown'} MHz</td></tr>
<tr><td>Band</td><td>${device.band || 'Unknown'}</td></tr>
<tr><td>Power</td><td>${device.power?.toFixed(1) || '--'} dBm</td></tr>
<tr><td>Signal Strength</td><td>+${(device.signal_strength || 0).toFixed(1)} dB above noise</td></tr>
`;
}
if (device.known_device) {
const knownLabel = device.known_device_name ? `Yes (${escapeHtml(device.known_device_name)})` : 'Yes';
html += `<tr><td>Known Device</td><td>${knownLabel}</td></tr>`;
}
if (device.score_modifier && device.score_modifier !== 0) {
const modLabel = `${device.score_modifier > 0 ? '+' : ''}${device.score_modifier}`;
html += `<tr><td>Score Modifier</td><td>${modLabel}</td></tr>`;
}
html += `</table></div>`;
// Actions section - always show with "Add to Known Devices"
const deviceIdentifier = device.bssid || device.mac || (device.frequency ? device.frequency.toString() : id);
const deviceName = device.name || device.ssid || device.essid || (device.frequency ? device.frequency + ' MHz' : 'Unknown');
html += `
<div class="device-detail-section">
<h4>Actions</h4>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
`;
// Add "Listen" and "Decode (OOK)" buttons for RF signals
if (protocol === 'rf' && device.frequency) {
const freq = device.frequency;
html += `
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'fm')">
Listen (FM)
</button>
<button class="tscm-action-btn" onclick="listenToRfSignal(${freq}, 'am')">
Listen (AM)
</button>
<button class="tscm-action-btn" onclick="decodeWithOok(${freq})"
title="Open OOK decoder tuned to this frequency">
Decode (OOK)
</button>
`;
}
// Add "Add to Known Devices" button for all device types
html += `
<button class="tscm-action-btn" style="background: var(--accent-cyan);" onclick="tscmAddToKnownDevices('${escapeHtml(deviceIdentifier)}', '${escapeHtml(deviceName)}', '${protocol}')">
Add to Known Devices
</button>
<button class="tscm-action-btn" style="background: var(--accent-orange);" onclick="tscmShowInvestigateById('${escapeHtml(deviceIdentifier)}', '${protocol}')">
Investigate
</button>
</div>
<div style="font-size: 10px; color: var(--text-secondary); margin-top: 8px;">
${protocol === 'rf' ? 'Listen opens Spectrum Waterfall. Decode (OOK) opens the OOK decoder tuned to this frequency. ' : ''}Known devices are excluded from threat scoring in future sweeps.
</div>
</div>
`;
// Timeline section (loaded async)
html += `
<div class="device-detail-section" id="tscmTimelineSection">
<h4>Timeline</h4>
<div class="tscm-empty">Loading timeline...</div>
</div>
`;
if (protocol === 'wifi') {
html += `
<div class="device-detail-section" id="tscmWifiAdvancedSection">
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">Analyzing network...</div>
</div>
`;
} else if (protocol === 'bluetooth') {
html += `
<div class="device-detail-section" id="tscmBleExplainSection">
<h4>Bluetooth Risk Explanation</h4>
<div class="tscm-empty">Analyzing device...</div>
</div>
`;
}
// Add indicators section
if (device.indicators && device.indicators.length > 0) {
html += `
<div class="device-detail-section">
<h4>Risk Indicators (Why This Score)</h4>
<div class="indicator-list">
${device.indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${i.type}</span>
<span class="indicator-desc">${escapeHtml(i.desc || '')}</span>
</div>
`).join('')}
</div>
</div>
`;
}
// Add reasons section
if (device.reasons && device.reasons.length > 0) {
html += `
<div class="device-detail-section">
<h4>Detection Notes</h4>
<ul class="device-reasons-list">
${device.reasons.map(r => `<li>${escapeHtml(r)}</li>`).join('')}
</ul>
</div>
`;
}
// Signal Timeline Chart
html += `
<div class="device-detail-section">
<h4>Signal Timeline</h4>
<canvas id="deviceTimelineChart" width="600" height="180" style="width: 100%; max-height: 180px;"></canvas>
<div id="deviceTimelineMetrics" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;"></div>
</div>
`;
// Playbook section
html += `
<div class="device-detail-section" id="devicePlaybookSection" style="display: none;">
<h4>Recommended Playbook</h4>
<div id="devicePlaybookContent"></div>
</div>
`;
// Add disclaimer
html += `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> This analysis identifies indicators and anomalies.
It does NOT confirm surveillance activity. Professional verification required.
</div>
`;
content.innerHTML = html;
modal.style.display = 'flex';
const timelineIdentifier = tscmNormalizeIdentifier(id, protocol, device);
loadTscmTimeline(timelineIdentifier, protocol);
loadTscmAdvancedAnalysis(device, protocol);
// Load timeline chart
fetchDeviceTimelineChart(timelineIdentifier, protocol);
// Load playbook for this device
fetchDevicePlaybook(timelineIdentifier).then(playbook => {
if (playbook) {
const section = document.getElementById('devicePlaybookSection');
const pbContent = document.getElementById('devicePlaybookContent');
if (section && pbContent) {
section.style.display = 'block';
pbContent.innerHTML = renderPlaybook(playbook);
}
}
});
}
function tscmNormalizeIdentifier(identifier, protocol, device) {
let value = identifier;
if ((value === undefined || value === null || value === '') && device) {
value = device.bssid || device.mac || device.frequency || '';
}
if (protocol === 'rf') {
const freq = device && device.frequency !== undefined ? device.frequency : parseFloat(value);
if (!isNaN(freq)) return freq.toFixed(3);
return String(value || '');
}
if (value === undefined || value === null) return '';
return String(value).toUpperCase();
}
function tscmShowInvestigateById(id, protocol) {
const devices = getAllTscmDevices();
const key = `${protocol}:${id}`;
let device = devices[key];
if (!device && protocol === 'rf') {
const freq = parseFloat(id);
if (!isNaN(freq)) {
const all = Object.values(devices);
device = all.find(d => d.protocol === 'rf' && d.frequency && Math.abs(d.frequency - freq) < 0.01);
}
}
if (!device) {
const normalized = tscmNormalizeIdentifier(id, protocol);
const all = Object.values(devices);
device = all.find(d => tscmNormalizeIdentifier(null, protocol, d) === normalized);
}
if (!device) {
console.warn('Investigate device not found:', key);
return;
}
tscmShowInvestigate(device, protocol);
}
async function tscmShowInvestigate(device, protocol) {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
const identifier = tscmNormalizeIdentifier(null, protocol, device);
const displayName = device.name || device.essid || device.ssid || device.mac || device.bssid ||
(device.frequency ? `${device.frequency.toFixed(3)} MHz` : 'Unknown Device');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading triage sheet...</div>';
modal.style.display = 'flex';
let cases = [];
try {
const response = await fetch('/tscm/cases');
const data = await response.json();
cases = data.cases || [];
} catch (e) {
console.warn('Failed to load cases for triage:', e);
}
const caseOptions = cases.length
? `<option value="">Select a case...</option>` +
cases.map(c => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join('')
: '<option value="">No cases available</option>';
const noteTemplate = [
`Device: ${displayName}`,
`Protocol: ${protocol.toUpperCase()}`,
`Identifier: ${identifier}`,
`Score: ${device.score || 0}`,
`Recommended Action: ${device.recommended_action || 'monitor'}`,
'Notes:'
].join('\n');
const indicatorList = (device.indicators || []).map(i => `
<div class="indicator-item">
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
<span class="indicator-desc">${escapeHtml(i.desc || i.description || '')}</span>
</div>
`).join('');
content.innerHTML = `
<div class="device-detail-header ${getClassificationClass(device.classification)}">
<h3>${getClassificationIcon(device.classification)} ${escapeHtml(displayName)}</h3>
<span class="device-detail-protocol">${protocol.toUpperCase()}</span>
</div>
<div class="device-detail-score">
<div class="score-circle ${device.score >= 6 ? 'high' : device.score >= 3 ? 'medium' : 'low'}">
<span class="score-value">${device.score || 0}</span>
<span class="score-label">SCORE</span>
</div>
<div class="score-breakdown">
<strong>Risk Level:</strong> ${device.classification === 'high_interest' ? 'HIGH INTEREST' : device.classification === 'review' ? 'NEEDS REVIEW' : 'INFORMATIONAL'}<br>
<strong>Recommended Action:</strong> ${device.recommended_action || 'Monitor'}
</div>
</div>
<div class="device-detail-section">
<h4>Device Profile</h4>
<div id="tscmTriageProfileSection" class="tscm-empty">Loading profile...</div>
</div>
<div class="device-detail-section" id="tscmTriageTimelineSection">
<h4>Timeline</h4>
<div class="tscm-empty">Loading timeline...</div>
</div>
${indicatorList ? `
<div class="device-detail-section">
<h4>Indicators</h4>
<div class="indicator-list">${indicatorList}</div>
</div>
` : ''}
<div class="device-detail-section">
<h4>Case Actions</h4>
${cases.length === 0 ? `
<div class="tscm-empty">No cases available. Create a case to attach notes.</div>
<button class="preset-btn" onclick="tscmCreateCase()" style="margin-top: 8px; font-size: 10px;">+ New Case</button>
` : `
<div class="tscm-case-note-form">
<label>Case</label>
<select id="tscmTriageCaseSelect">${caseOptions}</select>
<label>Note Type</label>
<select id="tscmTriageNoteType">
<option value="general" selected>General</option>
<option value="observation">Observation</option>
<option value="action">Action</option>
<option value="follow_up">Follow-up</option>
</select>
<label>Case Note</label>
<textarea id="tscmTriageNote" rows="5">${escapeHtml(noteTemplate)}</textarea>
<div class="tscm-case-note-actions">
<button class="preset-btn" onclick="tscmAddTriageNote()" style="font-size: 10px;">Add Note</button>
<button class="preset-btn" onclick="tscmOpenSelectedCase()" style="font-size: 10px;">Open Case</button>
${tscmLastSweepId ? `<button class="preset-btn" onclick="tscmPromptLinkSweep(${tscmLastSweepId})" style="font-size: 10px;" title="Link the current sweep to a case">Link to Case</button>` : ''}
</div>
</div>
`}
</div>
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> This triage sheet surfaces indicators only. It does NOT confirm surveillance activity.
</div>
`;
loadTscmTimeline(identifier, protocol, 'tscmTriageTimelineSection');
tscmLoadTriageProfile(identifier);
}
async function tscmLoadTriageProfile(identifier) {
const section = document.getElementById('tscmTriageProfileSection');
if (!section || !identifier) return;
try {
const response = await fetch(`/tscm/findings/device/${encodeURIComponent(identifier)}`);
const data = await response.json();
if (data.status !== 'success' || !data.profile) {
section.innerHTML = '<div class="tscm-empty">No profile available.</div>';
return;
}
const profile = data.profile;
section.innerHTML = `
<table class="device-detail-table">
<tr><td>Identifier</td><td>${escapeHtml(profile.identifier || '')}</td></tr>
<tr><td>Name</td><td>${escapeHtml(profile.name || 'N/A')}</td></tr>
<tr><td>Manufacturer</td><td>${escapeHtml(profile.manufacturer || 'N/A')}</td></tr>
<tr><td>Device Type</td><td>${escapeHtml(profile.device_type || 'N/A')}</td></tr>
${(profile.tracker_name || profile.tracker_type) ? `<tr><td>Tracker</td><td>${escapeHtml(profile.tracker_name || profile.tracker_type)}</td></tr>` : ''}
${profile.tracker_confidence ? `<tr><td>Tracker Confidence</td><td>${escapeHtml(profile.tracker_confidence)}</td></tr>` : ''}
<tr><td>First Seen</td><td>${profile.first_seen ? new Date(profile.first_seen).toLocaleString() : 'N/A'}</td></tr>
<tr><td>Last Seen</td><td>${profile.last_seen ? new Date(profile.last_seen).toLocaleString() : 'N/A'}</td></tr>
<tr><td>Detections</td><td>${profile.detection_count || 0}</td></tr>
<tr><td>Risk Level</td><td>${escapeHtml(profile.risk_level || 'informational')}</td></tr>
<tr><td>Score</td><td>${profile.total_score || 0}</td></tr>
<tr><td>Confidence</td><td>${profile.confidence !== undefined ? Math.round(profile.confidence * 100) + '%' : 'N/A'}</td></tr>
${profile.known_device ? `<tr><td>Known Device</td><td>${escapeHtml(profile.known_device_name || 'Yes')}</td></tr>` : ''}
</table>
${profile.indicators && profile.indicators.length > 0 ? `
<div style="margin-top: 12px;">
<h4>Profile Indicators</h4>
<div class="indicator-list">
${profile.indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
`;
} catch (e) {
console.error('Failed to load triage profile:', e);
section.innerHTML = '<div class="tscm-empty">Failed to load profile.</div>';
}
}
async function tscmSubmitCaseNote(caseId, content, noteType) {
if (!caseId) return false;
if (!content) {
alert('Note content is required.');
return false;
}
try {
const response = await fetch(`/tscm/cases/${caseId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: content,
note_type: noteType || 'general'
})
});
const data = await response.json();
if (data.status === 'success') {
return true;
}
alert(data.message || 'Failed to add note');
return false;
} catch (e) {
console.error('Failed to add case note:', e);
alert('Failed to add note');
return false;
}
}
async function tscmAddTriageNote() {
const caseSelect = document.getElementById('tscmTriageCaseSelect');
const noteInput = document.getElementById('tscmTriageNote');
const typeSelect = document.getElementById('tscmTriageNoteType');
if (!caseSelect || !noteInput || !typeSelect) return;
const caseId = caseSelect.value;
const content = noteInput.value.trim();
const noteType = typeSelect.value;
if (!caseId) {
alert('Select a case to attach this note.');
return;
}
const ok = await tscmSubmitCaseNote(caseId, content, noteType);
if (ok) {
noteInput.value = '';
alert('Note added to case.');
}
}
function tscmOpenSelectedCase() {
const caseSelect = document.getElementById('tscmTriageCaseSelect');
if (!caseSelect || !caseSelect.value) {
alert('Select a case to open.');
return;
}
tscmViewCase(caseSelect.value);
}
function formatTscmTimestamp(ts) {
if (!ts) return 'N/A';
try {
return new Date(ts).toLocaleString();
} catch (e) {
return ts;
}
}
async function loadTscmTimeline(identifier, protocol, targetId = 'tscmTimelineSection') {
const section = document.getElementById(targetId);
const normalizedIdentifier = tscmNormalizeIdentifier(identifier, protocol);
if (!section || !normalizedIdentifier) return;
try {
const response = await fetch(`/tscm/device/${encodeURIComponent(normalizedIdentifier)}/timeline?protocol=${encodeURIComponent(protocol)}`);
const data = await response.json();
if (data.status !== 'success' || !data.timeline) {
section.innerHTML = `
<h4>Timeline</h4>
<div class="tscm-empty">No timeline data available.</div>
`;
return;
}
const timeline = data.timeline;
const metrics = timeline.metrics || {};
const signal = timeline.signal || {};
const movement = timeline.movement || {};
const meeting = timeline.meeting_correlation || {};
const observations = timeline.observations || [];
const recent = observations.slice(-5);
const metricsRows = [];
if (metrics.first_seen) metricsRows.push(`<tr><td>First Seen</td><td>${formatTscmTimestamp(metrics.first_seen)}</td></tr>`);
if (metrics.last_seen) metricsRows.push(`<tr><td>Last Seen</td><td>${formatTscmTimestamp(metrics.last_seen)}</td></tr>`);
if (metrics.total_observations !== undefined) metricsRows.push(`<tr><td>Observations</td><td>${metrics.total_observations}</td></tr>`);
if (metrics.presence_ratio !== undefined) metricsRows.push(`<tr><td>Presence Ratio</td><td>${Math.round(metrics.presence_ratio * 100)}%</td></tr>`);
const signalRows = [];
if (signal.rssi_min !== undefined && signal.rssi_min !== null) signalRows.push(`<tr><td>RSSI Min</td><td>${signal.rssi_min} dBm</td></tr>`);
if (signal.rssi_max !== undefined && signal.rssi_max !== null) signalRows.push(`<tr><td>RSSI Max</td><td>${signal.rssi_max} dBm</td></tr>`);
if (signal.rssi_mean !== undefined && signal.rssi_mean !== null) signalRows.push(`<tr><td>RSSI Mean</td><td>${signal.rssi_mean} dBm</td></tr>`);
if (signal.stability !== undefined && signal.stability !== null) signalRows.push(`<tr><td>Stability</td><td>${Math.round(signal.stability * 100)}%</td></tr>`);
const movementRows = [];
if (movement.pattern) movementRows.push(`<tr><td>Movement</td><td>${escapeHtml(movement.pattern)}</td></tr>`);
if (movement.appears_stationary !== undefined) {
movementRows.push(`<tr><td>Stationary</td><td>${movement.appears_stationary ? 'Yes' : 'No'}</td></tr>`);
}
if (meeting.correlated !== undefined) {
movementRows.push(`<tr><td>Meeting Correlated</td><td>${meeting.correlated ? 'Yes' : 'No'}</td></tr>`);
}
let observationsHtml = '';
if (recent.length > 0) {
observationsHtml = `
<div style="margin-top: 12px;">
<h4>Recent Observations</h4>
<table class="device-detail-table">
${recent.map(o => `
<tr>
<td>${formatTscmTimestamp(o.timestamp)}</td>
<td>${o.rssi !== null && o.rssi !== undefined ? `${o.rssi} dBm` : '--'}</td>
<td>${o.channel || o.frequency || '--'}</td>
</tr>
`).join('')}
</table>
</div>
`;
}
const noMetrics = metricsRows.length === 0 && signalRows.length === 0 && movementRows.length === 0;
section.innerHTML = `
<h4>Timeline</h4>
${noMetrics ? '<div class="tscm-empty">No timeline metrics available yet.</div>' : `
<table class="device-detail-table">
${metricsRows.join('')}
${signalRows.join('')}
${movementRows.join('')}
</table>
`}
${observationsHtml}
`;
} catch (e) {
section.innerHTML = `
<h4>Timeline</h4>
<div class="tscm-empty">Failed to load timeline data.</div>
`;
}
}
async function loadDeviceTimelines() {
const container = document.getElementById('tscmDeviceTimelinesList');
if (!container) return;
container.innerHTML = '<div class="tscm-empty">Loading timelines...</div>';
try {
const response = await fetch('/tscm/timelines');
const data = await response.json();
if (data.status !== 'success' || !data.timelines || data.timelines.length === 0) {
container.innerHTML = '<div class="tscm-empty">No device timelines available</div>';
return;
}
const timelines = data.timelines;
let html = '';
timelines.forEach(tl => {
const identifier = tl.identifier || 'Unknown';
const protocol = tl.protocol || 'unknown';
const presencePct = tl.presence_ratio !== undefined ? Math.round(tl.presence_ratio * 100) : 0;
const pattern = tl.movement_pattern || 'UNKNOWN';
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
const pColor = patternColors[pattern] || '#9e9e9e';
// Create a compact swim-lane row
html += `
<div style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.05); cursor: pointer; font-size: 10px;"
onclick="tscmShowInvestigateById('${escapeHtml(identifier)}', '${escapeHtml(protocol)}')">
<span style="width: 12px; text-transform: uppercase; color: var(--text-muted); font-size: 8px;">${protocol.charAt(0).toUpperCase()}</span>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-primary); font-family: var(--font-mono);">${escapeHtml(identifier)}</span>
<div style="width: 100px; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden;">
<div style="width: ${presencePct}%; height: 100%; background: var(--accent-cyan); border-radius: 3px;"></div>
</div>
<span style="width: 35px; text-align: right; color: var(--accent-cyan);">${presencePct}%</span>
<span style="padding: 1px 6px; background: ${pColor}22; color: ${pColor}; border-radius: 3px; font-size: 8px; font-weight: bold;">${pattern}</span>
</div>
`;
});
container.innerHTML = html;
} catch (e) {
console.error('Failed to load device timelines:', e);
container.innerHTML = '<div class="tscm-empty">Failed to load timelines</div>';
}
}
let deviceTimelineChartInstance = null;
async function fetchDeviceTimelineChart(identifier, protocol) {
try {
const response = await fetch(`/tscm/device/${encodeURIComponent(identifier)}/timeline?protocol=${encodeURIComponent(protocol)}&since_hours=24`);
const data = await response.json();
if (data.status !== 'success' || !data.timeline) return;
const timeline = data.timeline;
const observations = timeline.observations || [];
const metrics = timeline.metrics || {};
const signal = timeline.signal || {};
const movement = timeline.movement || {};
// Render Chart.js RSSI timeline
const canvas = document.getElementById('deviceTimelineChart');
if (canvas && typeof Chart !== 'undefined' && observations.length > 0) {
if (deviceTimelineChartInstance) {
deviceTimelineChartInstance.destroy();
}
const chartData = observations.map(o => ({
x: new Date(o.timestamp),
y: o.rssi !== null && o.rssi !== undefined ? o.rssi : null,
})).filter(d => d.y !== null);
const pointColors = chartData.map(d => d.y !== null ? 'rgba(0, 230, 118, 0.8)' : 'rgba(158, 158, 158, 0.5)');
deviceTimelineChartInstance = new Chart(canvas, {
type: 'line',
data: {
datasets: [{
label: 'RSSI (dBm)',
data: chartData,
borderColor: 'rgba(0, 212, 255, 0.8)',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
fill: true,
pointBackgroundColor: pointColors,
pointRadius: 3,
pointHoverRadius: 5,
tension: 0.3,
borderWidth: 1.5,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
x: {
type: 'time',
time: { unit: 'hour', displayFormats: { hour: 'ha', minute: 'h:mm a' } },
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
grid: { color: 'rgba(255,255,255,0.05)' },
},
y: {
title: { display: true, text: 'dBm', color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
ticks: { color: 'rgba(255,255,255,0.5)', font: { size: 9 } },
grid: { color: 'rgba(255,255,255,0.05)' },
}
}
}
});
}
// Render metrics badges
renderTimelineMetrics(metrics, signal, movement);
} catch (e) {
console.error('Failed to load device timeline chart:', e);
}
}
function renderTimelineMetrics(metrics, signal, movement) {
const container = document.getElementById('deviceTimelineMetrics');
if (!container) return;
const badges = [];
if (metrics.total_observations !== undefined) {
badges.push(`<span style="padding: 4px 8px; background: rgba(0,212,255,0.15); color: var(--accent-cyan); border-radius: 4px; font-size: 10px;">${metrics.total_observations} observations</span>`);
}
if (metrics.presence_ratio !== undefined) {
const pct = Math.round(metrics.presence_ratio * 100);
badges.push(`<span style="padding: 4px 8px; background: rgba(0,230,118,0.15); color: #00e676; border-radius: 4px; font-size: 10px;">${pct}% presence</span>`);
}
if (signal.rssi_min !== undefined && signal.rssi_max !== undefined && signal.rssi_min !== null) {
badges.push(`<span style="padding: 4px 8px; background: rgba(255,152,0,0.15); color: #ff9800; border-radius: 4px; font-size: 10px;">${signal.rssi_min} to ${signal.rssi_max} dBm</span>`);
}
if (signal.stability !== undefined && signal.stability !== null) {
badges.push(`<span style="padding: 4px 8px; background: rgba(156,39,176,0.15); color: #ce93d8; border-radius: 4px; font-size: 10px;">${Math.round(signal.stability * 100)}% stability</span>`);
}
if (movement.pattern) {
const patternColors = { 'STATIONARY': '#00e676', 'MOBILE': '#ff3366', 'INTERMITTENT': '#ff9800' };
const color = patternColors[movement.pattern] || '#9e9e9e';
badges.push(`<span style="padding: 4px 8px; background: ${color}22; color: ${color}; border-radius: 4px; font-size: 10px; font-weight: bold;">${movement.pattern}</span>`);
}
container.innerHTML = badges.join('');
}
async function loadTscmAdvancedAnalysis(device, protocol) {
if (protocol === 'wifi') {
const section = document.getElementById('tscmWifiAdvancedSection');
if (!section) return;
if (device && device.is_client) {
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">Client devices do not have AP indicators.</div>
`;
return;
}
try {
const payload = {
bssid: device.bssid,
ssid: device.ssid || device.essid,
channel: device.channel,
encryption: device.security,
power: device.signal,
signal: device.signal,
};
const response = await fetch('/tscm/wifi/analyze-network', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.status !== 'success') {
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">No advanced indicators available.</div>
`;
return;
}
const indicators = data.indicators || [];
if (indicators.length === 0) {
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">No advanced indicators detected.</div>
`;
return;
}
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="indicator-list">
${indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${escapeHtml((i.type || 'indicator') + (i.severity ? `${i.severity}` : ''))}</span>
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
</div>
`).join('')}
</div>
`;
} catch (e) {
section.innerHTML = `
<h4>WiFi Advanced Indicators</h4>
<div class="tscm-empty">Failed to analyze network.</div>
`;
}
return;
}
if (protocol === 'bluetooth') {
const section = document.getElementById('tscmBleExplainSection');
if (!section) return;
const mac = device.mac || device.address;
if (!mac) {
section.innerHTML = `
<h4>Bluetooth Risk Explanation</h4>
<div class="tscm-empty">No identifier available for explanation.</div>
`;
return;
}
try {
const response = await fetch(`/tscm/bluetooth/${encodeURIComponent(mac)}/explain`);
const data = await response.json();
if (data.status !== 'success' || !data.explanation) {
section.innerHTML = `
<h4>Bluetooth Risk Explanation</h4>
<div class="tscm-empty">No explanation available.</div>
`;
return;
}
const exp = data.explanation;
const risk = exp.risk || {};
let proximity = exp.proximity || {};
let proximityNote = proximity.explanation || '';
let proximityDistance = proximity.estimated_distance || '';
let proximityRssi = null;
let proximityDisclaimer = '';
const tracker = exp.tracker || {};
const meeting = exp.meeting_correlation || {};
const action = exp.recommended_action || {};
const indicators = exp.indicators || [];
try {
const proxResponse = await fetch(`/tscm/bluetooth/${encodeURIComponent(mac)}/proximity`);
const proxData = await proxResponse.json();
if (proxData.status === 'success' && proxData.proximity) {
proximity = proxData.proximity;
proximityNote = proxData.proximity.explanation || proximityNote;
proximityDistance = proxData.proximity.estimated_distance || proximityDistance;
proximityRssi = proxData.proximity.rssi_used;
proximityDisclaimer = proxData.disclaimer || '';
}
} catch (e) {
console.warn('BLE proximity lookup failed:', e);
}
section.innerHTML = `
<h4>Bluetooth Risk Explanation</h4>
<table class="device-detail-table">
<tr><td>Risk Level</td><td>${escapeHtml(risk.level || 'unknown').toUpperCase()} (${risk.score || 0})</td></tr>
<tr><td>Risk Rationale</td><td>${escapeHtml(risk.explanation || 'N/A')}</td></tr>
<tr><td>Proximity</td><td>${escapeHtml(proximity.estimate || 'unknown')} ${proximityDistance ? `(${escapeHtml(proximityDistance)})` : ''}${proximityRssi !== null ? ` — RSSI ${proximityRssi} dBm` : ''}</td></tr>
<tr><td>Proximity Note</td><td>${escapeHtml(proximityNote || 'N/A')}</td></tr>
<tr><td>Tracker</td><td>${tracker.is_tracker ? `Yes (${escapeHtml(tracker.type || 'unknown')})` : 'No'}</td></tr>
<tr><td>Meeting Correlated</td><td>${meeting.correlated ? 'Yes' : 'No'}</td></tr>
<tr><td>Recommended Action</td><td>${escapeHtml(action.action || 'monitor')}${escapeHtml(action.rationale || '')}</td></tr>
</table>
${indicators.length > 0 ? `
<div style="margin-top: 12px;">
<h4>Indicators</h4>
<div class="indicator-list">
${indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}</span>
<span class="indicator-desc">${escapeHtml(i.description || i.explanation || '')}</span>
</div>
`).join('')}
</div>
</div>
` : ''}
${proximityDisclaimer ? `
<div class="device-detail-disclaimer">
<strong>Note:</strong> ${escapeHtml(proximityDisclaimer)}
</div>
` : ''}
${exp.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Note:</strong> ${escapeHtml(exp.disclaimer)}
</div>
` : ''}
`;
} catch (e) {
section.innerHTML = `
<h4>Bluetooth Risk Explanation</h4>
<div class="tscm-empty">Failed to load explanation.</div>
`;
}
}
}
function closeTscmDeviceModal() {
document.getElementById('tscmDeviceModal').style.display = 'none';
if (tscmCaseLinkContext) tscmCaseLinkContext = null;
}
function listenToRfSignal(frequency, modulation) {
// Close the modal
closeTscmDeviceModal();
// Switch to spectrum waterfall mode
switchMode('waterfall');
// Wait a moment for the mode to switch, then tune to the frequency
setTimeout(() => {
if (typeof Waterfall !== 'undefined' && typeof Waterfall.quickTune === 'function') {
Waterfall.quickTune(frequency, modulation);
} else {
// Fallback: update Waterfall center control directly
const freqInput = document.getElementById('wfCenterFreq');
if (freqInput) {
freqInput.value = frequency.toFixed(4);
}
alert(`Tune to ${frequency.toFixed(3)} MHz (${modulation.toUpperCase()}) to listen`);
}
}, 300);
}
function decodeWithOok(frequency) {
// Close the TSCM modal and switch to OOK decoder with the detected frequency pre-filled
closeTscmDeviceModal();
switchMode('ook');
setTimeout(function () {
if (typeof OokMode !== 'undefined' && typeof OokMode.setFreq === 'function') {
OokMode.setFreq(parseFloat(frequency).toFixed(3));
}
}, 300);
}
async function showDevicesByCategory(category) {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
let devices = [];
let title = '';
let titleClass = '';
if (category === 'correlations') {
// Show correlations
title = 'Cross-Protocol Correlations';
titleClass = 'classification-yellow';
if (tscmCorrelations.length === 0) {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title}</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No correlations detected yet.</p>
</div>
`;
} else {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title} (${tscmCorrelations.length})</h3>
</div>
<div class="device-detail-section">
${tscmCorrelations.map(c => `
<div class="correlation-detail-item">
<strong>${escapeHtml(c.description || 'Cross-protocol match')}</strong>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Protocols: ${(c.protocols || []).join(', ')}<br>
Devices: ${(c.devices || []).join(', ')}
</div>
</div>
`).join('')}
</div>
`;
}
modal.style.display = 'flex';
return;
}
if (category === 'identity') {
title = 'Identity Clusters (MAC-Randomization Resistant)';
titleClass = 'classification-cyan';
if (tscmIdentityClusters.length === 0) {
await tscmRefreshIdentityClusters();
}
if (tscmIdentityClusters.length === 0) {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title}</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No identity clusters detected yet.</p>
</div>
`;
} else {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title} (${tscmIdentityClusters.length})</h3>
<button class="preset-btn" onclick="tscmRefreshIdentityClusters().then(() => showDevicesByCategory('identity'))" style="font-size: 10px; padding: 6px 8px;">
Refresh
</button>
</div>
<div class="device-detail-section">
${tscmIdentityClusters.map(c => `
<div class="correlation-item">
<strong>${escapeHtml(c.best_name || c.manufacturer_name || c.cluster_id)}</strong>
<div class="correlation-devices">
Risk: ${escapeHtml(c.risk_level || 'informational')} (${c.risk_score || 0}) |
MACs: ${(c.linked_macs || []).length} |
Observations: ${c.total_observations || 0} |
Confidence: ${c.confidence !== undefined ? (c.confidence * 100).toFixed(0) + '%' : 'n/a'}
</div>
</div>
`).join('')}
</div>
<div class="device-detail-disclaimer">
<strong>Note:</strong> Identity clustering is probabilistic. It links observations by passive fingerprints and timing patterns.
</div>
`;
}
modal.style.display = 'flex';
return;
}
// Filter devices by classification
const filteredForCategory = getFilteredDevices({ ignoreRisk: true });
const allDevices = [
...filteredForCategory.wifi.map(d => ({ ...d, protocol: 'wifi', id: d.bssid })),
...filteredForCategory.bt.map(d => ({ ...d, protocol: 'bluetooth', id: d.mac })),
...filteredForCategory.rf.map(d => ({ ...d, protocol: 'rf', id: d.frequency }))
];
if (category === 'high_interest') {
devices = allDevices.filter(d => d.classification === 'high_interest');
title = 'High Interest Devices';
titleClass = 'classification-red';
} else if (category === 'review') {
devices = allDevices.filter(d => d.classification === 'review');
title = 'Devices Needing Review';
titleClass = 'classification-yellow';
} else if (category === 'informational') {
devices = allDevices.filter(d => d.classification === 'informational');
title = 'Informational Devices';
titleClass = 'classification-green';
}
// Sort by score descending
devices.sort((a, b) => (b.score || 0) - (a.score || 0));
if (devices.length === 0) {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title}</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No devices in this category.</p>
</div>
`;
} else {
content.innerHTML = `
<div class="device-detail-header ${titleClass}">
<h3>${title} (${devices.length})</h3>
</div>
<div class="category-device-list">
${devices.map(d => `
<div class="category-device-item" onclick="event.stopPropagation(); showDeviceDetails('${d.id}', '${d.protocol}')">
<div class="category-device-header">
<span class="category-device-name">
${getClassificationIcon(d.classification)}
${escapeHtml(d.name || d.ssid || d.mac || d.bssid || (d.frequency ? d.frequency.toFixed(3) + ' MHz' : 'Unknown'))}
</span>
<span class="category-device-score">${d.score || 0}</span>
</div>
<div class="category-device-meta">
<span class="protocol-badge">${d.protocol}</span>
${d.indicators ? d.indicators.slice(0, 2).map(i => `<span class="indicator-mini">${i.type}</span>`).join('') : ''}
</div>
</div>
`).join('')}
</div>
`;
}
modal.style.display = 'flex';
}
function showBaselineComparison() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
if (!tscmBaselineComparison) {
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>Baseline Comparison</h3>
</div>
<div class="device-detail-section">
<p style="text-align: center; color: var(--text-muted);">No baseline comparison data available.</p>
</div>
`;
modal.style.display = 'flex';
return;
}
const comparison = tscmBaselineComparison;
const baselineName = comparison.baseline_name || 'Baseline';
const formatItem = (item, protocol) => {
if (protocol === 'wifi') {
const name = item.essid || item.ssid || 'Hidden SSID';
const id = item.bssid || item.mac || '';
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
}
if (protocol === 'wifi_clients') {
const name = item.vendor || 'WiFi Client';
const id = item.mac || item.address || '';
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
}
if (protocol === 'bluetooth') {
const name = item.name || 'Unknown';
const id = item.mac || item.address || '';
return `${escapeHtml(name)} ${id ? `<span class="device-detail-id">${escapeHtml(id)}</span>` : ''}`;
}
if (protocol === 'rf') {
const freq = item.frequency ? `${item.frequency} MHz` : 'Unknown Frequency';
const band = item.band || '';
return `${escapeHtml(freq)} ${band ? `<span class="device-detail-id">${escapeHtml(band)}</span>` : ''}`;
}
return escapeHtml(item.name || item.identifier || 'Unknown');
};
const renderList = (items, protocol, limit = 10) => {
if (!items || items.length === 0) {
return '<div class="tscm-empty">None</div>';
}
const listItems = items.slice(0, limit).map(i => `<li>${formatItem(i, protocol)}</li>`).join('');
const more = items.length > limit
? `<div class="tscm-more-hint">+${items.length - limit} more</div>`
: '';
return `<ul class="device-reasons-list">${listItems}</ul>${more}`;
};
const sections = [
{ key: 'wifi', label: 'WiFi' },
{ key: 'wifi_clients', label: 'WiFi Clients' },
{ key: 'bluetooth', label: 'Bluetooth' },
{ key: 'rf', label: 'RF' },
];
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>Baseline Comparison — ${escapeHtml(baselineName)}</h3>
</div>
<div class="device-detail-section">
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
New: ${comparison.total_new || 0} | Missing: ${comparison.total_missing || 0}
</div>
${sections.map(section => {
const data = comparison[section.key] || {};
return `
<div style="margin-bottom: 16px;">
<h4>${section.label}</h4>
<div style="font-size: 10px; color: var(--text-muted); margin-bottom: 6px;">
New: ${data.new_count || 0} | Missing: ${data.missing_count || 0}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">
<div>
<strong style="font-size: 10px; color: var(--text-secondary);">New</strong>
${renderList(data.new || [], section.key)}
</div>
<div>
<strong style="font-size: 10px; color: var(--text-secondary);">Missing</strong>
${renderList(data.missing || [], section.key)}
</div>
</div>
</div>
`;
}).join('')}
</div>
<div class="device-detail-disclaimer">
<strong>Note:</strong> Baseline comparisons indicate environmental changes, not confirmed threats. Validate before action.
</div>
`;
modal.style.display = 'flex';
}
function updateTscmDisplays() {
const filtered = getFilteredDevices();
const filtersActive = tscmFilters.protocol !== 'all' || tscmFilters.risk !== 'all' ||
tscmFilters.status !== 'all' || tscmFilters.known !== 'all';
// Update WiFi list
const wifiList = document.getElementById('tscmWifiList');
if (filtered.wifi.length === 0) {
wifiList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi networks match filters' : 'No WiFi networks detected'}</div>`;
} else {
// Sort by score (highest first)
const sorted = [...filtered.wifi].sort((a, b) => (b.score || 0) - (a.score || 0));
wifiList.innerHTML = sorted.map(d => `
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.bssid}', 'wifi')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
${escapeHtml(d.ssid || d.bssid || 'Hidden')}
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(d.score)}
</div>
<div class="tscm-device-meta">
<span>${d.bssid}</span>
<span>${d.signal || '--'} dBm</span>
<span>${escapeHtml(d.vendor || 'Unknown')}${escapeHtml(d.security || 'Open')}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmWifiCount').textContent = filtered.wifi.length;
// Update WiFi clients list
const wifiClientList = document.getElementById('tscmWifiClientList');
if (filtered.wifi_clients.length === 0) {
wifiClientList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}</div>`;
} else {
const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0));
wifiClientList.innerHTML = sortedClients.map(c => `
<div class="tscm-device-item ${getClassificationClass(c.classification)}" onclick="showDeviceDetails('${c.mac}', 'wifi')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(c.classification)}</span>
${escapeHtml(c.vendor || 'WiFi Client')}
<span class="client-badge" title="WiFi client">CLIENT</span>
${c.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(c.score)}
</div>
<div class="tscm-device-meta">
<span>${c.mac}</span>
<span>${c.rssi || '--'} dBm</span>
<span>${c.associated_bssid ? `Assoc: ${c.associated_bssid}` : `Probes: ${c.probe_count || 0}`}</span>
</div>
${c.indicators && c.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(c.indicators)}</div>` : ''}
${c.recommended_action && c.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${c.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length;
// Update BT list
const btList = document.getElementById('tscmBtList');
if (filtered.bt.length === 0) {
btList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No Bluetooth devices match filters' : 'No Bluetooth devices detected'}</div>`;
} else {
// Sort by score (highest first)
const sorted = [...filtered.bt].sort((a, b) => (b.score || 0) - (a.score || 0));
btList.innerHTML = sorted.map(d => `
<div class="tscm-device-item ${getClassificationClass(d.classification)}" onclick="showDeviceDetails('${d.mac}', 'bluetooth')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(d.classification)}</span>
${escapeHtml(d.name || 'Unknown')}
${d.is_audio_capable ? '<span class="audio-badge" title="Audio-capable device">AUDIO</span>' : ''}
${formatTrackerBadge(d)}
${d.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(d.score)}
</div>
<div class="tscm-device-meta">
<span>${d.mac}</span>
<span>${d.rssi || '--'} dBm</span>
<span>${escapeHtml([d.device_type, d.manufacturer].filter(Boolean).join(' • ') || 'Unknown')}</span>
</div>
${d.indicators && d.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(d.indicators)}</div>` : ''}
${d.recommended_action && d.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${d.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmBtCount').textContent = filtered.bt.length;
// Update RF list
const rfList = document.getElementById('tscmRfList');
if (filtered.rf.length === 0) {
if (tscmRfStatusMessage) {
rfList.innerHTML = `<div class="tscm-status-message">${escapeHtml(tscmRfStatusMessage)}</div>`;
} else {
rfList.innerHTML = `<div class="tscm-empty">${filtersActive ? 'No RF signals match filters' : 'No RF signals detected'}</div>`;
}
} else {
// Sort by score (highest first)
const sorted = [...filtered.rf].sort((a, b) => (b.score || 0) - (a.score || 0));
rfList.innerHTML = sorted.map(s => `
<div class="tscm-device-item ${getClassificationClass(s.classification)}" onclick="showDeviceDetails('${s.frequency}', 'rf')">
<div class="tscm-device-header">
<div class="tscm-device-name">
<span class="classification-indicator">${getClassificationIcon(s.classification)}</span>
${s.frequency.toFixed(3)} MHz
${s.known_device ? '<span class="known-badge" title="Known device">KNOWN</span>' : ''}
</div>
${getScoreBadge(s.score)}
</div>
<div class="tscm-device-meta">
<span>${s.band}</span>
<span>${s.power.toFixed(1)} dBm</span>
<span>+${(s.signal_strength || 0).toFixed(1)} dB above noise</span>
</div>
${s.indicators && s.indicators.length > 0 ? `<div class="tscm-device-indicators">${formatIndicators(s.indicators)}</div>` : ''}
${s.recommended_action && s.recommended_action !== 'monitor' ? `<div class="tscm-action">Action: ${s.recommended_action}</div>` : ''}
</div>
`).join('');
}
document.getElementById('tscmRfCount').textContent = filtered.rf.length;
// Update threats list
const threatList = document.getElementById('tscmThreatList');
let threatItems = tscmThreats;
if (tscmFilters.protocol !== 'all') {
threatItems = threatItems.filter(t => t.source === tscmFilters.protocol);
}
if (threatItems.length === 0) {
threatList.innerHTML = '<div class="tscm-empty"><div class="tscm-empty-primary">Monitoring active — nothing flagged</div><div class="tscm-empty-secondary">Signals are being analyzed against baseline thresholds. This does not rule out passive or dormant devices.</div></div>';
} else {
threatList.innerHTML = '<div class="tscm-threat-list">' + threatItems.map(t => `
<div class="tscm-threat-item ${t.severity}" onclick="showDeviceDetails('${escapeHtml(t.identifier)}', '${escapeHtml(t.source)}')" style="cursor: pointer;">
<div class="tscm-threat-header">
<span class="tscm-threat-type">${escapeHtml(t.threat_type || 'Unknown')}</span>
<span class="tscm-threat-severity">${t.severity}</span>
${t.threat_id ? `
<button class="tscm-case-link-btn" onclick="event.stopPropagation(); tscmPromptLinkThreat(${t.threat_id})">
Link
</button>
` : ''}
</div>
<div class="tscm-threat-details">
<strong>${escapeHtml(t.name || t.identifier)}</strong><br>
Source: ${t.source} | Signal: ${t.signal_strength || '--'} dBm
</div>
</div>
`).join('') + '</div>';
}
}
function updateCorrelationsDisplay() {
const container = document.getElementById('tscmCorrelationsContainer');
if (!container) return;
const hasCorrelations = tscmCorrelations.length > 0;
const hasIdentity = tscmIdentityClusters.length > 0;
if (!hasCorrelations && !hasIdentity) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
container.style.display = 'block';
const sections = [];
if (hasCorrelations) {
sections.push(`
<div class="tscm-correlations">
<h4>Cross-Protocol Correlations (${tscmCorrelations.length})</h4>
${tscmCorrelations.map(c => `
<div class="correlation-item">
<strong>${escapeHtml(c.description)}</strong>
<div class="correlation-devices">
Devices: ${c.devices.join(', ')} | Protocols: ${c.protocols.join(', ')}
</div>
</div>
`).join('')}
</div>
`);
}
if (hasIdentity) {
const sortedClusters = [...tscmIdentityClusters]
.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
const topClusters = sortedClusters.slice(0, 10);
const summaryText = tscmIdentitySummary
? `High: ${tscmIdentitySummary.high || 0} | Medium: ${tscmIdentitySummary.medium || 0} | Total: ${tscmIdentitySummary.total || tscmIdentityClusters.length}`
: `Total: ${tscmIdentityClusters.length}`;
sections.push(`
<div class="tscm-correlations">
<h4>Identity Clusters (MAC-Randomization Resistant) — ${summaryText}</h4>
${topClusters.map(c => `
<div class="correlation-item">
<strong>${escapeHtml(c.best_name || c.manufacturer_name || c.cluster_id)}</strong>
<div class="correlation-devices">
Risk: ${escapeHtml(c.risk_level || 'informational')} (${c.risk_score || 0}) |
MACs: ${(c.linked_macs || []).length} |
Observations: ${c.total_observations || 0} |
Confidence: ${c.confidence !== undefined ? (c.confidence * 100).toFixed(0) + '%' : 'n/a'}
</div>
</div>
`).join('')}
${tscmIdentityClusters.length > topClusters.length ? `
<div class="tscm-more-hint">Showing top ${topClusters.length} clusters. Use the Identity Clusters card for full list.</div>
` : ''}
</div>
`);
}
container.innerHTML = sections.join('');
}
function completeTscmSweep(data) {
isTscmRunning = false;
if (tscmEventSource) {
tscmEventSource.close();
tscmEventSource = null;
}
document.getElementById('startTscmBtn').style.display = 'block';
document.getElementById('stopTscmBtn').style.display = 'none';
document.getElementById('tscmProgress').style.display = 'none';
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
document.getElementById('tscmProgressPercent').textContent = '100%';
document.getElementById('tscmProgressBar').style.width = '100%';
// Final update of counts
updateTscmThreatCounts();
// Display sweep summary with correlation results
const summaryContainer = document.getElementById('tscmSweepSummary');
if (summaryContainer && data) {
if (data.sweep_id) {
tscmLastSweepId = data.sweep_id;
}
const highInterest = data.high_interest_devices || 0;
const needsReview = data.needs_review_devices || 0;
const correlations = data.correlations_found || 0;
const identityClusters = data.identity_clusters || (tscmIdentitySummary ? tscmIdentitySummary.total : 0);
const baselineNew = data.baseline_new_devices || 0;
const baselineMissing = data.baseline_missing_devices || 0;
const wifiCount = data.wifi_count ?? tscmWifiDevices.length;
const wifiClientCount = data.wifi_client_count ?? tscmWifiClients.length;
const btCount = data.bt_count ?? tscmBtDevices.length;
const rfCount = data.rf_count ?? tscmRfSignals.length;
let assessment = 'BASELINE ENVIRONMENT';
let assessmentClass = 'informational';
if (highInterest > 0 || correlations > 0) {
assessment = 'ELEVATED CONCERN';
assessmentClass = 'high-interest';
} else if (needsReview > 3) {
assessment = 'MODERATE CONCERN';
assessmentClass = 'needs-review';
} else if (needsReview > 0) {
assessment = 'LOW CONCERN';
assessmentClass = 'needs-review';
}
summaryContainer.innerHTML = `
<div class="tscm-summary-box">
<div class="summary-stat high-interest">
<div class="count">${highInterest}</div>
<div class="label">High Interest</div>
</div>
<div class="summary-stat needs-review">
<div class="count">${needsReview}</div>
<div class="label">Needs Review</div>
</div>
<div class="summary-stat">
<div class="count">${correlations}</div>
<div class="label">Correlations</div>
</div>
<div class="summary-stat">
<div class="count">${identityClusters}</div>
<div class="label">Identity Clusters</div>
</div>
${(baselineNew || baselineMissing) ? `
<div class="summary-stat">
<div class="count">+${baselineNew} / -${baselineMissing}</div>
<div class="label">Baseline Delta</div>
</div>
` : ''}
</div>
<div class="tscm-summary-meta" style="margin-top: 8px; font-size: 10px; color: var(--text-muted);">
Devices: ${wifiCount} WiFi AP • ${wifiClientCount} WiFi Clients • ${btCount} BT • ${rfCount} RF
</div>
<div class="tscm-assessment ${assessmentClass}">
<strong>Assessment:</strong> ${assessment}
</div>
${(baselineNew || baselineMissing) && tscmBaselineComparison ? `
<div style="margin-top: 8px;">
<button class="preset-btn" onclick="showBaselineComparison()" style="font-size: 10px;">
View Baseline Diff
</button>
</div>
` : ''}
${data.sweep_id ? `
<div style="margin-top: 8px;">
<button class="preset-btn" onclick="tscmPromptLinkSweep(${data.sweep_id})" style="font-size: 10px;">
Link Sweep to Case
</button>
</div>
` : ''}
<div class="tscm-disclaimer">
This screening identifies wireless/RF anomalies, NOT confirmed surveillance devices.
Findings require professional verification.
</div>
`;
summaryContainer.style.display = 'block';
}
// Update correlations display
updateCorrelationsDisplay();
}
async function tscmRecordBaseline() {
const name = document.getElementById('tscmBaselineName').value ||
`Baseline ${new Date().toLocaleString()}`;
try {
const response = await fetch('/tscm/baseline/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
const data = await response.json();
if (data.status === 'success') {
isRecordingBaseline = true;
document.getElementById('tscmRecordBaselineBtn').style.display = 'none';
document.getElementById('tscmStopBaselineBtn').style.display = 'block';
document.getElementById('tscmBaselineStatus').textContent = 'Recording baseline...';
document.getElementById('tscmBaselineStatus').style.color = '#ff9933';
} else {
alert(data.message || 'Failed to start baseline recording');
}
} catch (e) {
console.error('Failed to start baseline:', e);
alert('Failed to start baseline recording');
}
}
async function tscmStopBaseline() {
try {
const response = await fetch('/tscm/baseline/stop', { method: 'POST' });
const data = await response.json();
isRecordingBaseline = false;
document.getElementById('tscmRecordBaselineBtn').style.display = 'block';
document.getElementById('tscmStopBaselineBtn').style.display = 'none';
if (data.status === 'success') {
document.getElementById('tscmBaselineStatus').textContent =
`Baseline saved: ${data.wifi_count} WiFi, ${data.wifi_client_count || 0} Clients, ${data.bt_count} BT, ${data.rf_count} RF`;
document.getElementById('tscmBaselineStatus').style.color = '#00ff88';
loadTscmBaselines();
} else {
document.getElementById('tscmBaselineStatus').textContent = data.message || 'Recording stopped';
document.getElementById('tscmBaselineStatus').style.color = 'var(--text-muted)';
}
} catch (e) {
console.error('Failed to stop baseline:', e);
document.getElementById('tscmBaselineStatus').textContent = 'Error stopping baseline';
}
}
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ========== TSCM Advanced Features ==========
// Meeting Window Management
let tscmActiveMeetingId = null;
let tscmMeetingStartTime = null;
async function tscmStartMeeting() {
const meetingName = document.getElementById('tscmMeetingName').value ||
`Meeting ${new Date().toLocaleString()}`;
try {
const response = await fetch('/tscm/meeting/start-tracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: meetingName })
});
const data = await response.json();
if (data.status === 'success') {
tscmActiveMeetingId = data.meeting_id;
tscmMeetingStartTime = new Date();
tscmLastMeetingId = null;
// Update UI
document.getElementById('tscmStartMeetingBtn').style.display = 'none';
document.getElementById('tscmEndMeetingBtn').style.display = 'block';
document.getElementById('tscmMeetingStatus').innerHTML =
`<span style="color: #ff9933;">Meeting active: ${escapeHtml(meetingName)}</span>`;
const summaryBtn = document.getElementById('tscmMeetingSummaryBtn');
if (summaryBtn) summaryBtn.style.display = 'block';
// Show meeting banner
const banner = document.getElementById('tscmMeetingBanner');
if (banner) {
banner.style.display = 'flex';
const nameSpan = document.getElementById('tscmMeetingBannerName');
if (nameSpan) nameSpan.textContent = meetingName;
const timeSpan = document.getElementById('tscmMeetingBannerTime');
if (timeSpan) timeSpan.textContent = `Started ${new Date().toLocaleTimeString()}`;
}
} else {
alert(data.message || 'Failed to start meeting window');
}
} catch (e) {
console.error('Failed to start meeting:', e);
alert('Failed to start meeting window');
}
}
async function tscmEndMeeting() {
if (!tscmActiveMeetingId) return;
try {
const response = await fetch(`/tscm/meeting/${tscmActiveMeetingId}/end`, {
method: 'POST'
});
const data = await response.json();
// Update UI
document.getElementById('tscmStartMeetingBtn').style.display = 'block';
document.getElementById('tscmEndMeetingBtn').style.display = 'none';
// Hide meeting banner
const banner = document.getElementById('tscmMeetingBanner');
if (banner) banner.style.display = 'none';
if (data.status === 'success') {
const duration = tscmMeetingStartTime ?
Math.round((new Date() - tscmMeetingStartTime) / 60000) : 0;
document.getElementById('tscmMeetingStatus').innerHTML =
`<span style="color: #00ff88;">Meeting ended (${duration} min) - ${data.devices_flagged || 0} devices flagged</span>`;
// Show export section if devices were flagged
if (data.devices_flagged > 0) {
document.getElementById('tscmExportSection').style.display = 'block';
}
} else {
document.getElementById('tscmMeetingStatus').textContent = 'Meeting ended';
}
tscmLastMeetingId = tscmActiveMeetingId;
tscmActiveMeetingId = null;
tscmMeetingStartTime = null;
const summaryBtn = document.getElementById('tscmMeetingSummaryBtn');
if (summaryBtn) summaryBtn.style.display = tscmLastMeetingId ? 'block' : 'none';
} catch (e) {
console.error('Failed to end meeting:', e);
}
}
async function tscmShowMeetingSummary(meetingId) {
let id = meetingId || tscmActiveMeetingId || tscmLastMeetingId;
if (!id) {
try {
const activeRes = await fetch('/tscm/meeting/active');
const activeData = await activeRes.json();
if (activeData && activeData.meeting) {
id = activeData.meeting.id;
tscmActiveMeetingId = id;
}
} catch (e) {
console.warn('Failed to fetch active meeting:', e);
}
}
if (!id) {
alert('No meeting window available for summary.');
return;
}
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading meeting summary...</div>';
modal.style.display = 'flex';
try {
const response = await fetch(`/tscm/meeting/${id}/summary`);
const data = await response.json();
if (data.status !== 'success' || !data.summary) {
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load meeting summary.</div>';
return;
}
const summary = data.summary;
const metrics = summary.summary || {};
const firstSeen = summary.devices_first_seen || [];
const behavior = summary.devices_behavior_change || [];
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>${escapeHtml(summary.name || 'Meeting Summary')}</h3>
</div>
<div class="device-detail-section">
<table class="device-detail-table">
<tr><td>Start</td><td>${summary.start_time ? new Date(summary.start_time).toLocaleString() : 'N/A'}</td></tr>
<tr><td>End</td><td>${summary.end_time ? new Date(summary.end_time).toLocaleString() : 'In progress'}</td></tr>
<tr><td>Duration</td><td>${summary.duration_minutes ? `${summary.duration_minutes} min` : 'N/A'}</td></tr>
<tr><td>Total Active Devices</td><td>${metrics.total_devices_active || 0}</td></tr>
<tr><td>New During Meeting</td><td>${metrics.new_devices || 0}</td></tr>
<tr><td>Behavior Changes</td><td>${metrics.behavior_changes || 0}</td></tr>
<tr><td>High Interest</td><td>${metrics.high_interest || 0}</td></tr>
</table>
</div>
<div class="device-detail-section">
<h4>Devices First Seen During Meeting (${firstSeen.length})</h4>
${firstSeen.length === 0
? '<div class="tscm-empty">No devices first seen during meeting.</div>'
: `<div class="tscm-summary-list">
${firstSeen.map(d => `
<div class="tscm-summary-item">
<strong>${escapeHtml(d.name || d.identifier)}</strong>
<div class="tscm-summary-meta">
${escapeHtml(d.protocol || 'unknown')}${escapeHtml(d.description || '')}
</div>
${d.risk_modifier ? `<div class="tscm-summary-risk">${escapeHtml(d.risk_modifier)}</div>` : ''}
</div>
`).join('')}
</div>`
}
</div>
<div class="device-detail-section">
<h4>Behavior Changes (${behavior.length})</h4>
${behavior.length === 0
? '<div class="tscm-empty">No behavior changes detected.</div>'
: `<div class="tscm-summary-list">
${behavior.map(d => `
<div class="tscm-summary-item">
<strong>${escapeHtml(d.name || d.identifier)}</strong>
<div class="tscm-summary-meta">
${escapeHtml(d.protocol || 'unknown')}${escapeHtml(d.description || '')}
</div>
</div>
`).join('')}
</div>`
}
</div>
${summary.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Note:</strong> ${escapeHtml(summary.disclaimer)}
</div>
` : ''}
`;
} catch (e) {
console.error('Failed to load meeting summary:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load meeting summary.</div>';
}
}
// Capabilities Display
async function tscmShowCapabilities() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading capabilities...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/capabilities');
const data = await response.json();
if (data.status === 'success') {
const caps = data.capabilities;
// Determine availability from nested structure
const wifiAvailable = caps.wifi && caps.wifi.mode !== 'unavailable';
const btAvailable = caps.bluetooth && caps.bluetooth.mode !== 'unavailable';
const rfAvailable = caps.rf && caps.rf.available;
// Build can/cannot detect lists based on capabilities
const canDetect = [];
const cannotDetect = [];
if (wifiAvailable) {
canDetect.push('WiFi access points and networks');
canDetect.push('Hidden SSIDs (presence only)');
if (caps.wifi.monitor_capable) {
canDetect.push('WiFi client devices (probe requests)');
canDetect.push('Deauthentication attacks');
}
} else {
cannotDetect.push('WiFi networks - no adapter available');
}
if (btAvailable) {
canDetect.push('Bluetooth Classic devices');
canDetect.push('BLE beacons and trackers');
canDetect.push('Audio-capable Bluetooth devices');
} else {
cannotDetect.push('Bluetooth devices - no adapter available');
}
if (rfAvailable) {
const minFreq = caps.rf.frequency_range_mhz?.min || 0;
const maxFreq = caps.rf.frequency_range_mhz?.max || 0;
canDetect.push(`RF signals (${minFreq}-${maxFreq} MHz)`);
canDetect.push('Unknown transmitters in frequency range');
} else {
cannotDetect.push('RF signals - no SDR device available');
}
// Always cannot detect
cannotDetect.push('Wired surveillance devices');
cannotDetect.push('Passive listening devices (no transmitter)');
cannotDetect.push('Devices that are powered off');
cannotDetect.push('Burst/store-and-forward transmitters (when idle)');
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>Sweep Capabilities</h3>
</div>
<div class="device-detail-section">
<h4>System Information</h4>
<div style="font-size: 11px; color: var(--text-muted); margin-bottom: 12px;">
OS: ${escapeHtml(caps.system?.os || 'Unknown')} ${escapeHtml(caps.system?.os_version || '')} |
Root: ${caps.system?.is_root ? 'Yes' : 'No'}
</div>
<h4>Available Detection Methods</h4>
<div class="capabilities-grid">
<div class="cap-detail-item ${wifiAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1" fill="currentColor" stroke="none"/></svg></span>
<span class="cap-name">WiFi Scanning</span>
<span class="cap-status">${wifiAvailable ? caps.wifi.mode : 'Not Available'}</span>
${caps.wifi?.interface ? `<span class="cap-detail">${escapeHtml(caps.wifi.interface)}</span>` : ''}
</div>
<div class="cap-detail-item ${btAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6.5 6.5 17.5 17.5 12 22 12 2 17.5 6.5 6.5 17.5"/></svg></span>
<span class="cap-name">Bluetooth Scanning</span>
<span class="cap-status">${btAvailable ? caps.bluetooth.mode : 'Not Available'}</span>
${caps.bluetooth?.adapter ? `<span class="cap-detail">${escapeHtml(caps.bluetooth.adapter)}</span>` : ''}
</div>
<div class="cap-detail-item ${rfAvailable ? 'available' : 'unavailable'}">
<span class="cap-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12c0-3 2-6 5-6s4 3 5 6c1 3 2 6 5 6s5-3 5-6"/></svg></span>
<span class="cap-name">RF/SDR Scanning</span>
<span class="cap-status">${rfAvailable ? 'Available' : 'Not Available'}</span>
${caps.rf?.device_type ? `<span class="cap-detail">${escapeHtml(caps.rf.device_type)}</span>` : ''}
</div>
</div>
</div>
<div class="device-detail-section">
<h4>What This Sweep CAN Detect</h4>
<ul class="cap-can-list">
${canDetect.map(item => `<li>✅ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
<div class="device-detail-section">
<h4>What This Sweep CANNOT Detect</h4>
<ul class="cap-cannot-list">
${cannotDetect.map(item => `<li>❌ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
${caps.all_limitations && caps.all_limitations.length > 0 ? `
<div class="device-detail-section">
<h4>Current Limitations</h4>
<ul class="cap-cannot-list">
${caps.all_limitations.map(item => `<li>⚠️ ${escapeHtml(item)}</li>`).join('')}
</ul>
</div>
` : ''}
<div class="device-detail-disclaimer">
<strong>Important:</strong> ${escapeHtml(caps.disclaimer || 'This tool detects wireless RF emissions only. Professional TSCM requires physical inspection, NLJD, thermal imaging, and spectrum analysis equipment.')}
</div>
`;
} else {
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load capabilities: ${data.message || 'Unknown error'}</div>`;
}
} catch (e) {
console.error('Failed to load capabilities:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load capabilities</div>';
}
}
async function tscmShowWifiIndicators() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading WiFi indicators...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/wifi/advanced-indicators');
const data = await response.json();
if (data.status !== 'success') {
content.innerHTML = `<div style="padding: 20px; color: #ff6666;">Failed to load indicators: ${escapeHtml(data.message || 'Unknown error')}</div>`;
return;
}
const indicators = data.indicators || [];
const unavailable = data.unavailable_features || [];
const disclaimer = data.disclaimer || 'Indicators are heuristic signals, not confirmations.';
content.innerHTML = `
<div class="device-detail-header classification-yellow">
<h3>WiFi Advanced Indicators (${indicators.length})</h3>
</div>
<div class="device-detail-section">
${indicators.length === 0 ? `
<div class="tscm-empty">No advanced indicators detected.</div>
` : `
<div class="indicator-list">
${indicators.map(i => `
<div class="indicator-item">
<span class="indicator-type">${escapeHtml(i.type || 'indicator')}${i.severity ? `${escapeHtml(i.severity)}` : ''}</span>
<span class="indicator-desc">${escapeHtml(i.description || '')}</span>
</div>
`).join('')}
</div>
`}
</div>
${unavailable.length > 0 ? `
<div class="device-detail-section">
<h4>Unavailable Features</h4>
<ul class="device-reasons-list">
${unavailable.map(u => `<li>${escapeHtml(u)}</li>`).join('')}
</ul>
</div>
` : ''}
<div class="device-detail-disclaimer">
<strong>Note:</strong> ${escapeHtml(disclaimer)}
</div>
`;
} catch (e) {
console.error('Failed to load WiFi indicators:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load WiFi indicators</div>';
}
}
// Known Devices Management
async function tscmShowKnownDevices() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading known devices...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/known-devices');
const data = await response.json();
const devices = data.devices || [];
content.innerHTML = `
<div class="device-detail-header classification-green">
<h3>✅ Known/Approved Devices (${devices.length})</h3>
</div>
<div class="device-detail-section">
<div style="margin-bottom: 12px;">
<button class="preset-btn" onclick="tscmAddKnownDevice()" style="font-size: 11px;">
+ Add Device
</button>
</div>
${devices.length === 0 ?
'<p style="color: var(--text-muted);">No known devices registered. Devices you mark as "known" will be excluded from threat scoring.</p>' :
`<div class="known-devices-list">
${devices.map(d => `
<div class="known-device-item">
<div class="known-device-info">
<strong>${escapeHtml(d.name || d.identifier)}</strong>
<span class="known-device-id">${escapeHtml(d.identifier)}</span>
<span class="known-device-type">${d.device_type}</span>
</div>
<div class="known-device-actions">
<button class="preset-btn" onclick="tscmRemoveKnownDevice('${encodeURIComponent(d.identifier)}')" style="font-size: 10px; background: #ff4444;">
Remove
</button>
</div>
</div>
`).join('')}
</div>`
}
</div>
`;
} catch (e) {
console.error('Failed to load known devices:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load known devices</div>';
}
}
async function tscmAddKnownDevice() {
const identifier = prompt('Enter device identifier (MAC address, BSSID, or frequency):');
if (!identifier) return;
const name = prompt('Enter friendly name for this device:');
const protocol = prompt('Enter protocol type (wifi/bluetooth/rf):') || 'wifi';
try {
const response = await fetch('/tscm/known-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: identifier,
protocol: protocol,
name: name || identifier
})
});
const data = await response.json();
if (data.status === 'success') {
tscmShowKnownDevices(); // Refresh list
} else {
alert(data.message || 'Failed to add device');
}
} catch (e) {
console.error('Failed to add known device:', e);
alert('Failed to add device');
}
}
async function tscmRemoveKnownDevice(identifier) {
const confirmed = await AppFeedback.confirmAction({
title: 'Remove Known Device',
message: 'Remove this device from known devices list?',
confirmLabel: 'Remove',
confirmClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/tscm/known-devices/${identifier}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.status === 'success') {
tscmShowKnownDevices(); // Refresh list
} else {
alert(data.message || 'Failed to remove device');
}
} catch (e) {
console.error('Failed to remove known device:', e);
}
}
async function tscmAddToKnownDevices(identifier, name, protocol) {
// Ask for optional custom name
const customName = prompt(`Add "${name}" to known devices.\n\nEnter a friendly name (or leave blank to use default):`, name);
if (customName === null) return; // User cancelled
try {
const response = await fetch('/tscm/known-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identifier: identifier,
protocol: protocol,
name: customName || name
})
});
const data = await response.json();
if (data.status === 'success') {
// Show success message
alert(`"${customName || name}" added to known devices.\n\nThis device will be excluded from threat scoring in future sweeps.`);
// Close the device modal
closeTscmDeviceModal();
} else {
alert(data.message || 'Failed to add device');
}
} catch (e) {
console.error('Failed to add to known devices:', e);
alert('Failed to add device to known list');
}
}
// Case Linking Helpers
function tscmPromptLinkSweep(sweepId) {
if (!sweepId) return;
tscmCaseLinkContext = { type: 'sweep', id: sweepId };
tscmShowCases();
}
function tscmPromptLinkThreat(threatId) {
if (!threatId) return;
tscmCaseLinkContext = { type: 'threat', id: threatId };
tscmShowCases();
}
function tscmCancelCaseLink() {
tscmCaseLinkContext = null;
tscmShowCases();
}
async function tscmLinkCase(caseId) {
if (!tscmCaseLinkContext || !caseId) return;
const ctx = tscmCaseLinkContext;
const endpoint = ctx.type === 'sweep'
? `/tscm/cases/${caseId}/sweeps/${ctx.id}`
: `/tscm/cases/${caseId}/threats/${ctx.id}`;
try {
const response = await fetch(endpoint, { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
tscmCaseLinkContext = null;
tscmViewCase(caseId);
} else {
alert(data.message || 'Failed to link case');
}
} catch (e) {
console.error('Failed to link case:', e);
alert('Failed to link case');
}
}
// Cases Management
async function tscmShowCases() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading cases...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/cases');
const data = await response.json();
const cases = data.cases || [];
const linkBanner = tscmCaseLinkContext ? `
<div class="tscm-case-link-banner">
<span>Linking ${tscmCaseLinkContext.type} ${tscmCaseLinkContext.id}. Select a case.</span>
<button class="preset-btn" onclick="tscmCancelCaseLink()" style="font-size: 10px; padding: 4px 6px;">Cancel</button>
</div>
` : '';
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>TSCM Cases (${cases.length})</h3>
</div>
<div class="device-detail-section">
${linkBanner}
<div style="margin-bottom: 12px;">
<button class="preset-btn" onclick="tscmCreateCase()" style="font-size: 11px;">
+ New Case
</button>
</div>
${cases.length === 0 ?
'<p style="color: var(--text-muted);">No cases created. Cases help you organize sweeps and findings for specific locations or clients.</p>' :
`<div class="cases-list">
${cases.map(c => `
<div class="case-item" onclick="tscmViewCase(${c.id})">
<div class="case-header">
<strong>${escapeHtml(c.name)}</strong>
<span class="case-status ${c.status}">${c.status}</span>
</div>
<div class="case-meta">
${c.client_name ? `Client: ${escapeHtml(c.client_name)} | ` : ''}
${c.location ? `Location: ${escapeHtml(c.location)} | ` : ''}
Sweeps: ${c.sweep_count || 0} | Threats: ${c.threat_count || 0}
</div>
<div class="case-date">
Created: ${new Date(c.created_at).toLocaleDateString()}
</div>
${tscmCaseLinkContext ? `
<div class="case-actions">
<button class="preset-btn" onclick="event.stopPropagation(); tscmLinkCase(${c.id})" style="font-size: 10px; padding: 4px 6px;">
Link
</button>
</div>
` : ''}
</div>
`).join('')}
</div>`
}
</div>
`;
} catch (e) {
console.error('Failed to load cases:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load cases</div>';
}
}
async function tscmCreateCase() {
const name = prompt('Enter case name:');
if (!name) return;
const clientName = prompt('Enter client name (optional):');
const location = prompt('Enter location (optional):');
try {
const response = await fetch('/tscm/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
client_name: clientName || null,
location: location || null
})
});
const data = await response.json();
if (data.status === 'success') {
tscmShowCases(); // Refresh list
} else {
alert(data.message || 'Failed to create case');
}
} catch (e) {
console.error('Failed to create case:', e);
alert('Failed to create case');
}
}
async function tscmViewCase(caseId) {
try {
const response = await fetch(`/tscm/cases/${caseId}`);
const data = await response.json();
if (data.status === 'success') {
const c = data.case;
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>${escapeHtml(c.name)}</h3>
<span class="case-status ${c.status}">${c.status}</span>
</div>
<div class="device-detail-section">
<h4>Case Details</h4>
<table class="device-detail-table">
<tr><td>Client</td><td>${escapeHtml(c.client_name || 'N/A')}</td></tr>
<tr><td>Location</td><td>${escapeHtml(c.location || 'N/A')}</td></tr>
<tr><td>Created</td><td>${new Date(c.created_at).toLocaleString()}</td></tr>
<tr><td>Status</td><td>${c.status}</td></tr>
</table>
</div>
<div class="device-detail-section">
<h4>Linked Sweeps (${(c.sweeps || []).length})</h4>
${(c.sweeps || []).length === 0 ?
'<p style="color: var(--text-muted);">No sweeps linked to this case yet.</p>' :
`<ul>${(c.sweeps || []).map(s => `<li>Sweep ${s.id} - ${new Date(s.timestamp).toLocaleString()}</li>`).join('')}</ul>`
}
</div>
<div class="device-detail-section">
<h4>Flagged Threats (${(c.threats || []).length})</h4>
${(c.threats || []).length === 0 ?
'<p style="color: var(--text-muted);">No threats flagged in this case.</p>' :
`<ul>${(c.threats || []).map(t => `<li>${escapeHtml(t.identifier)} - ${t.threat_type}</li>`).join('')}</ul>`
}
</div>
<div class="device-detail-section">
<h4>Case Notes (${(c.case_notes || []).length})</h4>
${(c.case_notes || []).length === 0
? '<div class="tscm-empty">No notes added yet.</div>'
: `<div class="tscm-case-notes">
${(c.case_notes || []).map(n => `
<div class="tscm-case-note">
<div class="tscm-case-note-meta">
<span class="tscm-case-note-type">${escapeHtml(n.note_type || 'general')}</span>
<span>${n.created_at ? new Date(n.created_at).toLocaleString() : ''}</span>
</div>
<div class="tscm-case-note-content">${escapeHtml(n.content || '')}</div>
${n.created_by ? `<div class="tscm-case-note-author">By ${escapeHtml(n.created_by)}</div>` : ''}
</div>
`).join('')}
</div>`
}
<div class="tscm-case-note-form">
<label>Note Type</label>
<select id="tscmCaseNoteType">
<option value="general" selected>General</option>
<option value="observation">Observation</option>
<option value="action">Action</option>
<option value="follow_up">Follow-up</option>
</select>
<label>Add Note</label>
<textarea id="tscmCaseNoteInput" rows="4" placeholder="Add a note to this case..."></textarea>
<button class="preset-btn" onclick="tscmAddCaseNote(${c.id})" style="margin-top: 6px; font-size: 10px;">Add Note</button>
</div>
</div>
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowCases()">← Back to Cases</button>
</div>
`;
}
} catch (e) {
console.error('Failed to view case:', e);
}
}
async function tscmAddCaseNote(caseId) {
const noteInput = document.getElementById('tscmCaseNoteInput');
const typeSelect = document.getElementById('tscmCaseNoteType');
if (!noteInput || !typeSelect) return;
const content = noteInput.value.trim();
const noteType = typeSelect.value;
const ok = await tscmSubmitCaseNote(caseId, content, noteType);
if (ok) {
tscmViewCase(caseId);
}
}
// Schedules Management
async function tscmShowSchedules() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading schedules...</div>';
modal.style.display = 'flex';
try {
const [scheduleRes, baselineRes] = await Promise.all([
fetch('/tscm/schedules'),
fetch('/tscm/baselines')
]);
const scheduleData = await scheduleRes.json();
const baselineData = await baselineRes.json();
const schedules = scheduleData.schedules || [];
const baselines = baselineData.baselines || [];
const baselineMap = {};
baselines.forEach(b => { baselineMap[String(b.id)] = b.name; });
const baselineOptions = [
'<option value="">No Baseline</option>',
...baselines.map(b => `<option value="${b.id}">${escapeHtml(b.name)}</option>`)
].join('');
content.innerHTML = `
<div class="device-detail-header classification-cyan">
<h3>TSCM Schedules (${schedules.length})</h3>
</div>
<div class="device-detail-section">
<div class="tscm-schedule-form">
<div class="form-group">
<label>Name</label>
<input type="text" id="tscmScheduleName" placeholder="Daily sweep">
</div>
<div class="form-group">
<label>Sweep Type</label>
<select id="tscmScheduleSweepType">
<option value="quick">Quick Scan (2 min)</option>
<option value="standard" selected>Standard (5 min)</option>
<option value="full">Full Sweep (15 min)</option>
<option value="wireless_cameras">Wireless Cameras</option>
<option value="body_worn">Body-Worn Devices</option>
<option value="gps_trackers">GPS Trackers</option>
</select>
</div>
<div class="form-group">
<label>Baseline</label>
<select id="tscmScheduleBaseline">
${baselineOptions}
</select>
</div>
<div class="form-group">
<label>Cadence</label>
<select id="tscmScheduleCadence" onchange="tscmScheduleCadenceChanged()">
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
<option value="hourly">Every N Hours</option>
</select>
</div>
<div class="form-group" id="tscmScheduleTimeRow">
<label>Time</label>
<input type="time" id="tscmScheduleTime" value="09:00">
</div>
<div class="form-group" id="tscmScheduleDayRow" style="display: none;">
<label>Day of Week</label>
<select id="tscmScheduleDay">
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
<div class="form-group" id="tscmScheduleIntervalRow" style="display: none;">
<label>Interval (hours)</label>
<input type="number" id="tscmScheduleInterval" min="1" max="24" value="6">
</div>
<button class="preset-btn" onclick="tscmCreateScheduleFromForm()" style="margin-top: 6px; font-size: 11px;">
Create Schedule
</button>
</div>
</div>
<div class="device-detail-section">
<h4>Existing Schedules</h4>
${schedules.length === 0
? '<div class="tscm-empty">No schedules created.</div>'
: `<div class="tscm-schedule-list">
${schedules.map(s => {
const isEnabled = !!s.enabled;
const baselineName = baselineMap[String(s.baseline_id)] || 'None';
const nextRun = s.next_run ? new Date(s.next_run).toLocaleString() : 'Not scheduled';
const lastRun = s.last_run ? new Date(s.last_run).toLocaleString() : 'Never';
return `
<div class="tscm-schedule-item ${isEnabled ? 'enabled' : 'disabled'}">
<div class="tscm-schedule-header">
<strong>${escapeHtml(s.name)}</strong>
<span class="tscm-schedule-status">${isEnabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div class="tscm-schedule-meta">
Sweep: ${escapeHtml(s.sweep_type || 'standard')} | Baseline: ${escapeHtml(baselineName)}
</div>
<div class="tscm-schedule-meta">
Cron: ${escapeHtml(s.cron_expression || '')}
</div>
<div class="tscm-schedule-meta">
Next: ${nextRun} | Last: ${lastRun}
</div>
<div class="tscm-schedule-actions">
<button class="preset-btn" onclick="tscmRunScheduleNow(${s.id})" style="font-size: 10px; padding: 4px 6px;">
Run Now
</button>
<button class="preset-btn" onclick="tscmToggleSchedule(${s.id}, ${isEnabled ? 'false' : 'true'})" style="font-size: 10px; padding: 4px 6px;">
${isEnabled ? 'Disable' : 'Enable'}
</button>
<button class="preset-btn" onclick="tscmDeleteSchedule(${s.id})" style="font-size: 10px; padding: 4px 6px;">
Delete
</button>
</div>
</div>
`;
}).join('')}
</div>`
}
</div>
`;
tscmScheduleCadenceChanged();
} catch (e) {
console.error('Failed to load schedules:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load schedules</div>';
}
}
function tscmScheduleCadenceChanged() {
const cadence = document.getElementById('tscmScheduleCadence')?.value || 'daily';
const timeRow = document.getElementById('tscmScheduleTimeRow');
const dayRow = document.getElementById('tscmScheduleDayRow');
const intervalRow = document.getElementById('tscmScheduleIntervalRow');
if (timeRow) timeRow.style.display = cadence === 'hourly' ? 'none' : 'block';
if (dayRow) dayRow.style.display = cadence === 'weekly' ? 'block' : 'none';
if (intervalRow) intervalRow.style.display = cadence === 'hourly' ? 'block' : 'none';
}
async function tscmCreateScheduleFromForm() {
const name = document.getElementById('tscmScheduleName')?.value.trim();
if (!name) {
alert('Schedule name required');
return;
}
const sweepType = document.getElementById('tscmScheduleSweepType')?.value || 'standard';
const baselineId = document.getElementById('tscmScheduleBaseline')?.value || null;
const cadence = document.getElementById('tscmScheduleCadence')?.value || 'daily';
let cronExpression = '';
if (cadence === 'hourly') {
const interval = parseInt(document.getElementById('tscmScheduleInterval')?.value || '6', 10);
if (isNaN(interval) || interval < 1 || interval > 24) {
alert('Interval must be between 1 and 24 hours');
return;
}
cronExpression = `0 */${interval} * * *`;
} else {
const timeValue = document.getElementById('tscmScheduleTime')?.value || '09:00';
const [hourStr, minStr] = timeValue.split(':');
const hour = parseInt(hourStr, 10);
const minute = parseInt(minStr, 10);
if (isNaN(hour) || isNaN(minute)) {
alert('Invalid time');
return;
}
if (cadence === 'weekly') {
const day = document.getElementById('tscmScheduleDay')?.value || '0';
cronExpression = `${minute} ${hour} * * ${day}`;
} else {
cronExpression = `${minute} ${hour} * * *`;
}
}
const zoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
try {
const response = await fetch('/tscm/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
sweep_type: sweepType,
baseline_id: baselineId ? Number(baselineId) : null,
cron_expression: cronExpression,
zone_name: zoneName
})
});
const data = await response.json();
if (data.status === 'success') {
tscmShowSchedules();
} else {
alert(data.message || 'Failed to create schedule');
}
} catch (e) {
console.error('Failed to create schedule:', e);
alert('Failed to create schedule');
}
}
async function tscmToggleSchedule(scheduleId, enabled) {
try {
const response = await fetch(`/tscm/schedules/${scheduleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled })
});
const data = await response.json();
if (data.status === 'success') {
tscmShowSchedules();
} else {
alert(data.message || 'Failed to update schedule');
}
} catch (e) {
console.error('Failed to update schedule:', e);
alert('Failed to update schedule');
}
}
async function tscmRunScheduleNow(scheduleId) {
try {
const response = await fetch(`/tscm/schedules/${scheduleId}/run`, { method: 'POST' });
const data = await response.json();
if (data.status === 'success') {
alert('Scheduled sweep started');
tscmShowSchedules();
} else {
alert(data.message || 'Failed to run schedule');
}
} catch (e) {
console.error('Failed to run schedule:', e);
alert('Failed to run schedule');
}
}
async function tscmDeleteSchedule(scheduleId) {
const confirmed = await AppFeedback.confirmAction({
title: 'Delete Schedule',
message: 'Delete this schedule?',
confirmLabel: 'Delete',
confirmClass: 'btn-danger'
});
if (!confirmed) return;
try {
const response = await fetch(`/tscm/schedules/${scheduleId}`, { method: 'DELETE' });
const data = await response.json();
if (data.status === 'success') {
tscmShowSchedules();
} else {
alert(data.message || 'Failed to delete schedule');
}
} catch (e) {
console.error('Failed to delete schedule:', e);
alert('Failed to delete schedule');
}
}
// Playbooks Display
async function tscmShowPlaybooks() {
const modal = document.getElementById('tscmDeviceModal');
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = '<div style="text-align: center; padding: 40px;">Loading playbooks...</div>';
modal.style.display = 'flex';
try {
const response = await fetch('/tscm/playbooks');
const data = await response.json();
const playbooks = data.playbooks || [];
content.innerHTML = `
<div class="device-detail-header classification-orange">
<h3>Operator Playbooks</h3>
</div>
<div class="device-detail-section">
<p style="color: var(--text-muted); margin-bottom: 16px;">
Playbooks provide step-by-step guidance for investigating specific types of findings.
</p>
<div class="playbooks-list">
${playbooks.map(p => `
<div class="playbook-item" onclick="tscmViewPlaybook('${p.id}')">
<div class="playbook-header">
<strong>${escapeHtml(p.name || p.title)}</strong>
<span class="playbook-category">${escapeHtml(p.risk_level || p.category || 'General')}</span>
</div>
<div class="playbook-desc">
${escapeHtml(p.description || 'No description')}
</div>
<div class="playbook-meta">
${p.steps?.length || 0} steps
</div>
</div>
`).join('')}
</div>
</div>
`;
} catch (e) {
console.error('Failed to load playbooks:', e);
content.innerHTML = '<div style="padding: 20px; color: #ff6666;">Failed to load playbooks</div>';
}
}
async function tscmViewPlaybook(playbookId) {
try {
const response = await fetch(`/tscm/playbooks/${playbookId}`);
const data = await response.json();
if (data.status === 'success') {
const p = data.playbook;
const content = document.getElementById('tscmDeviceModalContent');
content.innerHTML = renderPlaybook(p);
}
} catch (e) {
console.error('Failed to view playbook:', e);
}
}
function renderPlaybook(p) {
const riskColors = { 'critical': '#ff3366', 'high': '#ff6633', 'medium': '#ff9800', 'low': '#4caf50' };
const riskColor = riskColors[(p.risk_level || '').toLowerCase()] || '#ff9800';
return `
<div class="device-detail-header" style="border-left: 4px solid ${riskColor};">
<h3>${escapeHtml(p.title || p.name || 'Playbook')}</h3>
<span style="font-size: 10px; background: ${riskColor}; color: #000; padding: 2px 8px; border-radius: 3px; font-weight: bold; text-transform: uppercase;">${escapeHtml(p.risk_level || 'MEDIUM')}</span>
</div>
<div class="device-detail-section">
<p style="color: var(--text-muted);">${escapeHtml(p.description || '')}</p>
</div>
${p.when_to_escalate ? `
<div style="margin: 12px 0; padding: 10px 14px; background: rgba(255,51,102,0.1); border: 1px solid rgba(255,51,102,0.4); border-radius: 6px;">
<strong style="color: #ff3366; font-size: 11px; text-transform: uppercase;">Escalation Trigger</strong>
<p style="margin: 4px 0 0; font-size: 12px; color: #ff6666;">${escapeHtml(p.when_to_escalate)}</p>
</div>
` : ''}
<div class="device-detail-section">
<h4 style="margin-bottom: 10px;">Investigation Steps</h4>
<div class="playbook-checklist">
${(p.steps || []).map((step, i) => {
const stepNum = step.step_number || step.step || (i + 1);
return `
<div class="playbook-check-step" id="pbStep${i}" style="display: flex; gap: 10px; padding: 10px; margin-bottom: 8px; background: rgba(0,0,0,0.2); border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; transition: border-color 0.3s;" onclick="togglePlaybookStep(${i})">
<div style="flex-shrink: 0; display: flex; align-items: flex-start; padding-top: 2px;">
<input type="checkbox" id="pbCheck${i}" style="width: 16px; height: 16px; accent-color: #00e676; cursor: pointer;" onclick="event.stopPropagation(); togglePlaybookStep(${i})">
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
<span style="font-size: 10px; color: var(--accent-cyan); font-weight: bold;">STEP ${stepNum}</span>
<strong style="font-size: 12px;">${escapeHtml(step.action || step.title || '')}</strong>
</div>
<p style="font-size: 11px; color: var(--text-muted); margin: 0;">${escapeHtml(step.details || step.description || '')}</p>
${step.safety_note ? `<div style="margin-top: 6px; padding: 6px 8px; background: rgba(255,152,0,0.1); border-left: 3px solid #ff9800; border-radius: 3px; font-size: 10px; color: #ffb74d;"><strong>Safety:</strong> ${escapeHtml(step.safety_note)}</div>` : ''}
${step.evidence_needed && step.evidence_needed.length > 0 ? `<div style="margin-top: 6px; font-size: 10px; color: var(--text-muted);"><strong>Evidence needed:</strong> ${step.evidence_needed.map(e => escapeHtml(e)).join(', ')}</div>` : ''}
</div>
</div>`;
}).join('')}
</div>
</div>
${p.documentation_required && p.documentation_required.length > 0 ? `
<div class="device-detail-section">
<h4>Documentation Required</h4>
<ul style="list-style: none; padding: 0;">
${p.documentation_required.map(d => `<li style="padding: 4px 0; font-size: 11px; color: var(--text-secondary);">&#9744; ${escapeHtml(d)}</li>`).join('')}
</ul>
</div>
` : ''}
${p.disclaimer ? `
<div class="device-detail-disclaimer">
<strong>Disclaimer:</strong> ${escapeHtml(p.disclaimer)}
</div>
` : ''}
<div style="margin-top: 16px;">
<button class="preset-btn" onclick="tscmShowPlaybooks()">&#8592; Back to Playbooks</button>
</div>
`;
}
function togglePlaybookStep(index) {
const checkbox = document.getElementById('pbCheck' + index);
const stepEl = document.getElementById('pbStep' + index);
if (!checkbox || !stepEl) return;
// Toggle if triggered from the row (not the checkbox itself)
if (document.activeElement !== checkbox) {
checkbox.checked = !checkbox.checked;
}
if (checkbox.checked) {
stepEl.style.borderColor = '#00e676';
stepEl.style.background = 'rgba(0, 230, 118, 0.05)';
} else {
stepEl.style.borderColor = 'var(--border-color)';
stepEl.style.background = 'rgba(0,0,0,0.2)';
}
}
async function fetchDevicePlaybook(identifier) {
try {
const response = await fetch(`/tscm/findings/${encodeURIComponent(identifier)}/playbook`);
const data = await response.json();
if (data.status === 'success' && data.playbook) {
return data.playbook;
}
} catch (e) {
console.error('Failed to fetch device playbook:', e);
}
return null;
}
// Report Downloads
async function tscmDownloadPdf() {
try {
const response = await fetch('/tscm/report/pdf');
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TSCM_Report_${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const data = await response.json();
alert(data.message || 'Failed to generate PDF');
}
} catch (e) {
console.error('Failed to download PDF:', e);
alert('Failed to download PDF report');
}
}
async function tscmDownloadAnnex(format) {
try {
const response = await fetch(`/tscm/report/annex?format=${format}`);
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TSCM_Annex_${new Date().toISOString().split('T')[0]}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const data = await response.json();
alert(data.message || 'Failed to generate annex');
}
} catch (e) {
console.error('Failed to download annex:', e);
alert('Failed to download technical annex');
}
}
// Update capabilities bar on sweep start
async function updateTscmCapabilitiesBar(wifiInterface = '', btInterface = '') {
try {
const params = new URLSearchParams();
if (wifiInterface) params.append('wifi_interface', wifiInterface);
if (btInterface) params.append('bt_adapter', btInterface);
const query = params.toString();
const response = await fetch(`/tscm/capabilities${query ? `?${query}` : ''}`);
const data = await response.json();
if (data.status === 'success') {
const caps = data.capabilities;
const bar = document.getElementById('tscmCapabilitiesBar');
if (bar) {
const wifiAvailable = caps.wifi && caps.wifi.mode && caps.wifi.mode !== 'unavailable';
const btAvailable = caps.bluetooth && caps.bluetooth.mode && caps.bluetooth.mode !== 'unavailable';
const rfAvailable = caps.rf && caps.rf.available;
const isRoot = caps.system && caps.system.is_root;
const normalizeMode = (mode) => mode ? mode.replace(/_/g, ' ').toUpperCase() : 'ON';
document.getElementById('capWifiStatus').textContent = wifiAvailable ? normalizeMode(caps.wifi.mode) : 'OFF';
document.getElementById('capWifi').classList.toggle('active', wifiAvailable);
document.getElementById('capBtStatus').textContent = btAvailable ? normalizeMode(caps.bluetooth.mode) : 'OFF';
document.getElementById('capBt').classList.toggle('active', btAvailable);
document.getElementById('capRfStatus').textContent = rfAvailable ? 'ON' : 'OFF';
document.getElementById('capRf').classList.toggle('active', rfAvailable);
document.getElementById('capRootStatus').textContent = isRoot ? 'ROOT' : 'USER';
document.getElementById('capRoot').classList.toggle('active', isRoot);
const limitationCount = (caps.all_limitations || []).length;
document.getElementById('capLimitationCount').textContent = limitationCount;
bar.style.display = 'flex';
}
}
} catch (e) {
console.error('Failed to update capabilities bar:', e);
}
}
// Update baseline health indicator
async function updateTscmBaselineHealth(baselineId) {
if (!baselineId) {
const healthDiv = document.getElementById('tscmBaselineHealth');
if (healthDiv) healthDiv.style.display = 'none';
return;
}
try {
const response = await fetch(`/tscm/baseline/${baselineId}/health`);
const data = await response.json();
if (data.status === 'success') {
const healthDiv = document.getElementById('tscmBaselineHealth');
const badge = document.getElementById('baselineHealthBadge');
const nameEl = document.getElementById('baselineHealthName');
const ageEl = document.getElementById('baselineHealthAge');
if (healthDiv && badge) {
const health = data.health || {};
const status = (health.status || 'unknown').toLowerCase();
const displayStatus = status.replace(/_/g, ' ');
badge.textContent = displayStatus.toUpperCase();
badge.className = `health-badge health-${status}`;
if (health.reasons && health.reasons.length > 0) {
badge.title = health.reasons.join(' • ');
}
if (nameEl) {
const baselineSelect = document.getElementById('tscmBaselineSelect');
const selectedOption = baselineSelect ? baselineSelect.options[baselineSelect.selectedIndex] : null;
const selectedName = selectedOption ? selectedOption.textContent.replace(' (Active)', '') : 'Baseline';
nameEl.textContent = selectedName || 'Baseline';
}
if (ageEl) {
const ageHours = health.age_hours;
if (ageHours !== undefined && ageHours !== null) {
const ageLabel = ageHours >= 48
? `${(ageHours / 24).toFixed(1)}d`
: `${Math.round(ageHours)}h`;
ageEl.textContent = `${ageLabel} old`;
} else {
ageEl.textContent = '';
}
}
healthDiv.style.display = 'block';
}
}
} catch (e) {
console.error('Failed to update baseline health:', e);
}
}
// Listen for baseline selection changes
document.addEventListener('DOMContentLoaded', function () {
const baselineSelect = document.getElementById('tscmBaselineSelect');
if (baselineSelect) {
baselineSelect.addEventListener('change', function () {
updateTscmBaselineHealth(this.value);
});
}
const filterIds = ['tscmFilterProtocol', 'tscmFilterRisk', 'tscmFilterStatus', 'tscmFilterKnown'];
filterIds.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('change', applyTscmFilters);
}
});
applyTscmFilters();
});
</script>
{% include 'partials/help-modal.html' %}
<!-- Satellite Add Modal -->
<div id="satModal" class="help-modal" onclick="if(event.target === this) closeSatModal()">
<div class="help-content" style="max-width: 600px;">
<button class="help-close" onclick="closeSatModal()">×</button>
<h2>Add Satellites</h2>
<!-- Tabs -->
<div
style="display: flex; gap: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--border-color); padding-bottom: 10px;">
<button class="sat-modal-tab preset-btn active" onclick="switchSatModalTab('tle')"
style="flex: 1;">Manual TLE</button>
<button class="sat-modal-tab preset-btn" onclick="switchSatModalTab('celestrak')"
style="flex: 1;">Celestrak</button>
</div>
<!-- TLE Section -->
<div id="tleSection" class="sat-modal-section active">
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
Paste TLE (Two-Line Element) data. Format: Name on first line, followed by TLE lines 1 and 2.
</p>
<textarea id="tleInput" placeholder="ISS (ZARYA)
1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002
2 25544 51.6400 208.9163 0006703 296.5855 63.4606 15.49995465478450"
style="width: 100%; height: 150px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; font-family: var(--font-mono); font-size: 11px; resize: vertical;"></textarea>
<button class="preset-btn" onclick="addFromTLE()" style="margin-top: 10px; width: 100%;">Add
Satellite</button>
</div>
<!-- Celestrak Section -->
<div id="celestrakSection" class="sat-modal-section">
<p style="color: var(--text-secondary); font-size: 11px; margin-bottom: 10px;">
Fetch satellite TLE data from CelesTrak by category.
</p>
<div id="celestrakStatus" style="margin-bottom: 10px; font-size: 11px; min-height: 20px;"></div>
<div
style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 300px; overflow-y: auto;">
<button class="preset-btn" onclick="fetchCelestrakCategory('stations')">Space Stations</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('weather')">Weather</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('goes')">GOES</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('amateur')">Amateur</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('cubesat')">CubeSats</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('starlink')">Starlink</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('oneweb')">OneWeb</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('iridium-NEXT')">Iridium NEXT</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('visual')">Bright/Visual</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('geo')">Geostationary</button>
<button class="preset-btn" onclick="fetchCelestrakCategory('resource')">Earth Resources</button>
</div>
<p style="color: var(--text-muted); font-size: 10px; margin-top: 10px;">
Data from <a href="https://celestrak.org" target="_blank"
style="color: var(--accent-cyan);">celestrak.org</a>.
Note: Some categories (Starlink, OneWeb) contain many satellites.
</p>
</div>
</div>
</div>
<script>
// Check dependencies on page load
document.addEventListener('DOMContentLoaded', function () {
// Check if user dismissed the startup check
const dismissed = localStorage.getItem('depsCheckDismissed');
// Quick check for missing dependencies
fetch('/dependencies')
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
let missingModes = 0;
let missingTools = [];
for (const [modeKey, mode] of Object.entries(data.modes)) {
if (!mode.ready) {
missingModes++;
mode.missing_required.forEach(tool => {
if (!missingTools.includes(tool)) {
missingTools.push(tool);
}
});
}
}
// Show startup prompt if tools are missing and not dismissed
// Only show if disclaimer has been accepted
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
showStartupDepsPrompt(missingModes, missingTools.length);
}
}
});
});
function showStartupDepsPrompt(modeCount, toolCount) {
const notice = document.createElement('div');
notice.id = 'startupDepsModal';
notice.style.cssText = `
position: fixed;
top: 20px;
left: 20px;
z-index: 10000;
background: var(--bg-secondary);
border: 1px solid var(--accent-orange);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 165, 0, 0.2);
max-width: 380px;
animation: slideIn 0.3s ease-out;
`;
notice.innerHTML = `
<style>
@keyframes slideIn {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
<div style="padding: 15px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3 style="margin: 0; color: var(--accent-orange); font-size: 14px; display: flex; align-items: center; gap: 8px;">
Missing Dependencies
</h3>
<button onclick="closeStartupDeps()" style="background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 18px; padding: 0; line-height: 1;">&times;</button>
</div>
<p style="color: var(--text-secondary); margin: 0 0 15px 0; font-size: 13px; line-height: 1.4;">
<strong style="color: var(--accent-orange);">${modeCount} mode(s)</strong> require tools that aren't installed.
</p>
<div style="display: flex; flex-direction: column; gap: 8px;">
<button class="action-btn" onclick="closeStartupDeps(); showSettings(); switchSettingsTab('tools');" style="padding: 10px 16px; font-size: 12px;">
View Details & Install
</button>
<label style="display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim); cursor: pointer;">
<input type="checkbox" id="dontShowAgain" style="cursor: pointer;">
Don't show again
</label>
</div>
</div>
`;
document.body.appendChild(notice);
}
function closeStartupDeps() {
const modal = document.getElementById('startupDepsModal');
if (modal) {
if (document.getElementById('dontShowAgain')?.checked) {
localStorage.setItem('depsCheckDismissed', 'true');
}
modal.remove();
}
}
function logout(event) {
// We use event.currentTarget to ensure we select the button even if the icon was clicked
const btn = event.currentTarget;
// 1. Visual Feedback: Change color to red and add a "glow"
btn.style.color = "#ff4d4d";
btn.style.borderColor = "#ff4d4d";
btn.style.textShadow = "0 0 10px #ff4d4d"; // Glow effect
btn.style.transform = "scale(0.95)"; // Slight press effect
// 2. Disable the button to prevent double clicks
btn.style.pointerEvents = "none";
// 3. Logic execution
setTimeout(() => {
localStorage.removeItem('user_session');
window.location.href = '/login';
}, 600); // 600ms is enough for the user to perceive the color change
}
</script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Toast Container -->
<div id="toastContainer"></div>
<!-- Updater -->
<script src="{{ url_for('static', filename='js/core/updater.js') }}"></script>
<!-- Settings Manager -->
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<!-- Alerts + Recording -->
<script src="{{ url_for('static', filename='js/core/alerts.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/recordings.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/ui-feedback.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/sse-manager.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/run-state.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/command-palette.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/first-run-setup.js') }}"></script>
<!-- Cheat Sheet Modal -->
<div id="cheatSheetModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)CheatSheets.hide()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:480px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="CheatSheets.hide()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<div id="cheatSheetContent"></div>
</div>
</div>
<!-- Keyboard Shortcuts Modal -->
<div id="kbShortcutsModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:10000; align-items:center; justify-content:center; padding:20px;" onclick="if(event.target===this)KeyboardShortcuts.hideHelp()">
<div style="background:var(--bg-card, #1a1f2e); border:1px solid rgba(255,255,255,0.15); border-radius:12px; max-width:520px; width:100%; max-height:80vh; overflow-y:auto; padding:20px; position:relative;">
<button onclick="KeyboardShortcuts.hideHelp()" style="position:absolute; top:12px; right:12px; background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:18px; line-height:1;"></button>
<h2 style="margin:0 0 16px; font-size:16px; color:var(--accent-cyan, #4aa3ff); font-family:var(--font-mono);">Keyboard Shortcuts</h2>
<table style="width:100%; border-collapse:collapse; font-family:var(--font-mono); font-size:12px;">
<tbody>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+W</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Waterfall</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+M</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle voice mute</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+S</td><td style="padding:6px 8px; color:var(--text-secondary);">Toggle sidebar</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+K / ?</td><td style="padding:6px 8px; color:var(--text-secondary);">Show keyboard shortcuts</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+C</td><td style="padding:6px 8px; color:var(--text-secondary);">Show cheat sheet for current mode</td></tr>
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);"><td style="padding:6px 8px; color:var(--accent-cyan);">Alt+1..9</td><td style="padding:6px 8px; color:var(--text-secondary);">Switch to Nth mode in current group</td></tr>
<tr><td style="padding:6px 8px; color:var(--accent-cyan);">Escape</td><td style="padding:6px 8px; color:var(--text-secondary);">Close modal / Return to welcome</td></tr>
</tbody>
</table>
</div>
</div>
<!-- PWA Service Worker Registration -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
});
}
// Initialize global core modules after page load
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
});
// ── Weather-satellite handoff from the satellite dashboard iframe ─────────
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'weather-sat-handoff') return;
const { satellite, aosTime, tcaEl, duration } = event.data;
if (!satellite) return;
// Determine how far away the pass is
const aosMs = aosTime ? (new Date(aosTime) - Date.now()) : Infinity;
const minsAway = aosMs / 60000;
// Switch to weather-satellite mode and pre-select the satellite
switchMode('weathersat', { updateUrl: true }).then(() => {
if (typeof WeatherSat !== 'undefined') {
if (minsAway <= 2) {
// Pass is imminent — start immediately
WeatherSat.startPass(satellite);
showNotification('Weather Sat', `Auto-starting capture: ${satellite}`);
} else {
// Pre-select so the user can review settings and hit Start
WeatherSat.preSelect(satellite);
showHandoffBanner(satellite, minsAway, tcaEl, duration);
}
}
});
});
function showHandoffBanner(satellite, minsAway, tcaEl, duration) {
// Remove any existing banner
const existing = document.getElementById('weatherSatHandoffBanner');
if (existing) existing.remove();
const mins = Math.round(minsAway);
const elStr = tcaEl != null ? `${Number(tcaEl).toFixed(0)}°` : '?°';
const durStr = duration != null ? `${Math.round(duration)} min` : '';
const banner = document.createElement('div');
banner.id = 'weatherSatHandoffBanner';
banner.style.cssText = [
'position:fixed', 'top:60px', 'left:50%', 'transform:translateX(-50%)',
'background:rgba(0,20,30,0.95)', 'border:1px solid rgba(0,255,136,0.5)',
'color:#00ff88', 'font-family:var(--font-mono,monospace)', 'font-size:12px',
'padding:10px 18px', 'border-radius:6px', 'z-index:9999',
'display:flex', 'align-items:center', 'gap:12px',
'box-shadow:0 0 20px rgba(0,255,136,0.2)'
].join(';');
banner.innerHTML = `
<span>📡 <strong>${satellite}</strong> pass in <strong>${mins} min</strong> · max ${elStr}${durStr ? ' · ' + durStr : ''} — satellite pre-selected</span>
<button onclick="if(typeof WeatherSat!=='undefined')WeatherSat.start();this.closest('#weatherSatHandoffBanner').remove();"
style="background:rgba(0,255,136,0.2);border:1px solid rgba(0,255,136,0.5);color:#00ff88;padding:3px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;">
Start Now
</button>
<button onclick="this.closest('#weatherSatHandoffBanner').remove();"
style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 4px;">✕</button>
`;
document.body.appendChild(banner);
// Auto-dismiss after 2 minutes
setTimeout(() => { if (banner.parentNode) banner.remove(); }, 120000);
}
</script>
</body>
</html>