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