feat(drone): replace freeform inputs with populated device selects

Add /drone/devices endpoint that enumerates available WiFi interfaces
(via iw/iwconfig) and RTL-SDR devices (via SDRFactory.detect_devices),
matching the pattern used by TSCM.

Sidebar WiFi interface and RTL-SDR inputs are now <select> elements
populated on init() from /drone/devices, consistent with how other
modes expose hardware selection. HackRF checkbox remains as a toggle
since it's a binary capability rather than an enumerated device list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
James Smith
2026-05-13 09:25:21 +01:00
parent 6523686aca
commit 4ba8a40af9
3 changed files with 140 additions and 5 deletions
+77
View File
@@ -3,7 +3,9 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import platform
import queue import queue
import subprocess
import threading import threading
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, request
@@ -56,6 +58,81 @@ def _ensure_workers() -> None:
_relay_thread.start() _relay_thread.start()
@drone_bp.route("/devices")
def devices():
"""Return available WiFi interfaces and SDR devices for drone detection."""
result: dict = {"wifi_interfaces": [], "sdr_devices": []}
# WiFi interfaces via iw/iwconfig
if platform.system() == "Darwin":
try:
out = subprocess.run(
["networksetup", "-listallhardwareports"],
capture_output=True,
text=True,
timeout=5,
).stdout
lines = out.split("\n")
for i, line in enumerate(lines):
if "Wi-Fi" in line or "AirPort" in line:
port = line.replace("Hardware Port:", "").strip()
for j in range(i + 1, min(i + 3, len(lines))):
if "Device:" in lines[j]:
dev = lines[j].split("Device:")[1].strip()
result["wifi_interfaces"].append(
{"name": dev, "display_name": f"{port} ({dev})", "type": "internal"}
)
break
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
else:
try:
out = subprocess.run(["iw", "dev"], capture_output=True, text=True, timeout=5).stdout
current: str | None = None
for line in out.split("\n"):
line = line.strip()
if line.startswith("Interface"):
current = line.split()[1]
elif current and "type" in line:
iface_type = line.split()[-1]
result["wifi_interfaces"].append(
{
"name": current,
"display_name": f"{current} ({iface_type})",
"type": iface_type,
}
)
current = None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
try:
out = subprocess.run(["iwconfig"], capture_output=True, text=True, timeout=5).stdout
for line in out.split("\n"):
if "IEEE 802.11" in line:
iface = line.split()[0]
result["wifi_interfaces"].append(
{"name": iface, "display_name": f"{iface} (managed)", "type": "managed"}
)
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
pass
# SDR devices
try:
from utils.sdr import SDRFactory
for sdr in SDRFactory.detect_devices():
sdr_type = sdr.sdr_type.value if hasattr(sdr.sdr_type, "value") else str(sdr.sdr_type)
display = sdr.name
if sdr.serial and sdr.serial not in ("N/A", "Unknown"):
display = f"{sdr.name} (SN: {sdr.serial[-8:]})"
result["sdr_devices"].append(
{"index": sdr.index, "name": sdr.name, "display_name": display, "type": sdr_type}
)
except Exception:
pass
return jsonify({"status": "ok", "devices": result})
@drone_bp.route("/status") @drone_bp.route("/status")
def status(): def status():
vectors = [] vectors = []
+56 -2
View File
@@ -10,11 +10,63 @@
function init() { function init() {
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() { function _initMap() {
if (_map) return; if (_map) return;
const mapEl = document.getElementById('droneMainMap'); const mapEl = document.getElementById('droneMainMap');
@@ -163,8 +215,10 @@
} }
function _start() { function _start() {
const iface = document.getElementById('droneWifiIface')?.value.trim() || null; const ifaceVal = document.getElementById('droneWifiIface')?.value || '';
const rtlIndex = parseInt(document.getElementById('droneRtlIndex')?.value, 10) || 0; const iface = ifaceVal || null;
const rtlVal = document.getElementById('droneRtlIndex')?.value;
const rtlIndex = rtlVal !== '' && rtlVal != null ? parseInt(rtlVal, 10) : 0;
const useHackrf = document.getElementById('droneUseHackrf')?.checked ?? true; const useHackrf = document.getElementById('droneUseHackrf')?.checked ?? true;
fetch('/drone/start', { fetch('/drone/start', {
method: 'POST', method: 'POST',
+7 -3
View File
@@ -20,15 +20,19 @@
<h3>WiFi Interface</h3> <h3>WiFi Interface</h3>
<div class="form-group"> <div class="form-group">
<label for="droneWifiIface">Interface (monitor mode)</label> <label for="droneWifiIface">Interface (monitor mode)</label>
<input type="text" id="droneWifiIface" placeholder="e.g. wlan0mon"> <select id="droneWifiIface">
<option value="">Loading interfaces…</option>
</select>
</div> </div>
</div> </div>
<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 Index (433 MHz)</label> <label for="droneRtlIndex">RTL-SDR Device (433 MHz)</label>
<input type="number" id="droneRtlIndex" value="0" min="0" max="7" placeholder="Device index"> <select id="droneRtlIndex">
<option value="">Loading devices…</option>
</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="inline-checkbox"> <label class="inline-checkbox">