Files
ledgrab/server/tests/test_govee.py
T
alexei.dolgolyov 887131d4af feat(devices): Govee LAN target type
Adds support for Govee Wi-Fi smart bulbs and ambient-lighting kits via
their LAN API (opened in 2023). Discovery is multicast UDP on
239.255.255.250:4001; control commands go unicast to the device's port
4003; responses arrive on port 4002.

Each device requires "LAN Control" toggled ON in the Govee Home app
(Device -> settings -> LAN Control). Devices with LAN Control disabled
silently fail to appear in discovery and won't respond to commands; the
UI hint copy reminds users.

Backend:
- GoveeClient is a single-pixel UDP adapter: averages the strip to one
  RGB triple and pushes a 'colorwc' command with colorTemInKelvin=0 to
  select pure RGB mode (non-zero kelvin would switch the bulb to CCT
  mode and ignore the RGB values).
- Brightness folds into the RGB scaling so we burn one packet per
  frame instead of two.
- supports_fast_send=True with a synchronous send_pixels_fast hot path.
  Default rate gate 50 ms (~20 Hz); UDP fire-and-forget tolerates it.
- Multicast discovery: scan request to 239.255.255.250:4001, listen on
  port 4002, parse the inner data dict for IP + device-id + SKU +
  firmware version. Degrades to [] when port 4002 is already bound or
  network is unavailable.
- Health check sends devStatus and waits 1.5s for any reply; the error
  message points at the LAN-Control toggle since that's the #1 root
  cause of silent failures.
- GoveeConfig joins the typed config union; storage gains
  govee_min_interval_ms; full to_dict/from_dict/to_config wiring.
- 40 unit tests cover URL parsing, scan-reply parsing (rejecting
  non-scan commands and malformed JSON), payload builders (colorwc
  with colorTemInKelvin=0, brightness clamping, power as 1/0 not
  true/false), strip averaging, rate limiting, fast-send hot path,
  provider validate/discover/health, Device.to_config round-trip.

Frontend:
- 'govee' in DEVICE_TYPE_KEYS (next to 'lifx'), lightbulb icon
  (deliberate smart-bulb family grouping).
- isGoveeDevice predicate + per-type field show/hide.
- Rate-limit number input (default 50 ms).
- URL hint copy explicitly instructs users to enable LAN Control in
  the Govee Home app -- the #1 source of "why isn't my Govee
  responding?" support churn.
- Locale strings in en/ru/zh.
2026-05-16 02:47:15 +03:00

435 lines
13 KiB
Python

