diff --git a/TODO.md b/TODO.md index 85e82d4..cdbba61 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index d6e48ab..1b48a31 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -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, diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index 1b547cc..a4ce81a 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -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") diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index 5c28427..1dd555e 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -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, diff --git a/server/src/ledgrab/core/devices/govee_client.py b/server/src/ledgrab/core/devices/govee_client.py new file mode 100644 index 0000000..a862f61 --- /dev/null +++ b/server/src/ledgrab/core/devices/govee_client.py @@ -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://`` or bare ````. 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() diff --git a/server/src/ledgrab/core/devices/govee_provider.py b/server/src/ledgrab/core/devices/govee_provider.py new file mode 100644 index 0000000..e11f3e2 --- /dev/null +++ b/server/src/ledgrab/core/devices/govee_provider.py @@ -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 diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index 77a8b4e..33f279f 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -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 diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index f8dbcf3..9d8ceec 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -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'; } diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 41080d6..233a9bd 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -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), diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 9246d7b..f5c63c6 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -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://
), 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; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index be89ea4..1f5a74d 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -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() || ''; diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index afa6cdf..0cc4c67 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -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; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index c31c2ab..f503522 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 0467ff6..d4cf07a 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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 адрес:", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 910df97..4f094b5 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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 地址:", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 9d5ea31..5c9fa1c 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -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, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 75467a8..14998a2 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -49,6 +49,7 @@ + @@ -245,6 +246,15 @@ + +