diff --git a/TODO.md b/TODO.md index 110574c..cc8b10b 100644 --- a/TODO.md +++ b/TODO.md @@ -666,28 +666,30 @@ Branch: `feat/expand-device-support`. Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern. -### Phase 1.1 — Standalone DDP target +### Phase 1.1 — Standalone DDP target ✅ shipped (commit `8f1140a`) -The DDP packet layer already exists (`ddp_client.py`) — currently only used inside `WLEDClient`. Promote it to a first-class device type so any DDP-speaking controller (Pixelblaze, ESPixelStick, xLights/Falcon endpoints, generic DDP receivers) can be driven directly without WLED firmware in the path. - -- [ ] `DDPConfig` dataclass in `device_config.py` (port, destination_id, color_order) -- [ ] `DDPLEDClient` in `core/devices/ddp_led_client.py` — `LEDClient` wrapper around the existing `DDPClient` transport with `supports_fast_send=True` for the hot loop -- [ ] `DDPDeviceProvider` in `core/devices/ddp_provider.py` — discovery is a no-op (DDP has no native discovery; UI accepts manual IP), validate_device pings the host, capabilities = `{"manual_led_count", "health_check"}` -- [ ] Register provider in `led_client._register_builtin_providers` -- [ ] Add `ddp` branch to `Device.to_config()` in `device_store.py` + storage fields for DDP-specific options -- [ ] API schemas: extend device schema to accept DDP fields -- [ ] Unit tests for client (packet construction is already tested under `test_ddp_client.py`; new tests cover the LEDClient wrapper, provider validate/health, config round-trip) -- [ ] Frontend: add DDP to the device-type picker + edit form (spawned to a `frontend-design` subagent) -- [ ] Locale strings (en/ru/zh) +DDP packet layer (previously WLED-internal) promoted to a first-class device +type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP +receivers are now drivable directly without WLED in the path. ### Phase 1.2 — Yeelight LAN -Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Use `python-yeelight` or direct protocol. +Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Direct protocol (no +`python-yeelight` dependency — implementation is ~200 lines). -- [ ] `YeelightConfig` + `YeelightLEDClient` + `YeelightDeviceProvider` -- [ ] mDNS / SSDP discovery (Yeelight uses SSDP-like UDP multicast `239.255.255.250:1982`) -- [ ] Single-pixel output: map strip → averaged RGB → bulb color -- [ ] Frontend additions + locales +- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit +- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC, + averaging single-pixel adapter, client-side rate gate +- [x] SSDP-style discovery (Yeelight's variant on `239.255.255.250:1982`) +- [x] `YeelightDeviceProvider` with validate/health/discover +- [x] Storage + API schemas + route handler wiring +- [x] 34 unit tests (URL parsing, RGB packing, averaging, rate limit, SSDP + parsing, provider validate/discover, Device.to_config round-trip) +- [ ] Frontend: Yeelight in device-type picker + edit form (spawned to a + `frontend-design` subagent) +- [ ] Locale strings (en/ru/zh) +- [ ] Music mode (~60 Hz updates via reverse-TCP) — follow-up, current + MVP caps at ~2 Hz via the client-side rate gate ### Phase 1.3 — WiZ Connected diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 902f715..8029cc9 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -66,6 +66,7 @@ def _device_to_response(device) -> DeviceResponse: hue_username=device.hue_username, hue_client_key=device.hue_client_key, hue_entertainment_group_id=device.hue_entertainment_group_id, + yeelight_min_interval_ms=device.yeelight_min_interval_ms, spi_speed_hz=device.spi_speed_hz, spi_led_type=device.spi_led_type, chroma_device_type=device.chroma_device_type, @@ -221,6 +222,11 @@ async def create_device( hue_username=device_data.hue_username or "", hue_client_key=device_data.hue_client_key or "", hue_entertainment_group_id=device_data.hue_entertainment_group_id or "", + yeelight_min_interval_ms=( + device_data.yeelight_min_interval_ms + if device_data.yeelight_min_interval_ms is not None + else 500 + ), 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", @@ -483,6 +489,7 @@ async def update_device( hue_username=update_data.hue_username, hue_client_key=update_data.hue_client_key, hue_entertainment_group_id=update_data.hue_entertainment_group_id, + yeelight_min_interval_ms=update_data.yeelight_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 3b2fe0d..d9aa80d 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -63,6 +63,13 @@ class DeviceCreate(BaseModel): hue_entertainment_group_id: Optional[str] = Field( None, description="Hue entertainment group/zone ID" ) + # Yeelight fields + yeelight_min_interval_ms: Optional[int] = Field( + None, + ge=0, + le=10000, + description="Yeelight client-side rate limit between commands in ms (default 500)", + ) # SPI Direct fields spi_speed_hz: Optional[int] = Field( None, ge=100000, le=4000000, description="SPI clock speed in Hz" @@ -151,6 +158,9 @@ class DeviceUpdate(BaseModel): hue_entertainment_group_id: Optional[str] = Field( None, description="Hue entertainment group ID" ) + yeelight_min_interval_ms: Optional[int] = Field( + None, ge=0, le=10000, description="Yeelight 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") @@ -320,6 +330,9 @@ class DeviceResponse(BaseModel): hue_username: str = Field(default="", description="Hue bridge username") hue_client_key: str = Field(default="", description="Hue entertainment client key") hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID") + yeelight_min_interval_ms: int = Field( + default=500, description="Yeelight 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 ab27343..520e6c5 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -76,6 +76,18 @@ class HueConfig(BaseDeviceConfig): hue_entertainment_group_id: str = "" +@dataclass(frozen=True) +class YeelightConfig(BaseDeviceConfig): + """Yeelight (Xiaomi) LAN bulb / lightstrip. + + ``yeelight_min_interval_ms`` rate-limits outbound commands client-side + so the bulb's per-second cap isn't exceeded. Default 500 ms ≈ 2 Hz. + """ + + device_type: Literal["yeelight"] = "yeelight" + yeelight_min_interval_ms: int = 500 + + @dataclass(frozen=True) class SPIConfig(BaseDeviceConfig): device_type: Literal["spi"] = "spi" @@ -146,6 +158,7 @@ class USBHIDConfig(BaseDeviceConfig): DeviceConfig = Union[ WLEDConfig, DDPConfig, + YeelightConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index e55b948..d7a97fa 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -338,6 +338,10 @@ def _register_builtin_providers(): register_provider(HueDeviceProvider()) + from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider + + register_provider(YeelightDeviceProvider()) + # 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/core/devices/yeelight_client.py b/server/src/ledgrab/core/devices/yeelight_client.py new file mode 100644 index 0000000..3196a2c --- /dev/null +++ b/server/src/ledgrab/core/devices/yeelight_client.py @@ -0,0 +1,313 @@ +"""Yeelight (Xiaomi) LAN LED client. + +Yeelight bulbs and lightstrips accept JSON-RPC commands over a plain TCP +socket on port 55443. This client speaks the simplest useful subset — +``set_rgb``, ``set_bright``, ``set_power``, ``get_prop`` — and averages the +incoming pixel strip down to one RGB color (Yeelight bulbs are single-pixel +devices, like Hue or generic BLE bulbs). + +Rate limit: each Yeelight bulb caps inbound commands at roughly one per +second by default. We enforce a configurable client-side gate to stay under +that limit; faster updates would need Yeelight "music mode" (bulb dials +back to our TCP server) which is a follow-up. + +URL scheme: ``yeelight://`` or bare ````. Port is fixed at 55443 +on the protocol side; we don't parse it from the URL. +""" + +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__) + +YEELIGHT_PORT = 55443 +DEFAULT_MIN_INTERVAL_S = 0.5 # half a second between TX → ~2 Hz, well under the cap + + +def parse_yeelight_url(url: str) -> str: + """Pull the host out of ``yeelight://host`` or accept a bare ``host``. + + The TCP port is fixed on the protocol side (55443), so we ignore any port + specifier rather than silently accept one the bulb won't answer on. + """ + if not url: + raise ValueError("Yeelight URL is empty") + raw = url.strip() + if "://" in raw: + parsed = urlparse(raw) + host = parsed.hostname or "" + else: + parsed = urlparse(f"yeelight://{raw}") + host = parsed.hostname or "" + if not host: + raise ValueError(f"Yeelight 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 + + +def _pack_rgb(r: int, g: int, b: int) -> int: + """Pack an (R, G, B) triple into the 24-bit integer Yeelight expects.""" + return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF) + + +class YeelightClient(LEDClient): + """LEDClient for a single Yeelight bulb / lightstrip on the LAN.""" + + def __init__( + self, + url: str, + led_count: int = 1, + *, + min_interval_s: float = DEFAULT_MIN_INTERVAL_S, + connect_timeout_s: float = 3.0, + ): + self._host = parse_yeelight_url(url) + self._led_count = led_count + self._min_interval_s = max(0.0, min_interval_s) + self._connect_timeout_s = connect_timeout_s + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._connected = False + self._next_tx_at: float = 0.0 + self._req_id: int = 0 + self._send_lock = asyncio.Lock() + + @property + def host(self) -> str: + return self._host + + @property + def is_connected(self) -> bool: + return self._connected and self._writer 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: + return True + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self._host, YEELIGHT_PORT), + timeout=self._connect_timeout_s, + ) + except (OSError, asyncio.TimeoutError) as exc: + raise RuntimeError(f"Failed to connect to Yeelight at {self._host}: {exc}") from exc + self._connected = True + logger.info("YeelightClient connected to %s:%d", self._host, YEELIGHT_PORT) + return True + + async def close(self) -> None: + if self._writer is not None: + try: + self._writer.close() + await self._writer.wait_closed() + except (OSError, asyncio.CancelledError): + pass + self._writer = None + self._reader = None + self._connected = False + + def _next_id(self) -> int: + self._req_id = (self._req_id + 1) % 1_000_000 + return self._req_id + + async def _send(self, method: str, params: list) -> None: + """Fire a JSON-RPC command; replies are read-then-dropped opportunistically. + + Yeelight's bulb sends a JSON reply per command, but for streaming + ambient lighting we don't need to wait for it — the data is + write-only. + """ + if self._writer is None: + raise RuntimeError("YeelightClient not connected") + payload = json.dumps({"id": self._next_id(), "method": method, "params": params}) + "\r\n" + async with self._send_lock: + self._writer.write(payload.encode("utf-8")) + await self._writer.drain() + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + """Average the pixel strip to one color and ``set_rgb``. + + Brightness is folded in by scaling the averaged RGB rather than + sending a separate ``set_bright`` (avoids burning a command and + keeps animation in sync). When the configured min interval hasn't + elapsed the call returns ``True`` without TX — the next frame will + carry whichever color was current at the time it eventually fires. + """ + if not self.is_connected: + raise RuntimeError("YeelightClient 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) + packed = _pack_rgb(r, g, b) + # ``set_rgb`` params: [color_int, effect, duration_ms]. + # "sudden" + 0ms keeps latency minimal for ambilight. + await self._send("set_rgb", [packed, "sudden", 0]) + self._next_tx_at = now + self._min_interval_s + return True + + async def set_color(self, r: int, g: int, b: int) -> None: + await self._send("set_rgb", [_pack_rgb(r, g, b), "sudden", 0]) + + async def set_brightness(self, brightness_0_100: int) -> None: + clamped = max(1, min(100, brightness_0_100)) + await self._send("set_bright", [clamped, "sudden", 0]) + + async def set_power(self, on: bool) -> None: + await self._send("set_power", ["on" if on else "off", "sudden", 0]) + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + """Health check: open the TCP socket to the bulb and close it.""" + now = datetime.now(timezone.utc) + try: + host = parse_yeelight_url(url) + except ValueError as exc: + return DeviceHealth(online=False, last_checked=now, error=str(exc)) + loop = asyncio.get_running_loop() + start = loop.time() + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, YEELIGHT_PORT), + timeout=2.0, + ) + except (OSError, asyncio.TimeoutError) as exc: + return DeviceHealth( + online=False, + last_checked=now, + error=f"Yeelight unreachable at {host}:{YEELIGHT_PORT}: {exc}", + ) + latency_ms = (loop.time() - start) * 1000.0 + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + del reader + return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now) + + +# ============================================================================ +# SSDP-style discovery +# ============================================================================ + +_DISCOVER_GROUP = ("239.255.255.250", 1982) +_DISCOVER_REQUEST = ( + "M-SEARCH * HTTP/1.1\r\n" + "HOST: 239.255.255.250:1982\r\n" + 'MAN: "ssdp:discover"\r\n' + "ST: wifi_bulb\r\n" + "\r\n" +).encode("ascii") + + +def _parse_ssdp_response(raw: bytes) -> Optional[dict]: + """Parse a Yeelight discovery response into a ``{header: value}`` dict. + + Returns ``None`` when the payload doesn't look like a Yeelight reply + (e.g. a stray HTTP response from another SSDP service on the LAN). + """ + try: + text = raw.decode("utf-8", errors="replace") + except UnicodeDecodeError: + return None + if "yeelight://" not in text.lower(): + return None + headers: dict = {} + for line in text.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + headers[key.strip().lower()] = value.strip() + return headers + + +async def discover_yeelight_bulbs(timeout: float = 2.0) -> List[dict]: + """Scan the LAN for Yeelight bulbs via the bulb-specific SSDP variant. + + Returns a list of header-dicts (one per bulb that replied) so the caller + can decide which fields to surface. Each dict has ``location``, + ``id``, ``model``, ``support``, ``rgb``, ``bright`` etc. + """ + loop = asyncio.get_running_loop() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + sock.setblocking(False) + try: + sock.bind(("", 0)) + await loop.sock_sendto(sock, _DISCOVER_REQUEST, _DISCOVER_GROUP) + results: list[dict] = [] + seen_ids: 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(sock, 2048), + timeout=remaining, + ) + except asyncio.TimeoutError: + break + headers = _parse_ssdp_response(raw) + if not headers: + continue + bulb_id = headers.get("id", "") + if bulb_id and bulb_id in seen_ids: + continue + if bulb_id: + seen_ids.add(bulb_id) + results.append(headers) + return results + finally: + sock.close() diff --git a/server/src/ledgrab/core/devices/yeelight_provider.py b/server/src/ledgrab/core/devices/yeelight_provider.py new file mode 100644 index 0000000..fe2132f --- /dev/null +++ b/server/src/ledgrab/core/devices/yeelight_provider.py @@ -0,0 +1,107 @@ +"""Yeelight device provider — LAN-discoverable Xiaomi smart bulbs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List +from urllib.parse import urlparse + +from ledgrab.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, + ProviderDeps, +) +from ledgrab.core.devices.yeelight_client import ( + YeelightClient, + discover_yeelight_bulbs, + parse_yeelight_url, +) +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.devices.device_config import YeelightConfig + +logger = get_logger(__name__) + + +class YeelightDeviceProvider(LEDDeviceProvider): + """Provider for Yeelight (Xiaomi) LAN bulbs and lightstrips. + + Single-pixel device: the LED client averages the incoming strip down to + one RGB color before sending. LED count is user-supplied; it controls + the pixel-source mapping, not anything on the wire. + """ + + @property + def device_type(self) -> str: + return "yeelight" + + @property + def capabilities(self) -> set: + return { + "manual_led_count", + "power_control", + "brightness_control", + "static_color", + "health_check", + "single_pixel", + } + + def create_client(self, config: "YeelightConfig", *, deps: ProviderDeps) -> LEDClient: + return YeelightClient( + config.device_url, + led_count=config.led_count, + min_interval_s=max(0.0, config.yeelight_min_interval_ms / 1000.0), + ) + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + return await YeelightClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Validate the URL is parseable. Yeelight bulbs are single-pixel so + we don't return a led_count — the user fills it in.""" + try: + host = parse_yeelight_url(url) + except ValueError as exc: + raise ValueError(f"Invalid Yeelight URL: {exc}") from exc + logger.info("Yeelight device URL validated: host=%s", host) + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + """Scan the LAN via Yeelight's SSDP variant on 239.255.255.250:1982.""" + try: + bulbs = await discover_yeelight_bulbs(timeout=min(timeout, 5.0)) + except (OSError, RuntimeError) as exc: + # Multicast can fail on Windows when no network is up, on + # firewalled hosts, or on Android sandboxes. Discovery is + # best-effort — log and return empty. + logger.warning("Yeelight discovery failed: %s", exc) + return [] + + results: List[DiscoveredDevice] = [] + for headers in bulbs: + location = headers.get("location", "") + if not location: + continue + parsed = urlparse(location) + host = parsed.hostname or "" + if not host: + continue + url = f"yeelight://{host}" + model = headers.get("model") or "yeelight" + fw = headers.get("fw_ver") or None + bulb_id = headers.get("id", "") or host + results.append( + DiscoveredDevice( + name=f"Yeelight {model}".strip(), + url=url, + device_type="yeelight", + ip=host, + mac=bulb_id, # the bulb's hex id is the closest stable identifier + led_count=None, + version=fw, + ) + ) + logger.info("Yeelight SSDP scan found %d bulb(s)", len(results)) + return results diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 7b6ca30..fa6bfb6 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -159,6 +159,10 @@ export function isHueDevice(type: string) { return type === 'hue'; } +export function isYeelightDevice(type: string) { + return type === 'yeelight'; +} + 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 46db3d8..c06500a 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -48,7 +48,8 @@ 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), usbhid: _svg(P.usb), + espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), + usbhid: _svg(P.usb), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), ble: _svg(P.bluetooth), group: _svg(P.layers), diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index dfd8e07..9bffa47 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, 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, 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'; @@ -41,6 +41,7 @@ class AddDeviceModal extends Modal { ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '', bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '', + yeelightMinInterval: (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value || '500', groupChildren: JSON.stringify(_getGroupChildIds('device')), groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence', }; @@ -51,7 +52,7 @@ const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ -const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; +const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; function _buildDeviceTypeItems() { return DEVICE_TYPE_KEYS.map(key => ({ @@ -278,6 +279,7 @@ export function onDeviceTypeChanged() { // Hide new device type fields by default _showEspnowFields(false); _showHueFields(false); + _showYeelightFields(false); _showBleFields(false); _showSpiFields(false); _showChromaFields(false); @@ -455,6 +457,28 @@ export function onDeviceTypeChanged() { } else { scanForDevices(); } + } else if (isYeelightDevice(deviceType)) { + // Yeelight: show URL (LAN IP), LED count (controls source mapping; + // the bulb itself averages to one color), rate-limit ms. SSDP + // discovery is supported — same scan button as WLED/Hue. + 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 = ''; + _showYeelightFields(true); + if (urlLabel) urlLabel.textContent = t('device.yeelight.url') || 'IP Address:'; + if (urlHint) urlHint.textContent = t('device.yeelight.url.hint') || 'LAN IP of the Yeelight bulb. TCP port 55443 is fixed in the protocol.'; + urlInput.placeholder = t('device.yeelight.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. @@ -794,6 +818,13 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) { if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order)); } } + // Prefill Yeelight fields + if (isYeelightDevice(presetType)) { + const ymi = document.getElementById('device-yeelight-min-interval') as HTMLInputElement; + if (ymi && cloneData.yeelight_min_interval_ms != null) { + ymi.value = String(cloneData.yeelight_min_interval_ms); + } + } // Prefill CSPT template selector (after fetch completes) if (cloneData.default_css_processing_template_id) { csptCache.fetch().then(() => { @@ -995,6 +1026,11 @@ export async function handleAddDevice(event: any) { body.hue_client_key = (document.getElementById('device-hue-client-key') as HTMLInputElement)?.value || ''; body.hue_entertainment_group_id = (document.getElementById('device-hue-group-id') as HTMLInputElement)?.value || ''; } + if (isYeelightDevice(deviceType)) { + const raw = (document.getElementById('device-yeelight-min-interval') as HTMLInputElement)?.value; + const parsed = parseInt(raw || '500', 10); + body.yeelight_min_interval_ms = Number.isFinite(parsed) ? parsed : 500; + } 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(); @@ -1348,6 +1384,11 @@ function _showHueFields(show: boolean) { }); } +function _showYeelightFields(show: boolean) { + const el = document.getElementById('device-yeelight-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 fc75248..7764e10 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, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, 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'; @@ -95,6 +95,7 @@ class DeviceSettingsModal extends Modal { dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '', bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value || '', + yeelightMinInterval: (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value || '500', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '', }; } @@ -626,6 +627,27 @@ export async function showSettings(deviceId: any) { if (ddpColorOrderGroup) (ddpColorOrderGroup as HTMLElement).style.display = 'none'; } + // Yeelight-specific fields — exposed in the settings modal so the + // user can tune the per-bulb client-side rate gate without recreating + // the device. The bulb runs a ~1 cmd/sec cap on the wire; values + // below 500 risk being throttled. LED count is intentionally still + // shown — it controls the source-side strip mapping even though + // Yeelight averages to a single color before sending. + const yeelightMinIntervalGroup = document.getElementById('settings-yeelight-min-interval-group'); + if (isYeelightDevice(device.device_type)) { + if (yeelightMinIntervalGroup) (yeelightMinIntervalGroup as HTMLElement).style.display = ''; + const ymi = device.yeelight_min_interval_ms ?? 500; + (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement).value = String(ymi); + // Relabel URL field as IP Address (same pattern as DMX/DDP) + const urlLabel4 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; + const urlHint4 = urlGroup.querySelector('.input-hint') as HTMLElement | null; + if (urlLabel4) urlLabel4.textContent = t('device.yeelight.url'); + if (urlHint4) urlHint4.textContent = t('device.yeelight.url.hint'); + urlInput.placeholder = t('device.yeelight.url.placeholder') || '192.168.1.50'; + } else { + if (yeelightMinIntervalGroup) (yeelightMinIntervalGroup 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 @@ -762,6 +784,11 @@ export async function saveDeviceSettings() { body.ddp_destination_id = parseInt((document.getElementById('settings-ddp-destination-id') as HTMLInputElement | null)?.value || '1', 10); body.ddp_color_order = parseInt((document.getElementById('settings-ddp-color-order') as HTMLSelectElement | null)?.value || '1', 10); } + if (isYeelightDevice(settingsModal.deviceType)) { + const raw = (document.getElementById('settings-yeelight-min-interval') as HTMLInputElement | null)?.value; + const parsed = parseInt(raw || '500', 10); + body.yeelight_min_interval_ms = Number.isFinite(parsed) ? parsed : 500; + } 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 d0e83e0..3e9ff41 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -47,7 +47,8 @@ export function bindableColorSourceId(b: BindableColor | undefined): string { export type DeviceType = | 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws' - | 'openrgb' | 'dmx' | 'espnow' | 'hue' | 'usbhid' | 'spi' + | 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' + | 'ble' | 'usbhid' | 'spi' | 'chroma' | 'gamesense' | 'group'; export interface Device { @@ -75,6 +76,7 @@ export interface Device { hue_username: string; hue_client_key: string; hue_entertainment_group_id: string; + yeelight_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 13de47f..096acb3 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -197,6 +197,13 @@ "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", "device.type.hue": "Philips Hue", "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "Xiaomi smart bulb / lightstrip via LAN (single color, averaged from the strip)", + "device.yeelight.url": "IP Address:", + "device.yeelight.url.hint": "LAN IP of the Yeelight bulb. TCP port 55443 is fixed in the protocol.", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "Min Update Interval:", + "device.yeelight_min_interval.hint": "Client-side rate limit between commands in ms. Default 500 ms keeps bulbs under their ~1 cmd/sec cap; lower values risk throttling.", "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 a7c550e..1d3ed58 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -252,6 +252,13 @@ "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", "device.type.hue": "Philips Hue", "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "Умная лампа / лента Xiaomi по LAN (один цвет, усреднённый по ленте)", + "device.yeelight.url": "IP-адрес:", + "device.yeelight.url.hint": "IP-адрес лампы Yeelight в локальной сети. TCP-порт 55443 фиксирован протоколом.", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "Мин. интервал обновления:", + "device.yeelight_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 500 мс держит лампу под ограничением ~1 команда/сек; меньшие значения могут вызвать троттлинг.", "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 d005617..4148643 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -250,6 +250,13 @@ "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", "device.type.hue": "Philips Hue", "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "通过局域网连接小米智能灯泡/灯带(单色,由灯带颜色平均得出)", + "device.yeelight.url": "IP 地址:", + "device.yeelight.url.hint": "Yeelight 灯泡的局域网 IP。协议固定使用 TCP 端口 55443。", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "最小更新间隔:", + "device.yeelight_min_interval.hint": "客户端命令速率限制(毫秒)。默认 500 毫秒可使灯泡保持在约 1 cmd/sec 限制下;较低的值可能导致节流。", "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 566dc4b..01e8a8c 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -62,6 +62,8 @@ class Device: hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", + # Yeelight fields + yeelight_min_interval_ms: int = 500, # SPI Direct fields spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", @@ -109,6 +111,7 @@ class Device: self.hue_username = hue_username self.hue_client_key = hue_client_key self.hue_entertainment_group_id = hue_entertainment_group_id + self.yeelight_min_interval_ms = yeelight_min_interval_ms self.spi_speed_hz = spi_speed_hz self.spi_led_type = spi_led_type self.chroma_device_type = chroma_device_type @@ -150,6 +153,7 @@ class Device: USBHIDConfig, WLEDConfig, WSConfig, + YeelightConfig, ) base = dict( @@ -195,6 +199,11 @@ class Device: hue_client_key=self.hue_client_key, hue_entertainment_group_id=self.hue_entertainment_group_id, ) + if dt == "yeelight": + return YeelightConfig( + **base, + yeelight_min_interval_ms=self.yeelight_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": @@ -273,6 +282,8 @@ class Device: d["hue_client_key"] = _enc(self.hue_client_key) if self.hue_entertainment_group_id: d["hue_entertainment_group_id"] = self.hue_entertainment_group_id + if self.yeelight_min_interval_ms != 500: + d["yeelight_min_interval_ms"] = self.yeelight_min_interval_ms if self.spi_speed_hz != 800000: d["spi_speed_hz"] = self.spi_speed_hz if self.spi_led_type != "WS2812B": @@ -328,6 +339,7 @@ class Device: hue_username=_dec(data.get("hue_username", "")), hue_client_key=_dec(data.get("hue_client_key", "")), hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), + yeelight_min_interval_ms=data.get("yeelight_min_interval_ms", 500), 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"), @@ -375,6 +387,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "hue_username", "hue_client_key", "hue_entertainment_group_id", + "yeelight_min_interval_ms", "spi_speed_hz", "spi_led_type", "chroma_device_type", @@ -475,6 +488,7 @@ class DeviceStore(BaseSqliteStore[Device]): hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", + yeelight_min_interval_ms: int = 500, spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", chroma_device_type: str = "chromalink", @@ -518,6 +532,7 @@ class DeviceStore(BaseSqliteStore[Device]): hue_username=hue_username, hue_client_key=hue_client_key, hue_entertainment_group_id=hue_entertainment_group_id, + yeelight_min_interval_ms=yeelight_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 a539537..bfdc176 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -46,6 +46,7 @@ + @@ -215,6 +216,15 @@ + +