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.
This commit is contained in:
@@ -727,7 +727,11 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f
|
||||
conversion; broadcast discovery via GetService/StateService probe;
|
||||
47 unit tests. Single-pixel adapter shape, identical to WiZ
|
||||
structurally. Frontend wired via subagent.
|
||||
- [ ] Govee LAN API (2023+)
|
||||
- [x] **Govee LAN API** — UDP JSON on port 4003 (control) + 4002
|
||||
(responses) + 4001 (multicast discovery on 239.255.255.250).
|
||||
Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
|
||||
mode. **Per-device "LAN Control" toggle required in Govee Home
|
||||
app.** 40 unit tests. Frontend wired via subagent.
|
||||
- [ ] Twinkly
|
||||
- [ ] Nanoleaf OpenAPI
|
||||
- [ ] Mi-Light / MiBoxer UDP gateway
|
||||
|
||||
@@ -69,6 +69,7 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
yeelight_min_interval_ms=device.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=device.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=device.govee_min_interval_ms,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
@@ -239,6 +240,11 @@ async def create_device(
|
||||
if device_data.lifx_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
govee_min_interval_ms=(
|
||||
device_data.govee_min_interval_ms
|
||||
if device_data.govee_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
@@ -504,6 +510,7 @@ async def update_device(
|
||||
yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=update_data.wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=update_data.lifx_min_interval_ms,
|
||||
govee_min_interval_ms=update_data.govee_min_interval_ms,
|
||||
spi_speed_hz=update_data.spi_speed_hz,
|
||||
spi_led_type=update_data.spi_led_type,
|
||||
chroma_device_type=update_data.chroma_device_type,
|
||||
|
||||
@@ -84,6 +84,13 @@ class DeviceCreate(BaseModel):
|
||||
le=10000,
|
||||
description="LIFX client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# Govee fields
|
||||
govee_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="Govee client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: Optional[int] = Field(
|
||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||
@@ -181,6 +188,9 @@ class DeviceUpdate(BaseModel):
|
||||
lifx_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="LIFX client-side rate limit in ms"
|
||||
)
|
||||
govee_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Govee client-side rate limit in ms"
|
||||
)
|
||||
spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||
@@ -355,6 +365,7 @@ class DeviceResponse(BaseModel):
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ client-side rate limit in ms")
|
||||
lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms")
|
||||
govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms")
|
||||
spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||
|
||||
@@ -112,6 +112,18 @@ class LIFXConfig(BaseDeviceConfig):
|
||||
lifx_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GoveeConfig(BaseDeviceConfig):
|
||||
"""Govee Wi-Fi bulb / ambient kit reachable via the LAN API.
|
||||
|
||||
Each device needs "LAN Control" toggled ON in the Govee Home app before
|
||||
it answers discovery or commands. UDP fire-and-forget tolerates ~20 Hz.
|
||||
"""
|
||||
|
||||
device_type: Literal["govee"] = "govee"
|
||||
govee_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SPIConfig(BaseDeviceConfig):
|
||||
device_type: Literal["spi"] = "spi"
|
||||
@@ -185,6 +197,7 @@ DeviceConfig = Union[
|
||||
YeelightConfig,
|
||||
WiZConfig,
|
||||
LIFXConfig,
|
||||
GoveeConfig,
|
||||
AdalightConfig,
|
||||
AmbiLEDConfig,
|
||||
DMXConfig,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Govee LAN API LED client.
|
||||
|
||||
Govee opened a local LAN API in 2023 for its Wi-Fi smart bulbs and
|
||||
ambient-light kits. Discovery is multicast on ``239.255.255.250:4001``;
|
||||
control commands go unicast to the bulb's port ``4003``; the bulb sends
|
||||
responses on port ``4002`` (which we don't listen on for ambient streaming).
|
||||
|
||||
Prerequisite for every device: the user must toggle "LAN Control" ON in the
|
||||
Govee Home app (Device → ⚙ → LAN Control). Devices with LAN Control disabled
|
||||
do not respond to discovery or commands. The UI hint copy reminds the user.
|
||||
|
||||
URL scheme: ``govee://<host>`` or bare ``<host>``. Port 4003 is fixed.
|
||||
|
||||
Reference: https://app-h5.govee.com/user-manual/wlan-guide
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
GOVEE_DISCOVERY_PORT = 4001
|
||||
GOVEE_RESPONSE_PORT = 4002
|
||||
GOVEE_CONTROL_PORT = 4003
|
||||
GOVEE_MULTICAST_GROUP = "239.255.255.250"
|
||||
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz; UDP fire-and-forget, no ack
|
||||
|
||||
_DISCOVERY_REQUEST = json.dumps(
|
||||
{"msg": {"cmd": "scan", "data": {"account_topic": "reserve"}}}
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def parse_govee_url(url: str) -> str:
|
||||
"""Pull the host out of ``govee://host`` or bare ``host``."""
|
||||
if not url:
|
||||
raise ValueError("Govee URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
else:
|
||||
parsed = urlparse(f"govee://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
raise ValueError(f"Govee URL has no host: {url!r}")
|
||||
return host
|
||||
|
||||
|
||||
def _average_color(
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB triple."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
class _GoveeProtocol(asyncio.DatagramProtocol):
|
||||
"""Write-only datagram protocol. Bulb replies (on 4002) are not collected here."""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
logger.debug("Govee UDP error: %s", exc)
|
||||
|
||||
|
||||
class GoveeClient(LEDClient):
|
||||
"""LEDClient for a single Govee LAN-enabled bulb / ambient kit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
):
|
||||
self._host = parse_govee_url(url)
|
||||
self._port = GOVEE_CONTROL_PORT
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_GoveeProtocol] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._transport is not None:
|
||||
return True
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
_GoveeProtocol, remote_addr=(self._host, self._port)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open UDP to Govee at {self._host}: {exc}") from exc
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("GoveeClient connected to %s:%d", self._host, self._port)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._connected = False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
assert self._transport is not None
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self._transport.sendto(raw)
|
||||
|
||||
@staticmethod
|
||||
def _build_color_payload(r: int, g: int, b: int) -> dict:
|
||||
"""Govee colorwc command. ``colorTemInKelvin=0`` selects pure RGB."""
|
||||
return {
|
||||
"msg": {
|
||||
"cmd": "colorwc",
|
||||
"data": {
|
||||
"color": {"r": r & 0xFF, "g": g & 0xFF, "b": b & 0xFF},
|
||||
"colorTemInKelvin": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_brightness_payload(value_0_100: int) -> dict:
|
||||
return {
|
||||
"msg": {
|
||||
"cmd": "brightness",
|
||||
"data": {"value": max(1, min(100, value_0_100))},
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_power_payload(on: bool) -> dict:
|
||||
return {"msg": {"cmd": "turn", "data": {"value": 1 if on else 0}}}
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Average the strip → colorwc with the resulting RGB."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous variant for the hot loop."""
|
||||
if not self.is_connected or self._transport is None:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_color_payload(r, g, b))
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_brightness_payload(brightness_0_100))
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("GoveeClient not connected")
|
||||
self._send_json(self._build_power_payload(on))
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send devStatus and wait briefly for a reply on port 4002.
|
||||
|
||||
Govee bulbs send responses to whatever port the request came from
|
||||
when using ``connected`` UDP, so we bind to a random ephemeral port
|
||||
and accept any reply. Health is best-effort — a silent bulb may
|
||||
still be online (it just hasn't toggled LAN Control on yet).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host = parse_govee_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
probe = json.dumps({"msg": {"cmd": "devStatus", "data": {}}}).encode("utf-8")
|
||||
start = loop.time()
|
||||
await loop.sock_sendto(sock, probe, (host, GOVEE_CONTROL_PORT))
|
||||
try:
|
||||
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
|
||||
except asyncio.TimeoutError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=(
|
||||
f"No Govee reply from {host} within 1.5s — is "
|
||||
"LAN Control enabled in the Govee Home app?"
|
||||
),
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
except OSError as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"Govee probe failed for {host}: {exc}",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Multicast discovery
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _parse_scan_reply(raw: bytes) -> Optional[dict]:
|
||||
"""Parse a Govee scan reply into a flat metadata dict.
|
||||
|
||||
Govee sends ``{"msg": {"cmd": "scan", "data": {"ip": ..., "device": ...,
|
||||
"sku": ..., "wifiVersionSoft": ..., ...}}}``. Returns the inner ``data``
|
||||
dict, or ``None`` for malformed packets.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
msg = payload.get("msg")
|
||||
if not isinstance(msg, dict):
|
||||
return None
|
||||
if msg.get("cmd") != "scan":
|
||||
return None
|
||||
data = msg.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
async def discover_govee_devices(timeout: float = 2.0) -> List[dict]:
|
||||
"""Multicast a scan request and collect Govee scan replies.
|
||||
|
||||
Returns a list of ``{"ip": ..., "device": ..., "sku": ..., "version": ...}``
|
||||
dicts. Multicast / receive failures (no network, firewall, no LAN-enabled
|
||||
bulbs) yield an empty list rather than raising.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
# We bind a separate socket to the response port (4002). If something
|
||||
# else on the host already owns 4002 (rare; another Govee tool), the
|
||||
# bind fails and we degrade gracefully to an empty result.
|
||||
recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
recv_sock.setblocking(False)
|
||||
send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
||||
send_sock.setblocking(False)
|
||||
try:
|
||||
try:
|
||||
recv_sock.bind(("", GOVEE_RESPONSE_PORT))
|
||||
except OSError as exc:
|
||||
logger.warning("Govee discovery: cannot bind %d (%s)", GOVEE_RESPONSE_PORT, exc)
|
||||
return []
|
||||
send_sock.bind(("", 0))
|
||||
await loop.sock_sendto(
|
||||
send_sock, _DISCOVERY_REQUEST, (GOVEE_MULTICAST_GROUP, GOVEE_DISCOVERY_PORT)
|
||||
)
|
||||
results: list[dict] = []
|
||||
seen_ips: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(recv_sock, 4096),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
data = _parse_scan_reply(raw)
|
||||
if not data:
|
||||
continue
|
||||
ip = data.get("ip") or addr[0]
|
||||
if not ip or ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
results.append(
|
||||
{
|
||||
"ip": ip,
|
||||
"device": data.get("device", ""),
|
||||
"sku": data.get("sku", ""),
|
||||
"version": data.get("wifiVersionSoft", "") or data.get("bleVersionSoft", ""),
|
||||
}
|
||||
)
|
||||
return results
|
||||
finally:
|
||||
recv_sock.close()
|
||||
send_sock.close()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Govee LAN device provider — LAN-discoverable Govee Wi-Fi bulbs and kits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.govee_client import (
|
||||
GoveeClient,
|
||||
discover_govee_devices,
|
||||
parse_govee_url,
|
||||
)
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import GoveeConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GoveeDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Govee LAN-enabled Wi-Fi smart bulbs and ambient kits.
|
||||
|
||||
Single-pixel adapter (averaging shape). Note that **per-device LAN
|
||||
Control toggle must be enabled in the Govee Home app** before the bulb
|
||||
will respond to discovery or commands — the UI hint copy reminds users.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "govee"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "GoveeConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return GoveeClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.govee_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await GoveeClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host = parse_govee_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid Govee URL: {exc}") from exc
|
||||
logger.info("Govee device URL validated: host=%s", host)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
try:
|
||||
bulbs = await discover_govee_devices(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
logger.warning("Govee discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for bulb in bulbs:
|
||||
ip = bulb.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
url = f"govee://{ip}"
|
||||
sku = bulb.get("sku") or "Govee"
|
||||
mac_like = bulb.get("device", "")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"Govee {sku}".strip(),
|
||||
url=url,
|
||||
device_type="govee",
|
||||
ip=ip,
|
||||
mac=mac_like,
|
||||
led_count=None,
|
||||
version=bulb.get("version") or None,
|
||||
)
|
||||
)
|
||||
logger.info("Govee multicast scan found %d device(s)", len(results))
|
||||
return results
|
||||
@@ -350,6 +350,10 @@ def _register_builtin_providers():
|
||||
|
||||
register_provider(LIFXDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.govee_provider import GoveeDeviceProvider
|
||||
|
||||
register_provider(GoveeDeviceProvider())
|
||||
|
||||
# BLE support is optional — only register the provider if the ``bleak``
|
||||
# extra is installed. Importing the provider itself is safe (it doesn't
|
||||
# import bleak at module load), but we still want a clean skip on
|
||||
|
||||
@@ -171,6 +171,10 @@ export function isLifxDevice(type: string) {
|
||||
return type === 'lifx';
|
||||
}
|
||||
|
||||
export function isGoveeDevice(type: string) {
|
||||
return type === 'govee';
|
||||
}
|
||||
|
||||
export function isUsbhidDevice(type: string) {
|
||||
return type === 'usbhid';
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const _deviceTypeIcons = {
|
||||
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb),
|
||||
usbhid: _svg(P.usb),
|
||||
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
|
||||
ble: _svg(P.bluetooth),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||
@@ -44,6 +44,7 @@ class AddDeviceModal extends Modal {
|
||||
yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
|
||||
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
|
||||
lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50',
|
||||
goveeMinInterval: (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value || '50',
|
||||
groupChildren: JSON.stringify(_getGroupChildIds('device')),
|
||||
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
|
||||
};
|
||||
@@ -54,7 +55,7 @@ const addDeviceModal = new AddDeviceModal();
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
|
||||
function _buildDeviceTypeItems() {
|
||||
return DEVICE_TYPE_KEYS.map(key => ({
|
||||
@@ -284,6 +285,7 @@ export function onDeviceTypeChanged() {
|
||||
_showYeelightFields(false);
|
||||
_showWizFields(false);
|
||||
_showLifxFields(false);
|
||||
_showGoveeFields(false);
|
||||
_showBleFields(false);
|
||||
_showSpiFields(false);
|
||||
_showChromaFields(false);
|
||||
@@ -527,6 +529,31 @@ export function onDeviceTypeChanged() {
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
} else if (isGoveeDevice(deviceType)) {
|
||||
// Govee: 2023+ LAN API over UDP fire-and-forget on port 4003.
|
||||
// Discovery uses multicast UDP 239.255.255.250:4001 — same scan
|
||||
// flow as the rest of the LAN-bulb family. Each device requires
|
||||
// "LAN Control" toggled ON inside the Govee Home app
|
||||
// (Device → ⚙ → LAN Control); the hint copy mentions this since
|
||||
// it's the #1 source of "why isn't my Govee responding?" issues.
|
||||
urlGroup.style.display = '';
|
||||
urlInput.setAttribute('required', '');
|
||||
serialGroup.style.display = 'none';
|
||||
serialSelect.removeAttribute('required');
|
||||
ledCountGroup.style.display = '';
|
||||
baudRateGroup.style.display = 'none';
|
||||
if (ledTypeGroup) ledTypeGroup.style.display = 'none';
|
||||
if (sendLatencyGroup) sendLatencyGroup.style.display = 'none';
|
||||
if (scanBtn) scanBtn.style.display = '';
|
||||
_showGoveeFields(true);
|
||||
if (urlLabel) urlLabel.textContent = t('device.govee.url') || 'IP Address:';
|
||||
if (urlHint) urlHint.textContent = t('device.govee.url.hint') || 'LAN IP of the Govee device. Enable LAN Control in the Govee Home app first (Device → ⚙ → LAN Control), or the bulb won’t respond.';
|
||||
urlInput.placeholder = t('device.govee.url.placeholder') || '192.168.1.50';
|
||||
if (deviceType in _discoveryCache) {
|
||||
_renderDiscoveryList();
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
} else if (isBleDevice(deviceType)) {
|
||||
// BLE: show URL (ble://<address>), LED count, protocol family picker,
|
||||
// and a Govee-only AES key field that toggles with the family selection.
|
||||
@@ -887,6 +914,13 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
||||
lmi.value = String(cloneData.lifx_min_interval_ms);
|
||||
}
|
||||
}
|
||||
// Prefill Govee fields
|
||||
if (isGoveeDevice(presetType)) {
|
||||
const gmi = document.getElementById('device-govee-min-interval') as HTMLInputElement;
|
||||
if (gmi && cloneData.govee_min_interval_ms != null) {
|
||||
gmi.value = String(cloneData.govee_min_interval_ms);
|
||||
}
|
||||
}
|
||||
// Prefill CSPT template selector (after fetch completes)
|
||||
if (cloneData.default_css_processing_template_id) {
|
||||
csptCache.fetch().then(() => {
|
||||
@@ -1103,6 +1137,11 @@ export async function handleAddDevice(event: any) {
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isGoveeDevice(deviceType)) {
|
||||
const raw = (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isBleDevice(deviceType)) {
|
||||
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
|
||||
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
|
||||
@@ -1471,6 +1510,11 @@ function _showLifxFields(show: boolean) {
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function _showGoveeFields(show: boolean) {
|
||||
const el = document.getElementById('device-govee-min-interval-group') as HTMLElement | null;
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
// Tracks whether the BLE fields are currently shown — avoids reading
|
||||
// style.display strings in _updateBleGoveeKeyVisibility.
|
||||
let _bleFieldsVisible = false;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, isLifxDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { devicesCache } from '../core/state.ts';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
||||
import { t } from '../core/i18n.ts';
|
||||
@@ -98,6 +98,7 @@ class DeviceSettingsModal extends Modal {
|
||||
yeelightMinInterval: (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value || '500',
|
||||
wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50',
|
||||
lifxMinInterval: (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value || '50',
|
||||
goveeMinInterval: (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value || '50',
|
||||
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
|
||||
};
|
||||
}
|
||||
@@ -686,6 +687,24 @@ export async function showSettings(deviceId: any) {
|
||||
if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// Govee-specific fields — 2023+ LAN API over UDP fire-and-forget
|
||||
// on port 4003. Critical UX note: the hint reminds users to enable
|
||||
// "LAN Control" in the Govee Home app or the device won't respond.
|
||||
const goveeMinIntervalGroup = document.getElementById('settings-govee-min-interval-group');
|
||||
if (isGoveeDevice(device.device_type)) {
|
||||
if (goveeMinIntervalGroup) (goveeMinIntervalGroup as HTMLElement).style.display = '';
|
||||
const gmi = device.govee_min_interval_ms ?? 50;
|
||||
(document.getElementById('settings-govee-min-interval') as HTMLInputElement).value = String(gmi);
|
||||
// Relabel URL field as IP Address (same pattern as LIFX/WiZ/Yeelight/DMX/DDP)
|
||||
const urlLabel7 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
|
||||
const urlHint7 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
if (urlLabel7) urlLabel7.textContent = t('device.govee.url');
|
||||
if (urlHint7) urlHint7.textContent = t('device.govee.url.hint');
|
||||
urlInput.placeholder = t('device.govee.url.placeholder') || '192.168.1.50';
|
||||
} else {
|
||||
if (goveeMinIntervalGroup) (goveeMinIntervalGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// BLE-specific fields — exposed in the settings modal so the user
|
||||
// can fix a wrong protocol family pick without deleting+recreating
|
||||
// the device. Uses the shared IconSelect grid (project rule bans
|
||||
@@ -837,6 +856,11 @@ export async function saveDeviceSettings() {
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isGoveeDevice(settingsModal.deviceType)) {
|
||||
const raw = (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isBleDevice(settingsModal.deviceType)) {
|
||||
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e';
|
||||
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
|
||||
|
||||
@@ -47,7 +47,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
|
||||
|
||||
export type DeviceType =
|
||||
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
|
||||
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx'
|
||||
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee'
|
||||
| 'ble' | 'usbhid' | 'spi'
|
||||
| 'chroma' | 'gamesense' | 'group';
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface Device {
|
||||
yeelight_min_interval_ms: number;
|
||||
wiz_min_interval_ms: number;
|
||||
lifx_min_interval_ms: number;
|
||||
govee_min_interval_ms: number;
|
||||
spi_speed_hz: number;
|
||||
spi_led_type: string;
|
||||
chroma_device_type: string;
|
||||
|
||||
@@ -218,6 +218,13 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "Min Update Interval:",
|
||||
"device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.",
|
||||
"device.type.govee": "Govee",
|
||||
"device.type.govee.desc": "Govee Wi-Fi bulb / ambient kit via LAN API",
|
||||
"device.govee.url": "IP Address:",
|
||||
"device.govee.url.hint": "LAN IP of the Govee device. Enable LAN Control in the Govee Home app first (Device → ⚙ → LAN Control), or the bulb won’t respond.",
|
||||
"device.govee.url.placeholder": "192.168.1.50",
|
||||
"device.govee_min_interval": "Min Update Interval:",
|
||||
"device.govee_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.",
|
||||
"device.type.ble": "BLE LED Controller",
|
||||
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
|
||||
"device.ble.url": "BLE Address:",
|
||||
|
||||
@@ -273,6 +273,13 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "Мин. интервал обновления:",
|
||||
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.",
|
||||
"device.type.govee": "Govee",
|
||||
"device.type.govee.desc": "Лампа / комплект Govee Wi-Fi через LAN API",
|
||||
"device.govee.url": "IP-адрес:",
|
||||
"device.govee.url.hint": "IP-адрес устройства Govee в локальной сети. Сначала включите LAN Control в приложении Govee Home (Устройство → ⚙ → LAN Control), иначе лампа не ответит.",
|
||||
"device.govee.url.placeholder": "192.168.1.50",
|
||||
"device.govee_min_interval": "Мин. интервал обновления:",
|
||||
"device.govee_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.",
|
||||
"device.type.ble": "BLE LED контроллер",
|
||||
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
|
||||
"device.ble.url": "BLE адрес:",
|
||||
|
||||
@@ -271,6 +271,13 @@
|
||||
"device.lifx.url.placeholder": "192.168.1.50",
|
||||
"device.lifx_min_interval": "最小更新间隔:",
|
||||
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。",
|
||||
"device.type.govee": "Govee",
|
||||
"device.type.govee.desc": "通过 LAN API 连接 Govee Wi-Fi 灯泡/氛围套件",
|
||||
"device.govee.url": "IP 地址:",
|
||||
"device.govee.url.hint": "Govee 设备的局域网 IP。请先在 Govee Home 应用中启用 LAN Control(设备 → ⚙ → LAN Control),否则灯泡不会响应。",
|
||||
"device.govee.url.placeholder": "192.168.1.50",
|
||||
"device.govee_min_interval": "最小更新间隔:",
|
||||
"device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。",
|
||||
"device.type.ble": "BLE LED 控制器",
|
||||
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
||||
"device.ble.url": "BLE 地址:",
|
||||
|
||||
@@ -68,6 +68,8 @@ class Device:
|
||||
wiz_min_interval_ms: int = 50,
|
||||
# LIFX fields
|
||||
lifx_min_interval_ms: int = 50,
|
||||
# Govee fields
|
||||
govee_min_interval_ms: int = 50,
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: int = 800000,
|
||||
spi_led_type: str = "WS2812B",
|
||||
@@ -118,6 +120,7 @@ class Device:
|
||||
self.yeelight_min_interval_ms = yeelight_min_interval_ms
|
||||
self.wiz_min_interval_ms = wiz_min_interval_ms
|
||||
self.lifx_min_interval_ms = lifx_min_interval_ms
|
||||
self.govee_min_interval_ms = govee_min_interval_ms
|
||||
self.spi_speed_hz = spi_speed_hz
|
||||
self.spi_led_type = spi_led_type
|
||||
self.chroma_device_type = chroma_device_type
|
||||
@@ -156,6 +159,7 @@ class Device:
|
||||
MQTTConfig,
|
||||
OpenRGBConfig,
|
||||
SPIConfig,
|
||||
GoveeConfig,
|
||||
LIFXConfig,
|
||||
USBHIDConfig,
|
||||
WiZConfig,
|
||||
@@ -222,6 +226,11 @@ class Device:
|
||||
**base,
|
||||
lifx_min_interval_ms=self.lifx_min_interval_ms,
|
||||
)
|
||||
if dt == "govee":
|
||||
return GoveeConfig(
|
||||
**base,
|
||||
govee_min_interval_ms=self.govee_min_interval_ms,
|
||||
)
|
||||
if dt == "spi":
|
||||
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
|
||||
if dt == "chroma":
|
||||
@@ -306,6 +315,8 @@ class Device:
|
||||
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
|
||||
if self.lifx_min_interval_ms != 50:
|
||||
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms
|
||||
if self.govee_min_interval_ms != 50:
|
||||
d["govee_min_interval_ms"] = self.govee_min_interval_ms
|
||||
if self.spi_speed_hz != 800000:
|
||||
d["spi_speed_hz"] = self.spi_speed_hz
|
||||
if self.spi_led_type != "WS2812B":
|
||||
@@ -364,6 +375,7 @@ class Device:
|
||||
yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
|
||||
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
|
||||
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
|
||||
govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
|
||||
spi_speed_hz=data.get("spi_speed_hz", 800000),
|
||||
spi_led_type=data.get("spi_led_type", "WS2812B"),
|
||||
chroma_device_type=data.get("chroma_device_type", "chromalink"),
|
||||
@@ -414,6 +426,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
||||
"yeelight_min_interval_ms",
|
||||
"wiz_min_interval_ms",
|
||||
"lifx_min_interval_ms",
|
||||
"govee_min_interval_ms",
|
||||
"spi_speed_hz",
|
||||
"spi_led_type",
|
||||
"chroma_device_type",
|
||||
@@ -517,6 +530,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
yeelight_min_interval_ms: int = 500,
|
||||
wiz_min_interval_ms: int = 50,
|
||||
lifx_min_interval_ms: int = 50,
|
||||
govee_min_interval_ms: int = 50,
|
||||
spi_speed_hz: int = 800000,
|
||||
spi_led_type: str = "WS2812B",
|
||||
chroma_device_type: str = "chromalink",
|
||||
@@ -563,6 +577,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
yeelight_min_interval_ms=yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=wiz_min_interval_ms,
|
||||
lifx_min_interval_ms=lifx_min_interval_ms,
|
||||
govee_min_interval_ms=govee_min_interval_ms,
|
||||
spi_speed_hz=spi_speed_hz,
|
||||
spi_led_type=spi_led_type,
|
||||
chroma_device_type=chroma_device_type,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
<option value="yeelight">Yeelight</option>
|
||||
<option value="wiz">WiZ</option>
|
||||
<option value="lifx">LIFX</option>
|
||||
<option value="govee">Govee</option>
|
||||
<option value="ble">BLE LED Controller</option>
|
||||
<option value="usbhid">USB HID</option>
|
||||
<option value="spi">SPI Direct</option>
|
||||
@@ -245,6 +246,15 @@
|
||||
<small class="input-hint" style="display:none" data-i18n="device.lifx_min_interval.hint">Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.</small>
|
||||
<input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</div>
|
||||
<!-- Govee fields -->
|
||||
<div class="form-group" id="device-govee-min-interval-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-govee-min-interval" data-i18n="device.govee_min_interval">Min Update Interval:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.govee_min_interval.hint">Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.</small>
|
||||
<input type="number" id="device-govee-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</div>
|
||||
<!-- ESP-NOW fields -->
|
||||
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -277,6 +277,15 @@
|
||||
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-govee-min-interval-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-govee-min-interval" data-i18n="device.govee_min_interval">Min Update Interval:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="device.govee_min_interval.hint">Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.</small>
|
||||
<input type="number" id="settings-govee-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-send-latency-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user