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
View File
@@ -0,0 +1,109 @@
# BLE LED Controllers — Investigation & Implementation Notes
Reference for anyone touching the BLE device provider (`server/src/ledgrab/core/devices/ble_*`). Captures the protocol quirks, Windows/bleak traps, and hardware lockdown we hit while bringing up SP110E / Triones / Zengge / Govee support.
## Architecture
```
BLEDeviceProvider → BLEClient → BLETransport (desktop: bleak, Android: Kotlin BleBridge via Chaquopy)
└─ BLEProtocol (family-specific wire bytes: sp110e.py, triones.py, zengge.py, govee.py)
```
- One `BLEProtocol` dataclass per controller family. Each supplies GATT UUIDs, write type (with/without response), `encode_color` / `encode_power` functions, name prefixes for discovery, and an optional `init_writes` handshake sequence.
- `BLEClient` is whole-strip only. `send_pixels()` averages incoming pixel arrays and emits one solid color per frame — none of these protocols support per-pixel streaming.
- Discovery auto-detects the family via advertised name prefix first, falls back to service UUID matching. The detected family is returned on `DiscoveredDevice.ble_family` and preselected in the UI.
- The settings modal lets users change the family after creation — wrong family → writes go to a characteristic the device ignores → strip stays dark.
## Protocol Quirks
### SP110E / SP108E (critical handshake)
The controller **silently tears the GATT link down within ~1 second of connect** unless a two-write handshake arrives immediately:
```
Write 01 00 → characteristic FFE2
Write 01 B7 E3 D5 → characteristic FFE1
```
Without this, the first real write later hangs for 30 s because bleak thinks the link is up but the peripheral has already dropped it. We carry these writes in `PROTOCOL.init_writes` and execute them from `BLEClient.connect()` right after GATT open.
Color frame is **4 bytes** (`RR GG BB 1E`), not 5 — the earlier implementation had a stray `0x00` padding byte that the device tolerated but isn't documented.
Source: [mbullington's reverse-engineering gist](https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012).
### Triones / Zengge / Govee
No init handshake required. Color frames and command bytes documented inline in each protocol module. Notable: Zengge and SP110E share service UUID `FFE0/FFE1`, so name-based identification is the only reliable way to tell them apart. In `_register_builtins()`, SP110E is registered first so it wins the `identify_family_by_service_uuids` tie by default — change this if the user base flips.
## bleak + Windows WinRT Traps
These bit us hard. All are now worked around, but future BLE work should keep them in mind.
### 1. `asyncio.wait_for` hangs forever on WinRT
`BleakClient.connect()` / `write_gatt_char()` wrap WinRT `IAsyncOperation`s. When asyncio tries to cancel them (as `wait_for` does on timeout), the WinRT task **never finishes cancelling**, so `wait_for` itself blocks forever while awaiting the cancellation. Symptom: log stops with no timeout error, process is alive but wedged.
**Fix**: `_bounded_await()` in [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) uses `asyncio.wait()` instead, which returns on timeout without awaiting pending tasks. Orphans the hanging WinRT task but frees the caller.
### 2. Connect by raw MAC string fails on Windows
Passing `BleakClient("AA:BB:CC:DD:EE:FF")` makes WinRT guess the address type (public vs random static vs random resolvable). Guesses wrong → connect silently times out. Symptom: `TimeoutError: BLE connect to ... exceeded 10.0s` with no other signal.
**Fix**: Always pre-scan with `BleakScanner.find_device_by_address()` and pass the returned `BLEDevice` object to `BleakClient`. Costs ~400 ms but makes connect reliable.
### 3. Client-side fetch timeout too short for BLE target start
The target-start endpoint does a ~5 s pre-scan + up to 10 s GATT connect + init handshake. Default `fetchWithAuth` has a 10 s timeout and 3× retry, so the UI was aborting and retrying concurrent `/start` requests into the server.
**Fix**: `startTargetProcessing` overrides `timeout: 30000, retry: false`.
### 4. `Start-Process -WindowStyle Hidden` from bash/WSL strips handles
When `restart.ps1` is invoked from Git-Bash / WSL, `Start-Process` inherited handles cause the child uvicorn to exit immediately. Stream redirection fixes it.
**Fix**: `restart.ps1` always uses `-RedirectStandardOutput`/`-RedirectStandardError` to a temp log. Failed startups dump the stderr tail to the caller so root cause is visible.
## Vendor Lockdown (the dead end)
Some controllers — notably the one we tested, advertising as `AlexTable` at `16:61:05:70:68:44`**only accept connections from the vendor phone app**. Diagnostic sequence:
| Test | Result | Meaning |
| --- | --- | --- |
| LedGrab `BleakClient.connect()` | 10 s timeout | Windows can't connect |
| Windows "Bluetooth LE Explorer" | Hangs on connect | Same Windows stack as bleak — not our bug |
| Phone **OS** Bluetooth Settings | Can't connect | Phone OS uses generic BLE stack — also fails |
| Phone **LED Hue** app | Connects fine | Vendor app is the *only* working client |
At this point, further Windows/bleak tweaks have no effect. The peripheral firmware rejects generic GATT connects and only stays connected when the LED Hue app emits its vendor-specific handshake. To unlock such a controller from LedGrab you'd need to:
1. Enable **Developer Options → Bluetooth HCI snoop log** on Android.
2. Reproduce the LED Hue flow (connect → color change → disconnect).
3. `adb bugreport bugreport.zip`; extract `btsnoop_hci.log`.
4. Open in Wireshark; identify the vendor handshake bytes written during connect.
5. Add them to the protocol's `init_writes`.
Alternatively, replace the BLE controller hardware with **WLED on ESP32** — $3, fully supported, vastly more capable.
## Frontend
- BLE family picker uses the project's shared `IconSelect` grid (project rule — see [CLAUDE.md](../CLAUDE.md): "NEVER use plain HTML `<select>`").
- Registry in `device-discovery.ts` is keyed by element ID so both the add-device and settings modals get their own IconSelect instance. Helpers: `ensureBleFamilyIconSelect(selectId, onChange?)` / `destroyBleFamilyIconSelect(selectId)`.
- Govee AES key row is conditionally visible: only shows when the selected family is `govee`.
## HAOS Integration Pair
The sister repo `ledgrab-haos-integration` had its own WebSocket auth bug that surfaced during this session — the integration still used the deprecated `?token=<key>` query param instead of the new first-message handshake. Fixed in [v0.2.1](https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab-haos-integration). Unrelated to BLE but shared debugging time.
## Tests
`server/tests/test_ble_protocols.py` and `server/tests/test_ble_client.py` use a `FakeTransport` that logs every write with its `char_uuid`, so protocol wire formats and the init handshake are unit-testable without hardware or bleak installed. New protocol additions should extend these.
## Files
- [ble_client.py](../server/src/ledgrab/core/devices/ble_client.py) — provider-facing class; runs init handshake on connect; reconnect backoff.
- [ble_transport.py](../server/src/ledgrab/core/devices/ble_transport.py) — bleak desktop transport; `_bounded_await` helper; per-write char override.
- [android_ble_transport.py](../server/src/ledgrab/core/devices/android_ble_transport.py) — Chaquopy/Kotlin transport; currently ignores `char_uuid` override (bridge binds a single write characteristic).
- [ble_provider.py](../server/src/ledgrab/core/devices/ble_provider.py) — discovery, family detection, `set_color` / `set_power` short-lived sessions.
- [ble_protocols/](../server/src/ledgrab/core/devices/ble_protocols/) — one file per family (pure byte-encoding functions, no BLE deps).
- [BleBridge.kt](../android/app/src/main/java/com/ledgrab/android/BleBridge.kt) — Android-side BLE GATT wrapper exposed to Python via Chaquopy.
+2 -2
View File
@@ -15,8 +15,8 @@ auth:
# To enable LAN access, add one or more label: "api-key" entries below
# and send `Authorization: Bearer <api-key>` with each request.
# Generate secure keys: openssl rand -hex 32
api_keys: {}
# dev: "replace-with-openssl-rand-hex-32"
api_keys:
dev: "development-key-change-in-production"
# Storage paths default to ./data relative to the server's working directory.
# Set LEDGRAB_DATA_DIR in the environment to point at a different data root
@@ -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">
+38 -10
View File
@@ -19,6 +19,9 @@ class FakeTransport:
def __init__(self, *_, **__):
self.writes: List[bytes] = []
# Full log preserving char_uuid alongside payload so tests can
# assert on the SP110E vendor handshake's characteristic targets.
self.writes_detailed: List[tuple] = []
self._connected = False
@property
@@ -31,10 +34,17 @@ class FakeTransport:
async def close(self) -> None:
self._connected = False
async def write(self, data: bytes) -> None:
async def write(self, data: bytes, char_uuid: str | None = None) -> None:
if not self._connected:
raise RuntimeError("fake transport not connected")
self.writes.append(data)
self.writes_detailed.append((char_uuid, data))
@property
def color_writes(self) -> list:
"""Writes that went to the protocol's default write characteristic
(i.e. not the init handshake). Most existing tests want this view."""
return [data for (char_uuid, data) in self.writes_detailed if char_uuid is None]
@pytest.fixture
@@ -79,14 +89,27 @@ class TestBLEClientLifecycle:
await client.connect()
assert client.is_connected
@pytest.mark.asyncio
async def test_connect_runs_vendor_init_handshake(self, fake_transport_cls):
# SP110E requires a two-write handshake on connect or the GATT
# link silently drops — verify both writes actually go out.
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect()
init_writes = client._transport.writes_detailed
assert len(init_writes) == 2
(char_a, payload_a), (char_b, payload_b) = init_writes
assert "ffe2" in char_a and payload_a == b"\x01\x00"
assert "ffe1" in char_b and payload_b == b"\x01\xb7\xe3\xd5"
@pytest.mark.asyncio
async def test_close_does_not_send_power_off(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect()
await client.close()
# Strip is left in whatever state it's in — rapid power toggles on
# connect/close cause BLE stack hangs on Windows.
assert bytes((0, 0, 0, 0, 0xAB)) not in client._transport.writes
# connect/close cause BLE stack hangs on Windows. (Only check color
# writes since the vendor init handshake is unrelated.)
assert bytes((0, 0, 0, 0xAB)) not in client._transport.color_writes
@pytest.mark.asyncio
async def test_unknown_family_raises(self):
@@ -101,10 +124,12 @@ class TestBLEClientSendPixels:
await client.connect()
ok = await client.send_pixels([(255, 0, 0), (0, 0, 0)], brightness=255)
assert ok
assert len(client._transport.writes) == 1
frame = client._transport.writes[0]
# Averaged to (127, 0, 0), SP110E trailer 00 1E
assert frame == bytes((127, 0, 0, 0, 0x1E))
# Color frames go to the default write characteristic; init-handshake
# writes have their own char_uuid and don't count here.
color_writes = client._transport.color_writes
assert len(color_writes) == 1
# Averaged to (127, 0, 0), SP110E 4-byte frame with 0x1E cmd tail.
assert color_writes[0] == bytes((127, 0, 0, 0x1E))
@pytest.mark.asyncio
async def test_duplicate_frames_are_dropped(self, fake_transport_cls):
@@ -113,7 +138,7 @@ class TestBLEClientSendPixels:
await client.send_pixels([(10, 20, 30)])
await client.send_pixels([(10, 20, 30)])
await client.send_pixels([(10, 20, 30)])
assert len(client._transport.writes) == 1
assert len(client._transport.color_writes) == 1
@pytest.mark.asyncio
async def test_send_returns_false_when_disconnected(self, fake_transport_cls):
@@ -127,6 +152,7 @@ class TestBLEClientSendPixels:
client = BLEClient("ble://AA:BB:CC", ble_family="triones")
await client.connect()
await client.send_pixels([(100, 150, 200)])
# Triones has no init handshake — first write is the color frame.
frame = client._transport.writes[0]
assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 100, 150, 200, 0x10, 0xEF))
@@ -209,5 +235,7 @@ class TestGoveeAESEncryption:
assert client._aes_key is None
await client.connect()
await client.send_pixels([(100, 100, 100)])
# SP110E frame is 5 bytes, not encrypted.
assert len(client._transport.writes[0]) == 5
# SP110E color frame is 4 bytes (RR GG BB CMD), not encrypted.
color_writes = client._transport.color_writes
assert len(color_writes) == 1
assert len(color_writes[0]) == 4
+17 -7
View File
@@ -18,24 +18,34 @@ from ledgrab.core.devices.ble_protocols import (
class TestSP110E:
def test_color_frame_is_five_bytes_with_cmd_tail(self):
def test_color_frame_is_four_bytes_with_cmd_tail(self):
frame = sp110e.encode_color(255, 128, 64)
assert frame == bytes((255, 128, 64, 0x00, 0x1E))
assert frame == bytes((255, 128, 64, 0x1E))
def test_brightness_scales_rgb(self):
frame = sp110e.encode_color(200, 200, 200, brightness=128)
# 200 * 128 // 255 == 100
assert frame == bytes((100, 100, 100, 0x00, 0x1E))
assert frame == bytes((100, 100, 100, 0x1E))
def test_brightness_255_is_passthrough(self):
assert sp110e.encode_color(1, 2, 3, 255) == bytes((1, 2, 3, 0x00, 0x1E))
assert sp110e.encode_color(1, 2, 3, 255) == bytes((1, 2, 3, 0x1E))
def test_clamps_out_of_range(self):
assert sp110e.encode_color(-5, 300, 128) == bytes((0, 255, 128, 0x00, 0x1E))
assert sp110e.encode_color(-5, 300, 128) == bytes((0, 255, 128, 0x1E))
def test_power_frames(self):
assert sp110e.encode_power(True) == bytes((0, 0, 0, 0, 0xAA))
assert sp110e.encode_power(False) == bytes((0, 0, 0, 0, 0xAB))
assert sp110e.encode_power(True) == bytes((0, 0, 0, 0xAA))
assert sp110e.encode_power(False) == bytes((0, 0, 0, 0xAB))
def test_init_handshake_is_defined(self):
# SP110E silently drops the GATT link within ~1s of connect unless
# this two-write handshake arrives — see module docstring.
assert len(sp110e.PROTOCOL.init_writes) == 2
(ffe2_uuid, ffe2_payload), (ffe1_uuid, ffe1_payload) = sp110e.PROTOCOL.init_writes
assert ffe2_uuid.endswith("ffe2-0000-1000-8000-00805f9b34fb") or "ffe2" in ffe2_uuid
assert ffe2_payload == b"\x01\x00"
assert ffe1_uuid == sp110e.PROTOCOL.write_char_uuid
assert ffe1_payload == b"\x01\xb7\xe3\xd5"
class TestTriones: