diff --git a/static/js/modes/drone.js b/static/js/modes/drone.js
new file mode 100644
index 0000000..55af9d8
--- /dev/null
+++ b/static/js/modes/drone.js
@@ -0,0 +1,189 @@
+(function DroneMode() {
+ 'use strict';
+
+ let _sse = null;
+ let _map = null;
+ let _markers = {};
+ let _trails = {};
+ let _running = false;
+
+ function init() {
+ document.getElementById('droneStartBtn')?.addEventListener('click', _start);
+ document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
+ _connectSSE();
+ _refreshStatus();
+ }
+
+ function destroy() {
+ _disconnectSSE();
+ if (_map) {
+ _map.remove();
+ _map = null;
+ }
+ _markers = {};
+ _trails = {};
+ }
+
+ function _connectSSE() {
+ if (_sse) return;
+ _sse = new EventSource('/drone/stream');
+ _sse.addEventListener('message', function (e) {
+ try {
+ const msg = JSON.parse(e.data);
+ if (msg.type === 'contact') _handleContact(msg.data);
+ } catch (_) {}
+ });
+ _sse.onerror = function () {
+ setTimeout(_connectSSE, 3000);
+ };
+ }
+
+ function _disconnectSSE() {
+ if (_sse) { _sse.close(); _sse = null; }
+ }
+
+ function _handleContact(contact) {
+ _upsertCard(contact);
+ if (contact.position) _upsertMapMarker(contact);
+ _updateStats();
+ }
+
+ function _upsertCard(contact) {
+ const listEl = document.getElementById('droneContactList');
+ if (!listEl) return;
+ let card = document.getElementById('drone-card-' + contact.id);
+ if (!card) {
+ card = document.createElement('div');
+ card.id = 'drone-card-' + contact.id;
+ card.className = 'drone-contact-card';
+ card.addEventListener('click', function () { _focusContact(contact.id); });
+ listEl.prepend(card);
+ }
+ card.className = 'drone-contact-card ' + contact.risk_level + '-risk';
+ const complianceLabel = contact.compliant
+ ? 'Remote ID'
+ : 'No Remote ID';
+ const vectors = (contact.detection_vectors || []).map(function (v) {
+ return '' + v + '';
+ }).join('');
+ const alt = contact.altitude_m != null ? contact.altitude_m.toFixed(0) + 'm' : '—';
+ const spd = contact.speed_ms != null ? contact.speed_ms.toFixed(1) + 'm/s' : '—';
+ card.innerHTML = [
+ '
',
+ ' ' + (contact.serial_number || contact.id) + '',
+ ' ' + complianceLabel,
+ '
',
+ '' + vectors + '
',
+ 'Alt: ' + alt + ' Speed: ' + spd + '
',
+ ].join('');
+ }
+
+ function _upsertMapMarker(contact) {
+ if (!_map) return;
+ const lat = contact.position[0];
+ const lon = contact.position[1];
+ if (_markers[contact.id]) {
+ _markers[contact.id].setLatLng([lat, lon]);
+ } else {
+ const color = contact.risk_level === 'high' ? 'var(--accent-red)' :
+ contact.risk_level === 'medium' ? 'var(--accent-yellow)' :
+ 'var(--accent-cyan)';
+ const icon = L.divIcon({
+ className: 'drone-map-icon' + (contact.risk_level === 'high' ? ' drone-marker-high-risk' : ''),
+ html: '',
+ iconSize: [10, 10],
+ iconAnchor: [5, 5],
+ });
+ _markers[contact.id] = L.marker([lat, lon], { icon: icon })
+ .addTo(_map)
+ .bindPopup('' + (contact.serial_number || contact.id) + '
Risk: ' + contact.risk_level);
+ }
+ const trailPoints = (contact.position_history || []).map(function (p) {
+ return [p.lat, p.lon];
+ });
+ if (_trails[contact.id]) {
+ _trails[contact.id].setLatLngs(trailPoints);
+ } else if (trailPoints.length > 1) {
+ _trails[contact.id] = L.polyline(trailPoints, {
+ color: contact.risk_level === 'high' ? '#ff4444' : '#00ccff',
+ weight: 1.5,
+ opacity: 0.6,
+ }).addTo(_map);
+ }
+ }
+
+ function _focusContact(contactId) {
+ if (_map && _markers[contactId]) {
+ _map.panTo(_markers[contactId].getLatLng());
+ _markers[contactId].openPopup();
+ }
+ }
+
+ function _updateStats() {
+ fetch('/drone/contacts')
+ .then(function (r) { return r.json(); })
+ .then(function (contacts) {
+ const nonCompliant = contacts.filter(function (c) { return !c.compliant; }).length;
+ const countEl = document.getElementById('droneContactCount');
+ const ncEl = document.getElementById('droneNonCompliantCount');
+ if (countEl) countEl.textContent = contacts.length;
+ if (ncEl) ncEl.textContent = nonCompliant;
+ })
+ .catch(function () {});
+ }
+
+ function _refreshStatus() {
+ fetch('/drone/status')
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ _running = data.running;
+ _setRunningUI(data.running);
+ _updateVectorPills(data.vectors || []);
+ })
+ .catch(function () {});
+ }
+
+ function _start() {
+ const iface = document.getElementById('droneWifiIface')?.value.trim() || null;
+ fetch('/drone/start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ wifi_iface: iface }),
+ })
+ .then(function (r) { return r.json(); })
+ .then(function () { _setRunningUI(true); _refreshStatus(); })
+ .catch(function () {});
+ }
+
+ function _stop() {
+ fetch('/drone/stop', { method: 'POST' })
+ .then(function () { _setRunningUI(false); _refreshStatus(); })
+ .catch(function () {});
+ }
+
+ function _setRunningUI(running) {
+ const startBtn = document.getElementById('droneStartBtn');
+ const stopBtn = document.getElementById('droneStopBtn');
+ const statusEl = document.getElementById('droneStatusText');
+ if (startBtn) startBtn.disabled = running;
+ if (stopBtn) stopBtn.disabled = !running;
+ if (statusEl) {
+ statusEl.textContent = running ? 'Active' : 'Standby';
+ statusEl.style.color = running ? 'var(--accent-green)' : 'var(--accent-yellow)';
+ }
+ }
+
+ function _updateVectorPills(activeVectors) {
+ const pillMap = {
+ 'REMOTE_ID': 'dronePillRemoteId',
+ 'RTL433': 'dronePill433',
+ 'HACKRF': 'dronePillHackrf',
+ };
+ Object.entries(pillMap).forEach(function ([key, id]) {
+ const el = document.getElementById(id);
+ if (el) el.classList.toggle('active', activeVectors.some(function (v) { return v.includes(key); }));
+ });
+ }
+
+ window.DroneMode = { init: init, destroy: destroy };
+})();
diff --git a/templates/index.html b/templates/index.html
index 0caf6f3..afad27b 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -187,7 +187,8 @@
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"
+ waterfall: "{{ url_for('static', filename='js/modes/waterfall.js') }}?v={{ version }}&r=wfdeck21",
+ drone: "{{ url_for('static', filename='js/modes/drone.js') }}"
};
window.INTERCEPT_MODE_SCRIPT_LOADED = {};
window.INTERCEPT_MODE_SCRIPT_PROMISES = {};
@@ -3692,6 +3693,7 @@
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' },
+ drone: { label: 'Drone Intel', indicator: 'DRONE', outputTitle: 'Drone Intelligence', 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' },
@@ -4318,6 +4320,7 @@
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
+ drone: () => typeof DroneMode !== 'undefined' && DroneMode.destroy?.(),
};
return moduleDestroyMap[mode] || null;
}
@@ -4927,6 +4930,8 @@
SystemHealth.init();
} else if (mode === 'ook') {
OokMode.init();
+ } else if (mode === 'drone') {
+ if (typeof DroneMode !== 'undefined') DroneMode.init();
}
if (requestId !== modeSwitchRequestId) return;