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:
@@ -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.
|
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.
|
DDP packet layer (previously WLED-internal) promoted to a first-class device
|
||||||
|
type. Pixelblaze, ESPixelStick, xLights/Falcon endpoints, and generic DDP
|
||||||
- [ ] `DDPConfig` dataclass in `device_config.py` (port, destination_id, color_order)
|
receivers are now drivable directly without WLED in the path.
|
||||||
- [ ] `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)
|
|
||||||
|
|
||||||
### Phase 1.2 — Yeelight LAN
|
### 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`
|
- [x] `YeelightConfig` dataclass with `yeelight_min_interval_ms` rate limit
|
||||||
- [ ] mDNS / SSDP discovery (Yeelight uses SSDP-like UDP multicast `239.255.255.250:1982`)
|
- [x] `YeelightClient` in `core/devices/yeelight_client.py` — TCP JSON-RPC,
|
||||||
- [ ] Single-pixel output: map strip → averaged RGB → bulb color
|
averaging single-pixel adapter, client-side rate gate
|
||||||
- [ ] Frontend additions + locales
|
- [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
|
### Phase 1.3 — WiZ Connected
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
hue_username=device.hue_username,
|
hue_username=device.hue_username,
|
||||||
hue_client_key=device.hue_client_key,
|
hue_client_key=device.hue_client_key,
|
||||||
hue_entertainment_group_id=device.hue_entertainment_group_id,
|
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_speed_hz=device.spi_speed_hz,
|
||||||
spi_led_type=device.spi_led_type,
|
spi_led_type=device.spi_led_type,
|
||||||
chroma_device_type=device.chroma_device_type,
|
chroma_device_type=device.chroma_device_type,
|
||||||
@@ -221,6 +222,11 @@ async def create_device(
|
|||||||
hue_username=device_data.hue_username or "",
|
hue_username=device_data.hue_username or "",
|
||||||
hue_client_key=device_data.hue_client_key or "",
|
hue_client_key=device_data.hue_client_key or "",
|
||||||
hue_entertainment_group_id=device_data.hue_entertainment_group_id 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_speed_hz=device_data.spi_speed_hz or 800000,
|
||||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||||
@@ -483,6 +489,7 @@ async def update_device(
|
|||||||
hue_username=update_data.hue_username,
|
hue_username=update_data.hue_username,
|
||||||
hue_client_key=update_data.hue_client_key,
|
hue_client_key=update_data.hue_client_key,
|
||||||
hue_entertainment_group_id=update_data.hue_entertainment_group_id,
|
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_speed_hz=update_data.spi_speed_hz,
|
||||||
spi_led_type=update_data.spi_led_type,
|
spi_led_type=update_data.spi_led_type,
|
||||||
chroma_device_type=update_data.chroma_device_type,
|
chroma_device_type=update_data.chroma_device_type,
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ class DeviceCreate(BaseModel):
|
|||||||
hue_entertainment_group_id: Optional[str] = Field(
|
hue_entertainment_group_id: Optional[str] = Field(
|
||||||
None, description="Hue entertainment group/zone ID"
|
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 Direct fields
|
||||||
spi_speed_hz: Optional[int] = Field(
|
spi_speed_hz: Optional[int] = Field(
|
||||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||||
@@ -151,6 +158,9 @@ class DeviceUpdate(BaseModel):
|
|||||||
hue_entertainment_group_id: Optional[str] = Field(
|
hue_entertainment_group_id: Optional[str] = Field(
|
||||||
None, description="Hue entertainment group ID"
|
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_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed")
|
||||||
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
spi_led_type: Optional[str] = Field(None, description="LED chipset type")
|
||||||
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type")
|
||||||
@@ -320,6 +330,9 @@ class DeviceResponse(BaseModel):
|
|||||||
hue_username: str = Field(default="", description="Hue bridge username")
|
hue_username: str = Field(default="", description="Hue bridge username")
|
||||||
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
hue_client_key: str = Field(default="", description="Hue entertainment client key")
|
||||||
hue_entertainment_group_id: str = Field(default="", description="Hue entertainment group ID")
|
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_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
|
||||||
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
|
||||||
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ class HueConfig(BaseDeviceConfig):
|
|||||||
hue_entertainment_group_id: str = ""
|
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)
|
@dataclass(frozen=True)
|
||||||
class SPIConfig(BaseDeviceConfig):
|
class SPIConfig(BaseDeviceConfig):
|
||||||
device_type: Literal["spi"] = "spi"
|
device_type: Literal["spi"] = "spi"
|
||||||
@@ -146,6 +158,7 @@ class USBHIDConfig(BaseDeviceConfig):
|
|||||||
DeviceConfig = Union[
|
DeviceConfig = Union[
|
||||||
WLEDConfig,
|
WLEDConfig,
|
||||||
DDPConfig,
|
DDPConfig,
|
||||||
|
YeelightConfig,
|
||||||
AdalightConfig,
|
AdalightConfig,
|
||||||
AmbiLEDConfig,
|
AmbiLEDConfig,
|
||||||
DMXConfig,
|
DMXConfig,
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ def _register_builtin_providers():
|
|||||||
|
|
||||||
register_provider(HueDeviceProvider())
|
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``
|
# BLE support is optional — only register the provider if the ``bleak``
|
||||||
# extra is installed. Importing the provider itself is safe (it doesn't
|
# extra is installed. Importing the provider itself is safe (it doesn't
|
||||||
# import bleak at module load), but we still want a clean skip on
|
# import bleak at module load), but we still want a clean skip on
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -159,6 +159,10 @@ export function isHueDevice(type: string) {
|
|||||||
return type === 'hue';
|
return type === 'hue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isYeelightDevice(type: string) {
|
||||||
|
return type === 'yeelight';
|
||||||
|
}
|
||||||
|
|
||||||
export function isUsbhidDevice(type: string) {
|
export function isUsbhidDevice(type: string) {
|
||||||
return type === 'usbhid';
|
return type === 'usbhid';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ const _deviceTypeIcons = {
|
|||||||
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
||||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||||
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench),
|
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench),
|
||||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), 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),
|
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
|
||||||
ble: _svg(P.bluetooth),
|
ble: _svg(P.bluetooth),
|
||||||
group: _svg(P.layers),
|
group: _svg(P.layers),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
_discoveryCache, set_discoveryCache,
|
_discoveryCache, set_discoveryCache,
|
||||||
csptCache,
|
csptCache,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, 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 { devicesCache } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, desktopFocus } from '../core/ui.ts';
|
import { showToast, desktopFocus } from '../core/ui.ts';
|
||||||
@@ -41,6 +41,7 @@ class AddDeviceModal extends Modal {
|
|||||||
ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1',
|
ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1',
|
||||||
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
|
bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '',
|
||||||
bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.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')),
|
groupChildren: JSON.stringify(_getGroupChildIds('device')),
|
||||||
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
|
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
|
||||||
};
|
};
|
||||||
@@ -51,7 +52,7 @@ const addDeviceModal = new AddDeviceModal();
|
|||||||
|
|
||||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||||
|
|
||||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', '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() {
|
function _buildDeviceTypeItems() {
|
||||||
return DEVICE_TYPE_KEYS.map(key => ({
|
return DEVICE_TYPE_KEYS.map(key => ({
|
||||||
@@ -278,6 +279,7 @@ export function onDeviceTypeChanged() {
|
|||||||
// Hide new device type fields by default
|
// Hide new device type fields by default
|
||||||
_showEspnowFields(false);
|
_showEspnowFields(false);
|
||||||
_showHueFields(false);
|
_showHueFields(false);
|
||||||
|
_showYeelightFields(false);
|
||||||
_showBleFields(false);
|
_showBleFields(false);
|
||||||
_showSpiFields(false);
|
_showSpiFields(false);
|
||||||
_showChromaFields(false);
|
_showChromaFields(false);
|
||||||
@@ -455,6 +457,28 @@ export function onDeviceTypeChanged() {
|
|||||||
} else {
|
} else {
|
||||||
scanForDevices();
|
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)) {
|
} else if (isBleDevice(deviceType)) {
|
||||||
// BLE: show URL (ble://<address>), LED count, protocol family picker,
|
// BLE: show URL (ble://<address>), LED count, protocol family picker,
|
||||||
// and a Govee-only AES key field that toggles with the family selection.
|
// and a Govee-only AES key field that toggles with the family selection.
|
||||||
@@ -794,6 +818,13 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
|||||||
if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order));
|
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)
|
// Prefill CSPT template selector (after fetch completes)
|
||||||
if (cloneData.default_css_processing_template_id) {
|
if (cloneData.default_css_processing_template_id) {
|
||||||
csptCache.fetch().then(() => {
|
csptCache.fetch().then(() => {
|
||||||
@@ -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_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 || '';
|
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)) {
|
if (isBleDevice(deviceType)) {
|
||||||
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
|
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
|
||||||
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
|
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
|
||||||
@@ -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
|
// Tracks whether the BLE fields are currently shown — avoids reading
|
||||||
// style.display strings in _updateBleGoveeKeyVisibility.
|
// style.display strings in _updateBleGoveeKeyVisibility.
|
||||||
let _bleFieldsVisible = false;
|
let _bleFieldsVisible = false;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
_deviceBrightnessCache, updateDeviceBrightness,
|
_deviceBrightnessCache, updateDeviceBrightness,
|
||||||
csptCache,
|
csptCache,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, 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 { devicesCache } from '../core/state.ts';
|
||||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
@@ -95,6 +95,7 @@ class DeviceSettingsModal extends Modal {
|
|||||||
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
|
dmxStartChannel: (document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1',
|
||||||
bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '',
|
bleFamily: (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || '',
|
||||||
bleGoveeKey: (document.getElementById('settings-ble-govee-key') as HTMLInputElement | 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 || '',
|
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';
|
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
|
// BLE-specific fields — exposed in the settings modal so the user
|
||||||
// can fix a wrong protocol family pick without deleting+recreating
|
// can fix a wrong protocol family pick without deleting+recreating
|
||||||
// the device. Uses the shared IconSelect grid (project rule bans
|
// the device. Uses the shared IconSelect grid (project rule bans
|
||||||
@@ -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_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);
|
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)) {
|
if (isBleDevice(settingsModal.deviceType)) {
|
||||||
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e';
|
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e';
|
||||||
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
|
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
|
|||||||
|
|
||||||
export type DeviceType =
|
export type DeviceType =
|
||||||
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
|
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
|
||||||
| 'openrgb' | 'dmx' | 'espnow' | 'hue' | 'usbhid' | 'spi'
|
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight'
|
||||||
|
| 'ble' | 'usbhid' | 'spi'
|
||||||
| 'chroma' | 'gamesense' | 'group';
|
| 'chroma' | 'gamesense' | 'group';
|
||||||
|
|
||||||
export interface Device {
|
export interface Device {
|
||||||
@@ -75,6 +76,7 @@ export interface Device {
|
|||||||
hue_username: string;
|
hue_username: string;
|
||||||
hue_client_key: string;
|
hue_client_key: string;
|
||||||
hue_entertainment_group_id: string;
|
hue_entertainment_group_id: string;
|
||||||
|
yeelight_min_interval_ms: number;
|
||||||
spi_speed_hz: number;
|
spi_speed_hz: number;
|
||||||
spi_led_type: string;
|
spi_led_type: string;
|
||||||
chroma_device_type: string;
|
chroma_device_type: string;
|
||||||
|
|||||||
@@ -197,6 +197,13 @@
|
|||||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||||
"device.type.hue": "Philips Hue",
|
"device.type.hue": "Philips Hue",
|
||||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
"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": "BLE LED Controller",
|
||||||
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
|
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
|
||||||
"device.ble.url": "BLE Address:",
|
"device.ble.url": "BLE Address:",
|
||||||
|
|||||||
@@ -252,6 +252,13 @@
|
|||||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||||
"device.type.hue": "Philips Hue",
|
"device.type.hue": "Philips Hue",
|
||||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
"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": "BLE LED контроллер",
|
||||||
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
|
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
|
||||||
"device.ble.url": "BLE адрес:",
|
"device.ble.url": "BLE адрес:",
|
||||||
|
|||||||
@@ -250,6 +250,13 @@
|
|||||||
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
|
||||||
"device.type.hue": "Philips Hue",
|
"device.type.hue": "Philips Hue",
|
||||||
"device.type.hue.desc": "Hue Entertainment API streaming",
|
"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": "BLE LED 控制器",
|
||||||
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
||||||
"device.ble.url": "BLE 地址:",
|
"device.ble.url": "BLE 地址:",
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ class Device:
|
|||||||
hue_username: str = "",
|
hue_username: str = "",
|
||||||
hue_client_key: str = "",
|
hue_client_key: str = "",
|
||||||
hue_entertainment_group_id: str = "",
|
hue_entertainment_group_id: str = "",
|
||||||
|
# Yeelight fields
|
||||||
|
yeelight_min_interval_ms: int = 500,
|
||||||
# SPI Direct fields
|
# SPI Direct fields
|
||||||
spi_speed_hz: int = 800000,
|
spi_speed_hz: int = 800000,
|
||||||
spi_led_type: str = "WS2812B",
|
spi_led_type: str = "WS2812B",
|
||||||
@@ -109,6 +111,7 @@ class Device:
|
|||||||
self.hue_username = hue_username
|
self.hue_username = hue_username
|
||||||
self.hue_client_key = hue_client_key
|
self.hue_client_key = hue_client_key
|
||||||
self.hue_entertainment_group_id = hue_entertainment_group_id
|
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_speed_hz = spi_speed_hz
|
||||||
self.spi_led_type = spi_led_type
|
self.spi_led_type = spi_led_type
|
||||||
self.chroma_device_type = chroma_device_type
|
self.chroma_device_type = chroma_device_type
|
||||||
@@ -150,6 +153,7 @@ class Device:
|
|||||||
USBHIDConfig,
|
USBHIDConfig,
|
||||||
WLEDConfig,
|
WLEDConfig,
|
||||||
WSConfig,
|
WSConfig,
|
||||||
|
YeelightConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
base = dict(
|
base = dict(
|
||||||
@@ -195,6 +199,11 @@ class Device:
|
|||||||
hue_client_key=self.hue_client_key,
|
hue_client_key=self.hue_client_key,
|
||||||
hue_entertainment_group_id=self.hue_entertainment_group_id,
|
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":
|
if dt == "spi":
|
||||||
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
|
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
|
||||||
if dt == "chroma":
|
if dt == "chroma":
|
||||||
@@ -273,6 +282,8 @@ class Device:
|
|||||||
d["hue_client_key"] = _enc(self.hue_client_key)
|
d["hue_client_key"] = _enc(self.hue_client_key)
|
||||||
if self.hue_entertainment_group_id:
|
if self.hue_entertainment_group_id:
|
||||||
d["hue_entertainment_group_id"] = 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:
|
if self.spi_speed_hz != 800000:
|
||||||
d["spi_speed_hz"] = self.spi_speed_hz
|
d["spi_speed_hz"] = self.spi_speed_hz
|
||||||
if self.spi_led_type != "WS2812B":
|
if self.spi_led_type != "WS2812B":
|
||||||
@@ -328,6 +339,7 @@ class Device:
|
|||||||
hue_username=_dec(data.get("hue_username", "")),
|
hue_username=_dec(data.get("hue_username", "")),
|
||||||
hue_client_key=_dec(data.get("hue_client_key", "")),
|
hue_client_key=_dec(data.get("hue_client_key", "")),
|
||||||
hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""),
|
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_speed_hz=data.get("spi_speed_hz", 800000),
|
||||||
spi_led_type=data.get("spi_led_type", "WS2812B"),
|
spi_led_type=data.get("spi_led_type", "WS2812B"),
|
||||||
chroma_device_type=data.get("chroma_device_type", "chromalink"),
|
chroma_device_type=data.get("chroma_device_type", "chromalink"),
|
||||||
@@ -375,6 +387,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
|||||||
"hue_username",
|
"hue_username",
|
||||||
"hue_client_key",
|
"hue_client_key",
|
||||||
"hue_entertainment_group_id",
|
"hue_entertainment_group_id",
|
||||||
|
"yeelight_min_interval_ms",
|
||||||
"spi_speed_hz",
|
"spi_speed_hz",
|
||||||
"spi_led_type",
|
"spi_led_type",
|
||||||
"chroma_device_type",
|
"chroma_device_type",
|
||||||
@@ -475,6 +488,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
|||||||
hue_username: str = "",
|
hue_username: str = "",
|
||||||
hue_client_key: str = "",
|
hue_client_key: str = "",
|
||||||
hue_entertainment_group_id: str = "",
|
hue_entertainment_group_id: str = "",
|
||||||
|
yeelight_min_interval_ms: int = 500,
|
||||||
spi_speed_hz: int = 800000,
|
spi_speed_hz: int = 800000,
|
||||||
spi_led_type: str = "WS2812B",
|
spi_led_type: str = "WS2812B",
|
||||||
chroma_device_type: str = "chromalink",
|
chroma_device_type: str = "chromalink",
|
||||||
@@ -518,6 +532,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
|||||||
hue_username=hue_username,
|
hue_username=hue_username,
|
||||||
hue_client_key=hue_client_key,
|
hue_client_key=hue_client_key,
|
||||||
hue_entertainment_group_id=hue_entertainment_group_id,
|
hue_entertainment_group_id=hue_entertainment_group_id,
|
||||||
|
yeelight_min_interval_ms=yeelight_min_interval_ms,
|
||||||
spi_speed_hz=spi_speed_hz,
|
spi_speed_hz=spi_speed_hz,
|
||||||
spi_led_type=spi_led_type,
|
spi_led_type=spi_led_type,
|
||||||
chroma_device_type=chroma_device_type,
|
chroma_device_type=chroma_device_type,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<option value="ddp">DDP</option>
|
<option value="ddp">DDP</option>
|
||||||
<option value="espnow">ESP-NOW</option>
|
<option value="espnow">ESP-NOW</option>
|
||||||
<option value="hue">Philips Hue</option>
|
<option value="hue">Philips Hue</option>
|
||||||
|
<option value="yeelight">Yeelight</option>
|
||||||
<option value="ble">BLE LED Controller</option>
|
<option value="ble">BLE LED Controller</option>
|
||||||
<option value="usbhid">USB HID</option>
|
<option value="usbhid">USB HID</option>
|
||||||
<option value="spi">SPI Direct</option>
|
<option value="spi">SPI Direct</option>
|
||||||
@@ -215,6 +216,15 @@
|
|||||||
<option value="5">GBR</option>
|
<option value="5">GBR</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 -->
|
<!-- ESP-NOW fields -->
|
||||||
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
|
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
@@ -250,6 +250,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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="form-group" id="settings-send-latency-group" style="display: none;">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
|
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user