Files
ledgrab/server/tests/test_lifx.py
T
alexei.dolgolyov 888f8fd16e refactor(types): PEP-604 union sweep + UP007/UP045 enforcement
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).
2026-05-23 01:21:44 +03:00

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