"""Tests for BLEClient using a fake transport — no bleak, no hardware.""" from typing import List import numpy as np import pytest from ledgrab.core.devices import ble_client as ble_client_module from ledgrab.core.devices.ble_client import ( BLEClient, _average_color, _encrypt_govee_frame, _strip_ble_scheme, ) class FakeTransport: """In-memory stand-in for ``BLETransport`` — records every write.""" def __init__(self, *_, **__): self.writes: List[bytes] = [] # Full log preserving char_uuid alongside payload so tests can # assert on the SP110E vendor handshake's characteristic targets. self.writes_detailed: List[tuple] = [] self._connected = False @property def is_connected(self) -> bool: return self._connected async def connect(self) -> None: self._connected = True async def close(self) -> None: self._connected = False async def write(self, data: bytes, char_uuid: str | None = None) -> None: if not self._connected: raise RuntimeError("fake transport not connected") self.writes.append(data) self.writes_detailed.append((char_uuid, data)) @property def color_writes(self) -> list: """Writes that went to the protocol's default write characteristic (i.e. not the init handshake). Most existing tests want this view.""" return [data for (char_uuid, data) in self.writes_detailed if char_uuid is None] @pytest.fixture def fake_transport_cls(monkeypatch): monkeypatch.setattr(ble_client_module, "make_transport", lambda *_, **__: FakeTransport()) # Disable the inter-write delay so tests don't sleep. monkeypatch.setattr(ble_client_module, "_MIN_WRITE_INTERVAL_SEC", 0.0) return FakeTransport class TestStripBleScheme: def test_strips_ble_prefix(self): assert _strip_ble_scheme("ble://AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF" def test_leaves_bare_address_alone(self): assert _strip_ble_scheme("AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF" def test_strips_trailing_slashes(self): assert _strip_ble_scheme("ble://abc/") == "abc" class TestAverageColor: def test_returns_black_on_empty_list(self): assert _average_color([]) == (0, 0, 0) def test_averages_list_of_tuples(self): assert _average_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)]) == (85, 85, 85) def test_returns_black_on_empty_array(self): assert _average_color(np.empty((0, 3), dtype=np.uint8)) == (0, 0, 0) def test_averages_numpy_array(self): arr = np.array([[100, 100, 100], [200, 200, 200]], dtype=np.uint8) assert _average_color(arr) == (150, 150, 150) class TestBLEClientLifecycle: @pytest.mark.asyncio async def test_connect_sets_connected_flag(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") assert not client.is_connected await client.connect() assert client.is_connected @pytest.mark.asyncio async def test_connect_runs_vendor_init_handshake(self, fake_transport_cls): # SP110E requires a two-write handshake on connect or the GATT # link silently drops — verify both writes actually go out. client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") await client.connect() init_writes = client._transport.writes_detailed assert len(init_writes) == 2 (char_a, payload_a), (char_b, payload_b) = init_writes assert "ffe2" in char_a and payload_a == b"\x01\x00" assert "ffe1" in char_b and payload_b == b"\x01\xb7\xe3\xd5" @pytest.mark.asyncio async def test_close_does_not_send_power_off(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") await client.connect() await client.close() # Strip is left in whatever state it's in — rapid power toggles on # connect/close cause BLE stack hangs on Windows. (Only check color # writes since the vendor init handshake is unrelated.) assert bytes((0, 0, 0, 0xAB)) not in client._transport.color_writes @pytest.mark.asyncio async def test_unknown_family_raises(self): with pytest.raises(ValueError, match="Unknown BLE family"): BLEClient("ble://AA:BB:CC", ble_family="not-real") class TestBLEClientSendPixels: @pytest.mark.asyncio async def test_send_averages_and_writes_one_frame(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") await client.connect() ok = await client.send_pixels([(255, 0, 0), (0, 0, 0)], brightness=255) assert ok # Color frames go to the default write characteristic; init-handshake # writes have their own char_uuid and don't count here. color_writes = client._transport.color_writes assert len(color_writes) == 1 # Averaged to (127, 0, 0), SP110E 4-byte frame with 0x1E cmd tail. assert color_writes[0] == bytes((127, 0, 0, 0x1E)) @pytest.mark.asyncio async def test_duplicate_frames_are_dropped(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") await client.connect() 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.color_writes) == 1 @pytest.mark.asyncio async def test_send_returns_false_when_disconnected(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="sp110e") # No connect() call. ok = await client.send_pixels([(1, 2, 3)]) assert ok is False @pytest.mark.asyncio async def test_triones_frame_format(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="triones") await client.connect() await client.send_pixels([(100, 150, 200)]) # Triones has no init handshake — first write is the color frame. frame = client._transport.writes[0] assert frame == bytes((0x7E, 0x07, 0x05, 0x03, 100, 150, 200, 0x10, 0xEF)) class TestBLEClientHealth: @pytest.mark.asyncio async def test_health_preserves_previous_state(self): from ledgrab.core.devices.led_client import DeviceHealth prev = DeviceHealth(online=True, device_name="Test", device_led_count=60) result = await BLEClient.check_health("ble://AA:BB:CC", http_client=None, prev_health=prev) assert result.online is True assert result.device_name == "Test" assert result.device_led_count == 60 @pytest.mark.asyncio async def test_health_defaults_offline_when_no_prev(self): result = await BLEClient.check_health("ble://AA:BB:CC", http_client=None) assert result.online is False assert result.device_name == "AA:BB:CC" class TestGoveeAESEncryption: _KEY = bytes.fromhex("74657374746573747465737474657374") # "testtesttesttest" def test_encrypt_produces_32_bytes(self): from ledgrab.core.devices.ble_protocols.govee import encode_color frame = encode_color(255, 0, 0) encrypted = _encrypt_govee_frame(frame, self._KEY) assert len(encrypted) == 32 def test_encrypt_is_deterministic(self): frame = bytes(range(20)) assert _encrypt_govee_frame(frame, self._KEY) == _encrypt_govee_frame(frame, self._KEY) def test_encrypt_differs_from_plaintext(self): frame = bytes(range(20)) assert _encrypt_govee_frame(frame, self._KEY) != frame + b"\x00" * 12 def test_decrypt_roundtrip(self): from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes frame = bytes(range(20)) encrypted = _encrypt_govee_frame(frame, self._KEY) cipher = Cipher(algorithms.AES(self._KEY), modes.ECB()) dec = cipher.decryptor() decrypted = dec.update(encrypted) + dec.finalize() assert decrypted[:20] == frame @pytest.mark.asyncio async def test_govee_client_encrypts_frames(self, fake_transport_cls): key_hex = self._KEY.hex() client = BLEClient("ble://AA:BB:CC", ble_family="govee", ble_govee_key=key_hex) await client.connect() await client.send_pixels([(255, 0, 0)]) assert len(client._transport.writes) == 1 # Encrypted frame is 32 bytes, not the raw 20. assert len(client._transport.writes[0]) == 32 @pytest.mark.asyncio async def test_govee_client_without_key_sends_plaintext(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="govee") await client.connect() await client.send_pixels([(255, 0, 0)]) assert len(client._transport.writes[0]) == 20 @pytest.mark.asyncio async def test_invalid_key_hex_falls_back_gracefully(self, fake_transport_cls): client = BLEClient("ble://AA:BB:CC", ble_family="govee", ble_govee_key="not-hex!") await client.connect() ok = await client.send_pixels([(100, 100, 100)]) assert ok is True assert len(client._transport.writes[0]) == 20 # plaintext fallback @pytest.mark.asyncio async def test_govee_key_ignored_for_non_govee_family(self, fake_transport_cls): key_hex = self._KEY.hex() client = BLEClient("ble://AA:BB:CC", ble_family="sp110e", ble_govee_key=key_hex) assert client._aes_key is None await client.connect() await client.send_pixels([(100, 100, 100)]) # SP110E color frame is 4 bytes (RR GG BB CMD), not encrypted. color_writes = client._transport.color_writes assert len(color_writes) == 1 assert len(color_writes[0]) == 4