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 # To enable LAN access, add one or more label: "api-key" entries below
# and send `Authorization: Bearer <api-key>` with each request. # and send `Authorization: Bearer <api-key>` with each request.
# Generate secure keys: openssl rand -hex 32 # Generate secure keys: openssl rand -hex 32
api_keys: {} api_keys:
# dev: "replace-with-openssl-rand-hex-32" dev: "development-key-change-in-production"
# Storage paths default to ./data relative to the server's working directory. # 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 # 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), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Start processing for a output target.""" """Start processing for a output target."""
logger.info("Start processing requested for target %s", target_id)
try: try:
# Verify target exists in store # Verify target exists in store
target_store.get_target(target_id) target_store.get_target(target_id)
@@ -120,14 +120,31 @@ class AndroidBLETransport:
except Exception as exc: except Exception as exc:
logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc) logger.warning("Android BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None: async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
"""Write bytes to the configured characteristic. """Write bytes to a characteristic on the connected peripheral.
Serialised through an internal lock — BLE stacks do not tolerate Serialised through an internal lock — BLE stacks do not tolerate
overlapping writes on the same characteristic. 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: if not self.is_connected or self._handle is None:
raise RuntimeError(f"Android BLE transport {self._address} not connected") 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() bridge = _bridge()
handle = self._handle handle = self._handle
with_response = self._write_with_response with_response = self._write_with_response
@@ -135,6 +135,31 @@ class BLEClient(LEDClient):
async def connect(self) -> bool: async def connect(self) -> bool:
await self._transport.connect() 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 self._connected = True
logger.info( logger.info(
"BLE client connected: address=%s family=%s", self._address, self._protocol.family "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_color: ``(r, g, b, brightness) -> bytes`` — frame setting a solid color.
encode_power: ``on -> bytes`` — frame toggling power. encode_power: ``on -> bytes`` — frame toggling power.
name_prefixes: Advertisement-name prefixes that identify this family. 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 family: str
@@ -38,6 +44,7 @@ class BLEProtocol:
encode_color: Callable[[int, int, int, int], bytes] encode_color: Callable[[int, int, int, int], bytes]
encode_power: Callable[[bool], bytes] encode_power: Callable[[bool], bytes]
name_prefixes: Tuple[str, ...] name_prefixes: Tuple[str, ...]
init_writes: Tuple[Tuple[str, bytes], ...] = ()
_registry: Dict[str, BLEProtocol] = {} _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. 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 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 "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, triple — there is no separate brightness command for solid-color mode,
which is simpler and lets LedGrab apply its own processing pipeline. 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: References:
* https://github.com/Lehkeda/SP110E_controller (reverse-engineered) * https://github.com/Lehkeda/SP110E_controller (reverse-engineered)
* https://github.com/sysofwan/ha-sp110e * https://github.com/sysofwan/ha-sp110e
* https://gist.github.com/mbullington/37957501a07ad065b67d4e8d39bfe012
(init handshake documentation)
""" """
from __future__ import annotations from __future__ import annotations
@@ -34,6 +44,7 @@ from ledgrab.core.devices.ble_protocols import BLEProtocol
_SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb" _SERVICE_UUID = "0000ffe0-0000-1000-8000-00805f9b34fb"
_WRITE_CHAR_UUID = "0000ffe1-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_SET_COLOR = 0x1E
_CMD_POWER_ON = 0xAA _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 r = (r * brightness) // 255
g = (g * brightness) // 255 g = (g * brightness) // 255
b = (b * 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: def encode_power(on: bool) -> bytes:
"""Build a power on/off frame.""" """Build a power on/off frame."""
cmd = _CMD_POWER_ON if on else _CMD_POWER_OFF 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( PROTOCOL = BLEProtocol(
@@ -81,4 +92,10 @@ PROTOCOL = BLEProtocol(
encode_color=encode_color, encode_color=encode_color,
encode_power=encode_power, encode_power=encode_power,
name_prefixes=("SP110E", "SP108E", "BLE-LED"), 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__) 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: def _bleak_available() -> bool:
try: try:
import bleak # noqa: F401 import bleak # noqa: F401
@@ -127,15 +152,73 @@ class BLETransport:
if self.is_connected: if self.is_connected:
return 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: try:
# bleak's WinRT backend does not always respect the constructor # bleak's WinRT backend does not always respect the constructor
# timeout — connect() can block 30s+ when the peripheral is gone. # timeout — connect() can block 30s+ when the peripheral is gone.
# Wrap in wait_for so the Python-side bound is enforced. # _bounded_await enforces the timeout on the Python side even when
await asyncio.wait_for(self._client.connect(), timeout=self._connect_timeout) # 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: except Exception as exc:
self._client = None 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) logger.info("BLE connected to %s", self._address)
@@ -151,8 +234,8 @@ class BLETransport:
except Exception as exc: except Exception as exc:
logger.warning("BLE disconnect of %s raised: %s", self._address, exc) logger.warning("BLE disconnect of %s raised: %s", self._address, exc)
async def write(self, data: bytes) -> None: async def write(self, data: bytes, char_uuid: Optional[str] = None) -> None:
"""Send bytes to the configured write characteristic. """Send bytes to a GATT write characteristic.
Serialised through an internal lock — BLE stacks do not like Serialised through an internal lock — BLE stacks do not like
overlapping writes on the same GATT characteristic. 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. its default 30s on the second write to certain cheap BLE LED chips.
Timing out keeps the target's processing loop responsive. 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: Raises:
RuntimeError: If not connected. RuntimeError: If not connected.
TimeoutError: If the write does not complete within 2 seconds. TimeoutError: If the write does not complete within 2 seconds.
""" """
if not self.is_connected or self._client is None: if not self.is_connected or self._client is None:
raise RuntimeError(f"BLE transport {self._address} not connected") raise RuntimeError(f"BLE transport {self._address} not connected")
target = char_uuid or self._write_char_uuid
async with self._lock: async with self._lock:
await asyncio.wait_for( await _bounded_await(
self._client.write_gatt_char( self._client.write_gatt_char(target, data, response=self._write_with_response),
self._write_char_uuid, data, response=self._write_with_response
),
timeout=2.0, 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; const bleFamilyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (bleFamilyEl && cloneData.ble_family) { if (bleFamilyEl && cloneData.ble_family) {
bleFamilyEl.value = 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; const goveeKeyEl = document.getElementById('device-ble-govee-key') as HTMLInputElement;
if (goveeKeyEl && cloneData.ble_govee_key) goveeKeyEl.value = cloneData.ble_govee_key; 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) { if (isBleDevice(device.device_type) && device.ble_family) {
const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement; const familyEl = document.getElementById('device-ble-family') as HTMLSelectElement;
if (familyEl) familyEl.value = device.ble_family; 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(); _updateBleGoveeKeyVisibility();
} }
showToast(t('device.scan.selected'), 'info'); 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() { export function destroyBleFamilyIconSelect(selectId: string) {
if (_bleFamilyIconSelect) { if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelect.destroy(); _bleFamilyIconSelects[selectId].destroy();
_bleFamilyIconSelect = null; delete _bleFamilyIconSelects[selectId];
} }
} }
function _ensureBleFamilyIconSelect() { export function ensureBleFamilyIconSelect(selectId: string, onChange?: () => void): any {
const sel = document.getElementById('device-ble-family') as HTMLSelectElement; const sel = document.getElementById(selectId) as HTMLSelectElement | null;
if (!sel) return; if (!sel) return null;
if (_bleFamilyIconSelect) { if (_bleFamilyIconSelects[selectId]) {
_bleFamilyIconSelect.updateItems(_buildBleFamilyItems()); _bleFamilyIconSelects[selectId].updateItems(_buildBleFamilyItems());
} else { return _bleFamilyIconSelects[selectId];
_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);
} }
_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(); _updateBleGoveeKeyVisibility();
} }
@@ -6,9 +6,9 @@ import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { 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 { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.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', dmxProtocol: (document.getElementById('settings-dmx-protocol') as HTMLSelectElement | null)?.value || 'artnet',
dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', dmxStartUniverse: (document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0',
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 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 || '', 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'; 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 // Group device fields
const groupChildrenGroup = document.getElementById('settings-group-children-group'); const groupChildrenGroup = document.getElementById('settings-group-children-group');
const groupModeGroup = document.getElementById('settings-group-mode-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 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 function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() { 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_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); 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) { if (isGroup) {
const childRows = document.querySelectorAll('#settings-group-children-list .group-child-row') as NodeListOf<HTMLElement>; 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 !== ''); 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) { export async function startTargetProcessing(targetId: any) {
await _targetAction(async () => { 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`, { const response = await fetchWithAuth(`/output-targets/${targetId}/start`, {
method: 'POST', method: 'POST',
timeout: 30000,
retry: false,
}); });
if (response.ok) { if (response.ok) {
showToast(t('device.started'), 'success'); showToast(t('device.started'), 'success');
@@ -132,6 +132,31 @@
<input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1"> <input type="number" id="settings-dmx-start-channel" min="1" max="512" value="1">
</div> </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 --> <!-- Group device fields -->
<div class="form-group" id="settings-group-children-group" style="display: none;"> <div class="form-group" id="settings-group-children-group" style="display: none;">
<div class="label-row"> <div class="label-row">
+38 -10
View File
@@ -19,6 +19,9 @@ class FakeTransport:
def __init__(self, *_, **__): def __init__(self, *_, **__):
self.writes: List[bytes] = [] 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 self._connected = False
@property @property
@@ -31,10 +34,17 @@ class FakeTransport:
async def close(self) -> None: async def close(self) -> None:
self._connected = False 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: if not self._connected:
raise RuntimeError("fake transport not connected") raise RuntimeError("fake transport not connected")
self.writes.append(data) 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 @pytest.fixture
@@ -79,14 +89,27 @@ class TestBLEClientLifecycle:
await client.connect() await client.connect()
assert client.is_connected 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 @pytest.mark.asyncio
async def test_close_does_not_send_power_off(self, fake_transport_cls): async def test_close_does_not_send_power_off(self, fake_transport_cls):
client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") client = BLEClient("ble://AA:BB:CC", ble_family="sp110e")
await client.connect() await client.connect()
await client.close() await client.close()
# Strip is left in whatever state it's in — rapid power toggles on # Strip is left in whatever state it's in — rapid power toggles on
# connect/close cause BLE stack hangs on Windows. # connect/close cause BLE stack hangs on Windows. (Only check color
assert bytes((0, 0, 0, 0, 0xAB)) not in client._transport.writes # writes since the vendor init handshake is unrelated.)
assert bytes((0, 0, 0, 0xAB)) not in client._transport.color_writes
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unknown_family_raises(self): async def test_unknown_family_raises(self):
@@ -101,10 +124,12 @@ class TestBLEClientSendPixels:
await client.connect() await client.connect()
ok = await client.send_pixels([(255, 0, 0), (0, 0, 0)], brightness=255) ok = await client.send_pixels([(255, 0, 0), (0, 0, 0)], brightness=255)
assert ok assert ok
assert len(client._transport.writes) == 1 # Color frames go to the default write characteristic; init-handshake
frame = client._transport.writes[0] # writes have their own char_uuid and don't count here.
# Averaged to (127, 0, 0), SP110E trailer 00 1E color_writes = client._transport.color_writes
assert frame == bytes((127, 0, 0, 0, 0x1E)) 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 @pytest.mark.asyncio
async def test_duplicate_frames_are_dropped(self, fake_transport_cls): 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)]) 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 @pytest.mark.asyncio
async def test_send_returns_false_when_disconnected(self, fake_transport_cls): 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") client = BLEClient("ble://AA:BB:CC", ble_family="triones")
await client.connect() await client.connect()
await client.send_pixels([(100, 150, 200)]) await client.send_pixels([(100, 150, 200)])
# Triones has no init handshake — first write is the color frame.
frame = client._transport.writes[0] frame = client._transport.writes[0]
assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 100, 150, 200, 0x10, 0xEF)) assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 100, 150, 200, 0x10, 0xEF))
@@ -209,5 +235,7 @@ class TestGoveeAESEncryption:
assert client._aes_key is None assert client._aes_key is None
await client.connect() await client.connect()
await client.send_pixels([(100, 100, 100)]) await client.send_pixels([(100, 100, 100)])
# SP110E frame is 5 bytes, not encrypted. # SP110E color frame is 4 bytes (RR GG BB CMD), not encrypted.
assert len(client._transport.writes[0]) == 5 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: 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) 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): def test_brightness_scales_rgb(self):
frame = sp110e.encode_color(200, 200, 200, brightness=128) frame = sp110e.encode_color(200, 200, 200, brightness=128)
# 200 * 128 // 255 == 100 # 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): 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): 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): def test_power_frames(self):
assert sp110e.encode_power(True) == bytes((0, 0, 0, 0, 0xAA)) assert sp110e.encode_power(True) == bytes((0, 0, 0, 0xAA))
assert sp110e.encode_power(False) == bytes((0, 0, 0, 0, 0xAB)) 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: class TestTriones: