3 Commits

Author SHA1 Message Date
b140aedf00 chore(submodules): bump led-tool for settings editor
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:24 +12:00
15f8c8a039 fix(wifi): limit outbound driver WS to hello-triggered attempts
Remove periodic UDP hello loop; dial each driver at most
wifi_driver_initial_connect_attempts times per discovery hello.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:22 +12:00
70641c63af feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial
ports, and load the modal via iframe instead of the legacy form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:18 +12:00
7 changed files with 81 additions and 419 deletions

View File

@@ -3,20 +3,40 @@ import os
import subprocess import subprocess
import sys import sys
from microdot import Microdot from microdot import Microdot, send_file
from serial.tools import list_ports from serial.tools import list_ports
controller = Microdot() controller = Microdot()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str: def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def _led_tool_static_dir() -> str:
return os.path.join(_repo_root(), "led-tool", "static")
def _led_cli_path() -> str: def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py") return os.path.join(_repo_root(), "led-tool", "cli.py")
def _filter_host_serial_ports(ports: list) -> list:
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
if not os.path.isfile(mod_path):
return ports
import importlib.util
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.filter_port_dicts(ports)
def _build_led_cli_command(port: str, payload: dict): def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port] cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -92,17 +112,41 @@ def _extract_settings_from_stdout(stdout: str):
return None return None
@controller.get("/editor")
async def settings_editor_page(request):
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
if not os.path.isfile(path):
return (
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
404,
{"Content-Type": "application/json"},
)
return send_file(path)
@controller.get("/static/<path:filename>")
async def led_tool_static(request, filename):
if filename not in _STATIC_ALLOWED:
return "Not found", 404
path = os.path.join(_led_tool_static_dir(), filename)
if not os.path.isfile(path):
return "Not found", 404
return send_file(path)
@controller.get("/ports") @controller.get("/ports")
async def list_serial_ports(request): async def list_serial_ports(request):
ports = [] ports = _filter_host_serial_ports(
for info in list_ports.comports(): [
ports.append(
{ {
"device": info.device, "device": info.device,
"description": info.description, "description": info.description,
"hwid": info.hwid, "hwid": info.hwid,
} }
) for info in list_ports.comports()
]
)
return ( return (
json.dumps( json.dumps(
{ {

View File

@@ -100,11 +100,7 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
def _prime_wifi_outbound_driver_connections() -> None: def _prime_wifi_outbound_driver_connections() -> None:
""" """On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
n = 0 n = 0
try: try:
dev = Device() dev = Device()
@@ -143,69 +139,6 @@ def _ipv4_address(addr: str) -> str | None:
return s return s
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while not udp_holder.get("closing"):
slept = 0.0
while slept < interval and not udp_holder.get("closing"):
chunk = min(1.0, interval - slept)
await asyncio.sleep(chunk)
slept += chunk
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None: async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False) sock.setblocking(False)
@@ -573,10 +506,6 @@ async def main(port=80):
asyncio.create_task( asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp" _run_udp_discovery_server(udp_holder), name="udp"
), ),
asyncio.create_task(
_periodic_wifi_driver_hello_loop(settings, udp_holder),
name="hello",
),
] ]
await asyncio.gather(*server_tasks) await asyncio.gather(*server_tasks)
except asyncio.CancelledError: except asyncio.CancelledError:

View File

@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {} _connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {} _send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {} _tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None _settings = None
_tcp_status_broadcast = None _tcp_status_broadcast = None
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
if not key: if not key:
return return
_connections[key] = ws _connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks: if key not in _send_locks:
_send_locks[key] = asyncio.Lock() _send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True) _schedule_status_broadcast(key, True)
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
if stagger > 0: if stagger > 0:
await asyncio.sleep(stagger) await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
boot_attempts = 0
try: try:
while True: for attempt in range(1, max_boot_attempts + 1):
if not connected_once:
if boot_attempts >= max_boot_attempts:
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
)
break
boot_attempts += 1
try: try:
print(f"[WS] connecting to {uri!r}") print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect( async with websockets.connect(
uri, uri,
ping_interval=20, ping_interval=20,
ping_timeout=15, ping_timeout=15,
open_timeout=open_timeout, open_timeout=open_timeout,
) as ws: ) as ws:
connected_once = True
_register_ws(ip, ws) _register_ws(ip, ws)
try: try:
await _recv_forward_loop(ip, ws) await _recv_forward_loop(ip, ws)
finally: finally:
unregister_tcp_writer(ip, ws) unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except ConnectionClosed as e: except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}") print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
return
except Exception as e: except Exception as e:
if _benign_ws_connect_failure(e): if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1 print(
_unreachable_counts[ip] = n f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
if n == 1 or (n % 30) == 0: )
print(
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
)
else: else:
print(f"[WS] driver {ip} session error: {e!r}") print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__) traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
await asyncio.sleep(retry_interval_s) if attempt < max_boot_attempts:
await asyncio.sleep(retry_interval_s)
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
"waiting for next UDP hello"
)
except asyncio.CancelledError: except asyncio.CancelledError:
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
raise raise
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
def ensure_driver_connection(peer_ip: str) -> None: def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``.""" """Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
key = normalize_tcp_peer_ip(peer_ip) key = normalize_tcp_peer_ip(peer_ip)
if not key: if not key:
return return
if tcp_client_connected(key):
return
t = _tasks.get(key) t = _tasks.get(key)
if t is not None and not t.done(): if t is not None and not t.done():
return return
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False) _schedule_status_broadcast(ip, False)
_connections.clear() _connections.clear()
_send_locks.clear() _send_locks.clear()
_unreachable_counts.clear()

