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
+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>