Files
ledgrab/server/tests/test_yeelight.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

421 lines
13 KiB
Python

"""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()