View File

@@ -57,12 +57,9 @@ class Settings(dict):
self['wifi_driver_ws_port'] = 80 self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self: if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws' self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is # Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self: if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0 self['wifi_driver_hello_interval_s'] = 0
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
if 'wifi_driver_connect_retry_window_s' not in self: if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0 self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once. # Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
@@ -74,7 +71,7 @@ class Settings(dict):
# Pause between outbound WebSocket dial attempts (seconds). # Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self: if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0 self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery. # Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
if 'wifi_driver_initial_connect_attempts' not in self: if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4 self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial). # UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).

View File

@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
const openBtn = document.getElementById('led-tool-btn'); const openBtn = document.getElementById('led-tool-btn');
const modal = document.getElementById('led-tool-modal'); const modal = document.getElementById('led-tool-modal');
const closeBtn = document.getElementById('led-tool-close-btn'); const closeBtn = document.getElementById('led-tool-close-btn');
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn'); const iframe = document.getElementById('led-tool-iframe');
const form = document.getElementById('led-tool-form');
const readBtn = document.getElementById('led-tool-read-btn');
const resetBtn = document.getElementById('led-tool-reset-btn');
const portSelect = document.getElementById('led-tool-port');
const outputEl = document.getElementById('led-tool-output');
const messageEl = document.getElementById('led-tool-message');
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) { if (!openBtn || !modal || !iframe) {
return; return;
} }
const showMessage = (text, type = 'success') => {
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
};
const setOutput = (text) => {
outputEl.value = text || '';
};
const parseApiResponse = async (response) => {
const bodyText = await response.text();
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
data = { error: bodyText || `HTTP ${response.status}` };
}
return data;
};
const setFieldValue = (id, value) => {
const el = document.getElementById(id);
if (!el) return;
if (value === undefined || value === null) return;
el.value = String(value);
};
const populateFormFromSettings = (settings) => {
if (!settings || typeof settings !== 'object') return false;
setFieldValue('led-tool-name', settings.name);
setFieldValue('led-tool-num-leds', settings.num_leds);
setFieldValue('led-tool-led-pin', settings.led_pin);
setFieldValue('led-tool-brightness', settings.brightness);
setFieldValue('led-tool-transport', settings.transport_type);
setFieldValue('led-tool-ssid', settings.ssid);
setFieldValue('led-tool-password', settings.password);
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
setFieldValue('led-tool-default', settings.default);
return true;
};
const loadPorts = async () => {
const defaultPort = '/dev/ttyACM0';
try {
const response = await fetch('/led-tool/ports');
const data = await response.json();
const previous = portSelect.value;
portSelect.innerHTML = '<option value="">Select a serial port</option>';
for (const port of data.ports || []) {
const option = document.createElement('option');
option.value = port.device;
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
portSelect.appendChild(option);
}
if (previous) {
portSelect.value = previous;
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
portSelect.value = defaultPort;
} else {
const fallback = document.createElement('option');
fallback.value = defaultPort;
fallback.textContent = `${defaultPort} - default`;
portSelect.appendChild(fallback);
portSelect.value = defaultPort;
}
if (!data.led_cli_exists) {
showMessage('led-tool/cli.py was not found on the host.', 'error');
} else if ((data.ports || []).length === 0) {
showMessage('No serial ports found.', 'error');
} else {
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
}
} catch (error) {
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
}
};
openBtn.addEventListener('click', () => { openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active'); modal.classList.add('active');
loadPorts();
}); });
if (closeBtn) { if (closeBtn) {
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
modal.classList.remove('active'); modal.classList.remove('active');
iframe.src = 'about:blank';
}); });
} }
if (refreshPortsBtn) {
refreshPortsBtn.addEventListener('click', () => {
loadPorts();
});
}
if (readBtn) {
readBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Reading settings from device...');
showMessage('Reading settings over USB...', 'success');
try {
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Read failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
const populated = populateFormFromSettings(data.settings);
if (populated) {
showMessage('Settings read and fields populated.', 'success');
} else {
showMessage('Settings read successfully.', 'success');
}
} else {
showMessage('Read completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
if (resetBtn) {
resetBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Resetting device and following output...');
showMessage('Resetting device over USB...', 'success');
try {
const response = await fetch('/led-tool/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Reset failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Device reset complete.', 'success');
} else {
showMessage('Reset completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
const payload = {
port,
name: document.getElementById('led-tool-name')?.value?.trim() || '',
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
password: document.getElementById('led-tool-password')?.value?.trim() || '',
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
default: document.getElementById('led-tool-default')?.value?.trim() || '',
};
setOutput('Running led-tool command...');
showMessage('Running command over USB...', 'success');
try {
const response = await fetch('/led-tool/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Command failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Settings applied via USB.', 'success');
} else {
showMessage('Command completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}); });

View File

@@ -765,79 +765,14 @@
</div> </div>
</div> </div>
<!-- LED Tool Modal --> <!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal"> <div id="led-tool-modal" class="modal">
<div class="modal-content"> <div class="modal-content" style="max-width: 960px; width: 95vw;">
<h2>LED Tool (USB)</h2> <div class="modal-actions" style="margin-bottom: 0.5rem;">
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p> <h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div> <button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
<form id="led-tool-form"> </div>
<div class="form-group"> <iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
<label for="led-tool-port">Serial port</label>
<div class="profiles-actions" style="gap: 0.5rem;">
<select id="led-tool-port" required style="flex:1;">
<option value="">Select a serial port</option>
</select>
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
</div>
</div>
<div class="form-group">
<label for="led-tool-name">Name</label>
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-num-leds">Num LEDs</label>
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
</div>
<div class="preset-editor-field">
<label for="led-tool-led-pin">LED pin</label>
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-brightness">Brightness</label>
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
</div>
<div class="preset-editor-field">
<label for="led-tool-wifi-channel">WiFi channel</label>
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-transport">Transport</label>
<select id="led-tool-transport">
<option value="">(no change)</option>
<option value="espnow">espnow</option>
<option value="wifi">wifi</option>
</select>
</div>
<div class="preset-editor-field">
<label for="led-tool-default">Default preset</label>
<input type="text" id="led-tool-default" placeholder="on">
</div>
</div>
<div class="form-group">
<label for="led-tool-ssid">SSID</label>
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
</div>
<div class="form-group">
<label for="led-tool-password">WiFi password</label>
<input type="password" id="led-tool-password" placeholder="WiFi password">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
<button type="submit" class="btn btn-primary">Apply via USB</button>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
</form>
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div> </div>
</div> </div>