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:
@@ -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.
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user