fix(drone): conform to established SPA patterns throughout

- Device population: move refreshDroneDevices() inline to index.html
  (same pattern as refreshTscmDevices) and call it from switchMode
  alongside DroneMode.init(); remove _refreshDevices/populateSelect
  from drone.js which was never guaranteed to run before lazy-load
  completed, causing selects to stay on "Loading…" permanently

- IIFE pattern: change from named IIFE + window.DroneMode assignment
  to var DroneMode = (function(){...return{...}})() matching OOK/
  SpyStations convention

- Init guard: add _initialized flag (OOK state.initialized pattern);
  re-entry after destroy() re-registers map/SSE cleanly without
  duplicating click listeners on every mode switch

- Lifecycle: destroy() resets _initialized = false so map and SSE
  are correctly rebuilt on re-entry

- Stop phase: add isDroneRunning tracking variable in index.html;
  _setRunningUI() syncs it; switchMode stop phase now POSTs
  /drone/stop when leaving drone mode while active, matching TSCM

- /drone/devices: add monitor_capable field to WiFi interfaces,
  add running_as_root and warnings array to response (mirrors
  /tscm/devices shape); add os import; show privilege warning div
  in drone.html when not running as root

- drone.html: remove for= attribute from SDR label (plain <label>
  inside .form-group matches TSCM convention); add droneDeviceWarnings
  div for privilege warnings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-05-13 09:33:38 +01:00
