feat(devices): Yeelight LAN target type

Adds support for Xiaomi/Yeelight smart bulbs and lightstrips that speak
the bulb-vendor's JSON-RPC protocol over TCP port 55443 with SSDP-style
LAN discovery on 239.255.255.250:1982.

Backend:
- YeelightClient is a single-pixel adapter: it averages the incoming
  strip down to one RGB triple, packs it into the 24-bit color int the
  bulb expects, and pushes it via set_rgb with sudden+0ms effect.
- Brightness folds into the RGB scaling on the wire so we burn one
  command per frame instead of two.
- A configurable client-side rate gate (yeelight_min_interval_ms, default
  500) keeps us under the bulb's ~1 cmd/sec cap. Frames that arrive
  inside the gate no-op without TX. Music mode (~60 Hz via reverse-TCP)
  is deferred -- the MVP caps at ~2 Hz and that's fine for a strip-to-
  single-pixel averaging device.
- SSDP discovery scans 239.255.255.250:1982 with the bulb-specific
  ST: wifi_bulb header; replies are parsed into DiscoveredDevice
  entries. Multicast failures (no network, firewall) yield [] rather
  than raising -- discovery is best-effort.
- Health check opens a TCP socket to the bulb and closes it.
- YeelightConfig joins the typed config union; Device storage gains a
  yeelight_min_interval_ms field; full to_dict/from_dict/to_config wiring.
- 34 unit tests cover URL parsing, RGB packing, strip averaging, rate
  limiting, SSDP response parsing, provider validate/discover/health,
  and Device.to_config round-trip.

Frontend:
- 'yeelight' in DEVICE_TYPE_KEYS (next to 'hue'), lightbulb icon
  (intentional family-grouping signal with Hue).
- isYeelightDevice predicate + per-type field show/hide in create and
  settings modals.
- Rate-limit number input (default 500 ms) in both modals with hint
  text explaining the trade-off.
- Locale strings in en/ru/zh.
- Drive-by: types.ts DeviceType union backfilled with 'ddp' and 'ble'
  for type-safety consistency.

