fix(devices): SP110E vendor handshake + Windows/bleak robustness
Build Android APK / build-android (push) Failing after 1m38s
Lint & Test / test (push) Successful in 4m32s

SP110E peripherals silently tear down the GATT link ~1s after connect
unless a two-write vendor handshake (01 00 → FFE2, 01 B7 E3 D5 → FFE1)
arrives immediately. Without it the first real write hangs 30s then
reconnect-loops forever. Adds optional BLEProtocol.init_writes executed
on connect, plumbs a per-write char_uuid through both transports, and
fixes the SP110E color/power frames from an incorrect 5 bytes to the
documented 4 bytes.

Windows/WinRT robustness:
- asyncio.wait_for hangs on bleak because WinRT IAsyncOperations refuse
  to cancel. _bounded_await() uses asyncio.wait() instead so timeouts
  actually return control even when the inner task is uncancellable.
- BleakClient connect by raw MAC string times out when WinRT guesses
  address type wrong; switched to pre-scanning with BleakScanner and
  passing the resolved BLEDevice, which carries the address type.
- Target-start fetch timeout bumped to 30s with retry disabled so the
  UI doesn't abort during the BLE pre-scan + connect + handshake path.

UI:
- Settings modal exposes Protocol Family (IconSelect grid, shared with
  add-device via parameterized ensureBleFamilyIconSelect) so users can
  fix a wrong family pick without recreating the device. Govee AES key
  row toggles on/off with family selection.

Also turns LAN auth back on in default_config.yaml, logs start_processing
requests on entry for easier diagnosis, and captures the full debug trail
in docs/BLE_LED_CONTROLLERS.md for future BLE work.

