888f8fd16e
ruff --select UP007,UP045 --fix converted ~1760 sites across the backend: `Optional[T]` → `T | None`, `Union[X, Y]` → `X | Y`. The remaining module-level alias targets that ruff conservatively skips (BindableFloatInput, ColorList, DeviceConfig) were converted by hand earlier in the pass. black -formatted the result so the wider unions fit cleanly under the 100-char line budget. pyproject.toml now sets [tool.ruff.lint] extend-select = ["UP007", "UP045"] so future legacy imports fire CI on every push. The pre-commit ruff hook was bumped from v0.8.0 -> v0.15.12 to recognise UP045 (split off from UP007 in v0.13).
479 lines
14 KiB
Python
479 lines
14 KiB
Python
"""Tests for the LIFX LAN LED client + provider."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
from unittest.mock import MagicMock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from ledgrab.core.devices.device_config import LIFXConfig
|
|
from ledgrab.core.devices.led_client import ProviderDeps
|
|
from ledgrab.core.devices.lifx_client import (
|
|
LIFX_PORT,
|
|
MSG_GET_SERVICE,
|
|
MSG_SET_COLOR,
|
|
MSG_SET_POWER,
|
|
MSG_STATE_SERVICE,
|
|
LIFXClient,
|
|
_average_color,
|
|
_build_packet,
|
|
_build_set_color_payload,
|
|
_build_set_power_payload,
|
|
_parse_state_service_reply,
|
|
parse_lifx_url,
|
|
rgb_to_hsbk,
|
|
)
|
|
from ledgrab.core.devices.lifx_provider import LIFXDeviceProvider
|
|
|
|
# ============================================================================
|
|
# URL parsing
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,expected",
|
|
[
|
|
("lifx://192.168.1.50", ("192.168.1.50", LIFX_PORT)),
|
|
("lifx://192.168.1.50:56700", ("192.168.1.50", 56700)),
|
|
("192.168.1.50", ("192.168.1.50", LIFX_PORT)),
|
|
("192.168.1.50:56700", ("192.168.1.50", 56700)),
|
|
("bulb.local", ("bulb.local", LIFX_PORT)),
|
|
],
|
|
)
|
|
def test_parse_lifx_url(url, expected):
|
|
assert parse_lifx_url(url) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["", " ", "lifx://", "://192.168.1.1"])
|
|
def test_parse_lifx_url_rejects_empty(url):
|
|
with pytest.raises(ValueError):
|
|
parse_lifx_url(url)
|
|
|
|
|
|
# ============================================================================
|
|
# RGB → HSBK conversion
|
|
# ============================================================================
|
|
|
|
|
|
def test_rgb_to_hsbk_pure_red():
|
|
h, s, b, k = rgb_to_hsbk(255, 0, 0)
|
|
assert h == 0
|
|
assert s == 65535
|
|
assert b == 65535
|
|
assert 2500 <= k <= 9000
|
|
|
|
|
|
def test_rgb_to_hsbk_pure_green():
|
|
h, s, b, k = rgb_to_hsbk(0, 255, 0)
|
|
# Green is at 120° → hue = 120/360 * 65535 ≈ 21845
|
|
assert abs(h - 21845) < 5
|
|
assert s == 65535
|
|
assert b == 65535
|
|
|
|
|
|
def test_rgb_to_hsbk_pure_blue():
|
|
h, s, b, _k = rgb_to_hsbk(0, 0, 255)
|
|
assert abs(h - 43690) < 5
|
|
assert s == 65535
|
|
assert b == 65535
|
|
|
|
|
|
def test_rgb_to_hsbk_black_is_zero_brightness():
|
|
h, s, b, _k = rgb_to_hsbk(0, 0, 0)
|
|
assert b == 0
|
|
assert s == 0
|
|
|
|
|
|
def test_rgb_to_hsbk_white_has_full_brightness_zero_saturation():
|
|
_h, s, b, _k = rgb_to_hsbk(255, 255, 255)
|
|
assert s == 0
|
|
assert b == 65535
|
|
|
|
|
|
def test_rgb_to_hsbk_clamps_out_of_range_input():
|
|
# Negative / >255 should still produce a valid HSBK
|
|
h, s, b, _k = rgb_to_hsbk(-50, 999, 128)
|
|
assert 0 <= h <= 0xFFFF
|
|
assert 0 <= s <= 0xFFFF
|
|
assert 0 <= b <= 0xFFFF
|
|
|
|
|
|
# ============================================================================
|
|
# Packet construction
|
|
# ============================================================================
|
|
|
|
|
|
def test_build_packet_size_header_matches_total():
|
|
packet = _build_packet(msg_type=MSG_SET_POWER, payload=b"\x00\x01\x02\x03\x04\x05")
|
|
size = struct.unpack_from("<H", packet, 0)[0]
|
|
assert size == len(packet)
|
|
|
|
|
|
def test_build_packet_encodes_msg_type():
|
|
packet = _build_packet(msg_type=MSG_SET_COLOR, payload=b"")
|
|
msg_type = struct.unpack_from("<H", packet, 32)[0]
|
|
assert msg_type == MSG_SET_COLOR
|
|
|
|
|
|
def test_build_packet_tagged_flag_set():
|
|
packet = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True)
|
|
frame_field = struct.unpack_from("<H", packet, 2)[0]
|
|
# Tagged bit (0x2000) must be set in the frame header
|
|
assert frame_field & 0x2000
|
|
|
|
|
|
def test_build_packet_target_mac_at_offset_8():
|
|
target = b"\xaa\xbb\xcc\xdd\xee\xff"
|
|
packet = _build_packet(msg_type=MSG_SET_COLOR, payload=b"", target_mac=target)
|
|
assert packet[8:14] == target
|
|
|
|
|
|
def test_build_packet_sequence_byte_at_offset_23():
|
|
packet = _build_packet(msg_type=MSG_SET_COLOR, payload=b"", sequence=42)
|
|
assert packet[23] == 42
|
|
|
|
|
|
def test_set_color_payload_layout():
|
|
payload = _build_set_color_payload(h=10, s=20, b=30, k=4000, duration_ms=500)
|
|
# reserved byte + four uint16 + uint32 = 13 bytes
|
|
assert len(payload) == 13
|
|
assert payload[0] == 0
|
|
h, s, br, k, duration = struct.unpack_from("<HHHHI", payload, 1)
|
|
assert (h, s, br, k, duration) == (10, 20, 30, 4000, 500)
|
|
|
|
|
|
def test_set_power_payload_on_off():
|
|
on_payload = _build_set_power_payload(True)
|
|
off_payload = _build_set_power_payload(False)
|
|
on_level = struct.unpack_from("<H", on_payload, 0)[0]
|
|
off_level = struct.unpack_from("<H", off_payload, 0)[0]
|
|
assert on_level == 65535
|
|
assert off_level == 0
|
|
|
|
|
|
# ============================================================================
|
|
# StateService reply parsing
|
|
# ============================================================================
|
|
|
|
|
|
def test_parse_state_service_extracts_mac_and_port():
|
|
"""Build a synthetic LIFX reply and parse it back."""
|
|
mac = b"\xaa\xbb\xcc\xdd\xee\xff"
|
|
payload = struct.pack("<BI", 1, 56700) # service=1, port=56700
|
|
packet = _build_packet(
|
|
msg_type=MSG_STATE_SERVICE,
|
|
payload=payload,
|
|
target_mac=mac,
|
|
)
|
|
|
|
parsed = _parse_state_service_reply(packet)
|
|
assert parsed is not None
|
|
assert parsed["mac"] == mac.hex()
|
|
assert parsed["service"] == 1
|
|
assert parsed["port"] == 56700
|
|
|
|
|
|
def test_parse_state_service_rejects_wrong_msg_type():
|
|
"""A SetColor packet sent back must not be misread as a StateService."""
|
|
payload = _build_set_color_payload(0, 0, 0, 0)
|
|
packet = _build_packet(msg_type=MSG_SET_COLOR, payload=payload)
|
|
assert _parse_state_service_reply(packet) is None
|
|
|
|
|
|
def test_parse_state_service_rejects_runt_payload():
|
|
assert _parse_state_service_reply(b"") is None
|
|
assert _parse_state_service_reply(b"\x00" * 35) is None
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
|
|
def test_average_color_numpy():
|
|
pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
|
|
assert _average_color(pixels) == (40, 50, 60)
|
|
|
|
|
|
def test_average_color_list_and_empty():
|
|
assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0)
|
|
assert _average_color([]) == (0, 0, 0)
|
|
|
|
|
|
# ============================================================================
|
|
# LIFXClient (mocked transport)
|
|
# ============================================================================
|
|
|
|
|
|
def _make_connected_client(min_interval_s: float = 0.0) -> LIFXClient:
|
|
client = LIFXClient("lifx://127.0.0.1", led_count=10, min_interval_s=min_interval_s)
|
|
transport = MagicMock()
|
|
transport.sendto = MagicMock()
|
|
transport.close = MagicMock()
|
|
client._transport = transport
|
|
client._protocol = MagicMock()
|
|
client._connected = True
|
|
return client
|
|
|
|
|
|
def _sent_packets(client: LIFXClient) -> list[bytes]:
|
|
return [bytes(call.args[0]) for call in client._transport.sendto.call_args_list]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_emits_one_set_color_packet():
|
|
client = _make_connected_client()
|
|
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels)
|
|
|
|
packets = _sent_packets(client)
|
|
assert len(packets) == 1
|
|
msg_type = struct.unpack_from("<H", packets[0], 32)[0]
|
|
assert msg_type == MSG_SET_COLOR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_encodes_red_into_hsbk_payload():
|
|
client = _make_connected_client()
|
|
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels)
|
|
|
|
packet = _sent_packets(client)[0]
|
|
# SetColor payload starts at offset 36 (header) + 1 (reserved byte) = 37
|
|
h, s, b, _k, _dur = struct.unpack_from("<HHHHI", packet, 37)
|
|
assert h == 0
|
|
assert s == 65535
|
|
assert b == 65535
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_scales_brightness_by_dimming_rgb():
|
|
client = _make_connected_client()
|
|
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels, brightness=128)
|
|
|
|
packet = _sent_packets(client)[0]
|
|
_h, _s, b, _k, _dur = struct.unpack_from("<HHHHI", packet, 37)
|
|
# 255 * 128/255 = 128 → brightness ≈ 128/255 * 65535 ≈ 32896
|
|
assert abs(b - int((128 / 255) * 65535)) < 256
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rate_limit_drops_subsequent_frames():
|
|
client = _make_connected_client(min_interval_s=10.0)
|
|
pixels = np.array([[10, 20, 30]], dtype=np.uint8)
|
|
|
|
await client.send_pixels(pixels)
|
|
await client.send_pixels(pixels)
|
|
await client.send_pixels(pixels)
|
|
|
|
assert len(_sent_packets(client)) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_pixels_when_not_connected_raises():
|
|
client = LIFXClient("lifx://127.0.0.1", led_count=1)
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
|
await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))
|
|
|
|
|
|
def test_send_pixels_fast_runs_synchronously():
|
|
client = _make_connected_client(min_interval_s=0.0)
|
|
pixels = np.array([[10, 20, 30]], dtype=np.uint8)
|
|
|
|
client.send_pixels_fast(pixels)
|
|
|
|
packets = _sent_packets(client)
|
|
assert len(packets) == 1
|
|
msg_type = struct.unpack_from("<H", packets[0], 32)[0]
|
|
assert msg_type == MSG_SET_COLOR
|
|
|
|
|
|
def test_supports_fast_send_is_true():
|
|
assert LIFXClient("lifx://127.0.0.1", led_count=1).supports_fast_send is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_power_sends_set_power_msg():
|
|
client = _make_connected_client()
|
|
|
|
await client.set_power(True)
|
|
await client.set_power(False)
|
|
|
|
packets = _sent_packets(client)
|
|
types = [struct.unpack_from("<H", p, 32)[0] for p in packets]
|
|
assert types == [MSG_SET_POWER, MSG_SET_POWER]
|
|
on_level = struct.unpack_from("<H", packets[0], 36)[0]
|
|
off_level = struct.unpack_from("<H", packets[1], 36)[0]
|
|
assert on_level == 65535
|
|
assert off_level == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_color_sends_set_color_msg():
|
|
client = _make_connected_client()
|
|
|
|
await client.set_color(255, 0, 0)
|
|
|
|
packet = _sent_packets(client)[0]
|
|
msg_type = struct.unpack_from("<H", packet, 32)[0]
|
|
assert msg_type == MSG_SET_COLOR
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_releases_transport():
|
|
client = _make_connected_client()
|
|
transport = client._transport
|
|
await client.close()
|
|
transport.close.assert_called_once()
|
|
assert client._transport is None
|
|
assert client.is_connected is False
|
|
|
|
|
|
# ============================================================================
|
|
# Provider
|
|
# ============================================================================
|
|
|
|
|
|
def test_provider_device_type_and_capabilities():
|
|
provider = LIFXDeviceProvider()
|
|
assert provider.device_type == "lifx"
|
|
caps = provider.capabilities
|
|
assert "manual_led_count" in caps
|
|
assert "power_control" in caps
|
|
assert "single_pixel" in caps
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_accepts_bare_host():
|
|
provider = LIFXDeviceProvider()
|
|
assert await provider.validate_device("192.168.1.50") == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_validate_rejects_empty():
|
|
provider = LIFXDeviceProvider()
|
|
with pytest.raises(ValueError, match="Invalid LIFX URL"):
|
|
await provider.validate_device("")
|
|
|
|
|
|
def test_provider_create_client_threads_config():
|
|
provider = LIFXDeviceProvider()
|
|
config = LIFXConfig(
|
|
device_id="device_test",
|
|
device_url="lifx://192.168.1.50",
|
|
led_count=30,
|
|
lifx_min_interval_ms=100,
|
|
)
|
|
|
|
client = provider.create_client(config, deps=ProviderDeps())
|
|
|
|
assert isinstance(client, LIFXClient)
|
|
assert client.host == "192.168.1.50"
|
|
assert client.port == LIFX_PORT
|
|
assert client._led_count == 30
|
|
assert client._min_interval_s == pytest.approx(0.1)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_discover_returns_empty_on_failure(monkeypatch):
|
|
async def _explode(timeout):
|
|
raise OSError("network unreachable")
|
|
|
|
monkeypatch.setattr("ledgrab.core.devices.lifx_provider.discover_lifx_bulbs", _explode)
|
|
provider = LIFXDeviceProvider()
|
|
assert await provider.discover() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_discover_maps_replies_to_discovered_devices(monkeypatch):
|
|
async def _fake(timeout):
|
|
return [
|
|
{"ip": "192.168.1.50", "mac": "aabbccddeeff", "port": 56700},
|
|
{"ip": "", "mac": "1234567890ab", "port": 56700},
|
|
]
|
|
|
|
monkeypatch.setattr("ledgrab.core.devices.lifx_provider.discover_lifx_bulbs", _fake)
|
|
provider = LIFXDeviceProvider()
|
|
results = await provider.discover()
|
|
|
|
assert len(results) == 1
|
|
[bulb] = results
|
|
assert bulb.device_type == "lifx"
|
|
assert bulb.url == "lifx://192.168.1.50"
|
|
assert bulb.ip == "192.168.1.50"
|
|
assert bulb.mac == "aabbccddeeff"
|
|
|
|
|
|
# ============================================================================
|
|
# Device.to_config() round-trip
|
|
# ============================================================================
|
|
|
|
|
|
def test_device_to_config_round_trip_lifx():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Office LIFX",
|
|
url="lifx://192.168.1.42",
|
|
led_count=30,
|
|
device_type="lifx",
|
|
lifx_min_interval_ms=100,
|
|
)
|
|
|
|
config = device.to_config()
|
|
|
|
assert isinstance(config, LIFXConfig)
|
|
assert config.device_url == "lifx://192.168.1.42"
|
|
assert config.led_count == 30
|
|
assert config.lifx_min_interval_ms == 100
|
|
|
|
|
|
def test_device_to_dict_omits_lifx_default_interval():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Default",
|
|
url="lifx://192.168.1.42",
|
|
led_count=1,
|
|
device_type="lifx",
|
|
)
|
|
assert "lifx_min_interval_ms" not in device.to_dict()
|
|
|
|
|
|
def test_device_to_dict_preserves_non_default_lifx_interval():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
device = Device(
|
|
device_id="device_abc12345",
|
|
name="Custom",
|
|
url="lifx://192.168.1.42",
|
|
led_count=1,
|
|
device_type="lifx",
|
|
lifx_min_interval_ms=200,
|
|
)
|
|
assert device.to_dict()["lifx_min_interval_ms"] == 200
|
|
|
|
|
|
def test_device_from_dict_lifx_round_trip():
|
|
from ledgrab.storage.device_store import Device
|
|
|
|
restored = Device.from_dict(
|
|
{
|
|
"id": "device_abc12345",
|
|
"name": "Roundtrip",
|
|
"url": "lifx://10.0.0.1",
|
|
"led_count": 1,
|
|
"device_type": "lifx",
|
|
"lifx_min_interval_ms": 150,
|
|
}
|
|
)
|
|
assert restored.lifx_min_interval_ms == 150
|