parent 4ba8a40af9
commit 410225d54d
5 changed files with 163 additions and 109 deletions
+32 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import platform import platform
import queue import queue
import subprocess import subprocess
@@ -80,7 +81,12 @@ def devices():
if "Device:" in lines[j]: if "Device:" in lines[j]:
dev = lines[j].split("Device:")[1].strip() dev = lines[j].split("Device:")[1].strip()
result["wifi_interfaces"].append( result["wifi_interfaces"].append(
{"name": dev, "display_name": f"{port} ({dev})", "type": "internal"} {
"name": dev,
"display_name": f"{port} ({dev})",
"type": "internal",
"monitor_capable": False,
}
) )
break break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
@@ -100,6 +106,7 @@ def devices():
"name": current, "name": current,
"display_name": f"{current} ({iface_type})", "display_name": f"{current} ({iface_type})",
"type": iface_type, "type": iface_type,
"monitor_capable": True,
} }
) )
current = None current = None
@@ -110,7 +117,12 @@ def devices():
if "IEEE 802.11" in line: if "IEEE 802.11" in line:
iface = line.split()[0] iface = line.split()[0]
result["wifi_interfaces"].append( result["wifi_interfaces"].append(
{"name": iface, "display_name": f"{iface} (managed)", "type": "managed"} {
"name": iface,
"display_name": f"{iface} (managed)",
"type": "managed",
"monitor_capable": True,
}
) )
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError): except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass pass
@@ -130,7 +142,24 @@ def devices():
except Exception: except Exception:
pass pass
return jsonify({"status": "ok", "devices": result}) running_as_root = os.geteuid() == 0
warnings = []
if not running_as_root:
warnings.append(
{
"type": "privileges",
"message": "Not running as root — WiFi monitor mode may be unavailable.",
}
)
return jsonify(
{
"status": "ok",
"devices": result,
"running_as_root": running_as_root,
"warnings": warnings,
}
)
@drone_bp.route("/status") @drone_bp.route("/status")
+2
View File
@@ -8,6 +8,8 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--bg-primary); background: var(--bg-primary);
padding: 0;
box-sizing: border-box;
} }
.drone-visuals-header { .drone-visuals-header {
+59 -104
View File
@@ -1,83 +1,25 @@
(function DroneMode() { var DroneMode = (function () {
'use strict'; 'use strict';
let _sse = null; var _sse = null;
let _map = null; var _map = null;
let _markers = {}; var _markers = {};
let _trails = {}; var _trails = {};
let _running = false; var _initialized = false;
function init() { function init() {
if (_initialized) {
_refreshStatus();
return;
}
_initialized = true;
document.getElementById('droneStartBtn')?.addEventListener('click', _start); document.getElementById('droneStartBtn')?.addEventListener('click', _start);
document.getElementById('droneStopBtn')?.addEventListener('click', _stop); document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
_refreshDevices();
_initMap(); _initMap();
_connectSSE(); _connectSSE();
_refreshStatus(); _refreshStatus();
} }
function _refreshDevices() {
fetch('/drone/devices')
.then(function (r) { return r.json(); })
.then(function (data) {
const devs = data.devices || {};
_populateSelect(
'droneWifiIface',
devs.wifi_interfaces || [],
function (i) { return i.name; },
function (i) { return i.display_name || i.name; },
'No WiFi interfaces found'
);
_populateSelect(
'droneRtlIndex',
devs.sdr_devices || [],
function (d) { return d.index; },
function (d) { return d.display_name || d.name; },
'No SDR devices found'
);
})
.catch(function () {
_setSelectError('droneWifiIface', 'Failed to load interfaces');
_setSelectError('droneRtlIndex', 'Failed to load devices');
});
}
function _populateSelect(id, items, valFn, labelFn, emptyMsg) {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '';
if (!items.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = emptyMsg;
sel.appendChild(opt);
return;
}
items.forEach(function (item) {
const opt = document.createElement('option');
opt.value = valFn(item);
opt.textContent = labelFn(item);
sel.appendChild(opt);
});
}
function _setSelectError(id, msg) {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = '<option value="">' + msg + '</option>';
}
function _initMap() {
if (_map) return;
const mapEl = document.getElementById('droneMainMap');
if (!mapEl || typeof L === 'undefined') return;
_map = L.map('droneMainMap', { zoomControl: true }).setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
}
function destroy() { function destroy() {
_disconnectSSE(); _disconnectSSE();
if (_map) { if (_map) {
@@ -86,6 +28,22 @@
} }
_markers = {}; _markers = {};
_trails = {}; _trails = {};
_initialized = false;
}
function invalidateMap() {
if (_map) _map.invalidateSize();
}
function _initMap() {
if (_map) return;
var mapEl = document.getElementById('droneMainMap');
if (!mapEl || typeof L === 'undefined') return;
_map = L.map('droneMainMap', { zoomControl: true }).setView([20, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
} }
function _connectSSE() { function _connectSSE() {
@@ -93,7 +51,7 @@
_sse = new EventSource('/drone/stream'); _sse = new EventSource('/drone/stream');
_sse.addEventListener('message', function (e) { _sse.addEventListener('message', function (e) {
try { try {
const msg = JSON.parse(e.data); var msg = JSON.parse(e.data);
if (msg.type === 'contact') _handleContact(msg.data); if (msg.type === 'contact') _handleContact(msg.data);
} catch (_) {} } catch (_) {}
}); });
@@ -115,11 +73,11 @@
} }
function _upsertCard(contact) { function _upsertCard(contact) {
const listEl = document.getElementById('droneContactList'); var listEl = document.getElementById('droneContactList');
const emptyEl = document.getElementById('droneContactEmpty'); var emptyEl = document.getElementById('droneContactEmpty');
if (!listEl) return; if (!listEl) return;
if (emptyEl) emptyEl.style.display = 'none'; if (emptyEl) emptyEl.style.display = 'none';
let card = document.getElementById('drone-card-' + contact.id); var card = document.getElementById('drone-card-' + contact.id);
if (!card) { if (!card) {
card = document.createElement('div'); card = document.createElement('div');
card.id = 'drone-card-' + contact.id; card.id = 'drone-card-' + contact.id;
@@ -128,14 +86,14 @@
listEl.prepend(card); listEl.prepend(card);
} }
card.className = 'drone-contact-card ' + contact.risk_level + '-risk'; card.className = 'drone-contact-card ' + contact.risk_level + '-risk';
const complianceLabel = contact.compliant var complianceLabel = contact.compliant
? '<span class="drone-compliance-badge compliant">Remote ID</span>' ? '<span class="drone-compliance-badge compliant">Remote ID</span>'
: '<span class="drone-compliance-badge non-compliant">No Remote ID</span>'; : '<span class="drone-compliance-badge non-compliant">No Remote ID</span>';
const vectors = (contact.detection_vectors || []).map(function (v) { var vectors = (contact.detection_vectors || []).map(function (v) {
return '<span class="drone-vector-pill active">' + v + '</span>'; return '<span class="drone-vector-pill active">' + v + '</span>';
}).join(''); }).join('');
const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + ' m' : '—'; var alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + ' m' : '—';
const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + ' m/s' : '—'; var spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + ' m/s' : '—';
card.innerHTML = [ card.innerHTML = [
'<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">', '<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">',
' <span style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan);">' + (contact.serial_number || contact.id) + '</span>', ' <span style="font-family:var(--font-mono); font-size:11px; color:var(--accent-cyan);">' + (contact.serial_number || contact.id) + '</span>',
@@ -148,15 +106,15 @@
function _upsertMapMarker(contact) { function _upsertMapMarker(contact) {
if (!_map) return; if (!_map) return;
const lat = contact.position[0]; var lat = contact.position[0];
const lon = contact.position[1]; var lon = contact.position[1];
if (_markers[contact.id]) { if (_markers[contact.id]) {
_markers[contact.id].setLatLng([lat, lon]); _markers[contact.id].setLatLng([lat, lon]);
} else { } else {
const color = contact.risk_level === 'high' ? 'var(--accent-red)' : var color = contact.risk_level === 'high' ? 'var(--accent-red)' :
contact.risk_level === 'medium' ? 'var(--accent-yellow)' : contact.risk_level === 'medium' ? 'var(--accent-yellow)' :
'var(--accent-cyan)'; 'var(--accent-cyan)';
const icon = L.divIcon({ var icon = L.divIcon({
className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''), className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''),
html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid #fff;"></div>', html: '<div style="width:10px;height:10px;border-radius:50%;background:' + color + ';border:2px solid #fff;"></div>',
iconSize: [10, 10], iconSize: [10, 10],
@@ -166,7 +124,7 @@
.addTo(_map) .addTo(_map)
.bindPopup('<b>' + (contact.serial_number || contact.id) + '</b><br>Risk: ' + contact.risk_level); .bindPopup('<b>' + (contact.serial_number || contact.id) + '</b><br>Risk: ' + contact.risk_level);
} }
const trailPoints = (contact.position_history || []).map(function (p) { var trailPoints = (contact.position_history || []).map(function (p) {
return [p.lat, p.lon]; return [p.lat, p.lon];
}); });
if (_trails[contact.id]) { if (_trails[contact.id]) {
@@ -191,9 +149,9 @@
fetch('/drone/contacts') fetch('/drone/contacts')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (contacts) { .then(function (contacts) {
const nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length; var nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
const highRisk = contacts.filter(function (c) { return c.risk_level === 'high'; }).length; var highRisk = contacts.filter(function (c) { return c.risk_level === 'high'; }).length;
const set = function (id, val) { const el = document.getElementById(id); if (el) el.textContent = val; }; var set = function (id, val) { var el = document.getElementById(id); if (el) el.textContent = val; };
set('droneContactCount', contacts.length); set('droneContactCount', contacts.length);
set('droneNonCompliantCount', nonCompliant); set('droneNonCompliantCount', nonCompliant);
set('droneVsContacts', contacts.length); set('droneVsContacts', contacts.length);
@@ -207,7 +165,6 @@
fetch('/drone/status') fetch('/drone/status')
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
_running = data.running;
_setRunningUI(data.running); _setRunningUI(data.running);
_updateVectorPills(data.vectors || []); _updateVectorPills(data.vectors || []);
}) })
@@ -215,11 +172,11 @@
} }
function _start() { function _start() {
const ifaceVal = document.getElementById('droneWifiIface')?.value || ''; var ifaceVal = document.getElementById('droneWifiIface')?.value || '';
const iface = ifaceVal || null; var iface = ifaceVal || null;
const rtlVal = document.getElementById('droneRtlIndex')?.value; var rtlVal = document.getElementById('droneRtlIndex')?.value;
const rtlIndex = rtlVal !== '' && rtlVal != null ? parseInt(rtlVal, 10) : 0; var rtlIndex = rtlVal !== '' && rtlVal != null ? parseInt(rtlVal, 10) : 0;
const useHackrf = document.getElementById('droneUseHackrf')?.checked ?? true; var useHackrf = document.getElementById('droneUseHackrf')?.checked ?? true;
fetch('/drone/start', { fetch('/drone/start', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -237,32 +194,30 @@
} }
function _setRunningUI(running) { function _setRunningUI(running) {
const startBtn = document.getElementById('droneStartBtn'); var startBtn = document.getElementById('droneStartBtn');
const stopBtn = document.getElementById('droneStopBtn'); var stopBtn = document.getElementById('droneStopBtn');
const statusEl = document.getElementById('droneStatusText'); var statusEl = document.getElementById('droneStatusText');
if (startBtn) startBtn.disabled = running; if (startBtn) startBtn.disabled = running;
if (stopBtn) stopBtn.disabled = !running; if (stopBtn) stopBtn.disabled = !running;
if (statusEl) { if (statusEl) {
statusEl.textContent = running ? 'Active' : 'Standby'; statusEl.textContent = running ? 'Active' : 'Standby';
statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)'; statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)';
} }
// Sync global state for switchMode stop phase
if (typeof isDroneRunning !== 'undefined') isDroneRunning = running;
} }
function _updateVectorPills(activeVectors) { function _updateVectorPills(activeVectors) {
const pillMap = { var pillMap = {
'REMOTE_ID': 'dronePillRemoteId', 'REMOTE_ID': 'dronePillRemoteId',
'RTL433': 'dronePill433', 'RTL433': 'dronePill433',
'HACKRF': 'dronePillHackrf', 'HACKRF': 'dronePillHackrf',
}; };
Object.entries(pillMap).forEach(function ([key, id]) { Object.keys(pillMap).forEach(function (key) {
const el = document.getElementById(id); var el = document.getElementById(pillMap[key]);
if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); })); if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); }));
}); });
} }
function invalidateMap() { return { init: init, destroy: destroy, invalidateMap: invalidateMap };
if (_map) _map.invalidateSize();
}
window.DroneMode = { init: init, destroy: destroy, invalidateMap: invalidateMap };
})(); })();
+66
View File
@@ -4694,6 +4694,9 @@
if (isTscmRunning) { if (isTscmRunning) {
stopTasks.push(awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS)); stopTasks.push(awaitStopAction('tscm', () => stopTscmSweep(), LOCAL_STOP_TIMEOUT_MS));
} }
if (isDroneRunning) {
stopTasks.push(awaitStopAction('drone', () => fetch('/drone/stop', { method: 'POST' }), LOCAL_STOP_TIMEOUT_MS));
}
if (stopTasks.length) { if (stopTasks.length) {
await Promise.allSettled(stopTasks); await Promise.allSettled(stopTasks);
@@ -4919,6 +4922,11 @@
refreshTscmDevices(); refreshTscmDevices();
} }
// Initialize Drone mode when selected
if (mode === 'drone') {
refreshDroneDevices();
}
// Module destroy is now handled by moduleDestroyMap above. // Module destroy is now handled by moduleDestroyMap above.
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
@@ -12056,6 +12064,64 @@
// Scanner and receiver logic are handled by Waterfall mode. // Scanner and receiver logic are handled by Waterfall mode.
// ============================================
// Drone Intelligence Functions
// ============================================
var isDroneRunning = false;
async function refreshDroneDevices() {
try {
const resp = await fetch('/drone/devices');
const data = await resp.json();
const devs = data.devices || {};
const wifiSel = document.getElementById('droneWifiIface');
if (wifiSel) {
wifiSel.innerHTML = '';
if (devs.wifi_interfaces && devs.wifi_interfaces.length > 0) {
devs.wifi_interfaces.forEach(iface => {
const opt = document.createElement('option');
opt.value = iface.name;
opt.textContent = iface.display_name || iface.name;
wifiSel.appendChild(opt);
});
} else {
wifiSel.innerHTML = '<option value="">No WiFi interfaces found</option>';
}
}
const sdrSel = document.getElementById('droneRtlIndex');
if (sdrSel) {
sdrSel.innerHTML = '';
if (devs.sdr_devices && devs.sdr_devices.length > 0) {
devs.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';
sdrSel.appendChild(opt);
});
} else {
sdrSel.innerHTML = '<option value="">No SDR devices found</option>';
}
}
const warnEl = document.getElementById('droneDeviceWarnings');
if (warnEl) {
if (!data.running_as_root) {
warnEl.textContent = 'Not running as root — WiFi monitor mode may be unavailable.';
warnEl.style.display = 'block';
} else {
warnEl.style.display = 'none';
}
}
} catch (e) {
const wifiSel = document.getElementById('droneWifiIface');
if (wifiSel) wifiSel.innerHTML = '<option value="">Failed to load interfaces</option>';
const sdrSel = document.getElementById('droneRtlIndex');
if (sdrSel) sdrSel.innerHTML = '<option value="">Failed to load devices</option>';
}
}
// ============================================ // ============================================
// TSCM (Counter-Surveillance) Functions // TSCM (Counter-Surveillance) Functions
// ============================================ // ============================================
+4 -2
View File
@@ -19,7 +19,7 @@
<div class="section"> <div class="section">
<h3>WiFi Interface</h3> <h3>WiFi Interface</h3>
<div class="form-group"> <div class="form-group">
<label for="droneWifiIface">Interface (monitor mode)</label> <label>Interface (monitor mode)</label>
<select id="droneWifiIface"> <select id="droneWifiIface">
<option value="">Loading interfaces…</option> <option value="">Loading interfaces…</option>
</select> </select>
@@ -29,7 +29,7 @@
<div class="section"> <div class="section">
<h3>SDR Settings</h3> <h3>SDR Settings</h3>
<div class="form-group"> <div class="form-group">
<label for="droneRtlIndex">RTL-SDR Device (433 MHz)</label> <label>RTL-SDR Device (433 MHz)</label>
<select id="droneRtlIndex"> <select id="droneRtlIndex">
<option value="">Loading devices…</option> <option value="">Loading devices…</option>
</select> </select>
@@ -42,6 +42,8 @@
</div> </div>
</div> </div>
<div id="droneDeviceWarnings" class="info-text" style="display:none; color:var(--accent-yellow); font-size:10px; padding: 0 4px;"></div>
<div class="section"> <div class="section">
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
<button id="droneStartBtn" class="run-btn" style="flex:1;">Start</button> <button id="droneStartBtn" class="run-btn" style="flex:1;">Start</button>