Refs the mbullington SP110E protocol gist for the handshake bytes.
This commit is contained in:
2026-04-21 17:45:21 +03:00
parent 2b5dac2c42
commit 45f93fd30e
14 changed files with 448 additions and 55 deletions
@@ -109,6 +109,7 @@ async def start_processing(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Start processing for a output target."""
logger.info("Start processing requested for target %s", target_id)
try:
# Verify target exists in store
target_store.get_target(target_id)
@@ -120,14 +120,31 @@ class AndroidBLETransport:
except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None:
"""Write bytes to the configured characteristic.
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
"""Write bytes to a characteristic on the connected peripheral.
Serialised through an internal lock — BLE stacks do not tolerate
overlapping writes on the same characteristic.
Args:
data: payload bytes.
char_uuid: currently unused on Android — the Kotlin BleBridge
binds a single "write characteristic" at connect time. If a
protocol's ``init_writes`` target a different characteristic
(e.g. SP110E's ``ffe2``), we log a warning and route the
write to the bound char anyway. This is lossy but keeps the
protocol layer compatible across backends until the bridge
grows per-write characteristic selection.
"""
if not self.is_connected or self._handle is None:
raise RuntimeError(f"Android BLE transport {self._address} not connected")
if char_uuid and char_uuid.lower() != self._write_char_uuid.lower():
logger.warning(
"Android BLE transport: init write to %s ignored (bridge is "
"bound to %s); vendor handshake may not complete",
char_uuid,
self._write_char_uuid,
)
bridge = _bridge()
handle = self._handle
with_response = self._write_with_response
@@ -135,6 +135,31 @@ class BLEClient(LEDClient):
async def connect(self) -> bool:
await self._transport.connect()
# Some controllers (notably SP110E) require a vendor handshake within
# ~1 s of GATT connect or they silently tear the link down — which
# later surfaces as writes hanging until the Windows BLE stack
# times them out at 30 s. Run it here so send_pixels can assume the
# link is actually live.
if self._protocol.init_writes:
logger.debug(
"BLE init handshake: %d write(s) for %s",
len(self._protocol.init_writes),
self._protocol.family,
)
for char_uuid, payload in self._protocol.init_writes:
try:
await self._transport.write(payload, char_uuid=char_uuid)
except Exception as exc:
# Handshake failure is fatal — close and surface it so
# the caller doesn't stream into a dead link.
logger.warning(
"BLE init write to %s on %s failed: %s",
char_uuid,
self._address,
exc,
)
await self._transport.close()
raise
self._connected = True
logger.info(
"BLE client connected: address=%s family=%s", self._address, self._protocol.family
@@ -28,6 +28,12 @@ class BLEProtocol:
encode_color: ``(r, g, b, brightness) -> bytes`` — frame setting a solid color.
encode_power: ``on -> bytes`` — frame toggling power.
name_prefixes: Advertisement-name prefixes that identify this family.
init_writes: Optional vendor-specific handshake performed right after
GATT connect. Each tuple is ``(char_uuid, payload)``. Some
controllers (notably SP110E) drop the link within ~1 s if this
handshake is missing, which is why our first real write hangs
for 30 s and then fails — the peripheral has already torn down
the connection, we just haven't noticed.
"""
family: str
@@ -38,6 +44,7 @@ class BLEProtocol:
encode_color: Callable[[int, int, int, int], bytes]
encode_power: Callable[[bool], bytes]
name_prefixes: Tuple[str, ...]
init_writes: Tuple[Tuple[str, bytes], ...] = ()
_registry: Dict[str, BLEProtocol] = {}
@@ -12,9 +12,9 @@ per-pixel frames. The BLE protocol exposes:
So from LedGrab's perspective, SP110E is a whole-strip ambient controller.
Frame format (5 bytes, big-endian):
Frame format (4 bytes):
``RR GG BB 00 CC``
``RR GG BB CC``
where ``CC`` is the command byte. Static-color command is ``0x1E`` (set
"RGB" mode = whole-strip solid color from the RR GG BB payload). Power is
@@ -23,9 +23,19 @@ bytes ignored). Brightness is applied by the *caller* scaling the RGB
triple — there is no separate brightness command for solid-color mode,
which is simpler and lets LedGrab apply its own processing pipeline.
Init handshake (critical):
The controller silently tears the GATT link down within ~1s if a
two-write handshake doesn't arrive immediately after connect. Omitting
it is indistinguishable from a successful connect on the Python side —
the first real write then hangs for 30s until Windows abandons the
queued packet. The handshake writes live in ``init_writes`` below and
are executed by ``BLEClient.connect()``.
References:
* https://github.com/Lehkeda/SP110E_controller (reverse-engineered)
* https://github.com/sysofwan/ha-sp110e
* https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012
(init handshake documentation)
"""
from __future__ import annotations
@@ -34,6 +44,7 @@ from ledgrab.core.devices.ble_protocols import BLEProtocol
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
_WRITE_CHAR_UUID = "0000ffe1-0000-1000-8000-00805f9b34fb"
_INIT_CHAR_UUID = "0000ffe2-0000-1000-8000-00805f9b34fb"
_CMD_SET_COLOR = 0x1E
_CMD_POWER_ON = 0xAA
@@ -62,13 +73,13 @@ def encode_color(r: int, g: int, b: int, brightness: int = 255) -> bytes:
r = (r * brightness) // 255
g = (g * brightness) // 255
b = (b * brightness) // 255
return bytes((r, g, b, 0x00, _CMD_SET_COLOR))
return bytes((r, g, b, _CMD_SET_COLOR))
def encode_power(on: bool) -> bytes:
"""Build a power on/off frame."""
cmd = _CMD_POWER_ON if on else _CMD_POWER_OFF
return bytes((0x00, 0x00, 0x00, 0x00, cmd))
return bytes((0x00, 0x00, 0x00, cmd))
PROTOCOL = BLEProtocol(
@@ -81,4 +92,10 @@ PROTOCOL = BLEProtocol(
encode_color=encode_color,
encode_power=encode_power,
name_prefixes=("SP110E", "SP108E", "BLE-LED"),
# Vendor handshake — see module docstring. Without these writes the
# controller drops the GATT connection ~1s after it opens.
init_writes=(
(_INIT_CHAR_UUID, b"\x01\x00"),
(_WRITE_CHAR_UUID, b"\x01\xb7\xe3\xd5"),
),
)
+100 -10
View File
@@ -26,6 +26,31 @@ from ledgrab.utils.platform import is_android
logger = get_logger(__name__)
async def _bounded_await(coro, timeout: float, what: str):
"""Like ``asyncio.wait_for`` but safe when the inner task can't be cancelled.
bleak's WinRT backend wraps WinRT ``IAsyncOperation`` s that ignore
asyncio cancellation. ``asyncio.wait_for`` responds to a timeout by
cancelling the inner task and then **awaiting its cancellation** — and
since the WinRT task never finishes cancelling, ``wait_for`` itself
hangs forever.
``asyncio.wait`` with a timeout returns as soon as the timeout fires
regardless of whether pending tasks can actually be cancelled, so the
caller always regains control. The orphaned task is best-effort
cancelled and then abandoned.
"""
task = asyncio.create_task(coro)
done, _pending = await asyncio.wait({task}, timeout=timeout)
if task not in done:
task.cancel()
raise asyncio.TimeoutError(f"{what} exceeded {timeout:.1f}s")
exc = task.exception()
if exc is not None:
raise exc
return task.result()
def _bleak_available() -> bool:
try:
import bleak # noqa: F401
@@ -127,15 +152,73 @@ class BLETransport:
if self.is_connected:
return
self._client = BleakClient(self._address, timeout=self._connect_timeout)
# Pre-scan for a fresh advertisement so bleak gets a BLEDevice (with
# address type + service UUIDs baked in) rather than just a raw MAC
# string. On Windows/WinRT, connecting by string fails/times out when
# the cached address type is wrong (common for cheap BLE LED chips
# that advertise as Random Static). Passing the BLEDevice avoids that.
from bleak import BleakScanner
scan_timeout = max(3.0, self._connect_timeout / 2)
logger.info("BLE pre-scan for %s (timeout=%.1fs)", self._address, scan_timeout)
try:
device = await _bounded_await(
BleakScanner.find_device_by_address(self._address, timeout=scan_timeout),
timeout=scan_timeout + 2.0,
what=f"BLE pre-scan for {self._address}",
)
except asyncio.TimeoutError as exc:
raise RuntimeError(str(exc)) from exc
except Exception as exc:
raise RuntimeError(
f"BLE pre-scan for {self._address} failed: {type(exc).__name__}: {exc}"
) from exc
if device is None:
raise RuntimeError(
f"BLE device {self._address} not found in scan "
f"(timeout={scan_timeout}s) — is it powered on and in range?"
)
# Log everything bleak knows about this peripheral from the fresh
# advertisement. If connects keep timing out, look here for the
# address type (public vs random static) and advertised services —
# Windows WinRT is strict about matching those on connect.
details_summary = ""
try:
det = getattr(device, "details", None)
if det is not None:
details_summary = f" details={det!r}"
except Exception:
pass
logger.info(
"BLE pre-scan found %s name=%r rssi=%s%s — opening GATT",
self._address,
getattr(device, "name", None),
getattr(device, "rssi", None),
details_summary,
)
self._client = BleakClient(device, timeout=self._connect_timeout)
try:
# bleak's WinRT backend does not always respect the constructor
# timeout — connect() can block 30s+ when the peripheral is gone.
# Wrap in wait_for so the Python-side bound is enforced.
await asyncio.wait_for(self._client.connect(), timeout=self._connect_timeout)
# _bounded_await enforces the timeout on the Python side even when
# the inner WinRT future refuses to be cancelled (wait_for hangs
# forever in that case — see _bounded_await docstring).
await _bounded_await(
self._client.connect(),
timeout=self._connect_timeout,
what=f"BLE connect to {self._address}",
)
except Exception as exc:
self._client = None
raise RuntimeError(f"Failed to connect to BLE device {self._address}: {exc}") from exc
# Many bleak/WinRT failures surface with an empty ``str(exc)`` —
# include the exception type so the log actually tells us which
# branch of "connect failed" we're in (timeout vs WinRT HRESULT
# vs BleakDeviceNotFoundError vs ...).
detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
raise RuntimeError(
f"Failed to connect to BLE device {self._address}: {detail}"
) from exc
logger.info("BLE connected to %s", self._address)
@@ -151,8 +234,8 @@ class BLETransport:
except Exception as exc:
logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None:
"""Send bytes to the configured write characteristic.
async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
"""Send bytes to a GATT write characteristic.
Serialised through an internal lock — BLE stacks do not like
overlapping writes on the same GATT characteristic.
@@ -161,18 +244,25 @@ class BLETransport:
its default 30s on the second write to certain cheap BLE LED chips.
Timing out keeps the target's processing loop responsive.
Args:
data: payload bytes.
char_uuid: target characteristic. Defaults to the transport's
configured ``write_char_uuid`` (used for color/power frames);
pass an alternate UUID for vendor-specific init writes that
target a different characteristic (e.g. SP110E's ``ffe2``).
Raises:
RuntimeError: If not connected.
TimeoutError: If the write does not complete within 2 seconds.
"""
if not self.is_connected or self._client is None:
raise RuntimeError(f"BLE transport {self._address} not connected")
target = char_uuid or self._write_char_uuid
async with self._lock:
await asyncio.wait_for(
self._client.write_gatt_char(
self._write_char_uuid, data, response=self._write_with_response
),
await _bounded_await(
self._client.write_gatt_char(target, data, response=self._write_with_response),
timeout=2.0,
what=f"BLE write to {self._address}",
)
@@ -696,7 +696,8 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
const bleFamilyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (bleFamilyEl && cloneData.ble_family) {
bleFamilyEl.value = cloneData.ble_family;
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(cloneData.ble_family);
const iconSelect = _bleFamilyIconSelects['device-ble-family'];
if (iconSelect) iconSelect.setValue(cloneData.ble_family);
}
const goveeKeyEl = document.getElementById('device-ble-govee-key') as HTMLInputElement;
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key;
@@ -822,7 +823,8 @@ export function selectDiscoveredDevice(device: any) {
if (isBleDevice(device.device_type) && device.ble_family) {
const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (familyEl) familyEl.value = device.ble_family;
if (_bleFamilyIconSelect) _bleFamilyIconSelect.setValue(device.ble_family);
const iconSelect = _bleFamilyIconSelects['device-ble-family'];
if (iconSelect) iconSelect.setValue(device.ble_family);
_updateBleGoveeKeyVisibility();
}
showToast(t('device.scan.selected'), 'info');
@@ -1286,26 +1288,43 @@ function _buildBleFamilyItems() {
];
}
let _bleFamilyIconSelect: any = null;
// Parameterized by select element ID so both the add-device modal
// (``device-ble-family``) and the settings modal (``settings-ble-family``)
// get their own IconSelect instance — same pattern as DMX/SPI/etc.
const _bleFamilyIconSelects: Record<string, any> = {};
function _destroyBleFamilyIconSelect() {
if (_bleFamilyIconSelect) {
_bleFamilyIconSelect.destroy();
_bleFamilyIconSelect = null;
export function destroyBleFamilyIconSelect(selectId: string) {
if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelects[selectId].destroy();
delete _bleFamilyIconSelects[selectId];
}
}
function _ensureBleFamilyIconSelect() {
const sel = document.getElementById('device-ble-family') as HTMLSelectElement;
if (!sel) return;
if (_bleFamilyIconSelect) {
_bleFamilyIconSelect.updateItems(_buildBleFamilyItems());
} else {
_bleFamilyIconSelect = new IconSelect({ target: sel, items: _buildBleFamilyItems(), columns: 2 } as any);
// Register once — native <select> change fires when IconSelect picks a value,
// which is what toggles the Govee key field.
sel.addEventListener('change', _updateBleGoveeKeyVisibility);
export function ensureBleFamilyIconSelect(selectId: string, onChange?: () => void): any {
const sel = document.getElementById(selectId) as HTMLSelectElement | null;
if (!sel) return null;
if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelects[selectId].updateItems(_buildBleFamilyItems());
return _bleFamilyIconSelects[selectId];
}
_bleFamilyIconSelects[selectId] = new IconSelect({
target: sel,
items: _buildBleFamilyItems(),
columns: 2,
} as any);
if (onChange) {
sel.addEventListener('change', onChange);
}
return _bleFamilyIconSelects[selectId];
}
// Thin wrappers used by the add-device modal.
function _destroyBleFamilyIconSelect() {
destroyBleFamilyIconSelect('device-ble-family');
}
function _ensureBleFamilyIconSelect() {
ensureBleFamilyIconSelect('device-ble-family', _updateBleGoveeKeyVisibility);
_updateBleGoveeKeyVisibility();
}
@@ -6,9 +6,9 @@ import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isGroupDevice } from '../core/api.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
import { devicesCache } from '../core/state.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect } from './device-discovery.ts';
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -66,6 +66,8 @@ class DeviceSettingsModal extends Modal {
dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet',
dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0',
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '',
bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value || '',
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
};
}
@@ -443,6 +445,37 @@ export async function showSettings(deviceId: any) {
if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none';
}
// BLE-specific fields — exposed in the settings modal so the user
// can fix a wrong protocol family pick without deleting+recreating
// the device. Uses the shared IconSelect grid (project rule bans
// plain <select> pickers).
const bleFamilyGroup = document.getElementById('settings-ble-family-group');
const bleGoveeKeyGroup = document.getElementById('settings-ble-govee-key-group');
const bleFamilySelect = document.getElementById('settings-ble-family') as HTMLSelectElement | null;
const bleGoveeKeyInput = document.getElementById('settings-ble-govee-key') as HTMLInputElement | null;
const _toggleSettingsGoveeKeyVisibility = () => {
const family = bleFamilySelect?.value || '';
if (bleGoveeKeyGroup) {
(bleGoveeKeyGroup as HTMLElement).style.display = family === 'govee' ? '' : 'none';
}
};
if (isBleDevice(device.device_type)) {
if (bleFamilyGroup) (bleFamilyGroup as HTMLElement).style.display = '';
const currentFamily = device.ble_family || 'sp110e';
if (bleFamilySelect) bleFamilySelect.value = currentFamily;
if (bleGoveeKeyInput) bleGoveeKeyInput.value = device.ble_govee_key || '';
const iconSelect = ensureBleFamilyIconSelect(
'settings-ble-family',
_toggleSettingsGoveeKeyVisibility,
);
if (iconSelect) iconSelect.setValue(currentFamily);
_toggleSettingsGoveeKeyVisibility();
} else {
destroyBleFamilyIconSelect('settings-ble-family');
if (bleFamilyGroup) (bleFamilyGroup as HTMLElement).style.display = 'none';
if (bleGoveeKeyGroup) (bleGoveeKeyGroup as HTMLElement).style.display = 'none';
}
// Group device fields
const groupChildrenGroup = document.getElementById('settings-group-children-group');
const groupModeGroup = document.getElementById('settings-group-mode-group');
@@ -499,7 +532,7 @@ export async function showSettings(deviceId: any) {
}
export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } settingsModal.forceClose(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
@@ -542,6 +575,11 @@ export async function saveDeviceSettings() {
body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10);
body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10);
}
if (isBleDevice(settingsModal.deviceType)) {
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e';
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
body.ble_govee_key = goveeKey;
}
if (isGroup) {
const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>;
body.group_device_ids = Array.from(childRows).map(r => r.dataset.deviceId || '').filter(v => v !== '');
@@ -1091,8 +1091,15 @@ async function _targetAction(action: any) {
export async function startTargetProcessing(targetId: any) {
await _targetAction(async () => {
// Start can take a while for devices whose connect path includes a
// BLE pre-scan + GATT connect (seconds, not milliseconds). Don't
// retry — a duplicate /start while the first one is in-flight just
// piles work on the server. 30s matches the server's own BLE
// connect budget with headroom.
const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
method: 'POST',
timeout: 30000,
retry: false,
});
if (response.ok) {
showToast(t('device.started'), 'success');
@@ -132,6 +132,31 @@
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
</div>
<!-- BLE LED Controller fields -->
<div class="form-group" id="settings-ble-family-group" style="display: none;">
<div class="label-row">
<label for="settings-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small>
<select id="settings-ble-family">
<option value="sp110e">SP110E / SP108E</option>
<option value="triones">Triones / HappyLighting / LEDnet</option>
<option value="zengge">Zengge / iLightsIn</option>
<option value="govee">Govee (experimental)</option>
</select>
</div>
<div class="form-group" id="settings-ble-govee-key-group" style="display: none;">
<div class="label-row">
<label for="settings-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
<input type="text" id="settings-ble-govee-key"
data-i18n-placeholder="device.ble.govee_key.placeholder"
placeholder="32 hex digits, e.g. 0102…1f20">
</div>
<!-- Group device fields -->
<div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row">