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
import logging
import platform
import queue
import subprocess
import threading
from flask import Blueprint, Response, jsonify, request
@@ -56,6 +58,81 @@ def _ensure_workers() -> None:
_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")
def status():
vectors = []
+56 -2
View File
@@ -10,11 +10,63 @@
function init() {
document.getElementById('droneStartBtn')?.addEventListener('click', _start);
document.getElementById('droneStopBtn')?.addEventListener('click', _stop);
_refreshDevices();
_initMap();
_connectSSE();
_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');
@@ -163,8 +215,10 @@
}
function _start() {
const iface = document.getElementById('droneWifiIface')?.value.trim() || null;
const rtlIndex = parseInt(document.getElementById('droneRtlIndex')?.value, 10) || 0;
const ifaceVal = document.getElementById('droneWifiIface')?.value || '';
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;
fetch('/drone/start', {
method: 'POST',
+7 -3
View File
@@ -20,15 +20,19 @@
<h3>WiFi Interface</h3>
<div class="form-group">
<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 class="section">
<h3>SDR Settings</h3>
<div class="form-group">
<label for="droneRtlIndex">RTL-SDR Device Index (433 MHz)</label>
<input type="number" id="droneRtlIndex" value="0" min="0" max="7" placeholder="Device index">
<label for="droneRtlIndex">RTL-SDR Device (433 MHz)</label>
<select id="droneRtlIndex">
<option value="">Loading devices…</option>
</select>
</div>
<div class="form-group">
<label class="inline-checkbox">