45f93fd30e
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.
141 lines
5.0 KiB
Python
141 lines
5.0 KiB
Python
"""Unit tests for BLE LED controller wire protocols.
|
|
|
|
These tests exercise the pure byte-encoding functions for each family,
|
|
so they run without ``bleak`` installed and without any real BLE hardware.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from ledgrab.core.devices.ble_protocols import (
|
|
all_protocols,
|
|
get_protocol,
|
|
identify_family,
|
|
govee,
|
|
sp110e,
|
|
triones,
|
|
zengge,
|
|
)
|
|
|
|
|
|
class TestSP110E:
|
|
def test_color_frame_is_four_bytes_with_cmd_tail(self):
|
|
frame = sp110e.encode_color(255, 128, 64)
|
|
assert frame == bytes((255, 128, 64, 0x1E))
|
|
|
|
def test_brightness_scales_rgb(self):
|
|
frame = sp110e.encode_color(200, 200, 200, brightness=128)
|
|
# 200 * 128 // 255 == 100
|
|
assert frame == bytes((100, 100, 100, 0x1E))
|
|
|
|
def test_brightness_255_is_passthrough(self):
|
|
assert sp110e.encode_color(1, 2, 3, 255) == bytes((1, 2, 3, 0x1E))
|
|
|
|
def test_clamps_out_of_range(self):
|
|
assert sp110e.encode_color(-5, 300, 128) == bytes((0, 255, 128, 0x1E))
|
|
|
|
def test_power_frames(self):
|
|
assert sp110e.encode_power(True) == bytes((0, 0, 0, 0xAA))
|
|
assert sp110e.encode_power(False) == bytes((0, 0, 0, 0xAB))
|
|
|
|
def test_init_handshake_is_defined(self):
|
|
# SP110E silently drops the GATT link within ~1s of connect unless
|
|
# this two-write handshake arrives — see module docstring.
|
|
assert len(sp110e.PROTOCOL.init_writes) == 2
|
|
(ffe2_uuid, ffe2_payload), (ffe1_uuid, ffe1_payload) = sp110e.PROTOCOL.init_writes
|
|
assert ffe2_uuid.endswith("ffe2-0000-1000-8000-00805f9b34fb") or "ffe2" in ffe2_uuid
|
|
assert ffe2_payload == b"\x01\x00"
|
|
assert ffe1_uuid == sp110e.PROTOCOL.write_char_uuid
|
|
assert ffe1_payload == b"\x01\xb7\xe3\xd5"
|
|
|
|
|
|
class TestTriones:
|
|
def test_color_frame_matches_documented_template(self):
|
|
# 7E 07 05 03 RR GG BB 10 EF
|
|
frame = triones.encode_color(0xAA, 0xBB, 0xCC)
|
|
assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 0xAA, 0xBB, 0xCC, 0x10, 0xEF))
|
|
|
|
def test_frame_is_nine_bytes(self):
|
|
assert len(triones.encode_color(10, 20, 30)) == 9
|
|
|
|
def test_brightness_scales(self):
|
|
frame = triones.encode_color(255, 255, 255, brightness=51) # 20%
|
|
# 255 * 51 // 255 == 51
|
|
assert frame[4:7] == bytes((51, 51, 51))
|
|
|
|
def test_power_on_off_distinct(self):
|
|
on = triones.encode_power(True)
|
|
off = triones.encode_power(False)
|
|
assert on != off
|
|
assert on[0] == 0x7E and off[0] == 0x7E
|
|
assert on[-1] == 0xEF and off[-1] == 0xEF
|
|
|
|
|
|
class TestZengge:
|
|
def test_color_frame_format(self):
|
|
# 56 RR GG BB 00 F0 AA
|
|
frame = zengge.encode_color(0x11, 0x22, 0x33)
|
|
assert frame == bytes((0x56, 0x11, 0x22, 0x33, 0x00, 0xF0, 0xAA))
|
|
|
|
def test_frame_is_seven_bytes(self):
|
|
assert len(zengge.encode_color(1, 2, 3)) == 7
|
|
|
|
def test_power_frames(self):
|
|
assert zengge.encode_power(True) == bytes((0xCC, 0x23, 0x33))
|
|
assert zengge.encode_power(False) == bytes((0xCC, 0x24, 0x33))
|
|
|
|
|
|
class TestGovee:
|
|
def test_color_frame_is_twenty_bytes_with_checksum(self):
|
|
frame = govee.encode_color(255, 0, 0)
|
|
assert len(frame) == 20
|
|
# Verify XOR checksum
|
|
expected_checksum = 0
|
|
for i in range(19):
|
|
expected_checksum ^= frame[i]
|
|
assert frame[19] == expected_checksum
|
|
|
|
def test_color_frame_header(self):
|
|
# 33 05 02 RR GG BB ...
|
|
frame = govee.encode_color(0x10, 0x20, 0x30)
|
|
assert frame[0] == 0x33
|
|
assert frame[1] == 0x05
|
|
assert frame[2] == 0x02
|
|
assert frame[3:6] == bytes((0x10, 0x20, 0x30))
|
|
|
|
def test_brightness_scales(self):
|
|
frame = govee.encode_color(100, 100, 100, brightness=0)
|
|
assert frame[3:6] == bytes((0, 0, 0))
|
|
|
|
def test_power_frames_have_valid_checksum(self):
|
|
for state in (True, False):
|
|
frame = govee.encode_power(state)
|
|
assert len(frame) == 20
|
|
checksum = 0
|
|
for i in range(19):
|
|
checksum ^= frame[i]
|
|
assert frame[19] == checksum
|
|
|
|
|
|
class TestRegistry:
|
|
def test_all_four_families_registered(self):
|
|
families = set(all_protocols().keys())
|
|
assert families == {"sp110e", "triones", "zengge", "govee"}
|
|
|
|
def test_get_protocol_raises_on_unknown(self):
|
|
with pytest.raises(ValueError, match="Unknown BLE family"):
|
|
get_protocol("not-a-real-family")
|
|
|
|
def test_get_protocol_returns_correct_family(self):
|
|
assert get_protocol("sp110e").family == "sp110e"
|
|
assert get_protocol("triones").family == "triones"
|
|
|
|
def test_identify_family_by_name(self):
|
|
assert identify_family("SP110E_A1B2C3") == "sp110e"
|
|
assert identify_family("Triones-ABCDEF") == "triones"
|
|
assert identify_family("Zengge-123456") == "zengge"
|
|
assert identify_family("ihoment_H6008_xxxx") == "govee"
|
|
|
|
def test_identify_family_returns_none_for_unknown(self):
|
|
assert identify_family("SomeRandomDevice") is None
|
|
assert identify_family("") is None
|