diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js
index 2a7c856..f432e98 100644
--- a/static/js/modes/bluetooth.js
+++ b/static/js/modes/bluetooth.js
@@ -27,22 +27,22 @@ const BluetoothMode = (function() {
trackers: []
};
- // Zone counts for proximity display
- let zoneCounts = { immediate: 0, near: 0, far: 0 };
+ // Zone counts for proximity display
+ let zoneCounts = { immediate: 0, near: 0, far: 0 };
// New visualization components
let radarInitialized = false;
let radarPaused = false;
- // Device list filter
- let currentDeviceFilter = 'all';
- let currentSearchTerm = '';
- let visibleDeviceCount = 0;
- let pendingDeviceFlush = false;
- let selectedDeviceNeedsRefresh = false;
- let filterListenersBound = false;
- let listListenersBound = false;
- const pendingDeviceIds = new Set();
+ // Device list filter
+ let currentDeviceFilter = 'all';
+ let currentSearchTerm = '';
+ let visibleDeviceCount = 0;
+ let pendingDeviceFlush = false;
+ let selectedDeviceNeedsRefresh = false;
+ let filterListenersBound = false;
+ let listListenersBound = false;
+ const pendingDeviceIds = new Set();
// Agent support
let showAllAgentsMode = false;
@@ -116,9 +116,9 @@ const BluetoothMode = (function() {
// Initialize legacy heatmap (zone counts)
initHeatmap();
- // Initialize device list filters
- initDeviceFilters();
- initListInteractions();
+ // Initialize device list filters
+ initDeviceFilters();
+ initListInteractions();
// Set initial panel states
updateVisualizationPanels();
@@ -127,133 +127,133 @@ const BluetoothMode = (function() {
/**
* Initialize device list filter buttons
*/
- function initDeviceFilters() {
- if (filterListenersBound) return;
- const filterContainer = document.getElementById('btDeviceFilters');
- if (filterContainer) {
- filterContainer.addEventListener('click', (e) => {
- const btn = e.target.closest('.bt-filter-btn');
- if (!btn) return;
-
- const filter = btn.dataset.filter;
- if (!filter) return;
-
- // Update active state
- filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
- btn.classList.add('active');
-
- // Apply filter
- currentDeviceFilter = filter;
- applyDeviceFilter();
- });
- }
-
- const searchInput = document.getElementById('btDeviceSearch');
- if (searchInput) {
- searchInput.addEventListener('input', () => {
- currentSearchTerm = searchInput.value.trim().toLowerCase();
- applyDeviceFilter();
- });
- }
- filterListenersBound = true;
- }
-
- function initListInteractions() {
- if (listListenersBound) return;
- if (deviceContainer) {
- deviceContainer.addEventListener('click', (event) => {
- const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
- if (locateBtn) {
- event.preventDefault();
- locateById(locateBtn.dataset.locateId);
- return;
- }
-
- const row = event.target.closest('.bt-device-row[data-bt-device-id]');
- if (!row) return;
- selectDevice(row.dataset.btDeviceId);
- });
- }
-
- const trackerList = document.getElementById('btTrackerList');
- if (trackerList) {
- trackerList.addEventListener('click', (event) => {
- const row = event.target.closest('.bt-tracker-item[data-device-id]');
- if (!row) return;
- selectDevice(row.dataset.deviceId);
- });
- }
- listListenersBound = true;
- }
+ function initDeviceFilters() {
+ if (filterListenersBound) return;
+ const filterContainer = document.getElementById('btDeviceFilters');
+ if (filterContainer) {
+ filterContainer.addEventListener('click', (e) => {
+ const btn = e.target.closest('.bt-filter-btn');
+ if (!btn) return;
+
+ const filter = btn.dataset.filter;
+ if (!filter) return;
+
+ // Update active state
+ filterContainer.querySelectorAll('.bt-filter-btn').forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ // Apply filter
+ currentDeviceFilter = filter;
+ applyDeviceFilter();
+ });
+ }
+
+ const searchInput = document.getElementById('btDeviceSearch');
+ if (searchInput) {
+ searchInput.addEventListener('input', () => {
+ currentSearchTerm = searchInput.value.trim().toLowerCase();
+ applyDeviceFilter();
+ });
+ }
+ filterListenersBound = true;
+ }
+
+ function initListInteractions() {
+ if (listListenersBound) return;
+ if (deviceContainer) {
+ deviceContainer.addEventListener('click', (event) => {
+ const locateBtn = event.target.closest('.bt-locate-btn[data-locate-id]');
+ if (locateBtn) {
+ event.preventDefault();
+ locateById(locateBtn.dataset.locateId);
+ return;
+ }
+
+ const row = event.target.closest('.bt-device-row[data-bt-device-id]');
+ if (!row) return;
+ selectDevice(row.dataset.btDeviceId);
+ });
+ }
+
+ const trackerList = document.getElementById('btTrackerList');
+ if (trackerList) {
+ trackerList.addEventListener('click', (event) => {
+ const row = event.target.closest('.bt-tracker-item[data-device-id]');
+ if (!row) return;
+ selectDevice(row.dataset.deviceId);
+ });
+ }
+ listListenersBound = true;
+ }
/**
* Apply current filter to device list
*/
- function applyDeviceFilter() {
- if (!deviceContainer) return;
-
- const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
- let visibleCount = 0;
- cards.forEach(card => {
- const isNew = card.dataset.isNew === 'true';
- const hasName = card.dataset.hasName === 'true';
- const rssi = parseInt(card.dataset.rssi) || -100;
- const isTracker = card.dataset.isTracker === 'true';
- const searchHaystack = (card.dataset.search || '').toLowerCase();
-
- let matchesFilter = true;
- switch (currentDeviceFilter) {
- case 'new':
- matchesFilter = isNew;
- break;
- case 'named':
- matchesFilter = hasName;
- break;
- case 'strong':
- matchesFilter = rssi >= -70;
- break;
- case 'trackers':
- matchesFilter = isTracker;
- break;
- case 'all':
- default:
- matchesFilter = true;
- }
-
- const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm);
- const visible = matchesFilter && matchesSearch;
- card.style.display = visible ? '' : 'none';
- if (visible) visibleCount++;
- });
-
- visibleDeviceCount = visibleCount;
-
- let stateEl = deviceContainer.querySelector('.bt-device-filter-state');
- if (visibleCount === 0 && devices.size > 0) {
- if (!stateEl) {
- stateEl = document.createElement('div');
- stateEl.className = 'bt-device-filter-state app-collection-state is-empty';
- deviceContainer.appendChild(stateEl);
- }
- stateEl.textContent = 'No devices match current filters';
- } else if (stateEl) {
- stateEl.remove();
- }
-
- // Update visible count
- updateFilteredCount();
- }
+ function applyDeviceFilter() {
+ if (!deviceContainer) return;
+
+ const cards = deviceContainer.querySelectorAll('[data-bt-device-id]');
+ let visibleCount = 0;
+ cards.forEach(card => {
+ const isNew = card.dataset.isNew === 'true';
+ const hasName = card.dataset.hasName === 'true';
+ const rssi = parseInt(card.dataset.rssi) || -100;
+ const isTracker = card.dataset.isTracker === 'true';
+ const searchHaystack = (card.dataset.search || '').toLowerCase();
+
+ let matchesFilter = true;
+ switch (currentDeviceFilter) {
+ case 'new':
+ matchesFilter = isNew;
+ break;
+ case 'named':
+ matchesFilter = hasName;
+ break;
+ case 'strong':
+ matchesFilter = rssi >= -70;
+ break;
+ case 'trackers':
+ matchesFilter = isTracker;
+ break;
+ case 'all':
+ default:
+ matchesFilter = true;
+ }
+
+ const matchesSearch = !currentSearchTerm || searchHaystack.includes(currentSearchTerm);
+ const visible = matchesFilter && matchesSearch;
+ card.style.display = visible ? '' : 'none';
+ if (visible) visibleCount++;
+ });
+
+ visibleDeviceCount = visibleCount;
+
+ let stateEl = deviceContainer.querySelector('.bt-device-filter-state');
+ if (visibleCount === 0 && devices.size > 0) {
+ if (!stateEl) {
+ stateEl = document.createElement('div');
+ stateEl.className = 'bt-device-filter-state app-collection-state is-empty';
+ deviceContainer.appendChild(stateEl);
+ }
+ stateEl.textContent = 'No devices match current filters';
+ } else if (stateEl) {
+ stateEl.remove();
+ }
+
+ // Update visible count
+ updateFilteredCount();
+ }
/**
* Update the device count display based on visible devices
*/
- function updateFilteredCount() {
- const countEl = document.getElementById('btDeviceListCount');
- if (!countEl || !deviceContainer) return;
-
- const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0;
- countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size;
- }
+ function updateFilteredCount() {
+ const countEl = document.getElementById('btDeviceListCount');
+ if (!countEl || !deviceContainer) return;
+
+ const hasFilter = currentDeviceFilter !== 'all' || currentSearchTerm.length > 0;
+ countEl.textContent = hasFilter ? `${visibleDeviceCount}/${devices.size}` : devices.size;
+ }
/**
* Initialize the new proximity radar component
@@ -369,20 +369,20 @@ const BluetoothMode = (function() {
/**
* Update proximity zone counts (simple HTML, no canvas)
*/
- function updateProximityZones() {
- zoneCounts = { immediate: 0, near: 0, far: 0 };
-
- devices.forEach(device => {
- const rssi = device.rssi_current;
- if (rssi == null) return;
-
- if (rssi >= -50) zoneCounts.immediate++;
- else if (rssi >= -70) zoneCounts.near++;
- else zoneCounts.far++;
- });
-
- updateProximityZoneCounts(zoneCounts);
- }
+ function updateProximityZones() {
+ zoneCounts = { immediate: 0, near: 0, far: 0 };
+
+ devices.forEach(device => {
+ const rssi = device.rssi_current;
+ if (rssi == null) return;
+
+ if (rssi >= -50) zoneCounts.immediate++;
+ else if (rssi >= -70) zoneCounts.near++;
+ else zoneCounts.far++;
+ });
+
+ updateProximityZoneCounts(zoneCounts);
+ }
// Currently selected device
let selectedDeviceId = null;
@@ -944,59 +944,59 @@ const BluetoothMode = (function() {
}
}
- async function stopScan() {
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
- const timeoutMs = isAgentMode ? 8000 : 2200;
- const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
- const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
-
- // Optimistic UI teardown keeps mode changes responsive.
- setScanning(false);
- stopEventStream();
-
- try {
- if (isAgentMode) {
- await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
- method: 'POST',
- ...(controller ? { signal: controller.signal } : {}),
- });
- } else {
- await fetch('/api/bluetooth/scan/stop', {
- method: 'POST',
- ...(controller ? { signal: controller.signal } : {}),
- });
- }
- } catch (err) {
- console.error('Failed to stop scan:', err);
- } finally {
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- }
- }
+ async function stopScan() {
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ const timeoutMs = isAgentMode ? 8000 : 2200;
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
+ const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
+
+ // Optimistic UI teardown keeps mode changes responsive.
+ setScanning(false);
+ stopEventStream();
+
+ try {
+ if (isAgentMode) {
+ await fetch(`/controller/agents/${currentAgent}/bluetooth/stop`, {
+ method: 'POST',
+ ...(controller ? { signal: controller.signal } : {}),
+ });
+ } else {
+ await fetch('/api/bluetooth/scan/stop', {
+ method: 'POST',
+ ...(controller ? { signal: controller.signal } : {}),
+ });
+ }
+ } catch (err) {
+ console.error('Failed to stop scan:', err);
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
function setScanning(scanning) {
isScanning = scanning;
- if (startBtn) startBtn.style.display = scanning ? 'none' : 'block';
- if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
-
- if (scanning && deviceContainer) {
- pendingDeviceIds.clear();
- selectedDeviceNeedsRefresh = false;
- pendingDeviceFlush = false;
- if (typeof renderCollectionState === 'function') {
- renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' });
- } else {
- deviceContainer.innerHTML = '';
- }
- devices.clear();
- resetStats();
- } else if (!scanning && deviceContainer && devices.size === 0) {
- if (typeof renderCollectionState === 'function') {
- renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
- }
- }
+ if (startBtn) startBtn.style.display = scanning ? 'none' : 'block';
+ if (stopBtn) stopBtn.style.display = scanning ? 'block' : 'none';
+
+ if (scanning && deviceContainer) {
+ pendingDeviceIds.clear();
+ selectedDeviceNeedsRefresh = false;
+ pendingDeviceFlush = false;
+ if (typeof renderCollectionState === 'function') {
+ renderCollectionState(deviceContainer, { type: 'loading', message: 'Scanning for Bluetooth devices...' });
+ } else {
+ deviceContainer.innerHTML = '';
+ }
+ devices.clear();
+ resetStats();
+ } else if (!scanning && deviceContainer && devices.size === 0) {
+ if (typeof renderCollectionState === 'function') {
+ renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
+ }
+ }
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
@@ -1004,22 +1004,22 @@ const BluetoothMode = (function() {
if (statusText) statusText.textContent = scanning ? 'Scanning...' : 'Idle';
}
- function resetStats() {
- deviceStats = {
- strong: 0,
- medium: 0,
- weak: 0,
- trackers: []
- };
- visibleDeviceCount = 0;
- updateVisualizationPanels();
- updateProximityZones();
- updateFilteredCount();
-
- // Clear radar
- if (radarInitialized && typeof ProximityRadar !== 'undefined') {
- ProximityRadar.clear();
- }
+ function resetStats() {
+ deviceStats = {
+ strong: 0,
+ medium: 0,
+ weak: 0,
+ trackers: []
+ };
+ visibleDeviceCount = 0;
+ updateVisualizationPanels();
+ updateProximityZones();
+ updateFilteredCount();
+
+ // Clear radar
+ if (radarInitialized && typeof ProximityRadar !== 'undefined') {
+ ProximityRadar.clear();
+ }
}
function startEventStream() {
@@ -1161,43 +1161,43 @@ const BluetoothMode = (function() {
}, pollInterval);
}
- function handleDeviceUpdate(device) {
- devices.set(device.device_id, device);
- pendingDeviceIds.add(device.device_id);
- if (selectedDeviceId === device.device_id) {
- selectedDeviceNeedsRefresh = true;
- }
- scheduleDeviceFlush();
- }
-
- function scheduleDeviceFlush() {
- if (pendingDeviceFlush) return;
- pendingDeviceFlush = true;
-
- requestAnimationFrame(() => {
- pendingDeviceFlush = false;
-
- pendingDeviceIds.forEach((deviceId) => {
- const device = devices.get(deviceId);
- if (device) {
- renderDevice(device, false);
- }
- });
- pendingDeviceIds.clear();
-
- applyDeviceFilter();
- updateDeviceCount();
- updateStatsFromDevices();
- updateVisualizationPanels();
- updateProximityZones();
- updateRadar();
-
- if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) {
- showDeviceDetail(selectedDeviceId);
- }
- selectedDeviceNeedsRefresh = false;
- });
- }
+ function handleDeviceUpdate(device) {
+ devices.set(device.device_id, device);
+ pendingDeviceIds.add(device.device_id);
+ if (selectedDeviceId === device.device_id) {
+ selectedDeviceNeedsRefresh = true;
+ }
+ scheduleDeviceFlush();
+ }
+
+ function scheduleDeviceFlush() {
+ if (pendingDeviceFlush) return;
+ pendingDeviceFlush = true;
+
+ requestAnimationFrame(() => {
+ pendingDeviceFlush = false;
+
+ pendingDeviceIds.forEach((deviceId) => {
+ const device = devices.get(deviceId);
+ if (device) {
+ renderDevice(device, false);
+ }
+ });
+ pendingDeviceIds.clear();
+
+ applyDeviceFilter();
+ updateDeviceCount();
+ updateStatsFromDevices();
+ updateVisualizationPanels();
+ updateProximityZones();
+ updateRadar();
+
+ if (selectedDeviceNeedsRefresh && selectedDeviceId && devices.has(selectedDeviceId)) {
+ showDeviceDetail(selectedDeviceId);
+ }
+ selectedDeviceNeedsRefresh = false;
+ });
+ }
/**
* Update stats from all devices
@@ -1232,9 +1232,9 @@ const BluetoothMode = (function() {
/**
* Update visualization panels
*/
- function updateVisualizationPanels() {
- // Signal Distribution
- const total = devices.size || 1;
+ function updateVisualizationPanels() {
+ // Signal Distribution
+ const total = devices.size || 1;
const strongBar = document.getElementById('btSignalStrong');
const mediumBar = document.getElementById('btSignalMedium');
const weakBar = document.getElementById('btSignalWeak');
@@ -1245,120 +1245,120 @@ const BluetoothMode = (function() {
if (strongBar) strongBar.style.width = (deviceStats.strong / total * 100) + '%';
if (mediumBar) mediumBar.style.width = (deviceStats.medium / total * 100) + '%';
if (weakBar) weakBar.style.width = (deviceStats.weak / total * 100) + '%';
- if (strongCount) strongCount.textContent = deviceStats.strong;
- if (mediumCount) mediumCount.textContent = deviceStats.medium;
- if (weakCount) weakCount.textContent = deviceStats.weak;
-
- // Device summary strip
- const totalEl = document.getElementById('btSummaryTotal');
- const newEl = document.getElementById('btSummaryNew');
- const trackersEl = document.getElementById('btSummaryTrackers');
- const strongestEl = document.getElementById('btSummaryStrongest');
- if (totalEl || newEl || trackersEl || strongestEl) {
- let newCount = 0;
- let strongest = null;
- devices.forEach(d => {
- if (!d.in_baseline) newCount++;
- if (d.rssi_current != null) {
- strongest = strongest == null ? d.rssi_current : Math.max(strongest, d.rssi_current);
- }
- });
- if (totalEl) totalEl.textContent = devices.size;
- if (newEl) newEl.textContent = newCount;
- if (trackersEl) trackersEl.textContent = deviceStats.trackers.length;
- if (strongestEl) strongestEl.textContent = strongest == null ? '--' : `${strongest} dBm`;
- }
-
- // Tracker Detection - Enhanced display with confidence and evidence
- const trackerList = document.getElementById('btTrackerList');
- if (trackerList) {
- if (devices.size === 0) {
- if (typeof renderCollectionState === 'function') {
- renderCollectionState(trackerList, { type: 'empty', message: 'Start scanning to detect trackers' });
- } else {
- trackerList.innerHTML = '
' +
- '
' +
- '
' +
- protoBadge +
+ // Row border color - highlight trackers in red/orange
+ const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' :
+ isTracker ? '#f97316' : rssiColor;
+
+ return '
' +
+ '
' +
+ '
' +
+ protoBadge +
'' + name + '' +
trackerBadge +
irkBadge +
@@ -1495,13 +1495,13 @@ const BluetoothMode = (function() {
'
' +
statusDot +
'
' +
- '
' +
- '
' + secondaryInfo + '
' +
- '
' +
- '
' +
- '
' +
+ '
' +
+ '
' + secondaryInfo + '
' +
+ '
' +
+ '
' +
+ '
' +
'
';
}
@@ -1514,16 +1514,16 @@ const BluetoothMode = (function() {
return '#ef4444';
}
- function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = String(text);
- return div.innerHTML;
- }
-
- function escapeAttr(text) {
- return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
- }
+ function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = String(text);
+ return div.innerHTML;
+ }
+
+ function escapeAttr(text) {
+ return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
+ }
async function setBaseline() {
try {
@@ -1632,22 +1632,22 @@ const BluetoothMode = (function() {
/**
* Clear all collected data.
*/
- function clearData() {
- devices.clear();
- pendingDeviceIds.clear();
- pendingDeviceFlush = false;
- selectedDeviceNeedsRefresh = false;
- resetStats();
- clearSelection();
-
- if (deviceContainer) {
- if (typeof renderCollectionState === 'function') {
- renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
- } else {
- deviceContainer.innerHTML = '';
- }
- }
- }
+ function clearData() {
+ devices.clear();
+ pendingDeviceIds.clear();
+ pendingDeviceFlush = false;
+ selectedDeviceNeedsRefresh = false;
+ resetStats();
+ clearSelection();
+
+ if (deviceContainer) {
+ if (typeof renderCollectionState === 'function') {
+ renderCollectionState(deviceContainer, { type: 'empty', message: 'Start scanning to discover Bluetooth devices' });
+ } else {
+ deviceContainer.innerHTML = '';
+ }
+ }
+ }
/**
* Toggle "Show All Agents" mode.
@@ -1682,27 +1682,27 @@ const BluetoothMode = (function() {
}
});
- toRemove.forEach(deviceId => devices.delete(deviceId));
-
- // Re-render device list
- if (deviceContainer) {
- deviceContainer.innerHTML = '';
- devices.forEach(device => renderDevice(device, false));
- applyDeviceFilter();
- if (devices.size === 0 && typeof renderCollectionState === 'function') {
- renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' });
- }
- }
-
- if (selectedDeviceId && !devices.has(selectedDeviceId)) {
- clearSelection();
- }
-
- updateDeviceCount();
- updateStatsFromDevices();
- updateVisualizationPanels();
- updateProximityZones();
- updateRadar();
+ toRemove.forEach(deviceId => devices.delete(deviceId));
+
+ // Re-render device list
+ if (deviceContainer) {
+ deviceContainer.innerHTML = '';
+ devices.forEach(device => renderDevice(device, false));
+ applyDeviceFilter();
+ if (devices.size === 0 && typeof renderCollectionState === 'function') {
+ renderCollectionState(deviceContainer, { type: 'empty', message: 'No devices for current agent' });
+ }
+ }
+
+ if (selectedDeviceId && !devices.has(selectedDeviceId)) {
+ clearSelection();
+ }
+
+ updateDeviceCount();
+ updateStatsFromDevices();
+ updateVisualizationPanels();
+ updateProximityZones();
+ updateRadar();
}
/**
@@ -1730,23 +1730,23 @@ const BluetoothMode = (function() {
function doLocateHandoff(device) {
console.log('[BT] doLocateHandoff, BtLocate defined:', typeof BtLocate !== 'undefined');
- if (typeof BtLocate !== 'undefined') {
- BtLocate.handoff({
- device_id: device.device_id,
- device_key: device.device_key || null,
- mac_address: device.address,
- address_type: device.address_type || null,
- irk_hex: device.irk_hex || null,
- known_name: device.name || null,
- known_manufacturer: device.manufacturer_name || null,
- last_known_rssi: device.rssi_current,
- tx_power: device.tx_power || null,
- appearance_name: device.appearance_name || null,
- fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
- mac_cluster_count: device.mac_cluster_count || 0
- });
- }
- }
+ if (typeof BtLocate !== 'undefined') {
+ BtLocate.handoff({
+ device_id: device.device_id,
+ device_key: device.device_key || null,
+ mac_address: device.address,
+ address_type: device.address_type || null,
+ irk_hex: device.irk_hex || null,
+ known_name: device.name || null,
+ known_manufacturer: device.manufacturer_name || null,
+ last_known_rssi: device.rssi_current,
+ tx_power: device.tx_power || null,
+ appearance_name: device.appearance_name || null,
+ fingerprint_id: device.fingerprint_id || device.fingerprint?.id || null,
+ mac_cluster_count: device.mac_cluster_count || 0
+ });
+ }
+ }
// Public API
return {
@@ -1773,8 +1773,18 @@ const BluetoothMode = (function() {
// Getters
getDevices: () => Array.from(devices.values()),
isScanning: () => isScanning,
- isShowAllAgents: () => showAllAgentsMode
+ isShowAllAgents: () => showAllAgentsMode,
+
+ // Lifecycle
+ destroy
};
+
+ /**
+ * Destroy — close SSE stream and clear polling timers for clean mode switching.
+ */
+ function destroy() {
+ stopEventStream();
+ }
})();
// Global functions for onclick handlers
diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js
index 7187c45..a52d127 100644
--- a/static/js/modes/bt_locate.js
+++ b/static/js/modes/bt_locate.js
@@ -1909,7 +1909,42 @@ const BtLocate = (function() {
handleDetection,
invalidateMap,
fetchPairedIrks,
+ destroy,
};
+
+ /**
+ * Destroy — close SSE stream and clear all timers for clean mode switching.
+ */
+ function destroy() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ if (durationTimer) {
+ clearInterval(durationTimer);
+ durationTimer = null;
+ }
+ if (mapStabilizeTimer) {
+ clearInterval(mapStabilizeTimer);
+ mapStabilizeTimer = null;
+ }
+ if (queuedDetectionTimer) {
+ clearTimeout(queuedDetectionTimer);
+ queuedDetectionTimer = null;
+ }
+ if (crosshairResetTimer) {
+ clearTimeout(crosshairResetTimer);
+ crosshairResetTimer = null;
+ }
+ if (beepTimer) {
+ clearInterval(beepTimer);
+ beepTimer = null;
+ }
+ }
})();
window.BtLocate = BtLocate;
diff --git a/static/js/modes/meshtastic.js b/static/js/modes/meshtastic.js
index 6f6a093..939037e 100644
--- a/static/js/modes/meshtastic.js
+++ b/static/js/modes/meshtastic.js
@@ -117,13 +117,13 @@ const Meshtastic = (function() {
Settings.createTileLayer().addTo(meshMap);
Settings.registerMap(meshMap);
} else {
- L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
- attribution: '©
OSM ©
CARTO',
- maxZoom: 19,
- subdomains: 'abcd',
- className: 'tile-layer-cyan'
- }).addTo(meshMap);
- }
+ L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
+ attribution: '©
OSM ©
CARTO',
+ maxZoom: 19,
+ subdomains: 'abcd',
+ className: 'tile-layer-cyan'
+ }).addTo(meshMap);
+ }
// Handle resize
setTimeout(() => {
@@ -401,10 +401,10 @@ const Meshtastic = (function() {
// Position is nested in the response
const pos = info.position;
- if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
- if (posRow) posRow.style.display = 'flex';
- if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
- } else {
+ if (pos && pos.latitude !== undefined && pos.latitude !== null && pos.longitude !== undefined && pos.longitude !== null) {
+ if (posRow) posRow.style.display = 'flex';
+ if (posEl) posEl.textContent = `${pos.latitude.toFixed(5)}, ${pos.longitude.toFixed(5)}`;
+ } else {
if (posRow) posRow.style.display = 'none';
}
}
@@ -2295,7 +2295,8 @@ const Meshtastic = (function() {
// Store & Forward
showStoreForwardModal,
requestStoreForward,
- closeStoreForwardModal
+ closeStoreForwardModal,
+ destroy
};
/**
@@ -2306,6 +2307,13 @@ const Meshtastic = (function() {
setTimeout(() => meshMap.invalidateSize(), 100);
}
}
+
+ /**
+ * Destroy — tear down SSE, timers, and event listeners for clean mode switching.
+ */
+ function destroy() {
+ stopStream();
+ }
})();
// Initialize when DOM is ready (will be called by selectMode)
diff --git a/static/js/modes/spy-stations.js b/static/js/modes/spy-stations.js
index a6176f6..09b4955 100644
--- a/static/js/modes/spy-stations.js
+++ b/static/js/modes/spy-stations.js
@@ -515,6 +515,13 @@ const SpyStations = (function() {
}
}
+ /**
+ * Destroy — no-op placeholder for consistent lifecycle interface.
+ */
+ function destroy() {
+ // SpyStations has no background timers or streams to clean up.
+ }
+
// Public API
return {
init,
@@ -524,7 +531,8 @@ const SpyStations = (function() {
showDetails,
closeDetails,
showHelp,
- closeHelp
+ closeHelp,
+ destroy
};
})();
diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js
index c16791d..3bec33d 100644
--- a/static/js/modes/sstv-general.js
+++ b/static/js/modes/sstv-general.js
@@ -858,6 +858,13 @@ const SSTVGeneral = (function() {
}
}
+ /**
+ * Destroy — close SSE stream and stop scope animation for clean mode switching.
+ */
+ function destroy() {
+ stopStream();
+ }
+
// Public API
return {
init,
@@ -869,6 +876,7 @@ const SSTVGeneral = (function() {
deleteImage,
deleteAllImages,
downloadImage,
- selectPreset
+ selectPreset,
+ destroy
};
})();
diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js
index 24e2f29..bb60d1d 100644
--- a/static/js/modes/sstv.js
+++ b/static/js/modes/sstv.js
@@ -12,12 +12,12 @@ const SSTV = (function() {
let progress = 0;
let issMap = null;
let issMarker = null;
- let issTrackLine = null;
- let issPosition = null;
- let issUpdateInterval = null;
- let countdownInterval = null;
- let nextPassData = null;
- let pendingMapInvalidate = false;
+ let issTrackLine = null;
+ let issPosition = null;
+ let issUpdateInterval = null;
+ let countdownInterval = null;
+ let nextPassData = null;
+ let pendingMapInvalidate = false;
// ISS frequency
const ISS_FREQ = 145.800;
@@ -38,31 +38,31 @@ const SSTV = (function() {
/**
* Initialize the SSTV mode
*/
- function init() {
- checkStatus();
- loadImages();
- loadLocationInputs();
- loadIssSchedule();
- initMap();
- startIssTracking();
- startCountdown();
- // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
- setTimeout(() => invalidateMap(), 80);
- setTimeout(() => invalidateMap(), 260);
- }
-
- function isMapContainerVisible() {
- if (!issMap || typeof issMap.getContainer !== 'function') return false;
- const container = issMap.getContainer();
- if (!container) return false;
- if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
- if (container.style && container.style.display === 'none') return false;
- if (typeof window.getComputedStyle === 'function') {
- const style = window.getComputedStyle(container);
- if (style.display === 'none' || style.visibility === 'hidden') return false;
- }
- return true;
- }
+ function init() {
+ checkStatus();
+ loadImages();
+ loadLocationInputs();
+ loadIssSchedule();
+ initMap();
+ startIssTracking();
+ startCountdown();
+ // Ensure Leaflet recomputes dimensions after the SSTV pane becomes visible.
+ setTimeout(() => invalidateMap(), 80);
+ setTimeout(() => invalidateMap(), 260);
+ }
+
+ function isMapContainerVisible() {
+ if (!issMap || typeof issMap.getContainer !== 'function') return false;
+ const container = issMap.getContainer();
+ if (!container) return false;
+ if (container.offsetWidth <= 0 || container.offsetHeight <= 0) return false;
+ if (container.style && container.style.display === 'none') return false;
+ if (typeof window.getComputedStyle === 'function') {
+ const style = window.getComputedStyle(container);
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
+ }
+ return true;
+ }
/**
* Load location into input fields
@@ -189,9 +189,9 @@ const SSTV = (function() {
/**
* Initialize Leaflet map for ISS tracking
*/
- async function initMap() {
- const mapContainer = document.getElementById('sstvIssMap');
- if (!mapContainer || issMap) return;
+ async function initMap() {
+ const mapContainer = document.getElementById('sstvIssMap');
+ if (!mapContainer || issMap) return;
// Create map
issMap = L.map('sstvIssMap', {
@@ -231,21 +231,21 @@ const SSTV = (function() {
issMarker = L.marker([0, 0], { icon: issIcon }).addTo(issMap);
// Create ground track line
- issTrackLine = L.polyline([], {
- color: '#00d4ff',
- weight: 2,
- opacity: 0.6,
- dashArray: '5, 5'
- }).addTo(issMap);
-
- issMap.on('resize moveend zoomend', () => {
- if (pendingMapInvalidate) invalidateMap();
- });
-
- // Initial layout passes for first-time mode load.
- setTimeout(() => invalidateMap(), 40);
- setTimeout(() => invalidateMap(), 180);
- }
+ issTrackLine = L.polyline([], {
+ color: '#00d4ff',
+ weight: 2,
+ opacity: 0.6,
+ dashArray: '5, 5'
+ }).addTo(issMap);
+
+ issMap.on('resize moveend zoomend', () => {
+ if (pendingMapInvalidate) invalidateMap();
+ });
+
+ // Initial layout passes for first-time mode load.
+ setTimeout(() => invalidateMap(), 40);
+ setTimeout(() => invalidateMap(), 180);
+ }
/**
* Start ISS position tracking
@@ -454,9 +454,9 @@ const SSTV = (function() {
/**
* Update map with ISS position
*/
- function updateMap() {
- if (!issMap || !issPosition) return;
- if (pendingMapInvalidate) invalidateMap();
+ function updateMap() {
+ if (!issMap || !issPosition) return;
+ if (pendingMapInvalidate) invalidateMap();
const lat = issPosition.lat;
const lon = issPosition.lon;
@@ -516,13 +516,13 @@ const SSTV = (function() {
issTrackLine.setLatLngs(segments.length > 0 ? segments : []);
}
- // Pan map to follow ISS only when the map pane is currently renderable.
- if (isMapContainerVisible()) {
- issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
- } else {
- pendingMapInvalidate = true;
- }
- }
+ // Pan map to follow ISS only when the map pane is currently renderable.
+ if (isMapContainerVisible()) {
+ issMap.panTo([lat, lon], { animate: true, duration: 0.5 });
+ } else {
+ pendingMapInvalidate = true;
+ }
+ }
/**
* Check current decoder status
@@ -1335,27 +1335,27 @@ const SSTV = (function() {
/**
* Show status message
*/
- function showStatusMessage(message, type) {
- if (typeof showNotification === 'function') {
- showNotification('SSTV', message);
- } else {
- console.log(`[SSTV ${type}] ${message}`);
- }
- }
-
- /**
- * Invalidate ISS map size after pane/layout changes.
- */
- function invalidateMap() {
- if (!issMap) return false;
- if (!isMapContainerVisible()) {
- pendingMapInvalidate = true;
- return false;
- }
- issMap.invalidateSize({ pan: false, animate: false });
- pendingMapInvalidate = false;
- return true;
- }
+ function showStatusMessage(message, type) {
+ if (typeof showNotification === 'function') {
+ showNotification('SSTV', message);
+ } else {
+ console.log(`[SSTV ${type}] ${message}`);
+ }
+ }
+
+ /**
+ * Invalidate ISS map size after pane/layout changes.
+ */
+ function invalidateMap() {
+ if (!issMap) return false;
+ if (!isMapContainerVisible()) {
+ pendingMapInvalidate = true;
+ return false;
+ }
+ issMap.invalidateSize({ pan: false, animate: false });
+ pendingMapInvalidate = false;
+ return true;
+ }
// Public API
return {
@@ -1370,12 +1370,25 @@ const SSTV = (function() {
deleteAllImages,
downloadImage,
useGPS,
- updateTLE,
- stopIssTracking,
- stopCountdown,
- invalidateMap
- };
-})();
+ updateTLE,
+ stopIssTracking,
+ stopCountdown,
+ invalidateMap,
+ destroy
+ };
+
+ /**
+ * Destroy — close SSE stream and clear ISS tracking/countdown timers for clean mode switching.
+ */
+ function destroy() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ stopIssTracking();
+ stopCountdown();
+ }
+})();
// Initialize when DOM is ready (will be called by selectMode)
document.addEventListener('DOMContentLoaded', function() {
diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js
index b2a60fe..f99a6ea 100644
--- a/static/js/modes/websdr.js
+++ b/static/js/modes/websdr.js
@@ -1005,6 +1005,15 @@ function escapeHtmlWebsdr(str) {
// ============== EXPORTS ==============
+/**
+ * Destroy — disconnect audio and clear S-meter timer for clean mode switching.
+ */
+function destroyWebSDR() {
+ disconnectFromReceiver();
+}
+
+const WebSDR = { destroy: destroyWebSDR };
+
window.initWebSDR = initWebSDR;
window.searchReceivers = searchReceivers;
window.selectReceiver = selectReceiver;
@@ -1015,3 +1024,4 @@ window.disconnectFromReceiver = disconnectFromReceiver;
window.tuneKiwi = tuneKiwi;
window.tuneFromBar = tuneFromBar;
window.setKiwiVolume = setKiwiVolume;
+window.WebSDR = WebSDR;
diff --git a/static/js/modes/wifi.js b/static/js/modes/wifi.js
index 35c93c0..bc44c02 100644
--- a/static/js/modes/wifi.js
+++ b/static/js/modes/wifi.js
@@ -28,9 +28,9 @@ const WiFiMode = (function() {
maxProbes: 1000,
};
- // ==========================================================================
- // Agent Support
- // ==========================================================================
+ // ==========================================================================
+ // Agent Support
+ // ==========================================================================
/**
* Get the API base URL, routing through agent proxy if agent is selected.
@@ -59,49 +59,49 @@ const WiFiMode = (function() {
/**
* Check for agent mode conflicts before starting WiFi scan.
*/
- function checkAgentConflicts() {
- if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
- return true;
- }
- if (typeof checkAgentModeConflict === 'function') {
- return checkAgentModeConflict('wifi');
- }
- return true;
- }
-
- function getChannelPresetList(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 buildChannelConfig() {
- const preset = document.getElementById('wifiChannelPreset')?.value || '';
- const listInput = document.getElementById('wifiChannelList')?.value || '';
- const singleInput = document.getElementById('wifiChannel')?.value || '';
-
- const listValue = listInput.trim();
- const presetValue = getChannelPresetList(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,
- };
- }
+ function checkAgentConflicts() {
+ if (typeof currentAgent === 'undefined' || currentAgent === 'local') {
+ return true;
+ }
+ if (typeof checkAgentModeConflict === 'function') {
+ return checkAgentModeConflict('wifi');
+ }
+ return true;
+ }
+
+ function getChannelPresetList(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 buildChannelConfig() {
+ const preset = document.getElementById('wifiChannelPreset')?.value || '';
+ const listInput = document.getElementById('wifiChannelList')?.value || '';
+ const singleInput = document.getElementById('wifiChannel')?.value || '';
+
+ const listValue = listInput.trim();
+ const presetValue = getChannelPresetList(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,
+ };
+ }
// ==========================================================================
// State
@@ -120,23 +120,23 @@ const WiFiMode = (function() {
let channelStats = [];
let recommendations = [];
- // UI state
- let selectedNetwork = null;
- let currentFilter = 'all';
- let currentSort = { field: 'rssi', order: 'desc' };
- let renderFramePending = false;
- const pendingRender = {
- table: false,
- stats: false,
- radar: false,
- chart: false,
- detail: false,
- };
- const listenersBound = {
- scanTabs: false,
- filters: false,
- sort: false,
- };
+ // UI state
+ let selectedNetwork = null;
+ let currentFilter = 'all';
+ let currentSort = { field: 'rssi', order: 'desc' };
+ let renderFramePending = false;
+ const pendingRender = {
+ table: false,
+ stats: false,
+ radar: false,
+ chart: false,
+ detail: false,
+ };
+ const listenersBound = {
+ scanTabs: false,
+ filters: false,
+ sort: false,
+ };
// Agent state
let showAllAgentsMode = false; // Show combined results from all agents
@@ -165,11 +165,11 @@ const WiFiMode = (function() {
// Initialize components
initScanModeTabs();
- initNetworkFilters();
- initSortControls();
- initProximityRadar();
- initChannelChart();
- scheduleRender({ table: true, stats: true, radar: true, chart: true });
+ initNetworkFilters();
+ initSortControls();
+ initProximityRadar();
+ initChannelChart();
+ scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Check if already scanning
checkScanStatus();
@@ -378,16 +378,16 @@ const WiFiMode = (function() {
// Scan Mode Tabs
// ==========================================================================
- function initScanModeTabs() {
- if (listenersBound.scanTabs) return;
- if (elements.scanModeQuick) {
- elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
- }
- if (elements.scanModeDeep) {
- elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
- }
- listenersBound.scanTabs = true;
- }
+ function initScanModeTabs() {
+ if (listenersBound.scanTabs) return;
+ if (elements.scanModeQuick) {
+ elements.scanModeQuick.addEventListener('click', () => setScanMode('quick'));
+ }
+ if (elements.scanModeDeep) {
+ elements.scanModeDeep.addEventListener('click', () => setScanMode('deep'));
+ }
+ listenersBound.scanTabs = true;
+ }
function setScanMode(mode) {
scanMode = mode;
@@ -511,10 +511,10 @@ const WiFiMode = (function() {
setScanning(true, 'deep');
try {
- const iface = elements.interfaceSelect?.value || null;
- const band = document.getElementById('wifiBand')?.value || 'all';
- const channelConfig = buildChannelConfig();
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ const iface = elements.interfaceSelect?.value || null;
+ const band = document.getElementById('wifiBand')?.value || 'all';
+ const channelConfig = buildChannelConfig();
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
let response;
if (isAgentMode) {
@@ -523,25 +523,25 @@ const WiFiMode = (function() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- interface: iface,
- scan_type: 'deep',
- band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
- channel: channelConfig.channel,
- channels: channelConfig.channels,
- }),
- });
- } else {
- response = await fetch(`${CONFIG.apiBase}/scan/start`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- interface: iface,
- band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
- channel: channelConfig.channel,
- channels: channelConfig.channels,
- }),
- });
- }
+ interface: iface,
+ scan_type: 'deep',
+ band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
+ channel: channelConfig.channel,
+ channels: channelConfig.channels,
+ }),
+ });
+ } else {
+ response = await fetch(`${CONFIG.apiBase}/scan/start`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ interface: iface,
+ band: band === 'abg' ? 'all' : band === 'bg' ? '2.4' : '5',
+ channel: channelConfig.channel,
+ channels: channelConfig.channels,
+ }),
+ });
+ }
if (!response.ok) {
const error = await response.json();
@@ -572,8 +572,8 @@ const WiFiMode = (function() {
}
}
- async function stopScan() {
- console.log('[WiFiMode] Stopping scan...');
+ async function stopScan() {
+ console.log('[WiFiMode] Stopping scan...');
// Stop polling
if (pollTimer) {
@@ -585,41 +585,41 @@ const WiFiMode = (function() {
stopAgentDeepScanPolling();
// Close event stream
- if (eventSource) {
- eventSource.close();
- eventSource = null;
- }
-
- // Update UI immediately so mode transitions are responsive even if the
- // backend needs extra time to terminate subprocesses.
- setScanning(false);
-
- // Stop scan on server (local or agent)
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
- const timeoutMs = isAgentMode ? 8000 : 2200;
- const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
- const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
-
- try {
- if (isAgentMode) {
- await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
- method: 'POST',
- ...(controller ? { signal: controller.signal } : {}),
- });
- } else if (scanMode === 'deep') {
- await fetch(`${CONFIG.apiBase}/scan/stop`, {
- method: 'POST',
- ...(controller ? { signal: controller.signal } : {}),
- });
- }
- } catch (error) {
- console.warn('[WiFiMode] Error stopping scan:', error);
- } finally {
- if (timeoutId) {
- clearTimeout(timeoutId);
- }
- }
- }
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+
+ // Update UI immediately so mode transitions are responsive even if the
+ // backend needs extra time to terminate subprocesses.
+ setScanning(false);
+
+ // Stop scan on server (local or agent)
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ const timeoutMs = isAgentMode ? 8000 : 2200;
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
+ const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
+
+ try {
+ if (isAgentMode) {
+ await fetch(`/controller/agents/${currentAgent}/wifi/stop`, {
+ method: 'POST',
+ ...(controller ? { signal: controller.signal } : {}),
+ });
+ } else if (scanMode === 'deep') {
+ await fetch(`${CONFIG.apiBase}/scan/stop`, {
+ method: 'POST',
+ ...(controller ? { signal: controller.signal } : {}),
+ });
+ }
+ } catch (error) {
+ console.warn('[WiFiMode] Error stopping scan:', error);
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
function setScanning(scanning, mode = null) {
isScanning = scanning;
@@ -713,10 +713,10 @@ const WiFiMode = (function() {
}, CONFIG.pollInterval);
}
- function processQuickScanResult(result) {
- // Update networks
- result.access_points.forEach(ap => {
- networks.set(ap.bssid, ap);
+ function processQuickScanResult(result) {
+ // Update networks
+ result.access_points.forEach(ap => {
+ networks.set(ap.bssid, ap);
});
// Update channel stats (calculate from networks if not provided by API)
@@ -724,12 +724,12 @@ const WiFiMode = (function() {
recommendations = result.recommendations || [];
// If no channel stats from API, calculate from networks
- if (channelStats.length === 0 && networks.size > 0) {
- channelStats = calculateChannelStats();
- }
-
- // Update UI
- scheduleRender({ table: true, stats: true, radar: true, chart: true });
+ if (channelStats.length === 0 && networks.size > 0) {
+ channelStats = calculateChannelStats();
+ }
+
+ // Update UI
+ scheduleRender({ table: true, stats: true, radar: true, chart: true });
// Callbacks
result.access_points.forEach(ap => {
@@ -938,25 +938,25 @@ const WiFiMode = (function() {
}
}
- function handleNetworkUpdate(network) {
- networks.set(network.bssid, network);
- scheduleRender({
- table: true,
- stats: true,
- radar: true,
- chart: true,
- detail: selectedNetwork === network.bssid,
- });
-
- if (onNetworkUpdate) onNetworkUpdate(network);
- }
-
- function handleClientUpdate(client) {
- clients.set(client.mac, client);
- scheduleRender({ stats: true });
-
- // Update client display if this client belongs to the selected network
- updateClientInList(client);
+ function handleNetworkUpdate(network) {
+ networks.set(network.bssid, network);
+ scheduleRender({
+ table: true,
+ stats: true,
+ radar: true,
+ chart: true,
+ detail: selectedNetwork === network.bssid,
+ });
+
+ if (onNetworkUpdate) onNetworkUpdate(network);
+ }
+
+ function handleClientUpdate(client) {
+ clients.set(client.mac, client);
+ scheduleRender({ stats: true });
+
+ // Update client display if this client belongs to the selected network
+ updateClientInList(client);
if (onClientUpdate) onClientUpdate(client);
}
@@ -970,37 +970,37 @@ const WiFiMode = (function() {
if (onProbeRequest) onProbeRequest(probe);
}
- function handleHiddenRevealed(bssid, revealedSsid) {
- const network = networks.get(bssid);
- if (network) {
- network.revealed_essid = revealedSsid;
- network.display_name = `${revealedSsid} (revealed)`;
- scheduleRender({
- table: true,
- detail: selectedNetwork === bssid,
- });
-
- // Show notification
- showInfo(`Hidden SSID revealed: ${revealedSsid}`);
- }
- }
+ function handleHiddenRevealed(bssid, revealedSsid) {
+ const network = networks.get(bssid);
+ if (network) {
+ network.revealed_essid = revealedSsid;
+ network.display_name = `${revealedSsid} (revealed)`;
+ scheduleRender({
+ table: true,
+ detail: selectedNetwork === bssid,
+ });
+
+ // Show notification
+ showInfo(`Hidden SSID revealed: ${revealedSsid}`);
+ }
+ }
// ==========================================================================
// Network Table
// ==========================================================================
- function initNetworkFilters() {
- if (listenersBound.filters) return;
- if (!elements.networkFilters) return;
-
- elements.networkFilters.addEventListener('click', (e) => {
- if (e.target.matches('.wifi-filter-btn')) {
- const filter = e.target.dataset.filter;
- setNetworkFilter(filter);
- }
- });
- listenersBound.filters = true;
- }
+ function initNetworkFilters() {
+ if (listenersBound.filters) return;
+ if (!elements.networkFilters) return;
+
+ elements.networkFilters.addEventListener('click', (e) => {
+ if (e.target.matches('.wifi-filter-btn')) {
+ const filter = e.target.dataset.filter;
+ setNetworkFilter(filter);
+ }
+ });
+ listenersBound.filters = true;
+ }
function setNetworkFilter(filter) {
currentFilter = filter;
@@ -1015,11 +1015,11 @@ const WiFiMode = (function() {
updateNetworkTable();
}
- function initSortControls() {
- if (listenersBound.sort) return;
- if (!elements.networkTable) return;
-
- elements.networkTable.addEventListener('click', (e) => {
+ function initSortControls() {
+ if (listenersBound.sort) return;
+ if (!elements.networkTable) return;
+
+ elements.networkTable.addEventListener('click', (e) => {
const th = e.target.closest('th[data-sort]');
if (th) {
const field = th.dataset.sort;
@@ -1029,54 +1029,54 @@ const WiFiMode = (function() {
currentSort.field = field;
currentSort.order = 'desc';
}
- updateNetworkTable();
- }
- });
-
- if (elements.networkTableBody) {
- elements.networkTableBody.addEventListener('click', (e) => {
- const row = e.target.closest('tr[data-bssid]');
- if (!row) return;
- selectNetwork(row.dataset.bssid);
- });
- }
- listenersBound.sort = true;
- }
-
- function scheduleRender(flags = {}) {
- pendingRender.table = pendingRender.table || Boolean(flags.table);
- pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
- pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
- pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
- pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
-
- if (renderFramePending) return;
- renderFramePending = true;
-
- requestAnimationFrame(() => {
- renderFramePending = false;
-
- if (pendingRender.table) updateNetworkTable();
- if (pendingRender.stats) updateStats();
- if (pendingRender.radar) updateProximityRadar();
- if (pendingRender.chart) updateChannelChart();
- if (pendingRender.detail && selectedNetwork) {
- updateDetailPanel(selectedNetwork, { refreshClients: false });
- }
-
- pendingRender.table = false;
- pendingRender.stats = false;
- pendingRender.radar = false;
- pendingRender.chart = false;
- pendingRender.detail = false;
- });
- }
-
- function updateNetworkTable() {
- if (!elements.networkTableBody) return;
-
- // Filter networks
- let filtered = Array.from(networks.values());
+ updateNetworkTable();
+ }
+ });
+
+ if (elements.networkTableBody) {
+ elements.networkTableBody.addEventListener('click', (e) => {
+ const row = e.target.closest('tr[data-bssid]');
+ if (!row) return;
+ selectNetwork(row.dataset.bssid);
+ });
+ }
+ listenersBound.sort = true;
+ }
+
+ function scheduleRender(flags = {}) {
+ pendingRender.table = pendingRender.table || Boolean(flags.table);
+ pendingRender.stats = pendingRender.stats || Boolean(flags.stats);
+ pendingRender.radar = pendingRender.radar || Boolean(flags.radar);
+ pendingRender.chart = pendingRender.chart || Boolean(flags.chart);
+ pendingRender.detail = pendingRender.detail || Boolean(flags.detail);
+
+ if (renderFramePending) return;
+ renderFramePending = true;
+
+ requestAnimationFrame(() => {
+ renderFramePending = false;
+
+ if (pendingRender.table) updateNetworkTable();
+ if (pendingRender.stats) updateStats();
+ if (pendingRender.radar) updateProximityRadar();
+ if (pendingRender.chart) updateChannelChart();
+ if (pendingRender.detail && selectedNetwork) {
+ updateDetailPanel(selectedNetwork, { refreshClients: false });
+ }
+
+ pendingRender.table = false;
+ pendingRender.stats = false;
+ pendingRender.radar = false;
+ pendingRender.chart = false;
+ pendingRender.detail = false;
+ });
+ }
+
+ function updateNetworkTable() {
+ if (!elements.networkTableBody) return;
+
+ // Filter networks
+ let filtered = Array.from(networks.values());
switch (currentFilter) {
case 'hidden':
@@ -1126,44 +1126,44 @@ const WiFiMode = (function() {
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
} else {
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
- }
- });
-
- if (filtered.length === 0) {
- let message = 'Start scanning to discover networks';
- let type = 'empty';
- if (isScanning) {
- message = 'Scanning for networks...';
- type = 'loading';
- } else if (networks.size > 0) {
- message = 'No networks match current filters';
- }
- if (typeof renderCollectionState === 'function') {
- renderCollectionState(elements.networkTableBody, {
- type,
- message,
- columns: 7,
- });
- } else {
- elements.networkTableBody.innerHTML = `
${escapeHtml(message)} |
`;
- }
- return;
- }
-
- // Render table
- elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
- }
+ }
+ });
- function createNetworkRow(network) {
- const rssi = network.rssi_current;
- const security = network.security || 'Unknown';
- const signalClass = rssi >= -50 ? 'signal-strong' :
- rssi >= -70 ? 'signal-medium' :
- rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
-
- const securityClass = security === 'Open' ? 'security-open' :
- security === 'WEP' ? 'security-wep' :
- security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
+ if (filtered.length === 0) {
+ let message = 'Start scanning to discover networks';
+ let type = 'empty';
+ if (isScanning) {
+ message = 'Scanning for networks...';
+ type = 'loading';
+ } else if (networks.size > 0) {
+ message = 'No networks match current filters';
+ }
+ if (typeof renderCollectionState === 'function') {
+ renderCollectionState(elements.networkTableBody, {
+ type,
+ message,
+ columns: 7,
+ });
+ } else {
+ elements.networkTableBody.innerHTML = `
${escapeHtml(message)} |
`;
+ }
+ return;
+ }
+
+ // Render table
+ elements.networkTableBody.innerHTML = filtered.map(n => createNetworkRow(n)).join('');
+ }
+
+ function createNetworkRow(network) {
+ const rssi = network.rssi_current;
+ const security = network.security || 'Unknown';
+ const signalClass = rssi >= -50 ? 'signal-strong' :
+ rssi >= -70 ? 'signal-medium' :
+ rssi >= -85 ? 'signal-weak' : 'signal-very-weak';
+
+ const securityClass = security === 'Open' ? 'security-open' :
+ security === 'WEP' ? 'security-wep' :
+ security.includes('WPA3') ? 'security-wpa3' : 'security-wpa';
const hiddenBadge = network.is_hidden ? '
Hidden' : '';
const newBadge = network.is_new ? '
New' : '';
@@ -1172,25 +1172,25 @@ const WiFiMode = (function() {
const agentName = network._agent || 'Local';
const agentClass = agentName === 'Local' ? 'agent-local' : 'agent-remote';
- return `
-
- |
- ${escapeHtml(network.display_name || network.essid || '[Hidden]')}
- ${hiddenBadge}${newBadge}
- |
+ return `
+
+ |
+ ${escapeHtml(network.display_name || network.essid || '[Hidden]')}
+ ${hiddenBadge}${newBadge}
+ |
${escapeHtml(network.bssid)} |
${network.channel || '-'} |
-
-
- ${escapeHtml(security)}
- |
+
+
+ ${escapeHtml(security)}
+ |
${network.client_count || 0} |
${escapeHtml(agentName)}
@@ -1199,12 +1199,12 @@ const WiFiMode = (function() {
`;
}
- function updateNetworkRow(network) {
- scheduleRender({
- table: true,
- detail: selectedNetwork === network.bssid,
- });
- }
+ function updateNetworkRow(network) {
+ scheduleRender({
+ table: true,
+ detail: selectedNetwork === network.bssid,
+ });
+ }
function selectNetwork(bssid) {
selectedNetwork = bssid;
@@ -1227,9 +1227,9 @@ const WiFiMode = (function() {
// Detail Panel
// ==========================================================================
- function updateDetailPanel(bssid, options = {}) {
- const { refreshClients = true } = options;
- if (!elements.detailDrawer) return;
+ function updateDetailPanel(bssid, options = {}) {
+ const { refreshClients = true } = options;
+ if (!elements.detailDrawer) return;
const network = networks.get(bssid);
if (!network) {
@@ -1274,11 +1274,11 @@ const WiFiMode = (function() {
// Show the drawer
elements.detailDrawer.classList.add('open');
- // Fetch and display clients for this network
- if (refreshClients) {
- fetchClientsForNetwork(network.bssid);
- }
- }
+ // Fetch and display clients for this network
+ if (refreshClients) {
+ fetchClientsForNetwork(network.bssid);
+ }
+ }
function closeDetail() {
selectedNetwork = null;
@@ -1294,18 +1294,18 @@ const WiFiMode = (function() {
// Client Display
// ==========================================================================
- async function fetchClientsForNetwork(bssid) {
- if (!elements.detailClientList) return;
- const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
-
- if (listContainer && typeof renderCollectionState === 'function') {
- renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
- elements.detailClientList.style.display = 'block';
- }
-
- try {
- const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
- let response;
+ async function fetchClientsForNetwork(bssid) {
+ if (!elements.detailClientList) return;
+ const listContainer = elements.detailClientList.querySelector('.wifi-client-list');
+
+ if (listContainer && typeof renderCollectionState === 'function') {
+ renderCollectionState(listContainer, { type: 'loading', message: 'Loading clients...' });
+ elements.detailClientList.style.display = 'block';
+ }
+
+ try {
+ const isAgentMode = typeof currentAgent !== 'undefined' && currentAgent !== 'local';
+ let response;
if (isAgentMode) {
// Route through agent proxy
@@ -1314,44 +1314,44 @@ const WiFiMode = (function() {
response = await fetch(`${CONFIG.apiBase}/clients?bssid=${encodeURIComponent(bssid)}&associated=true`);
}
- if (!response.ok) {
- if (listContainer && typeof renderCollectionState === 'function') {
- renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
- elements.detailClientList.style.display = 'block';
- } else {
- elements.detailClientList.style.display = 'none';
- }
- return;
- }
+ if (!response.ok) {
+ if (listContainer && typeof renderCollectionState === 'function') {
+ renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
+ elements.detailClientList.style.display = 'block';
+ } else {
+ elements.detailClientList.style.display = 'none';
+ }
+ return;
+ }
const data = await response.json();
// Handle agent response format (may be nested in 'result')
const result = isAgentMode && data.result ? data.result : data;
const clientList = result.clients || [];
- if (clientList.length > 0) {
- renderClientList(clientList, bssid);
- elements.detailClientList.style.display = 'block';
- } else {
- const countBadge = document.getElementById('wifiClientCountBadge');
- if (countBadge) countBadge.textContent = '0';
- if (listContainer && typeof renderCollectionState === 'function') {
- renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
- elements.detailClientList.style.display = 'block';
- } else {
- elements.detailClientList.style.display = 'none';
- }
- }
- } catch (error) {
- console.debug('[WiFiMode] Error fetching clients:', error);
- if (listContainer && typeof renderCollectionState === 'function') {
- renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
- elements.detailClientList.style.display = 'block';
- } else {
- elements.detailClientList.style.display = 'none';
- }
- }
- }
+ if (clientList.length > 0) {
+ renderClientList(clientList, bssid);
+ elements.detailClientList.style.display = 'block';
+ } else {
+ const countBadge = document.getElementById('wifiClientCountBadge');
+ if (countBadge) countBadge.textContent = '0';
+ if (listContainer && typeof renderCollectionState === 'function') {
+ renderCollectionState(listContainer, { type: 'empty', message: 'No associated clients' });
+ elements.detailClientList.style.display = 'block';
+ } else {
+ elements.detailClientList.style.display = 'none';
+ }
+ }
+ } catch (error) {
+ console.debug('[WiFiMode] Error fetching clients:', error);
+ if (listContainer && typeof renderCollectionState === 'function') {
+ renderCollectionState(listContainer, { type: 'empty', message: 'Client list unavailable' });
+ elements.detailClientList.style.display = 'block';
+ } else {
+ elements.detailClientList.style.display = 'none';
+ }
+ }
+ }
function renderClientList(clientList, bssid) {
const container = elements.detailClientList?.querySelector('.wifi-client-list');
@@ -1708,16 +1708,16 @@ const WiFiMode = (function() {
/**
* Clear all collected data.
*/
- function clearData() {
- networks.clear();
- clients.clear();
- probeRequests = [];
- channelStats = [];
- recommendations = [];
- if (selectedNetwork) {
- closeDetail();
- }
- scheduleRender({ table: true, stats: true, radar: true, chart: true });
+ function clearData() {
+ networks.clear();
+ clients.clear();
+ probeRequests = [];
+ channelStats = [];
+ recommendations = [];
+ if (selectedNetwork) {
+ closeDetail();
+ }
+ scheduleRender({ table: true, stats: true, radar: true, chart: true });
}
/**
@@ -1763,12 +1763,12 @@ const WiFiMode = (function() {
clientsToRemove.push(mac);
}
});
- clientsToRemove.forEach(mac => clients.delete(mac));
- if (selectedNetwork && !networks.has(selectedNetwork)) {
- closeDetail();
- }
- scheduleRender({ table: true, stats: true, radar: true, chart: true });
- }
+ clientsToRemove.forEach(mac => clients.delete(mac));
+ if (selectedNetwork && !networks.has(selectedNetwork)) {
+ closeDetail();
+ }
+ scheduleRender({ table: true, stats: true, radar: true, chart: true });
+ }
/**
* Refresh WiFi interfaces from current agent.
@@ -1811,7 +1811,28 @@ const WiFiMode = (function() {
onNetworkUpdate: (cb) => { onNetworkUpdate = cb; },
onClientUpdate: (cb) => { onClientUpdate = cb; },
onProbeRequest: (cb) => { onProbeRequest = cb; },
+
+ // Lifecycle
+ destroy,
};
+
+ /**
+ * Destroy — close SSE stream and clear polling timers for clean mode switching.
+ */
+ function destroy() {
+ if (eventSource) {
+ eventSource.close();
+ eventSource = null;
+ }
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+ if (agentPollTimer) {
+ clearInterval(agentPollTimer);
+ agentPollTimer = null;
+ }
+ }
})();
// Auto-initialize when DOM is ready
diff --git a/templates/index.html b/templates/index.html
index 2844a23..92a459a 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -4140,12 +4140,27 @@
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
await styleReadyPromise;
- // Clean up SubGHz SSE connection when leaving the mode
- if (typeof SubGhz !== 'undefined' && currentMode === 'subghz' && mode !== 'subghz') {
- SubGhz.destroy();
- }
- if (typeof MorseMode !== 'undefined' && currentMode === 'morse' && mode !== 'morse' && typeof MorseMode.destroy === 'function') {
- MorseMode.destroy();
+ // Generic module cleanup — destroy previous mode's timers, SSE, etc.
+ const moduleDestroyMap = {
+ subghz: () => typeof SubGhz !== 'undefined' && SubGhz.destroy(),
+ morse: () => typeof MorseMode !== 'undefined' && MorseMode.destroy?.(),
+ spaceweather: () => typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy?.(),
+ weathersat: () => typeof WeatherSat !== 'undefined' && WeatherSat.suspend?.(),
+ 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?.(),
+ 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?.(),
+ };
+ if (previousMode && previousMode !== mode && moduleDestroyMap[previousMode]) {
+ try { moduleDestroyMap[previousMode](); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
}
currentMode = mode;
@@ -4301,25 +4316,7 @@
refreshTscmDevices();
}
- // Initialize/destroy Space Weather mode
- if (mode !== 'spaceweather') {
- if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
- }
-
- // Suspend Weather Satellite background timers/streams when leaving the mode
- if (mode !== 'weathersat') {
- if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend();
- }
-
- // Suspend WeFax background streams when leaving the mode
- if (mode !== 'wefax') {
- if (typeof WeFax !== 'undefined' && WeFax.destroy) WeFax.destroy();
- }
-
- // Disconnect System Health SSE when leaving the mode
- if (mode !== 'system') {
- if (typeof SystemHealth !== 'undefined' && SystemHealth.destroy) SystemHealth.destroy();
- }
+ // 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');
@@ -4460,10 +4457,7 @@
SystemHealth.init();
}
- // Destroy Waterfall WebSocket when leaving SDR receiver modes
- if (mode !== 'waterfall' && typeof Waterfall !== 'undefined' && Waterfall.destroy) {
- Promise.resolve(Waterfall.destroy()).catch(() => {});
- }
+ // Waterfall destroy is now handled by moduleDestroyMap above.
const totalMs = Math.round(performance.now() - switchStartMs);
console.info(
|