fix(devices): SP110E vendor handshake + Windows/bleak robustness
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:
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user