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:
2026-05-16 02:47:15 +03:00
parent 8f9d490063
commit 887131d4af
19 changed files with 1087 additions and 6 deletions
+5 -1
View File
@@ -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; conversion; broadcast discovery via GetService/StateService probe;
47 unit tests. Single-pixel adapter shape, identical to WiZ 47 unit tests. Single-pixel adapter shape, identical to WiZ
structurally. Frontend wired via subagent. 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 - [ ] Twinkly
- [ ] Nanoleaf OpenAPI - [ ] Nanoleaf OpenAPI
- [ ] Mi-Light / MiBoxer UDP gateway - [ ] Mi-Light / MiBoxer UDP gateway
+7
View File
@@ -69,6 +69,7 @@ def _device_to_response(device) -> DeviceResponse:
yeelight_min_interval_ms=device.yeelight_min_interval_ms, yeelight_min_interval_ms=device.yeelight_min_interval_ms,
wiz_min_interval_ms=device.wiz_min_interval_ms, wiz_min_interval_ms=device.wiz_min_interval_ms,
lifx_min_interval_ms=device.lifx_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_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type, spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_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 if device_data.lifx_min_interval_ms is not None
else 50 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_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B", spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink", 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, yeelight_min_interval_ms=update_data.yeelight_min_interval_ms,
wiz_min_interval_ms=update_data.wiz_min_interval_ms, wiz_min_interval_ms=update_data.wiz_min_interval_ms,
lifx_min_interval_ms=update_data.lifx_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_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type, spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type, chroma_device_type=update_data.chroma_device_type,
+11
View File
@@ -84,6 +84,13 @@ class DeviceCreate(BaseModel):
le=10000, le=10000,
description="LIFX client-side rate limit between commands in ms (default 50)", 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 Direct fields
spi_speed_hz: Optional[int] = Field( spi_speed_hz: Optional[int] = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" 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( lifx_min_interval_ms: Optional[int] = Field(
None, ge=0, le=10000, description="LIFX client-side rate limit in ms" 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_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") spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral 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") 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") 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_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type") spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral 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 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) @dataclass(frozen=True)
class SPIConfig(BaseDeviceConfig): class SPIConfig(BaseDeviceConfig):
device_type: Literal["spi"] = "spi" device_type: Literal["spi"] = "spi"
@@ -185,6 +197,7 @@ DeviceConfig = Union[
YeelightConfig, YeelightConfig,
WiZConfig, WiZConfig,
LIFXConfig, LIFXConfig,
GoveeConfig,
AdalightConfig, AdalightConfig,
AmbiLEDConfig, AmbiLEDConfig,
DMXConfig, 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()) 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`` # BLE support is optional — only register the provider if the ``bleak``
# extra is installed. Importing the provider itself is safe (it doesn't # 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 # import bleak at module load), but we still want a clean skip on
+4
View File
@@ -171,6 +171,10 @@ export function isLifxDevice(type: string) {
return type === 'lifx'; return type === 'lifx';
} }
export function isGoveeDevice(type: string) {
return type === 'govee';
}
export function isUsbhidDevice(type: string) { export function isUsbhidDevice(type: string) {
return type === 'usbhid'; return type === 'usbhid';
} }
+1 -1
View File
@@ -48,7 +48,7 @@ const _deviceTypeIcons = {
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb), wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette), mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench), 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), usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
ble: _svg(P.bluetooth), ble: _svg(P.bluetooth),
@@ -7,7 +7,7 @@ import {
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.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', yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500',
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50', wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
lifxMinInterval: (document.getElementById('device-lifx-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')), groupChildren: JSON.stringify(_getGroupChildIds('device')),
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence', groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
}; };
@@ -54,7 +55,7 @@ const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── 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() { function _buildDeviceTypeItems() {
return DEVICE_TYPE_KEYS.map(key => ({ return DEVICE_TYPE_KEYS.map(key => ({
@@ -284,6 +285,7 @@ export function onDeviceTypeChanged() {
_showYeelightFields(false); _showYeelightFields(false);
_showWizFields(false); _showWizFields(false);
_showLifxFields(false); _showLifxFields(false);
_showGoveeFields(false);
_showBleFields(false); _showBleFields(false);
_showSpiFields(false); _showSpiFields(false);
_showChromaFields(false); _showChromaFields(false);
@@ -527,6 +529,31 @@ export function onDeviceTypeChanged() {
} else { } else {
scanForDevices(); 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 wont respond.';
urlInput.placeholder = t('device.govee.url.placeholder') || '192.168.1.50';
if (deviceType in _discoveryCache) {
_renderDiscoveryList();
} else {
scanForDevices();
}
} else if (isBleDevice(deviceType)) { } else if (isBleDevice(deviceType)) {
// BLE: show URL (ble://<address>), LED count, protocol family picker, // BLE: show URL (ble://<address>), LED count, protocol family picker,
// and a Govee-only AES key field that toggles with the family selection. // 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); 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) // Prefill CSPT template selector (after fetch completes)
if (cloneData.default_css_processing_template_id) { if (cloneData.default_css_processing_template_id) {
csptCache.fetch().then(() => { csptCache.fetch().then(() => {
@@ -1103,6 +1137,11 @@ export async function handleAddDevice(event: any) {
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e'; body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim(); 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'; 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 // Tracks whether the BLE fields are currently shown — avoids reading
// style.display strings in _updateBleGoveeKeyVisibility. // style.display strings in _updateBleGoveeKeyVisibility.
let _bleFieldsVisible = false; let _bleFieldsVisible = false;
@@ -6,7 +6,7 @@ import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { 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 { _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'; 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', yeelightMinInterval: (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value || '500',
wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50', wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50',
lifxMinInterval: (document.getElementById('settings-lifx-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 || '', 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'; 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 // BLE-specific fields — exposed in the settings modal so the user
// can fix a wrong protocol family pick without deleting+recreating // can fix a wrong protocol family pick without deleting+recreating
// the device. Uses the shared IconSelect grid (project rule bans // the device. Uses the shared IconSelect grid (project rule bans
@@ -837,6 +856,11 @@ export async function saveDeviceSettings() {
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.lifx_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isBleDevice(settingsModal.deviceType)) {
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e'; 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() || ''; const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
+2 -1
View File
@@ -47,7 +47,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
export type DeviceType = export type DeviceType =
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws' | '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' | 'ble' | 'usbhid' | 'spi'
| 'chroma' | 'gamesense' | 'group'; | 'chroma' | 'gamesense' | 'group';
@@ -79,6 +79,7 @@ export interface Device {
yeelight_min_interval_ms: number; yeelight_min_interval_ms: number;
wiz_min_interval_ms: number; wiz_min_interval_ms: number;
lifx_min_interval_ms: number; lifx_min_interval_ms: number;
govee_min_interval_ms: number;
spi_speed_hz: number; spi_speed_hz: number;
spi_led_type: string; spi_led_type: string;
chroma_device_type: string; chroma_device_type: string;
@@ -218,6 +218,13 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Min Update Interval:", "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.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 wont 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": "BLE LED Controller",
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
"device.ble.url": "BLE Address:", "device.ble.url": "BLE Address:",
@@ -273,6 +273,13 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "Мин. интервал обновления:", "device.lifx_min_interval": "Мин. интервал обновления:",
"device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", "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": "BLE LED контроллер",
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
"device.ble.url": "BLE адрес:", "device.ble.url": "BLE адрес:",
@@ -271,6 +271,13 @@
"device.lifx.url.placeholder": "192.168.1.50", "device.lifx.url.placeholder": "192.168.1.50",
"device.lifx_min_interval": "最小更新间隔:", "device.lifx_min_interval": "最小更新间隔:",
"device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", "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": "BLE LED 控制器",
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
"device.ble.url": "BLE 地址:", "device.ble.url": "BLE 地址:",
@@ -68,6 +68,8 @@ class Device:
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
# LIFX fields # LIFX fields
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
# Govee fields
govee_min_interval_ms: int = 50,
# SPI Direct fields # SPI Direct fields
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
@@ -118,6 +120,7 @@ class Device:
self.yeelight_min_interval_ms = yeelight_min_interval_ms self.yeelight_min_interval_ms = yeelight_min_interval_ms
self.wiz_min_interval_ms = wiz_min_interval_ms self.wiz_min_interval_ms = wiz_min_interval_ms
self.lifx_min_interval_ms = lifx_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_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type self.chroma_device_type = chroma_device_type
@@ -156,6 +159,7 @@ class Device:
MQTTConfig, MQTTConfig,
OpenRGBConfig, OpenRGBConfig,
SPIConfig, SPIConfig,
GoveeConfig,
LIFXConfig, LIFXConfig,
USBHIDConfig, USBHIDConfig,
WiZConfig, WiZConfig,
@@ -222,6 +226,11 @@ class Device:
**base, **base,
lifx_min_interval_ms=self.lifx_min_interval_ms, 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": if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
if dt == "chroma": if dt == "chroma":
@@ -306,6 +315,8 @@ class Device:
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
if self.lifx_min_interval_ms != 50: if self.lifx_min_interval_ms != 50:
d["lifx_min_interval_ms"] = self.lifx_min_interval_ms 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: if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B": if self.spi_led_type != "WS2812B":
@@ -364,6 +375,7 @@ class Device:
yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500), yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500),
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50), wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
lifx_min_interval_ms=data.get("lifx_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_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"), spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"), chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -414,6 +426,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"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",
"spi_speed_hz", "spi_speed_hz",
"spi_led_type", "spi_led_type",
"chroma_device_type", "chroma_device_type",
@@ -517,6 +530,7 @@ class DeviceStore(BaseSqliteStore[Device]):
yeelight_min_interval_ms: int = 500, yeelight_min_interval_ms: int = 500,
wiz_min_interval_ms: int = 50, wiz_min_interval_ms: int = 50,
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
govee_min_interval_ms: int = 50,
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink", chroma_device_type: str = "chromalink",
@@ -563,6 +577,7 @@ class DeviceStore(BaseSqliteStore[Device]):
yeelight_min_interval_ms=yeelight_min_interval_ms, yeelight_min_interval_ms=yeelight_min_interval_ms,
wiz_min_interval_ms=wiz_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms,
lifx_min_interval_ms=lifx_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_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type, spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type, chroma_device_type=chroma_device_type,
@@ -49,6 +49,7 @@
<option value="yeelight">Yeelight</option> <option value="yeelight">Yeelight</option>
<option value="wiz">WiZ</option> <option value="wiz">WiZ</option>
<option value="lifx">LIFX</option> <option value="lifx">LIFX</option>
<option value="govee">Govee</option>
<option value="ble">BLE LED Controller</option> <option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option> <option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</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> <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"> <input type="number" id="device-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </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 --> <!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;"> <div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -277,6 +277,15 @@
<input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="settings-lifx-min-interval" min="0" max="10000" step="10" value="50">
</div> </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="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label> <label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
+434
View File
@@ -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