"""Tests for the Govee LAN LED client + provider."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import numpy as np
import pytest
from ledgrab.core.devices.device_config import GoveeConfig
from ledgrab.core.devices.govee_client import (
GOVEE_CONTROL_PORT,
GoveeClient,
_average_color,
_parse_scan_reply,
parse_govee_url,
)
from ledgrab.core.devices.govee_provider import GoveeDeviceProvider
from ledgrab.core.devices.led_client import ProviderDeps
# ============================================================================
# URL parsing
# ============================================================================
@pytest.mark.parametrize(
"url,expected",
[
("govee://192.168.1.50", "192.168.1.50"),
("192.168.1.50", "192.168.1.50"),
("bulb.local", "bulb.local"),
("govee://office-light.lan", "office-light.lan"),
],
)
def test_parse_govee_url(url, expected):
assert parse_govee_url(url) == expected
@pytest.mark.parametrize("url", ["", " ", "govee://", "://192.168.1.1"])
def test_parse_govee_url_rejects_empty(url):
with pytest.raises(ValueError):
parse_govee_url(url)
# ============================================================================
# Scan reply parsing
# ============================================================================
def test_parse_scan_reply_extracts_data_dict():
raw = json.dumps(
{
"msg": {
"cmd": "scan",
"data": {
"ip": "192.168.1.50",
"device": "AA:BB:CC:DD:EE:FF",
"sku": "H6076",
"wifiVersionSoft": "2.3.4",
},
}
}
).encode("utf-8")
parsed = _parse_scan_reply(raw)
assert parsed is not None
assert parsed["ip"] == "192.168.1.50"
assert parsed["sku"] == "H6076"
assert parsed["device"] == "AA:BB:CC:DD:EE:FF"
def test_parse_scan_reply_rejects_non_scan_cmd():
raw = json.dumps({"msg": {"cmd": "devStatus", "data": {"onOff": 1}}}).encode("utf-8")
assert _parse_scan_reply(raw) is None
def test_parse_scan_reply_rejects_malformed_json():
assert _parse_scan_reply(b"not json") is None
assert _parse_scan_reply(b"") is None
assert _parse_scan_reply(b'{"msg":') is None
def test_parse_scan_reply_rejects_non_dict_payload():
raw = json.dumps(["not", "a", "dict"]).encode("utf-8")
assert _parse_scan_reply(raw) 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)
# ============================================================================
# Payload builders
# ============================================================================
def test_build_color_payload_sets_colorTemInKelvin_to_zero():
"""colorTemInKelvin=0 selects RGB mode; otherwise the bulb uses CCT."""
payload = GoveeClient._build_color_payload(255, 128, 0)
assert payload == {
"msg": {
"cmd": "colorwc",
"data": {
"color": {"r": 255, "g": 128, "b": 0},
"colorTemInKelvin": 0,
},
}
}
def test_build_color_payload_clamps_channel_overflow():
payload = GoveeClient._build_color_payload(300, -5, 256)
data = payload["msg"]["data"]["color"]
assert data == {"r": 300 & 0xFF, "g": -5 & 0xFF, "b": 256 & 0xFF}
def test_build_brightness_payload_clamps_to_1_100():
assert GoveeClient._build_brightness_payload(0)["msg"]["data"]["value"] == 1
assert GoveeClient._build_brightness_payload(50)["msg"]["data"]["value"] == 50
assert GoveeClient._build_brightness_payload(200)["msg"]["data"]["value"] == 100
def test_build_power_payload_uses_integer_1_or_0():
"""Govee expects value 1/0, NOT JSON true/false."""
assert GoveeClient._build_power_payload(True)["msg"]["data"]["value"] == 1
assert GoveeClient._build_power_payload(False)["msg"]["data"]["value"] == 0
# ============================================================================
# GoveeClient (mocked transport)
# ============================================================================
def _make_connected_client(min_interval_s: float = 0.0) -> GoveeClient:
client = GoveeClient("govee://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_payloads(client: GoveeClient) -> list[dict]:
return [
json.loads(call.args[0].decode("utf-8")) for call in client._transport.sendto.call_args_list
]
def test_client_targets_control_port_4003():
client = GoveeClient("govee://192.168.1.50", led_count=1)
assert client.port == GOVEE_CONTROL_PORT
@pytest.mark.asyncio
async def test_send_pixels_averages_to_colorwc():
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]["msg"]["cmd"] == "colorwc"
# Average of (255,0,0), (0,255,0), (0,0,255) is (85, 85, 85)
assert payloads[0]["msg"]["data"]["color"] == {"r": 85, "g": 85, "b": 85}
assert payloads[0]["msg"]["data"]["colorTemInKelvin"] == 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)
color = payloads[0]["msg"]["data"]["color"]
assert color["r"] == int(200 * 128 / 255)
assert color["g"] == int(100 * 128 / 255)
assert color["b"] == int(50 * 128 / 255)
@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)
assert len(_sent_payloads(client)) == 1
@pytest.mark.asyncio
async def test_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)
assert len(_sent_payloads(client)) == 3
@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
client = GoveeClient("govee://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)
payloads = _sent_payloads(client)
assert len(payloads) == 1
assert payloads[0]["msg"]["cmd"] == "colorwc"
def test_supports_fast_send_is_true():
assert GoveeClient("govee://127.0.0.1", led_count=1).supports_fast_send is True
@pytest.mark.asyncio
async def test_set_power_sends_turn_cmd():
client = _make_connected_client()
await client.set_power(True)
await client.set_power(False)
payloads = _sent_payloads(client)
assert payloads[0] == {"msg": {"cmd": "turn", "data": {"value": 1}}}
assert payloads[1] == {"msg": {"cmd": "turn", "data": {"value": 0}}}
@pytest.mark.asyncio
async def test_set_brightness_sends_brightness_cmd_clamped():
client = _make_connected_client()
await client.set_brightness(5)
await client.set_brightness(50)
await client.set_brightness(150)
payloads = _sent_payloads(client)
values = [p["msg"]["data"]["value"] for p in payloads]
assert values == [5, 50, 100]
@pytest.mark.asyncio
async def test_set_color_sends_colorwc():
client = _make_connected_client()
await client.set_color(12, 34, 56)
payloads = _sent_payloads(client)
assert payloads[0]["msg"]["cmd"] == "colorwc"
assert payloads[0]["msg"]["data"]["color"] == {"r": 12, "g": 34, "b": 56}
@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 = GoveeDeviceProvider()
assert provider.device_type == "govee"
caps = provider.capabilities
assert "manual_led_count" in caps
assert "power_control" in caps
assert "brightness_control" in caps
assert "single_pixel" in caps
@pytest.mark.asyncio
async def test_provider_validate_accepts_bare_host():
provider = GoveeDeviceProvider()
assert await provider.validate_device("192.168.1.50") == {}
@pytest.mark.asyncio
async def test_provider_validate_rejects_empty():
provider = GoveeDeviceProvider()
with pytest.raises(ValueError, match="Invalid Govee URL"):
await provider.validate_device("")
def test_provider_create_client_threads_config():
provider = GoveeDeviceProvider()
config = GoveeConfig(
device_id="device_test",
device_url="govee://192.168.1.50",
led_count=30,
govee_min_interval_ms=100,
)
client = provider.create_client(config, deps=ProviderDeps())
assert isinstance(client, GoveeClient)
assert client.host == "192.168.1.50"
assert client.port == GOVEE_CONTROL_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("multicast unreachable")
monkeypatch.setattr("ledgrab.core.devices.govee_provider.discover_govee_devices", _explode)
provider = GoveeDeviceProvider()
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",
"device": "AA:BB:CC:DD:EE:FF",
"sku": "H6076",
"version": "2.3.4",
},
# Missing IP — should be skipped.
{"ip": "", "device": "00:00", "sku": "H1234"},
]
monkeypatch.setattr("ledgrab.core.devices.govee_provider.discover_govee_devices", _fake)
provider = GoveeDeviceProvider()
results = await provider.discover()
assert len(results) == 1
[bulb] = results
assert bulb.device_type == "govee"
assert bulb.url == "govee://192.168.1.50"
assert bulb.ip == "192.168.1.50"
assert bulb.mac == "AA:BB:CC:DD:EE:FF"
assert bulb.version == "2.3.4"
assert "h6076" in bulb.name.lower()
# ============================================================================
# Device.to_config() round-trip
# ============================================================================
def test_device_to_config_round_trip_govee():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Govee Immersion",
url="govee://192.168.1.42",
led_count=30,
device_type="govee",
govee_min_interval_ms=100,
)
config = device.to_config()
assert isinstance(config, GoveeConfig)
assert config.device_url == "govee://192.168.1.42"
assert config.led_count == 30
assert config.govee_min_interval_ms == 100
def test_device_to_dict_omits_govee_default_interval():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Default",
url="govee://192.168.1.42",
led_count=1,
device_type="govee",
)
assert "govee_min_interval_ms" not in device.to_dict()
def test_device_to_dict_preserves_non_default_govee_interval():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Custom",
url="govee://192.168.1.42",
led_count=1,
device_type="govee",
govee_min_interval_ms=200,
)
assert device.to_dict()["govee_min_interval_ms"] == 200
def test_device_from_dict_govee_round_trip():
from ledgrab.storage.device_store import Device
restored = Device.from_dict(
{
"id": "device_abc12345",
"name": "Roundtrip",
"url": "govee://10.0.0.1",
"led_count": 1,
"device_type": "govee",
"govee_min_interval_ms": 150,
}
)
assert restored.govee_min_interval_ms == 150