"""Tests for the Yeelight LAN LED client + provider.""" from __future__ import annotations import asyncio import json from unittest.mock import AsyncMock, MagicMock import numpy as np import pytest from ledgrab.core.devices.device_config import YeelightConfig from ledgrab.core.devices.led_client import ProviderDeps from ledgrab.core.devices.yeelight_client import ( YeelightClient, _average_color, _pack_rgb, _parse_ssdp_response, parse_yeelight_url, ) from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider # ============================================================================ # parse_yeelight_url # ============================================================================ @pytest.mark.parametrize( "url,expected", [ ("yeelight://192.168.1.50", "192.168.1.50"), ("yeelight://192.168.1.50:55443", "192.168.1.50"), ("192.168.1.50", "192.168.1.50"), ("192.168.1.50:55443", "192.168.1.50"), ("bulb.local", "bulb.local"), ], ) def test_parse_yeelight_url(url, expected): assert parse_yeelight_url(url) == expected @pytest.mark.parametrize("url", ["", " ", "yeelight://", "://192.168.1.1"]) def test_parse_yeelight_url_rejects_empty(url): with pytest.raises(ValueError): parse_yeelight_url(url) # ============================================================================ # Helpers # ============================================================================ def test_pack_rgb_packs_24_bit_int(): assert _pack_rgb(255, 0, 0) == 0xFF0000 assert _pack_rgb(0, 255, 0) == 0x00FF00 assert _pack_rgb(0, 0, 255) == 0x0000FF assert _pack_rgb(0x12, 0x34, 0x56) == 0x123456 # Clamps to a byte assert _pack_rgb(300, -5, 256) & 0xFFFFFF == _pack_rgb(300 & 0xFF, -5 & 0xFF, 256 & 0xFF) 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(): assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0) def test_average_color_empty(): assert _average_color([]) == (0, 0, 0) assert _average_color(np.array([], dtype=np.uint8)) == (0, 0, 0) # ============================================================================ # YeelightClient (mocked transport) # ============================================================================ def _make_connected_client(min_interval_s: float = 0.0) -> YeelightClient: client = YeelightClient("yeelight://127.0.0.1", led_count=10, min_interval_s=min_interval_s) writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() writer.close = MagicMock() writer.wait_closed = AsyncMock() client._writer = writer client._reader = MagicMock() client._connected = True return client def _sent_payloads(client: YeelightClient) -> list[dict]: """Decode every JSON-RPC body the client has written.""" payloads = [] for call in client._writer.write.call_args_list: raw = call.args[0].decode("utf-8").strip() payloads.append(json.loads(raw)) return payloads @pytest.mark.asyncio async def test_send_pixels_averages_and_packs_rgb(): client = _make_connected_client() pixels = np.array( [[255, 0, 0], [0, 255, 0], [0, 0, 255]], dtype=np.uint8, ) await client.send_pixels(pixels) payloads = _sent_payloads(client) assert len(payloads) == 1 assert payloads[0]["method"] == "set_rgb" # Average of (255,0,0), (0,255,0), (0,0,255) is (85, 85, 85) → 0x555555 assert payloads[0]["params"][0] == _pack_rgb(85, 85, 85) # Effect & duration: ambilight needs sudden + 0 ms assert payloads[0]["params"][1] == "sudden" assert payloads[0]["params"][2] == 0 @pytest.mark.asyncio async def test_send_pixels_scales_for_brightness(): client = _make_connected_client() pixels = np.array([[200, 100, 50]], dtype=np.uint8) await client.send_pixels(pixels, brightness=128) payloads = _sent_payloads(client) # Scaled: 200*0.501..→100, 100→50, 50→25 expected = _pack_rgb(int(200 * 128 / 255), int(100 * 128 / 255), int(50 * 128 / 255)) assert payloads[0]["params"][0] == expected @pytest.mark.asyncio async def test_send_pixels_full_brightness_passthrough(): client = _make_connected_client() pixels = np.array([[200, 100, 50]], dtype=np.uint8) await client.send_pixels(pixels, brightness=255) payloads = _sent_payloads(client) assert payloads[0]["params"][0] == _pack_rgb(200, 100, 50) @pytest.mark.asyncio async def test_send_pixels_rate_limit_drops_subsequent_calls(): """Within the min interval, the second call no-ops without TX.""" client = _make_connected_client(min_interval_s=10.0) # huge gate pixels = np.array([[10, 20, 30]], dtype=np.uint8) await client.send_pixels(pixels) await client.send_pixels(pixels) await client.send_pixels(pixels) payloads = _sent_payloads(client) assert len(payloads) == 1 # only the first one made it through @pytest.mark.asyncio async def test_send_pixels_zero_interval_sends_every_frame(): client = _make_connected_client(min_interval_s=0.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) payloads = _sent_payloads(client) assert len(payloads) == 3 @pytest.mark.asyncio async def test_send_pixels_when_not_connected_raises(): client = YeelightClient("yeelight://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)) @pytest.mark.asyncio async def test_set_power_sends_set_power_command(): client = _make_connected_client() await client.set_power(True) await client.set_power(False) payloads = _sent_payloads(client) assert [p["method"] for p in payloads] == ["set_power", "set_power"] assert payloads[0]["params"][0] == "on" assert payloads[1]["params"][0] == "off" @pytest.mark.asyncio async def test_set_brightness_clamps_to_1_100(): client = _make_connected_client() await client.set_brightness(0) await client.set_brightness(50) await client.set_brightness(200) payloads = _sent_payloads(client) # Yeelight bulbs reject brightness 0 (use set_power off instead) — clamp to 1. assert payloads[0]["params"][0] == 1 assert payloads[1]["params"][0] == 50 assert payloads[2]["params"][0] == 100 @pytest.mark.asyncio async def test_close_releases_transport(): client = _make_connected_client() writer = client._writer await client.close() writer.close.assert_called_once() assert client._writer is None assert client.is_connected is False # ============================================================================ # SSDP response parsing # ============================================================================ _SAMPLE_RESPONSE = ( b"HTTP/1.1 200 OK\r\n" b"Cache-Control: max-age=3600\r\n" b"Date: \r\n" b"Ext: \r\n" b"Location: yeelight://192.168.1.50:55443\r\n" b"Server: POSIX UPnP/1.0 YGLC/1\r\n" b"id: 0x000000000035cb01\r\n" b"model: color\r\n" b"fw_ver: 18\r\n" b"support: get_prop set_default set_power set_bright\r\n" b"power: off\r\n" b"bright: 100\r\n" b"color_mode: 2\r\n" b"ct: 4000\r\n" b"rgb: 16711680\r\n" ) def test_parse_ssdp_response_extracts_headers(): headers = _parse_ssdp_response(_SAMPLE_RESPONSE) assert headers is not None assert headers["location"] == "yeelight://192.168.1.50:55443" assert headers["id"] == "0x000000000035cb01" assert headers["model"] == "color" assert headers["fw_ver"] == "18" def test_parse_ssdp_response_rejects_non_yeelight(): """A stray HTTP response from another SSDP service should be ignored.""" other = b"HTTP/1.1 200 OK\r\nLocation: upnp://something/else\r\n" assert _parse_ssdp_response(other) is None # ============================================================================ # Provider # ============================================================================ def test_provider_device_type_and_capabilities(): provider = YeelightDeviceProvider() assert provider.device_type == "yeelight" caps = provider.capabilities assert "manual_led_count" in caps assert "power_control" in caps assert "brightness_control" in caps assert "static_color" in caps assert "single_pixel" in caps @pytest.mark.asyncio async def test_provider_validate_device_accepts_bare_host(): provider = YeelightDeviceProvider() assert await provider.validate_device("192.168.1.50") == {} @pytest.mark.asyncio async def test_provider_validate_device_rejects_empty(): provider = YeelightDeviceProvider() with pytest.raises(ValueError, match="Invalid Yeelight URL"): await provider.validate_device("") def test_provider_create_client_threads_config(): provider = YeelightDeviceProvider() config = YeelightConfig( device_id="device_test", device_url="yeelight://192.168.1.50", led_count=30, yeelight_min_interval_ms=750, ) client = provider.create_client(config, deps=ProviderDeps()) assert isinstance(client, YeelightClient) assert client.host == "192.168.1.50" assert client._led_count == 30 assert client._min_interval_s == pytest.approx(0.75) @pytest.mark.asyncio async def test_provider_discover_returns_empty_on_failure(monkeypatch): """Multicast failures (no network, firewall) must yield [], not raise.""" async def _explode(timeout): raise OSError("no route to host") monkeypatch.setattr( "ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs", _explode, ) provider = YeelightDeviceProvider() assert await provider.discover() == [] @pytest.mark.asyncio async def test_provider_discover_maps_ssdp_to_discovered_device(monkeypatch): async def _fake(timeout): return [ { "location": "yeelight://192.168.1.50:55443", "id": "0x0035cb01", "model": "color", "fw_ver": "18", }, # Missing location should be skipped, not crash. {"id": "0xff", "model": "stripe"}, ] monkeypatch.setattr( "ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs", _fake, ) provider = YeelightDeviceProvider() results = await provider.discover() assert len(results) == 1 [bulb] = results assert bulb.device_type == "yeelight" assert bulb.url == "yeelight://192.168.1.50" assert bulb.ip == "192.168.1.50" assert bulb.mac == "0x0035cb01" assert bulb.version == "18" assert "color" in bulb.name.lower() # ============================================================================ # Device.to_config() round-trip # ============================================================================ def test_device_to_config_round_trip_yeelight(): from ledgrab.storage.device_store import Device device = Device( device_id="device_abc12345", name="Bedroom bulb", url="yeelight://192.168.1.42", led_count=30, device_type="yeelight", yeelight_min_interval_ms=750, ) config = device.to_config() assert isinstance(config, YeelightConfig) assert config.device_url == "yeelight://192.168.1.42" assert config.led_count == 30 assert config.yeelight_min_interval_ms == 750 def test_device_to_dict_omits_yeelight_default_interval(): from ledgrab.storage.device_store import Device device = Device( device_id="device_abc12345", name="Default", url="yeelight://192.168.1.42", led_count=1, device_type="yeelight", ) assert "yeelight_min_interval_ms" not in device.to_dict() def test_device_to_dict_preserves_non_default_yeelight_interval(): from ledgrab.storage.device_store import Device device = Device( device_id="device_abc12345", name="Custom", url="yeelight://192.168.1.42", led_count=1, device_type="yeelight", yeelight_min_interval_ms=1000, ) assert device.to_dict()["yeelight_min_interval_ms"] == 1000 def test_device_from_dict_yeelight_round_trip(): from ledgrab.storage.device_store import Device restored = Device.from_dict( { "id": "device_abc12345", "name": "Roundtrip", "url": "yeelight://10.0.0.1", "led_count": 1, "device_type": "yeelight", "yeelight_min_interval_ms": 250, } ) assert restored.yeelight_min_interval_ms == 250 # Suppress the asyncio CancelledError that asyncio raises on # garbage-collected mock writers — they're swallowed in close() but # pytest-asyncio still warns about them when MagicMock is used. @pytest.fixture(autouse=True) def _suppress_asyncio_warnings(): yield asyncio.get_event_loop_policy()