feat(devices): WiZ Connected LAN target type
Adds support for WiZ Connected (Philips' budget-tier) smart bulbs that accept JSON commands as UDP datagrams on port 38899 with broadcast LAN discovery on 255.255.255.255:38899. Backend: - WiZClient is a single-pixel UDP adapter: averages the incoming strip to one RGB triple and pushes it via setPilot with r/g/b params. Brightness folds into the RGB scaling so we burn one packet per frame instead of two. - UDP fire-and-forget tolerates high update rates with no ack overhead, so the default rate gate is 50 ms (~20 Hz) -- 10x faster than Yeelight. - supports_fast_send=True with a synchronous send_pixels_fast hot path. - Broadcast discovery sends the standard registration envelope; bulb replies are parsed for IP+MAC and surfaced as DiscoveredDevice entries. Broadcast failures (no network, firewall) yield [] rather than raising. - Health check sends getPilot and waits 1.5s for any reply on a one-shot UDP socket. - WiZConfig joins the typed config union; Device storage gains a wiz_min_interval_ms field; full to_dict/from_dict/to_config wiring. - 36 unit tests cover URL parsing, MAC extraction, strip averaging, rate limiting, fast-send hot path, provider validate/discover/health, and Device.to_config round-trip. Frontend: - 'wiz' in DEVICE_TYPE_KEYS (next to 'yeelight'), lightbulb icon (deliberate smart-bulb family grouping with Hue + Yeelight). - isWizDevice predicate + per-type field show/hide in create and settings modals. - Rate-limit number input (default 50 ms) in both modals with hint text noting the UDP fire-and-forget characteristic. - Locale strings in en/ru/zh. WiZ bulbs are reachable from the existing "Scan network" button -- no new discovery UI affordance was needed.
This commit is contained in:
@@ -67,6 +67,7 @@ def _device_to_response(device) -> DeviceResponse:
|
||||
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,
|
||||
wiz_min_interval_ms=device.wiz_min_interval_ms,
|
||||
spi_speed_hz=device.spi_speed_hz,
|
||||
spi_led_type=device.spi_led_type,
|
||||
chroma_device_type=device.chroma_device_type,
|
||||
@@ -227,6 +228,11 @@ async def create_device(
|
||||
if device_data.yeelight_min_interval_ms is not None
|
||||
else 500
|
||||
),
|
||||
wiz_min_interval_ms=(
|
||||
device_data.wiz_min_interval_ms
|
||||
if device_data.wiz_min_interval_ms is not None
|
||||
else 50
|
||||
),
|
||||
spi_speed_hz=device_data.spi_speed_hz or 800000,
|
||||
spi_led_type=device_data.spi_led_type or "WS2812B",
|
||||
chroma_device_type=device_data.chroma_device_type or "chromalink",
|
||||
@@ -490,6 +496,7 @@ async def update_device(
|
||||
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,
|
||||
wiz_min_interval_ms=update_data.wiz_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,
|
||||
|
||||
@@ -70,6 +70,13 @@ class DeviceCreate(BaseModel):
|
||||
le=10000,
|
||||
description="Yeelight client-side rate limit between commands in ms (default 500)",
|
||||
)
|
||||
# WiZ fields
|
||||
wiz_min_interval_ms: Optional[int] = Field(
|
||||
None,
|
||||
ge=0,
|
||||
le=10000,
|
||||
description="WiZ client-side rate limit between commands in ms (default 50)",
|
||||
)
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: Optional[int] = Field(
|
||||
None, ge=100000, le=4000000, description="SPI clock speed in Hz"
|
||||
@@ -161,6 +168,9 @@ class DeviceUpdate(BaseModel):
|
||||
yeelight_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: Optional[int] = Field(
|
||||
None, ge=0, le=10000, description="WiZ 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")
|
||||
@@ -333,6 +343,7 @@ class DeviceResponse(BaseModel):
|
||||
yeelight_min_interval_ms: int = Field(
|
||||
default=500, description="Yeelight client-side rate limit in ms"
|
||||
)
|
||||
wiz_min_interval_ms: int = Field(default=50, description="WiZ 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")
|
||||
|
||||
@@ -88,6 +88,18 @@ class YeelightConfig(BaseDeviceConfig):
|
||||
yeelight_min_interval_ms: int = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WiZConfig(BaseDeviceConfig):
|
||||
"""WiZ Connected (Philips budget-tier) UDP LAN bulb.
|
||||
|
||||
``wiz_min_interval_ms`` is a client-side rate gate. WiZ tolerates much
|
||||
higher rates than Yeelight (UDP, no ack) so the default is 50 ms ≈ 20 Hz.
|
||||
"""
|
||||
|
||||
device_type: Literal["wiz"] = "wiz"
|
||||
wiz_min_interval_ms: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SPIConfig(BaseDeviceConfig):
|
||||
device_type: Literal["spi"] = "spi"
|
||||
@@ -159,6 +171,7 @@ DeviceConfig = Union[
|
||||
WLEDConfig,
|
||||
DDPConfig,
|
||||
YeelightConfig,
|
||||
WiZConfig,
|
||||
AdalightConfig,
|
||||
AmbiLEDConfig,
|
||||
DMXConfig,
|
||||
|
||||
@@ -342,6 +342,10 @@ def _register_builtin_providers():
|
||||
|
||||
register_provider(YeelightDeviceProvider())
|
||||
|
||||
from ledgrab.core.devices.wiz_provider import WiZDeviceProvider
|
||||
|
||||
register_provider(WiZDeviceProvider())
|
||||
|
||||
# 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,323 @@
|
||||
"""WiZ Connected (Philips' budget tier) LAN LED client.
|
||||
|
||||
WiZ bulbs accept JSON commands as UDP datagrams on port 38899. There's no
|
||||
persistent connection — every frame is fire-and-forget — so the client is
|
||||
simpler than Yeelight and tolerates higher update rates.
|
||||
|
||||
URL scheme: ``wiz://<host>[:port]`` or bare ``<host>``. Default port 38899.
|
||||
|
||||
Discovery: UDP broadcast of a ``registration`` envelope on
|
||||
255.255.255.255:38899 — bulbs reply unicast with their MAC and state.
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
WIZ_PORT = 38899
|
||||
DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz cap; bulbs tolerate it, UDP costs nothing
|
||||
|
||||
|
||||
def parse_wiz_url(url: str) -> Tuple[str, int]:
|
||||
"""Pull ``(host, port)`` from ``wiz://host[:port]`` or a bare ``host[:port]``."""
|
||||
if not url:
|
||||
raise ValueError("WiZ URL is empty")
|
||||
raw = url.strip()
|
||||
if "://" in raw:
|
||||
parsed = urlparse(raw)
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or WIZ_PORT
|
||||
else:
|
||||
parsed = urlparse(f"wiz://{raw}")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port or WIZ_PORT
|
||||
if not host:
|
||||
raise ValueError(f"WiZ URL has no host: {url!r}")
|
||||
return host, port
|
||||
|
||||
|
||||
def _average_color(
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
) -> Tuple[int, int, int]:
|
||||
"""Reduce an N-pixel strip to one average RGB triple."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
if pixels.size == 0:
|
||||
return (0, 0, 0)
|
||||
arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3)
|
||||
mean = arr.mean(axis=0)
|
||||
return int(mean[0]), int(mean[1]), int(mean[2])
|
||||
if not pixels:
|
||||
return (0, 0, 0)
|
||||
total_r = total_g = total_b = 0
|
||||
for r, g, b in pixels:
|
||||
total_r += r
|
||||
total_g += g
|
||||
total_b += b
|
||||
n = len(pixels)
|
||||
return total_r // n, total_g // n, total_b // n
|
||||
|
||||
|
||||
class _WiZProtocol(asyncio.DatagramProtocol):
|
||||
"""Minimal protocol: sends only, drops any inbound packets silently."""
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
# WiZ bulbs reply to setPilot with a small ack. We don't need it for
|
||||
# ambilight streaming — just drop the bytes on the floor.
|
||||
pass
|
||||
|
||||
def error_received(self, exc):
|
||||
# UDP errors (ICMP unreachable, route changes) surface here. Log
|
||||
# once and let the next frame retry; transient drops are normal.
|
||||
logger.debug("WiZ UDP error: %s", exc)
|
||||
|
||||
|
||||
class WiZClient(LEDClient):
|
||||
"""LEDClient for a single WiZ Connected bulb on the LAN."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
led_count: int = 1,
|
||||
*,
|
||||
min_interval_s: float = DEFAULT_MIN_INTERVAL_S,
|
||||
):
|
||||
host, port = parse_wiz_url(url)
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._led_count = led_count
|
||||
self._min_interval_s = max(0.0, min_interval_s)
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._protocol: Optional[_WiZProtocol] = None
|
||||
self._connected = False
|
||||
self._next_tx_at: float = 0.0
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._transport is not None
|
||||
|
||||
@property
|
||||
def device_led_count(self) -> Optional[int]:
|
||||
return self._led_count or None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
if self._connected and self._transport is not None:
|
||||
return True
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
_WiZProtocol, remote_addr=(self._host, self._port)
|
||||
)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open UDP to WiZ at {self._host}: {exc}") from exc
|
||||
self._transport = transport
|
||||
self._protocol = protocol # type: ignore[assignment]
|
||||
self._connected = True
|
||||
logger.info("WiZClient connected to %s:%d", self._host, self._port)
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._transport is not None:
|
||||
try:
|
||||
self._transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._connected = False
|
||||
|
||||
def _send_json(self, payload: dict) -> None:
|
||||
"""Fire one JSON UDP packet. Caller must hold an open transport."""
|
||||
assert self._transport is not None
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
self._transport.sendto(raw)
|
||||
|
||||
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 push ``setPilot``."""
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return True
|
||||
r, g, b = _average_color(pixels)
|
||||
# WiZ has a separate "dimming" param (1-100). For ambilight we keep
|
||||
# things linear and fold brightness into the RGB scalars — that's
|
||||
# what the bulb shows anyway with state=on.
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
return True
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous variant for the hot path. Same shape as send_pixels."""
|
||||
if not self.is_connected or self._transport is None:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
now = time.monotonic()
|
||||
if now < self._next_tx_at:
|
||||
return
|
||||
r, g, b = _average_color(pixels)
|
||||
if brightness < 255:
|
||||
scale = max(0, min(255, brightness)) / 255.0
|
||||
r = int(r * scale)
|
||||
g = int(g * scale)
|
||||
b = int(b * scale)
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
self._next_tx_at = now + self._min_interval_s
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
# WiZ is UDP fire-and-forget — perfect candidate for the sync hot path.
|
||||
return True
|
||||
|
||||
async def set_color(self, r: int, g: int, b: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
self._send_json({"method": "setPilot", "params": {"r": r, "g": g, "b": b}})
|
||||
|
||||
async def set_brightness(self, brightness_0_100: int) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
clamped = max(10, min(100, brightness_0_100)) # WiZ rejects <10
|
||||
self._send_json({"method": "setPilot", "params": {"dimming": clamped}})
|
||||
|
||||
async def set_power(self, on: bool) -> None:
|
||||
if not self.is_connected:
|
||||
raise RuntimeError("WiZClient not connected")
|
||||
self._send_json({"method": "setPilot", "params": {"state": on}})
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Send a getPilot and wait briefly for any reply on a one-shot socket."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
host, port = parse_wiz_url(url)
|
||||
except ValueError as exc:
|
||||
return DeviceHealth(online=False, last_checked=now, error=str(exc))
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
start = loop.time()
|
||||
await loop.sock_sendto(sock, b'{"method":"getPilot","params":{}}', (host, port))
|
||||
try:
|
||||
await asyncio.wait_for(loop.sock_recv(sock, 4096), timeout=1.5)
|
||||
except asyncio.TimeoutError:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"No WiZ reply from {host}:{port} within 1.5s",
|
||||
)
|
||||
latency_ms = (loop.time() - start) * 1000.0
|
||||
return DeviceHealth(online=True, latency_ms=latency_ms, last_checked=now)
|
||||
except OSError as exc:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=now,
|
||||
error=f"WiZ probe failed for {host}: {exc}",
|
||||
)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Broadcast discovery
|
||||
# ============================================================================
|
||||
|
||||
|
||||
_DISCOVERY_REQUEST = (
|
||||
b'{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,'
|
||||
b'"phoneIp":"0.0.0.0","id":"1"}}'
|
||||
)
|
||||
|
||||
|
||||
def _extract_mac(payload: dict) -> str:
|
||||
"""Pull a bulb MAC out of the standard ``result`` envelope, if present."""
|
||||
result = payload.get("result")
|
||||
if isinstance(result, dict):
|
||||
return str(result.get("mac", "")).lower()
|
||||
return ""
|
||||
|
||||
|
||||
async def discover_wiz_bulbs(timeout: float = 2.0) -> List[dict]:
|
||||
"""Broadcast a registration probe and collect bulb replies.
|
||||
|
||||
Returns a list of ``{"ip": ..., "mac": ..., "raw": <parsed_json>}`` dicts.
|
||||
Multicast / broadcast failures (no network, firewall) raise OSError;
|
||||
callers handle that by returning an empty discovery list.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.bind(("", 0))
|
||||
await loop.sock_sendto(sock, _DISCOVERY_REQUEST, ("255.255.255.255", WIZ_PORT))
|
||||
results: list[dict] = []
|
||||
seen_ips: set[str] = set()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
raw, addr = await asyncio.wait_for(
|
||||
loop.sock_recvfrom(sock, 2048),
|
||||
timeout=remaining,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
ip = addr[0]
|
||||
if ip in seen_ips:
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8", errors="replace"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
results.append({"ip": ip, "mac": _extract_mac(payload), "raw": payload})
|
||||
return results
|
||||
finally:
|
||||
sock.close()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""WiZ Connected device provider — LAN-discoverable Philips budget-tier bulbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from ledgrab.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
ProviderDeps,
|
||||
)
|
||||
from ledgrab.core.devices.wiz_client import (
|
||||
WiZClient,
|
||||
discover_wiz_bulbs,
|
||||
parse_wiz_url,
|
||||
)
|
||||
from ledgrab.utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ledgrab.core.devices.device_config import WiZConfig
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class WiZDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for WiZ Connected (Philips budget-tier) bulbs.
|
||||
|
||||
Single-pixel device, identical adaptation shape as Yeelight/Hue.
|
||||
"""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "wiz"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {
|
||||
"manual_led_count",
|
||||
"power_control",
|
||||
"brightness_control",
|
||||
"static_color",
|
||||
"health_check",
|
||||
"single_pixel",
|
||||
}
|
||||
|
||||
def create_client(self, config: "WiZConfig", *, deps: ProviderDeps) -> LEDClient:
|
||||
return WiZClient(
|
||||
config.device_url,
|
||||
led_count=config.led_count,
|
||||
min_interval_s=max(0.0, config.wiz_min_interval_ms / 1000.0),
|
||||
)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
return await WiZClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
try:
|
||||
host, port = parse_wiz_url(url)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid WiZ URL: {exc}") from exc
|
||||
logger.info("WiZ device URL validated: host=%s port=%d", host, port)
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
try:
|
||||
bulbs = await discover_wiz_bulbs(timeout=min(timeout, 5.0))
|
||||
except (OSError, RuntimeError) as exc:
|
||||
logger.warning("WiZ discovery failed: %s", exc)
|
||||
return []
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for bulb in bulbs:
|
||||
ip = bulb.get("ip", "")
|
||||
if not ip:
|
||||
continue
|
||||
url = f"wiz://{ip}"
|
||||
mac = bulb.get("mac", "")
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=f"WiZ {mac[-6:]}" if mac else "WiZ bulb",
|
||||
url=url,
|
||||
device_type="wiz",
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
logger.info("WiZ broadcast scan found %d bulb(s)", len(results))
|
||||
return results
|
||||
@@ -163,6 +163,10 @@ export function isYeelightDevice(type: string) {
|
||||
return type === 'yeelight';
|
||||
}
|
||||
|
||||
export function isWizDevice(type: string) {
|
||||
return type === 'wiz';
|
||||
}
|
||||
|
||||
export function isUsbhidDevice(type: string) {
|
||||
return type === 'usbhid';
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const _deviceTypeIcons = {
|
||||
wled: _svg(P.wifi), adalight: _svg(P.usb), ambiled: _svg(P.usb),
|
||||
mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette),
|
||||
dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb),
|
||||
espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb),
|
||||
usbhid: _svg(P.usb),
|
||||
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
|
||||
ble: _svg(P.bluetooth),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
csptCache,
|
||||
} from '../core/state.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, 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';
|
||||
@@ -42,6 +42,7 @@ class AddDeviceModal extends Modal {
|
||||
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',
|
||||
wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
|
||||
groupChildren: JSON.stringify(_getGroupChildIds('device')),
|
||||
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
|
||||
};
|
||||
@@ -52,7 +53,7 @@ const addDeviceModal = new AddDeviceModal();
|
||||
|
||||
/* ── Icon-grid type selector ──────────────────────────────────── */
|
||||
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock'];
|
||||
|
||||
function _buildDeviceTypeItems() {
|
||||
return DEVICE_TYPE_KEYS.map(key => ({
|
||||
@@ -280,6 +281,7 @@ export function onDeviceTypeChanged() {
|
||||
_showEspnowFields(false);
|
||||
_showHueFields(false);
|
||||
_showYeelightFields(false);
|
||||
_showWizFields(false);
|
||||
_showBleFields(false);
|
||||
_showSpiFields(false);
|
||||
_showChromaFields(false);
|
||||
@@ -479,6 +481,28 @@ export function onDeviceTypeChanged() {
|
||||
} else {
|
||||
scanForDevices();
|
||||
}
|
||||
} else if (isWizDevice(deviceType)) {
|
||||
// WiZ: UDP fire-and-forget on port 38899. Show URL (LAN IP), LED
|
||||
// count (controls source mapping; the bulb averages to one color),
|
||||
// rate-limit ms. Discovery uses UDP broadcast — same scan flow.
|
||||
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 = '';
|
||||
_showWizFields(true);
|
||||
if (urlLabel) urlLabel.textContent = t('device.wiz.url') || 'IP Address:';
|
||||
if (urlHint) urlHint.textContent = t('device.wiz.url.hint') || 'LAN IP of the WiZ bulb. UDP port 38899 is the protocol default.';
|
||||
urlInput.placeholder = t('device.wiz.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.
|
||||
@@ -825,6 +849,13 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) {
|
||||
ymi.value = String(cloneData.yeelight_min_interval_ms);
|
||||
}
|
||||
}
|
||||
// Prefill WiZ fields
|
||||
if (isWizDevice(presetType)) {
|
||||
const wmi = document.getElementById('device-wiz-min-interval') as HTMLInputElement;
|
||||
if (wmi && cloneData.wiz_min_interval_ms != null) {
|
||||
wmi.value = String(cloneData.wiz_min_interval_ms);
|
||||
}
|
||||
}
|
||||
// Prefill CSPT template selector (after fetch completes)
|
||||
if (cloneData.default_css_processing_template_id) {
|
||||
csptCache.fetch().then(() => {
|
||||
@@ -1031,6 +1062,11 @@ export async function handleAddDevice(event: any) {
|
||||
const parsed = parseInt(raw || '500', 10);
|
||||
body.yeelight_min_interval_ms = Number.isFinite(parsed) ? parsed : 500;
|
||||
}
|
||||
if (isWizDevice(deviceType)) {
|
||||
const raw = (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.wiz_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isBleDevice(deviceType)) {
|
||||
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
|
||||
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim();
|
||||
@@ -1389,6 +1425,11 @@ function _showYeelightFields(show: boolean) {
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
}
|
||||
|
||||
function _showWizFields(show: boolean) {
|
||||
const el = document.getElementById('device-wiz-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, isYeelightDevice, isBleDevice, isGroupDevice } from '../core/api.ts';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, 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';
|
||||
@@ -96,6 +96,7 @@ class DeviceSettingsModal extends Modal {
|
||||
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',
|
||||
wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50',
|
||||
csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '',
|
||||
};
|
||||
}
|
||||
@@ -648,6 +649,24 @@ export async function showSettings(deviceId: any) {
|
||||
if (yeelightMinIntervalGroup) (yeelightMinIntervalGroup as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
// WiZ-specific fields — UDP fire-and-forget, no wire-level rate cap
|
||||
// beyond what the bulb's MCU can keep up with. Default 50 ms ≈ 20 Hz
|
||||
// is comfortable; users can push lower if they want to experiment.
|
||||
const wizMinIntervalGroup = document.getElementById('settings-wiz-min-interval-group');
|
||||
if (isWizDevice(device.device_type)) {
|
||||
if (wizMinIntervalGroup) (wizMinIntervalGroup as HTMLElement).style.display = '';
|
||||
const wmi = device.wiz_min_interval_ms ?? 50;
|
||||
(document.getElementById('settings-wiz-min-interval') as HTMLInputElement).value = String(wmi);
|
||||
// Relabel URL field as IP Address (same pattern as Yeelight/DMX/DDP)
|
||||
const urlLabel5 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null;
|
||||
const urlHint5 = urlGroup.querySelector('.input-hint') as HTMLElement | null;
|
||||
if (urlLabel5) urlLabel5.textContent = t('device.wiz.url');
|
||||
if (urlHint5) urlHint5.textContent = t('device.wiz.url.hint');
|
||||
urlInput.placeholder = t('device.wiz.url.placeholder') || '192.168.1.50';
|
||||
} else {
|
||||
if (wizMinIntervalGroup) (wizMinIntervalGroup 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
|
||||
@@ -789,6 +808,11 @@ export async function saveDeviceSettings() {
|
||||
const parsed = parseInt(raw || '500', 10);
|
||||
body.yeelight_min_interval_ms = Number.isFinite(parsed) ? parsed : 500;
|
||||
}
|
||||
if (isWizDevice(settingsModal.deviceType)) {
|
||||
const raw = (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value;
|
||||
const parsed = parseInt(raw || '50', 10);
|
||||
body.wiz_min_interval_ms = Number.isFinite(parsed) ? parsed : 50;
|
||||
}
|
||||
if (isBleDevice(settingsModal.deviceType)) {
|
||||
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e';
|
||||
const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || '';
|
||||
|
||||
@@ -47,7 +47,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string {
|
||||
|
||||
export type DeviceType =
|
||||
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
|
||||
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight'
|
||||
| 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' | 'wiz'
|
||||
| 'ble' | 'usbhid' | 'spi'
|
||||
| 'chroma' | 'gamesense' | 'group';
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface Device {
|
||||
hue_client_key: string;
|
||||
hue_entertainment_group_id: string;
|
||||
yeelight_min_interval_ms: number;
|
||||
wiz_min_interval_ms: number;
|
||||
spi_speed_hz: number;
|
||||
spi_led_type: string;
|
||||
chroma_device_type: string;
|
||||
|
||||
@@ -204,6 +204,13 @@
|
||||
"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.wiz": "WiZ",
|
||||
"device.type.wiz.desc": "WiZ Connected (Philips) UDP LAN bulb",
|
||||
"device.wiz.url": "IP Address:",
|
||||
"device.wiz.url.hint": "LAN IP of the WiZ bulb. UDP port 38899 is the protocol default.",
|
||||
"device.wiz.url.placeholder": "192.168.1.50",
|
||||
"device.wiz_min_interval": "Min Update Interval:",
|
||||
"device.wiz_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.",
|
||||
"device.type.ble": "BLE LED Controller",
|
||||
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
|
||||
"device.ble.url": "BLE Address:",
|
||||
|
||||
@@ -259,6 +259,13 @@
|
||||
"device.yeelight.url.placeholder": "192.168.1.50",
|
||||
"device.yeelight_min_interval": "Мин. интервал обновления:",
|
||||
"device.yeelight_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 500 мс держит лампу под ограничением ~1 команда/сек; меньшие значения могут вызвать троттлинг.",
|
||||
"device.type.wiz": "WiZ",
|
||||
"device.type.wiz.desc": "Лампа WiZ Connected (Philips) по UDP",
|
||||
"device.wiz.url": "IP-адрес:",
|
||||
"device.wiz.url.hint": "IP-адрес лампы WiZ в локальной сети. UDP-порт 38899 — по умолчанию.",
|
||||
"device.wiz.url.placeholder": "192.168.1.50",
|
||||
"device.wiz_min_interval": "Мин. интервал обновления:",
|
||||
"device.wiz_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.",
|
||||
"device.type.ble": "BLE LED контроллер",
|
||||
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
|
||||
"device.ble.url": "BLE адрес:",
|
||||
|
||||
@@ -257,6 +257,13 @@
|
||||
"device.yeelight.url.placeholder": "192.168.1.50",
|
||||
"device.yeelight_min_interval": "最小更新间隔:",
|
||||
"device.yeelight_min_interval.hint": "客户端命令速率限制(毫秒)。默认 500 毫秒可使灯泡保持在约 1 cmd/sec 限制下;较低的值可能导致节流。",
|
||||
"device.type.wiz": "WiZ",
|
||||
"device.type.wiz.desc": "WiZ Connected (飞利浦) UDP 局域网灯泡",
|
||||
"device.wiz.url": "IP 地址:",
|
||||
"device.wiz.url.hint": "WiZ 灯泡的局域网 IP。UDP 端口 38899 为协议默认值。",
|
||||
"device.wiz.url.placeholder": "192.168.1.50",
|
||||
"device.wiz_min_interval": "最小更新间隔:",
|
||||
"device.wiz_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。",
|
||||
"device.type.ble": "BLE LED 控制器",
|
||||
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
||||
"device.ble.url": "BLE 地址:",
|
||||
|
||||
@@ -64,6 +64,8 @@ class Device:
|
||||
hue_entertainment_group_id: str = "",
|
||||
# Yeelight fields
|
||||
yeelight_min_interval_ms: int = 500,
|
||||
# WiZ fields
|
||||
wiz_min_interval_ms: int = 50,
|
||||
# SPI Direct fields
|
||||
spi_speed_hz: int = 800000,
|
||||
spi_led_type: str = "WS2812B",
|
||||
@@ -112,6 +114,7 @@ class Device:
|
||||
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.wiz_min_interval_ms = wiz_min_interval_ms
|
||||
self.spi_speed_hz = spi_speed_hz
|
||||
self.spi_led_type = spi_led_type
|
||||
self.chroma_device_type = chroma_device_type
|
||||
@@ -151,6 +154,7 @@ class Device:
|
||||
OpenRGBConfig,
|
||||
SPIConfig,
|
||||
USBHIDConfig,
|
||||
WiZConfig,
|
||||
WLEDConfig,
|
||||
WSConfig,
|
||||
YeelightConfig,
|
||||
@@ -204,6 +208,11 @@ class Device:
|
||||
**base,
|
||||
yeelight_min_interval_ms=self.yeelight_min_interval_ms,
|
||||
)
|
||||
if dt == "wiz":
|
||||
return WiZConfig(
|
||||
**base,
|
||||
wiz_min_interval_ms=self.wiz_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":
|
||||
@@ -284,6 +293,8 @@ class Device:
|
||||
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.wiz_min_interval_ms != 50:
|
||||
d["wiz_min_interval_ms"] = self.wiz_min_interval_ms
|
||||
if self.spi_speed_hz != 800000:
|
||||
d["spi_speed_hz"] = self.spi_speed_hz
|
||||
if self.spi_led_type != "WS2812B":
|
||||
@@ -340,6 +351,7 @@ class Device:
|
||||
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),
|
||||
wiz_min_interval_ms=data.get("wiz_min_interval_ms", 50),
|
||||
spi_speed_hz=data.get("spi_speed_hz", 800000),
|
||||
spi_led_type=data.get("spi_led_type", "WS2812B"),
|
||||
chroma_device_type=data.get("chroma_device_type", "chromalink"),
|
||||
@@ -388,6 +400,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
|
||||
"hue_client_key",
|
||||
"hue_entertainment_group_id",
|
||||
"yeelight_min_interval_ms",
|
||||
"wiz_min_interval_ms",
|
||||
"spi_speed_hz",
|
||||
"spi_led_type",
|
||||
"chroma_device_type",
|
||||
@@ -489,6 +502,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
hue_client_key: str = "",
|
||||
hue_entertainment_group_id: str = "",
|
||||
yeelight_min_interval_ms: int = 500,
|
||||
wiz_min_interval_ms: int = 50,
|
||||
spi_speed_hz: int = 800000,
|
||||
spi_led_type: str = "WS2812B",
|
||||
chroma_device_type: str = "chromalink",
|
||||
@@ -533,6 +547,7 @@ class DeviceStore(BaseSqliteStore[Device]):
|
||||
hue_client_key=hue_client_key,
|
||||
hue_entertainment_group_id=hue_entertainment_group_id,
|
||||
yeelight_min_interval_ms=yeelight_min_interval_ms,
|
||||
wiz_min_interval_ms=wiz_min_interval_ms,
|
||||
spi_speed_hz=spi_speed_hz,
|
||||
spi_led_type=spi_led_type,
|
||||
chroma_device_type=chroma_device_type,
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
<option value="espnow">ESP-NOW</option>
|
||||
<option value="hue">Philips Hue</option>
|
||||
<option value="yeelight">Yeelight</option>
|
||||
<option value="wiz">WiZ</option>
|
||||
<option value="ble">BLE LED Controller</option>
|
||||
<option value="usbhid">USB HID</option>
|
||||
<option value="spi">SPI Direct</option>
|
||||
@@ -225,6 +226,15 @@
|
||||
<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>
|
||||
<!-- WiZ fields -->
|
||||
<div class="form-group" id="device-wiz-min-interval-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="device-wiz-min-interval" data-i18n="device.wiz_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.wiz_min_interval.hint">Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.</small>
|
||||
<input type="number" id="device-wiz-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</div>
|
||||
<!-- ESP-NOW fields -->
|
||||
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
|
||||
@@ -259,6 +259,15 @@
|
||||
<input type="number" id="settings-yeelight-min-interval" min="0" max="10000" step="50" value="500">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="settings-wiz-min-interval-group" style="display: none;">
|
||||
<div class="label-row">
|
||||
<label for="settings-wiz-min-interval" data-i18n="device.wiz_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.wiz_min_interval.hint">Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.</small>
|
||||
<input type="number" id="settings-wiz-min-interval" min="0" max="10000" step="10" value="50">
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user