Yeelight bulbs are now reachable from the existing "Scan network"
button -- no new discovery UI affordance was needed.
This commit is contained in:
2026-05-16 01:44:13 +03:00
parent 8f1140abad
commit 4b65005823
19 changed files with 1032 additions and 22 deletions
+19 -17
View File
@@ -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
+7
View File
@@ -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,
+13
View File
@@ -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")
@@ -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,
@@ -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
@@ -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://<host>`` or bare ``<host>``. 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()
@@ -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
+4
View File
@@ -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';
}
+2 -1
View File
@@ -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),
@@ -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://<address>), 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;
@@ -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() || '';
+3 -1
View File
@@ -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;
@@ -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:",
@@ -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 адрес:",
@@ -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 地址:",
@@ -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,
@@ -46,6 +46,7 @@
<option value="ddp">DDP</option>
<option value="espnow">ESP-NOW</option>
<option value="hue">Philips Hue</option>
<option value="yeelight">Yeelight</option>
<option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</option>
@@ -215,6 +216,15 @@
<option value="5">GBR</option>
</select>
</div>
<!-- Yeelight fields -->
<div class="form-group" id="device-yeelight-min-interval-group" style="display: none;">
<div class="label-row">
<label for="device-yeelight-min-interval" data-i18n="device.yeelight_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.yeelight_min_interval.hint">Client-side rate limit between commands in ms. Default 500 ms keeps the bulb under its ~1 cmd/sec cap. Lower = faster updates but risk throttling.</small>
<input type="number" id="device-yeelight-min-interval" min="0" max="10000" step="50" value="500">
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
@@ -250,6 +250,15 @@
</select>
</div>
<div class="form-group" id="settings-yeelight-min-interval-group" style="display: none;">
<div class="label-row">
<label for="settings-yeelight-min-interval" data-i18n="device.yeelight_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.yeelight_min_interval.hint">Client-side rate limit between commands in ms. Default 500 ms keeps the bulb under its ~1 cmd/sec cap. Lower = faster updates but risk throttling.</small>
<input type="number" id="settings-yeelight-min-interval" min="0" max="10000" step="50" value="500">
</div>
<div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
+421
View File
@@ -0,0 +1,421 @@
"""Tests for the Yeelight LAN LED client + provider."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pytest
from ledgrab.core.devices.device_config import YeelightConfig
from ledgrab.core.devices.led_client import ProviderDeps
from ledgrab.core.devices.yeelight_client import (
YeelightClient,
_average_color,
_pack_rgb,
_parse_ssdp_response,
parse_yeelight_url,
)
from ledgrab.core.devices.yeelight_provider import YeelightDeviceProvider
# ============================================================================
# parse_yeelight_url
# ============================================================================
@pytest.mark.parametrize(
"url,expected",
[
("yeelight://192.168.1.50", "192.168.1.50"),
("yeelight://192.168.1.50:55443", "192.168.1.50"),
("192.168.1.50", "192.168.1.50"),
("192.168.1.50:55443", "192.168.1.50"),
("bulb.local", "bulb.local"),
],
)
def test_parse_yeelight_url(url, expected):
assert parse_yeelight_url(url) == expected
@pytest.mark.parametrize("url", ["", " ", "yeelight://", "://192.168.1.1"])
def test_parse_yeelight_url_rejects_empty(url):
with pytest.raises(ValueError):
parse_yeelight_url(url)
# ============================================================================
# Helpers
# ============================================================================
def test_pack_rgb_packs_24_bit_int():
assert _pack_rgb(255, 0, 0) == 0xFF0000
assert _pack_rgb(0, 255, 0) == 0x00FF00
assert _pack_rgb(0, 0, 255) == 0x0000FF
assert _pack_rgb(0x12, 0x34, 0x56) == 0x123456
# Clamps to a byte
assert _pack_rgb(300, -5, 256) & 0xFFFFFF == _pack_rgb(300 & 0xFF, -5 & 0xFF, 256 & 0xFF)
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():
assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0)
def test_average_color_empty():
assert _average_color([]) == (0, 0, 0)
assert _average_color(np.array([], dtype=np.uint8)) == (0, 0, 0)
# ============================================================================
# YeelightClient (mocked transport)
# ============================================================================
def _make_connected_client(min_interval_s: float = 0.0) -> YeelightClient:
client = YeelightClient("yeelight://127.0.0.1", led_count=10, min_interval_s=min_interval_s)
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
writer.close = MagicMock()
writer.wait_closed = AsyncMock()
client._writer = writer
client._reader = MagicMock()
client._connected = True
return client
def _sent_payloads(client: YeelightClient) -> list[dict]:
"""Decode every JSON-RPC body the client has written."""
payloads = []
for call in client._writer.write.call_args_list:
raw = call.args[0].decode("utf-8").strip()
payloads.append(json.loads(raw))
return payloads
@pytest.mark.asyncio
async def test_send_pixels_averages_and_packs_rgb():
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]["method"] == "set_rgb"
# Average of (255,0,0), (0,255,0), (0,0,255) is (85, 85, 85) → 0x555555
assert payloads[0]["params"][0] == _pack_rgb(85, 85, 85)
# Effect & duration: ambilight needs sudden + 0 ms
assert payloads[0]["params"][1] == "sudden"
assert payloads[0]["params"][2] == 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)
# Scaled: 200*0.501..→100, 100→50, 50→25
expected = _pack_rgb(int(200 * 128 / 255), int(100 * 128 / 255), int(50 * 128 / 255))
assert payloads[0]["params"][0] == expected
@pytest.mark.asyncio
async def test_send_pixels_full_brightness_passthrough():
client = _make_connected_client()
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
await client.send_pixels(pixels, brightness=255)
payloads = _sent_payloads(client)
assert payloads[0]["params"][0] == _pack_rgb(200, 100, 50)
@pytest.mark.asyncio
async def test_send_pixels_rate_limit_drops_subsequent_calls():
"""Within the min interval, the second call no-ops without TX."""
client = _make_connected_client(min_interval_s=10.0) # huge gate
pixels = np.array([[10, 20, 30]], dtype=np.uint8)
await client.send_pixels(pixels)
await client.send_pixels(pixels)
await client.send_pixels(pixels)
payloads = _sent_payloads(client)
assert len(payloads) == 1 # only the first one made it through
@pytest.mark.asyncio
async def test_send_pixels_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)
payloads = _sent_payloads(client)
assert len(payloads) == 3
@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
client = YeelightClient("yeelight://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))
@pytest.mark.asyncio
async def test_set_power_sends_set_power_command():
client = _make_connected_client()
await client.set_power(True)
await client.set_power(False)
payloads = _sent_payloads(client)
assert [p["method"] for p in payloads] == ["set_power", "set_power"]
assert payloads[0]["params"][0] == "on"
assert payloads[1]["params"][0] == "off"
@pytest.mark.asyncio
async def test_set_brightness_clamps_to_1_100():
client = _make_connected_client()
await client.set_brightness(0)
await client.set_brightness(50)
await client.set_brightness(200)
payloads = _sent_payloads(client)
# Yeelight bulbs reject brightness 0 (use set_power off instead) — clamp to 1.
assert payloads[0]["params"][0] == 1
assert payloads[1]["params"][0] == 50
assert payloads[2]["params"][0] == 100
@pytest.mark.asyncio
async def test_close_releases_transport():
client = _make_connected_client()
writer = client._writer
await client.close()
writer.close.assert_called_once()
assert client._writer is None
assert client.is_connected is False
# ============================================================================
# SSDP response parsing
# ============================================================================
_SAMPLE_RESPONSE = (
b"HTTP/1.1 200 OK\r\n"
b"Cache-Control: max-age=3600\r\n"
b"Date: \r\n"
b"Ext: \r\n"
b"Location: yeelight://192.168.1.50:55443\r\n"
b"Server: POSIX UPnP/1.0 YGLC/1\r\n"
b"id: 0x000000000035cb01\r\n"
b"model: color\r\n"
b"fw_ver: 18\r\n"
b"support: get_prop set_default set_power set_bright\r\n"
b"power: off\r\n"
b"bright: 100\r\n"
b"color_mode: 2\r\n"
b"ct: 4000\r\n"
b"rgb: 16711680\r\n"
)
def test_parse_ssdp_response_extracts_headers():
headers = _parse_ssdp_response(_SAMPLE_RESPONSE)
assert headers is not None
assert headers["location"] == "yeelight://192.168.1.50:55443"
assert headers["id"] == "0x000000000035cb01"
assert headers["model"] == "color"
assert headers["fw_ver"] == "18"
def test_parse_ssdp_response_rejects_non_yeelight():
"""A stray HTTP response from another SSDP service should be ignored."""
other = b"HTTP/1.1 200 OK\r\nLocation: upnp://something/else\r\n"
assert _parse_ssdp_response(other) is None
# ============================================================================
# Provider
# ============================================================================
def test_provider_device_type_and_capabilities():
provider = YeelightDeviceProvider()
assert provider.device_type == "yeelight"
caps = provider.capabilities
assert "manual_led_count" in caps
assert "power_control" in caps
assert "brightness_control" in caps
assert "static_color" in caps
assert "single_pixel" in caps
@pytest.mark.asyncio
async def test_provider_validate_device_accepts_bare_host():
provider = YeelightDeviceProvider()
assert await provider.validate_device("192.168.1.50") == {}
@pytest.mark.asyncio
async def test_provider_validate_device_rejects_empty():
provider = YeelightDeviceProvider()
with pytest.raises(ValueError, match="Invalid Yeelight URL"):
await provider.validate_device("")
def test_provider_create_client_threads_config():
provider = YeelightDeviceProvider()
config = YeelightConfig(
device_id="device_test",
device_url="yeelight://192.168.1.50",
led_count=30,
yeelight_min_interval_ms=750,
)
client = provider.create_client(config, deps=ProviderDeps())
assert isinstance(client, YeelightClient)
assert client.host == "192.168.1.50"
assert client._led_count == 30
assert client._min_interval_s == pytest.approx(0.75)
@pytest.mark.asyncio
async def test_provider_discover_returns_empty_on_failure(monkeypatch):
"""Multicast failures (no network, firewall) must yield [], not raise."""
async def _explode(timeout):
raise OSError("no route to host")
monkeypatch.setattr(
"ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs",
_explode,
)
provider = YeelightDeviceProvider()
assert await provider.discover() == []
@pytest.mark.asyncio
async def test_provider_discover_maps_ssdp_to_discovered_device(monkeypatch):
async def _fake(timeout):
return [
{
"location": "yeelight://192.168.1.50:55443",
"id": "0x0035cb01",
"model": "color",
"fw_ver": "18",
},
# Missing location should be skipped, not crash.
{"id": "0xff", "model": "stripe"},
]
monkeypatch.setattr(
"ledgrab.core.devices.yeelight_provider.discover_yeelight_bulbs",
_fake,
)
provider = YeelightDeviceProvider()
results = await provider.discover()
assert len(results) == 1
[bulb] = results
assert bulb.device_type == "yeelight"
assert bulb.url == "yeelight://192.168.1.50"
assert bulb.ip == "192.168.1.50"
assert bulb.mac == "0x0035cb01"
assert bulb.version == "18"
assert "color" in bulb.name.lower()
# ============================================================================
# Device.to_config() round-trip
# ============================================================================
def test_device_to_config_round_trip_yeelight():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Bedroom bulb",
url="yeelight://192.168.1.42",
led_count=30,
device_type="yeelight",
yeelight_min_interval_ms=750,
)
config = device.to_config()
assert isinstance(config, YeelightConfig)
assert config.device_url == "yeelight://192.168.1.42"
assert config.led_count == 30
assert config.yeelight_min_interval_ms == 750
def test_device_to_dict_omits_yeelight_default_interval():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Default",
url="yeelight://192.168.1.42",
led_count=1,
device_type="yeelight",
)
assert "yeelight_min_interval_ms" not in device.to_dict()
def test_device_to_dict_preserves_non_default_yeelight_interval():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Custom",
url="yeelight://192.168.1.42",
led_count=1,
device_type="yeelight",
yeelight_min_interval_ms=1000,
)
assert device.to_dict()["yeelight_min_interval_ms"] == 1000
def test_device_from_dict_yeelight_round_trip():
from ledgrab.storage.device_store import Device
restored = Device.from_dict(
{
"id": "device_abc12345",
"name": "Roundtrip",
"url": "yeelight://10.0.0.1",
"led_count": 1,
"device_type": "yeelight",
"yeelight_min_interval_ms": 250,
}
)
assert restored.yeelight_min_interval_ms == 250
# Suppress the asyncio CancelledError that asyncio raises on
# garbage-collected mock writers — they're swallowed in close() but
# pytest-asyncio still warns about them when MagicMock is used.
@pytest.fixture(autouse=True)
def _suppress_asyncio_warnings():
yield
asyncio.get_event_loop_policy()