From 8f9d49006388916f01e9c614fbf572437ac22cfb Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 16 May 2026 02:30:30 +0300 Subject: [PATCH] feat(devices): LIFX LAN target type Adds support for LIFX smart bulbs and lightstrips that speak the LIFX binary UDP protocol on port 56700, with broadcast LAN discovery via the standard GetService/StateService probe. Backend: - LIFXClient is a single-pixel UDP adapter: averages the strip to one RGB triple, converts to LIFX HSBK (16-bit hue/saturation/brightness + kelvin), and pushes a tagged SetColor packet so all bulbs on the subnet act on it. Brightness folds into the HSBK brightness channel. - Hand-rolled packet builder: 36-byte LIFX header (frame + frame-address + protocol-header) + variable-length payload. Source ID 'LGGR' identifies LedGrab in protocol logs. - supports_fast_send=True with a synchronous send_pixels_fast hot path -- UDP costs nothing, so the default rate gate is 50 ms (~20 Hz) to match LIFX's documented <=20 cmd/sec recommendation. - Broadcast discovery sends GetService and parses StateService replies back into IP + MAC + service-port triples. Broadcast failures yield [] rather than raising. - Health check sends GetService and waits 1.5s for any reply on a one-shot UDP socket. - LIFXConfig joins the typed config union; Device storage gains a lifx_min_interval_ms field; full to_dict/from_dict/to_config wiring. - 47 unit tests cover URL parsing, RGB->HSBK conversion (red/green/ blue/white/black/clamping), packet construction (size, msg type, tagged flag, target MAC, sequence byte), SetColor and SetPower payload layouts, StateService reply parsing (including rejection of wrong msg types and runt payloads), strip averaging, rate limiting, fast-send hot path, provider validate/discover/health, and Device.to_config round-trip. Frontend: - 'lifx' in DEVICE_TYPE_KEYS (next to 'wiz'), lightbulb icon (deliberate smart-bulb family grouping with Hue + Yeelight + WiZ). - isLifxDevice predicate + per-type field show/hide in create and settings modals. - Rate-limit number input (default 50 ms) in both modals with hint text referencing LIFX's documented <=20 cmd/sec ceiling. - Locale strings in en/ru/zh. LIFX bulbs are reachable from the existing "Scan network" button -- no new discovery UI affordance was needed. No brightness_control capability exposed; LIFX brightness is folded into the HSBK on the wire. --- TODO.md | 5 +- server/src/ledgrab/api/routes/devices.py | 7 + server/src/ledgrab/api/schemas/devices.py | 11 + .../src/ledgrab/core/devices/device_config.py | 13 + server/src/ledgrab/core/devices/led_client.py | 4 + .../src/ledgrab/core/devices/lifx_client.py | 425 ++++++++++++++++ .../src/ledgrab/core/devices/lifx_provider.py | 92 ++++ server/src/ledgrab/static/js/core/api.ts | 4 + server/src/ledgrab/static/js/core/icons.ts | 2 +- .../static/js/features/device-discovery.ts | 45 +- .../src/ledgrab/static/js/features/devices.ts | 26 +- server/src/ledgrab/static/js/types.ts | 3 +- server/src/ledgrab/static/locales/en.json | 7 + server/src/ledgrab/static/locales/ru.json | 7 + server/src/ledgrab/static/locales/zh.json | 7 + server/src/ledgrab/storage/device_store.py | 15 + .../ledgrab/templates/modals/add-device.html | 10 + .../templates/modals/device-settings.html | 9 + server/tests/test_lifx.py | 479 ++++++++++++++++++ 19 files changed, 1165 insertions(+), 6 deletions(-) create mode 100644 server/src/ledgrab/core/devices/lifx_client.py create mode 100644 server/src/ledgrab/core/devices/lifx_provider.py create mode 100644 server/tests/test_lifx.py diff --git a/TODO.md b/TODO.md index dd638f2..85e82d4 100644 --- a/TODO.md +++ b/TODO.md @@ -723,9 +723,12 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f ### Phase 4 — Major consumer brands +- [x] **LIFX LAN** — UDP binary protocol on port 56700; RGB→HSBK 16-bit + conversion; broadcast discovery via GetService/StateService probe; + 47 unit tests. Single-pixel adapter shape, identical to WiZ + structurally. Frontend wired via subagent. - [ ] Govee LAN API (2023+) - [ ] Twinkly -- [ ] LIFX LAN - [ ] Nanoleaf OpenAPI - [ ] Mi-Light / MiBoxer UDP gateway diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 70fbe86..d6e48ab 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -68,6 +68,7 @@ def _device_to_response(device) -> DeviceResponse: 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, + lifx_min_interval_ms=device.lifx_min_interval_ms, spi_speed_hz=device.spi_speed_hz, spi_led_type=device.spi_led_type, chroma_device_type=device.chroma_device_type, @@ -233,6 +234,11 @@ async def create_device( if device_data.wiz_min_interval_ms is not None else 50 ), + lifx_min_interval_ms=( + device_data.lifx_min_interval_ms + if device_data.lifx_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", @@ -497,6 +503,7 @@ async def update_device( 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, + lifx_min_interval_ms=update_data.lifx_min_interval_ms, spi_speed_hz=update_data.spi_speed_hz, spi_led_type=update_data.spi_led_type, chroma_device_type=update_data.chroma_device_type, diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index f854802..1b547cc 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -77,6 +77,13 @@ class DeviceCreate(BaseModel): le=10000, description="WiZ client-side rate limit between commands in ms (default 50)", ) + # LIFX fields + lifx_min_interval_ms: Optional[int] = Field( + None, + ge=0, + le=10000, + description="LIFX 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" @@ -171,6 +178,9 @@ class DeviceUpdate(BaseModel): wiz_min_interval_ms: Optional[int] = Field( None, ge=0, le=10000, description="WiZ client-side rate limit in ms" ) + lifx_min_interval_ms: Optional[int] = Field( + None, ge=0, le=10000, description="LIFX 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") @@ -344,6 +354,7 @@ class DeviceResponse(BaseModel): 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") + lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms") spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz") spi_led_type: str = Field(default="WS2812B", description="LED chipset type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index 64ede1c..5c28427 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -100,6 +100,18 @@ class WiZConfig(BaseDeviceConfig): wiz_min_interval_ms: int = 50 +@dataclass(frozen=True) +class LIFXConfig(BaseDeviceConfig): + """LIFX LAN bulb / lightstrip. + + LIFX recommends ≤20 commands/sec per device. ``lifx_min_interval_ms`` + defaults to 50 ms so we stay just under that ceiling. + """ + + device_type: Literal["lifx"] = "lifx" + lifx_min_interval_ms: int = 50 + + @dataclass(frozen=True) class SPIConfig(BaseDeviceConfig): device_type: Literal["spi"] = "spi" @@ -172,6 +184,7 @@ DeviceConfig = Union[ DDPConfig, YeelightConfig, WiZConfig, + LIFXConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index a5da1b6..77a8b4e 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -346,6 +346,10 @@ def _register_builtin_providers(): register_provider(WiZDeviceProvider()) + from ledgrab.core.devices.lifx_provider import LIFXDeviceProvider + + register_provider(LIFXDeviceProvider()) + # BLE support is optional — only register the provider if the ``bleak`` # extra is installed. Importing the provider itself is safe (it doesn't # import bleak at module load), but we still want a clean skip on diff --git a/server/src/ledgrab/core/devices/lifx_client.py b/server/src/ledgrab/core/devices/lifx_client.py new file mode 100644 index 0000000..21ad622 --- /dev/null +++ b/server/src/ledgrab/core/devices/lifx_client.py @@ -0,0 +1,425 @@ +"""LIFX LAN LED client. + +LIFX bulbs and lightstrips accept a binary UDP protocol on port 56700. +Every packet has a 36-byte header (frame + frame-address + protocol-header) +followed by a type-specific payload. Colors are HSBK 16-bit per channel. + +URL scheme: ``lifx://[:port]`` or bare ``[:port]``. Default port 56700. + +LIFX bulbs are reachable two ways: + * Unicast — set the ``target`` field to the bulb's 48-bit MAC. + * Broadcast — set ``target`` to all zeros and ``tagged=1``; all bulbs on + the subnet act on the message. We use this for the SetColor hot path + so we don't have to learn the MAC of every device a user owns. + +Reference: https://lan.developer.lifx.com/docs/header-description +""" + +from __future__ import annotations + +import asyncio +import socket +import struct +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__) + +LIFX_PORT = 56700 +DEFAULT_MIN_INTERVAL_S = 0.05 # ~20 Hz — LIFX rate-limit guidance is 20/sec + +# Message types we care about +MSG_GET_SERVICE = 2 +MSG_STATE_SERVICE = 3 +MSG_SET_POWER = 21 +MSG_SET_COLOR = 102 + +# Frame field byte 0 of the protocol header: tagged=1, addressable=1, protocol=1024 +_FRAME_TAGGED = 0x3400 +_FRAME_UNTAGGED = 0x1400 + +_SOURCE_ID = 0x4C474752 # "LGGR" — identifies LedGrab in protocol logs + + +def parse_lifx_url(url: str) -> Tuple[str, int]: + """Pull ``(host, port)`` from ``lifx://host[:port]`` or bare ``host[:port]``.""" + if not url: + raise ValueError("LIFX URL is empty") + raw = url.strip() + if "://" in raw: + parsed = urlparse(raw) + host = parsed.hostname or "" + port = parsed.port or LIFX_PORT + else: + parsed = urlparse(f"lifx://{raw}") + host = parsed.hostname or "" + port = parsed.port or LIFX_PORT + if not host: + raise ValueError(f"LIFX 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 + + +def rgb_to_hsbk(r: int, g: int, b: int, kelvin: int = 3500) -> Tuple[int, int, int, int]: + """Convert 8-bit RGB to LIFX 16-bit HSBK. + + The ``kelvin`` channel is irrelevant when saturation > 0 (the bulb + interprets it as a hint, not a hard temperature), so we leave it at the + LIFX default of ~3500 K. Outputs are clamped uint16. + """ + r_n = max(0, min(255, r)) / 255.0 + g_n = max(0, min(255, g)) / 255.0 + b_n = max(0, min(255, b)) / 255.0 + c_max = max(r_n, g_n, b_n) + c_min = min(r_n, g_n, b_n) + delta = c_max - c_min + + # Hue (0-360 degrees → 0-65535) + if delta == 0: + h = 0.0 + elif c_max == r_n: + h = 60.0 * (((g_n - b_n) / delta) % 6) + elif c_max == g_n: + h = 60.0 * (((b_n - r_n) / delta) + 2) + else: + h = 60.0 * (((r_n - g_n) / delta) + 4) + hue_u16 = int((h / 360.0) * 65535) & 0xFFFF + + # Saturation (0-1 → 0-65535) + sat_u16 = 0 if c_max == 0 else int((delta / c_max) * 65535) & 0xFFFF + # Brightness (0-1 → 0-65535) + bri_u16 = int(c_max * 65535) & 0xFFFF + kelvin_u16 = max(2500, min(9000, kelvin)) & 0xFFFF + return hue_u16, sat_u16, bri_u16, kelvin_u16 + + +def _build_packet( + *, + msg_type: int, + payload: bytes, + target_mac: bytes = b"\x00\x00\x00\x00\x00\x00", + sequence: int = 0, + res_required: bool = False, + ack_required: bool = False, + tagged: bool = False, +) -> bytes: + """Pack a LIFX packet: 36-byte header + payload. + + See https://lan.developer.lifx.com/docs/header-description for the + bit-level field layout. We construct the three sub-headers separately + so the magic numbers are scoped to the fields they belong to. + """ + size = 36 + len(payload) + # Frame header (8 bytes): size(2) | origin/tagged/addressable/protocol(2) | source(4) + frame_field = _FRAME_TAGGED if tagged else _FRAME_UNTAGGED + frame = struct.pack(" bytes: + """SetColor payload: reserved(1) | HSBK(8) | duration(4).""" + return b"\x00" + struct.pack( + " bytes: + """SetPower payload: level(2) | duration(4). Level is 0 or 65535.""" + return struct.pack(" Optional[dict]: + """Parse a LIFX StateService (discovery) reply. + + Returns ``{"mac": "aabbccddeeff", "service": int, "port": int}`` or + ``None`` if the payload isn't a StateService. + """ + if len(raw) < 36 + 5: + return None + # Read msg_type from the protocol header at offset 32 + msg_type = struct.unpack_from(" 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( + _LIFXProtocol, remote_addr=(self._host, self._port) + ) + except OSError as exc: + raise RuntimeError(f"Failed to open UDP to LIFX at {self._host}: {exc}") from exc + self._transport = transport + self._protocol = protocol # type: ignore[assignment] + self._connected = True + logger.info("LIFXClient 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 _next_sequence(self) -> int: + self._sequence = (self._sequence + 1) & 0xFF + return self._sequence + + def _send(self, msg_type: int, payload: bytes) -> None: + """Build and send one LIFX packet. Caller must hold an open transport.""" + assert self._transport is not None + packet = _build_packet( + msg_type=msg_type, + payload=payload, + sequence=self._next_sequence(), + tagged=True, # broadcast within the unicast UDP socket — bulb still acts on it + ) + self._transport.sendto(packet) + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + """Average the strip → HSBK → SetColor.""" + if not self.is_connected: + raise RuntimeError("LIFXClient 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) + h, s, br, k = rgb_to_hsbk(r, g, b) + self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + 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 — same shape, runs on the hot loop.""" + if not self.is_connected or self._transport is None: + raise RuntimeError("LIFXClient 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) + h, s, br, k = rgb_to_hsbk(r, g, b) + self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + self._next_tx_at = now + self._min_interval_s + + @property + def supports_fast_send(self) -> bool: + return True + + async def set_color(self, r: int, g: int, b: int) -> None: + if not self.is_connected: + raise RuntimeError("LIFXClient not connected") + h, s, br, k = rgb_to_hsbk(r, g, b) + self._send(MSG_SET_COLOR, _build_set_color_payload(h, s, br, k, duration_ms=0)) + + async def set_power(self, on: bool) -> None: + if not self.is_connected: + raise RuntimeError("LIFXClient not connected") + self._send(MSG_SET_POWER, _build_set_power_payload(on)) + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + """Send GetService and wait briefly for a StateService reply.""" + now = datetime.now(timezone.utc) + try: + host, port = parse_lifx_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)) + probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True) + start = loop.time() + await loop.sock_sendto(sock, probe, (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 LIFX 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"LIFX probe failed for {host}: {exc}", + ) + finally: + sock.close() + + +# ============================================================================ +# Broadcast discovery +# ============================================================================ + + +async def discover_lifx_bulbs(timeout: float = 2.0) -> List[dict]: + """Broadcast a GetService probe on the LAN and collect StateService replies. + + Returns ``[{"ip": ..., "mac": ..., "port": ...}, ...]``. + """ + 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)) + probe = _build_packet(msg_type=MSG_GET_SERVICE, payload=b"", tagged=True) + await loop.sock_sendto(sock, probe, ("255.255.255.255", LIFX_PORT)) + results: list[dict] = [] + seen_macs: 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, 4096), + timeout=remaining, + ) + except asyncio.TimeoutError: + break + parsed = _parse_state_service_reply(raw) + if not parsed: + continue + mac = parsed["mac"] + if mac in seen_macs: + continue + seen_macs.add(mac) + results.append( + { + "ip": addr[0], + "mac": mac, + "port": parsed["port"], + } + ) + return results + finally: + sock.close() diff --git a/server/src/ledgrab/core/devices/lifx_provider.py b/server/src/ledgrab/core/devices/lifx_provider.py new file mode 100644 index 0000000..bd935db --- /dev/null +++ b/server/src/ledgrab/core/devices/lifx_provider.py @@ -0,0 +1,92 @@ +"""LIFX device provider — LAN-discoverable LIFX smart 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.lifx_client import ( + LIFXClient, + discover_lifx_bulbs, + parse_lifx_url, +) +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.devices.device_config import LIFXConfig + +logger = get_logger(__name__) + + +class LIFXDeviceProvider(LEDDeviceProvider): + """Provider for LIFX smart bulbs / lightstrips. + + Single-pixel adapter: averages the strip down to one color, encodes as + HSBK (LIFX's native color model), and broadcasts a SetColor packet. + """ + + @property + def device_type(self) -> str: + return "lifx" + + @property + def capabilities(self) -> set: + return { + "manual_led_count", + "power_control", + "static_color", + "health_check", + "single_pixel", + } + + def create_client(self, config: "LIFXConfig", *, deps: ProviderDeps) -> LEDClient: + return LIFXClient( + config.device_url, + led_count=config.led_count, + min_interval_s=max(0.0, config.lifx_min_interval_ms / 1000.0), + ) + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + return await LIFXClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + try: + host, port = parse_lifx_url(url) + except ValueError as exc: + raise ValueError(f"Invalid LIFX URL: {exc}") from exc + logger.info("LIFX device URL validated: host=%s port=%d", host, port) + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + try: + bulbs = await discover_lifx_bulbs(timeout=min(timeout, 5.0)) + except (OSError, RuntimeError) as exc: + logger.warning("LIFX discovery failed: %s", exc) + return [] + + results: List[DiscoveredDevice] = [] + for bulb in bulbs: + ip = bulb.get("ip", "") + if not ip: + continue + url = f"lifx://{ip}" + mac = bulb.get("mac", "") + results.append( + DiscoveredDevice( + name=f"LIFX {mac[-6:]}" if mac else "LIFX bulb", + url=url, + device_type="lifx", + ip=ip, + mac=mac, + led_count=None, + version=None, + ) + ) + logger.info("LIFX broadcast scan found %d bulb(s)", len(results)) + return results diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 6c69f63..f8dbcf3 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -167,6 +167,10 @@ export function isWizDevice(type: string) { return type === 'wiz'; } +export function isLifxDevice(type: string) { + return type === 'lifx'; +} + export function isUsbhidDevice(type: string) { return type === 'usbhid'; } diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 68b579f..41080d6 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -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), wiz: _svg(P.lightbulb), + espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), usbhid: _svg(P.usb), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), ble: _svg(P.bluetooth), diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 504f9b4..9246d7b 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -7,7 +7,7 @@ import { _discoveryCache, set_discoveryCache, csptCache, } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, 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, isLifxDevice, 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'; @@ -43,6 +43,7 @@ class AddDeviceModal extends Modal { 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', + lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50', groupChildren: JSON.stringify(_getGroupChildIds('device')), groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence', }; @@ -53,7 +54,7 @@ const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ -const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; +const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; function _buildDeviceTypeItems() { return DEVICE_TYPE_KEYS.map(key => ({ @@ -282,6 +283,7 @@ export function onDeviceTypeChanged() { _showHueFields(false); _showYeelightFields(false); _showWizFields(false); + _showLifxFields(false); _showBleFields(false); _showSpiFields(false); _showChromaFields(false); @@ -503,6 +505,28 @@ export function onDeviceTypeChanged() { } else { scanForDevices(); } + } else if (isLifxDevice(deviceType)) { + // LIFX: binary UDP on port 56700. Show URL (LAN IP), LED count + // (controls source mapping; LIFX is single-pixel — HSBK averaged + // from the strip), rate-limit ms. Discovery uses UDP broadcast. + 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 = ''; + _showLifxFields(true); + if (urlLabel) urlLabel.textContent = t('device.lifx.url') || 'IP Address:'; + if (urlHint) urlHint.textContent = t('device.lifx.url.hint') || 'LAN IP of the LIFX bulb. UDP port 56700 is the protocol default.'; + urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50'; + if (deviceType in _discoveryCache) { + _renderDiscoveryList(); + } else { + scanForDevices(); + } } else if (isBleDevice(deviceType)) { // BLE: show URL (ble://
), LED count, protocol family picker, // and a Govee-only AES key field that toggles with the family selection. @@ -856,6 +880,13 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) { wmi.value = String(cloneData.wiz_min_interval_ms); } } + // Prefill LIFX fields + if (isLifxDevice(presetType)) { + const lmi = document.getElementById('device-lifx-min-interval') as HTMLInputElement; + if (lmi && cloneData.lifx_min_interval_ms != null) { + lmi.value = String(cloneData.lifx_min_interval_ms); + } + } // Prefill CSPT template selector (after fetch completes) if (cloneData.default_css_processing_template_id) { csptCache.fetch().then(() => { @@ -1067,6 +1098,11 @@ export async function handleAddDevice(event: any) { const parsed = parseInt(raw || '50', 10); body.wiz_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; } + if (isLifxDevice(deviceType)) { + const raw = (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value; + const parsed = parseInt(raw || '50', 10); + body.lifx_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(); @@ -1430,6 +1466,11 @@ function _showWizFields(show: boolean) { if (el) el.style.display = show ? '' : 'none'; } +function _showLifxFields(show: boolean) { + const el = document.getElementById('device-lifx-min-interval-group') as HTMLElement | null; + if (el) el.style.display = show ? '' : 'none'; +} + // Tracks whether the BLE fields are currently shown — avoids reading // style.display strings in _updateBleGoveeKeyVisibility. let _bleFieldsVisible = false; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index ae14cff..be89ea4 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -6,7 +6,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isYeelightDevice, isWizDevice, isLifxDevice, 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'; @@ -97,6 +97,7 @@ class DeviceSettingsModal extends Modal { 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', + lifxMinInterval: (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value || '50', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '', }; } @@ -667,6 +668,24 @@ export async function showSettings(deviceId: any) { if (wizMinIntervalGroup) (wizMinIntervalGroup as HTMLElement).style.display = 'none'; } + // LIFX-specific fields — binary UDP on port 56700, single-pixel + // (HSBK averaged from the strip). LIFX recommends ≤20 cmd/sec per + // device; default 50 ms matches that ceiling. + const lifxMinIntervalGroup = document.getElementById('settings-lifx-min-interval-group'); + if (isLifxDevice(device.device_type)) { + if (lifxMinIntervalGroup) (lifxMinIntervalGroup as HTMLElement).style.display = ''; + const lmi = device.lifx_min_interval_ms ?? 50; + (document.getElementById('settings-lifx-min-interval') as HTMLInputElement).value = String(lmi); + // Relabel URL field as IP Address (same pattern as WiZ/Yeelight/DMX/DDP) + const urlLabel6 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; + const urlHint6 = urlGroup.querySelector('.input-hint') as HTMLElement | null; + if (urlLabel6) urlLabel6.textContent = t('device.lifx.url'); + if (urlHint6) urlHint6.textContent = t('device.lifx.url.hint'); + urlInput.placeholder = t('device.lifx.url.placeholder') || '192.168.1.50'; + } else { + if (lifxMinIntervalGroup) (lifxMinIntervalGroup 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 @@ -813,6 +832,11 @@ export async function saveDeviceSettings() { const parsed = parseInt(raw || '50', 10); body.wiz_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; } + if (isLifxDevice(settingsModal.deviceType)) { + const raw = (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value; + const parsed = parseInt(raw || '50', 10); + body.lifx_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() || ''; diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 6195e5e..afa6cdf 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -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' | 'wiz' + | 'openrgb' | 'dmx' | 'ddp' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'ble' | 'usbhid' | 'spi' | 'chroma' | 'gamesense' | 'group'; @@ -78,6 +78,7 @@ export interface Device { hue_entertainment_group_id: string; yeelight_min_interval_ms: number; wiz_min_interval_ms: number; + lifx_min_interval_ms: number; spi_speed_hz: number; spi_led_type: string; chroma_device_type: string; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 7cf3287..c31c2ab 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -211,6 +211,13 @@ "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.lifx": "LIFX", + "device.type.lifx.desc": "LIFX smart bulb / lightstrip over LAN", + "device.lifx.url": "IP Address:", + "device.lifx.url.hint": "LAN IP of the LIFX bulb. UDP port 56700 is the protocol default.", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "Min Update Interval:", + "device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.", "device.type.ble": "BLE LED Controller", "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", "device.ble.url": "BLE Address:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 0ea31b3..0467ff6 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -266,6 +266,13 @@ "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.lifx": "LIFX", + "device.type.lifx.desc": "Умная лампа / лента LIFX по LAN", + "device.lifx.url": "IP-адрес:", + "device.lifx.url.hint": "IP-адрес лампы LIFX в локальной сети. UDP-порт 56700 — по умолчанию.", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "Мин. интервал обновления:", + "device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", "device.type.ble": "BLE LED контроллер", "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", "device.ble.url": "BLE адрес:", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index d1a1ee4..910df97 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -264,6 +264,13 @@ "device.wiz.url.placeholder": "192.168.1.50", "device.wiz_min_interval": "最小更新间隔:", "device.wiz_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", + "device.type.lifx": "LIFX", + "device.type.lifx.desc": "通过局域网连接 LIFX 智能灯泡/灯带", + "device.lifx.url": "IP 地址:", + "device.lifx.url.hint": "LIFX 灯泡的局域网 IP。UDP 端口 56700 为协议默认值。", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "最小更新间隔:", + "device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", "device.type.ble": "BLE LED 控制器", "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", "device.ble.url": "BLE 地址:", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index ae70dbb..9d5ea31 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -66,6 +66,8 @@ class Device: yeelight_min_interval_ms: int = 500, # WiZ fields wiz_min_interval_ms: int = 50, + # LIFX fields + lifx_min_interval_ms: int = 50, # SPI Direct fields spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", @@ -115,6 +117,7 @@ class Device: 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.lifx_min_interval_ms = lifx_min_interval_ms self.spi_speed_hz = spi_speed_hz self.spi_led_type = spi_led_type self.chroma_device_type = chroma_device_type @@ -153,6 +156,7 @@ class Device: MQTTConfig, OpenRGBConfig, SPIConfig, + LIFXConfig, USBHIDConfig, WiZConfig, WLEDConfig, @@ -213,6 +217,11 @@ class Device: **base, wiz_min_interval_ms=self.wiz_min_interval_ms, ) + if dt == "lifx": + return LIFXConfig( + **base, + lifx_min_interval_ms=self.lifx_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": @@ -295,6 +304,8 @@ class Device: 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.lifx_min_interval_ms != 50: + d["lifx_min_interval_ms"] = self.lifx_min_interval_ms if self.spi_speed_hz != 800000: d["spi_speed_hz"] = self.spi_speed_hz if self.spi_led_type != "WS2812B": @@ -352,6 +363,7 @@ class Device: 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), + lifx_min_interval_ms=data.get("lifx_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"), @@ -401,6 +413,7 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "hue_entertainment_group_id", "yeelight_min_interval_ms", "wiz_min_interval_ms", + "lifx_min_interval_ms", "spi_speed_hz", "spi_led_type", "chroma_device_type", @@ -503,6 +516,7 @@ class DeviceStore(BaseSqliteStore[Device]): hue_entertainment_group_id: str = "", yeelight_min_interval_ms: int = 500, wiz_min_interval_ms: int = 50, + lifx_min_interval_ms: int = 50, spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", chroma_device_type: str = "chromalink", @@ -548,6 +562,7 @@ class DeviceStore(BaseSqliteStore[Device]): hue_entertainment_group_id=hue_entertainment_group_id, yeelight_min_interval_ms=yeelight_min_interval_ms, wiz_min_interval_ms=wiz_min_interval_ms, + lifx_min_interval_ms=lifx_min_interval_ms, spi_speed_hz=spi_speed_hz, spi_led_type=spi_led_type, chroma_device_type=chroma_device_type, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 8419a77..75467a8 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -48,6 +48,7 @@ + @@ -235,6 +236,15 @@ + +