diff --git a/TODO.md b/TODO.md index ed48db0..110574c 100644 --- a/TODO.md +++ b/TODO.md @@ -659,3 +659,79 @@ Replace flat `DeviceInfo` + `**kwargs` provider contract with a discriminated un - [x] Phases 2+3 — narrow `LEDDeviceProvider.create_client` to typed configs; migrate 3 call sites; delete `DeviceInfo` + `_get_device_info` + `_DEVICE_FIELD_DEFAULTS` (single PR) - [x] Phase 4 — migrate `tests/test_group_device.py` to `GroupConfig`/`ProviderDeps`; remove legacy `GroupLEDClient` init path; 47-test config suite with 100% coverage on `device_config.py` - [ ] Phase 5 (separate PR, optional) — Pydantic v2 discriminated union in `api/schemas/devices.py`; scope frontend POST/PATCH payloads by `device_type` + +## Expand device support (Phase 1: open protocols) + +Branch: `feat/expand-device-support`. + +Goal: maximize the universe of LED controllers LedGrab can drive by adding aggregator + open-protocol providers in roughly-this order. Each driver follows the established `LEDDeviceProvider` + `*Config` + tests pattern. + +### Phase 1.1 — Standalone DDP target + +The DDP packet layer already exists (`ddp_client.py`) — currently only used inside `WLEDClient`. Promote it to a first-class device type so any DDP-speaking controller (Pixelblaze, ESPixelStick, xLights/Falcon endpoints, generic DDP receivers) can be driven directly without WLED firmware in the path. + +- [ ] `DDPConfig` dataclass in `device_config.py` (port, destination_id, color_order) +- [ ] `DDPLEDClient` in `core/devices/ddp_led_client.py` — `LEDClient` wrapper around the existing `DDPClient` transport with `supports_fast_send=True` for the hot loop +- [ ] `DDPDeviceProvider` in `core/devices/ddp_provider.py` — discovery is a no-op (DDP has no native discovery; UI accepts manual IP), validate_device pings the host, capabilities = `{"manual_led_count", "health_check"}` +- [ ] Register provider in `led_client._register_builtin_providers` +- [ ] Add `ddp` branch to `Device.to_config()` in `device_store.py` + storage fields for DDP-specific options +- [ ] API schemas: extend device schema to accept DDP fields +- [ ] Unit tests for client (packet construction is already tested under `test_ddp_client.py`; new tests cover the LEDClient wrapper, provider validate/health, config round-trip) +- [ ] Frontend: add DDP to the device-type picker + edit form (spawned to a `frontend-design` subagent) +- [ ] Locale strings (en/ru/zh) + +### Phase 1.2 — Yeelight LAN + +Xiaomi/Yeelight bulbs, port 55443 TCP JSON. Use `python-yeelight` or direct protocol. + +- [ ] `YeelightConfig` + `YeelightLEDClient` + `YeelightDeviceProvider` +- [ ] mDNS / SSDP discovery (Yeelight uses SSDP-like UDP multicast `239.255.255.250:1982`) +- [ ] Single-pixel output: map strip → averaged RGB → bulb color +- [ ] Frontend additions + locales + +### Phase 1.3 — WiZ Connected + +Philips' UDP-local budget tier. Port 38899 JSON UDP. + +- [ ] Reuse the discovery scaffolding from Yeelight (UDP broadcast pattern) +- [ ] `WiZConfig` + `WiZLEDClient` + `WiZDeviceProvider` +- [ ] Frontend additions + locales + +### Phase 2 — Unified discovery + pairing UX layer + +After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen for replies, present a list". Factor that out into a generic discovery scaffold + a "first-run pairing" UX component before adding Tuya/Govee/etc., which each need a one-time pairing dance. + +- [ ] Generic `NetworkDiscoveryService` that fan-outs mDNS + SSDP + UDP-broadcast probes in parallel +- [ ] Unified "scan network for devices" UI affordance instead of per-type buttons +- [ ] Reusable "pair device" component (consent button, countdown, retry) + +### Phase 3 — Big aggregator unlocks + +- [ ] ESPHome native API (`aioesphomeapi`) +- [ ] Tuya Local (`tinytuya`) — biggest single market unlock; needs the pairing UX from Phase 2 +- [ ] Matter over IP (forward-looking) +- [ ] Hyperion JSON downstream + +### Phase 4 — Major consumer brands + +- [ ] Govee LAN API (2023+) +- [ ] Twinkly +- [ ] LIFX LAN +- [ ] Nanoleaf OpenAPI +- [ ] Mi-Light / MiBoxer UDP gateway + +### Phase 5 — Open pixel protocols (cheap completionism) + +- [ ] OPC (Open Pixel Control) +- [ ] TPM2.net + +### Phase 6 — PC gaming RGB completion + +- [ ] Corsair iCUE SDK +- [ ] Logitech LIGHTSYNC +- [ ] ASUS Aura SDK + +### Phase 7 — Proprietary USB HID ambient kits + +- [ ] Generic HID-ambient framework + VID/PID registry +- [ ] First reverse-engineered target (probably Govee Immersion / DreamView) diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 5111b3f..902f715 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -32,6 +32,7 @@ from ledgrab.core.processing.processor_manager import ProcessorManager from ledgrab.storage import DeviceStore from ledgrab.storage.output_target_store import OutputTargetStore from ledgrab.utils import get_logger +from ledgrab.utils.url_scheme import infer_http_scheme logger = get_logger(__name__) @@ -57,6 +58,9 @@ def _device_to_response(device) -> DeviceResponse: dmx_protocol=device.dmx_protocol, dmx_start_universe=device.dmx_start_universe, dmx_start_channel=device.dmx_start_channel, + ddp_port=device.ddp_port, + ddp_destination_id=device.ddp_destination_id, + ddp_color_order=device.ddp_color_order, espnow_peer_mac=device.espnow_peer_mac, espnow_channel=device.espnow_channel, hue_username=device.hue_username, @@ -134,6 +138,8 @@ async def create_device( detail="URL is required for non-group device types.", ) device_url = device_data.url.rstrip("/") + if device_type == "wled": + device_url = infer_http_scheme(device_url) # ── Non-group: validate via provider ── if device_type != "group": @@ -168,9 +174,19 @@ async def create_device( except HTTPException: raise except Exception as e: + # Don't leak the raw exception text — it can carry stack + # frames, host headers, or other internals that aren't safe + # to echo. Log with full context, return a generic message. + logger.warning( + "Failed to validate %s device at %s: %s", + device_type, + device_url, + e, + exc_info=True, + ) raise HTTPException( status_code=422, - detail=f"Failed to connect to {device_type} device at {device_url}: {e}", + detail=f"Failed to connect to {device_type} device at {device_url}.", ) # Resolve auto_shutdown default: False for all types @@ -181,7 +197,7 @@ async def create_device( # Create device in storage device = store.create_device( name=device_data.name, - url=device_data.url, + url=device_url, led_count=led_count, device_type=device_type, baud_rate=device_data.baud_rate, @@ -193,6 +209,13 @@ async def create_device( dmx_protocol=device_data.dmx_protocol or "artnet", dmx_start_universe=device_data.dmx_start_universe or 0, dmx_start_channel=device_data.dmx_start_channel or 1, + ddp_port=device_data.ddp_port or 0, + ddp_destination_id=( + device_data.ddp_destination_id if device_data.ddp_destination_id is not None else 1 + ), + ddp_color_order=( + device_data.ddp_color_order if device_data.ddp_color_order is not None else 1 + ), espnow_peer_mac=device_data.espnow_peer_mac or "", espnow_channel=device_data.espnow_channel or 1, hue_username=device_data.hue_username or "", @@ -266,11 +289,20 @@ async def discover_devices( raise HTTPException(status_code=400, detail=f"Unknown device type: {device_type}") discovered = await provider.discover(timeout=capped_timeout) else: - # Discover from all providers in parallel + # Discover from all providers in parallel. Discovery is best-effort: + # one provider failing (firewall, missing dep, mDNS race) must not + # take the entire scan down, so collect exceptions instead of + # raising and log them individually. providers = get_all_providers() - discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()] - all_results = await asyncio.gather(*discover_tasks) - discovered = [d for batch in all_results for d in batch] + provider_items = list(providers.items()) + discover_tasks = [p.discover(timeout=capped_timeout) for _, p in provider_items] + all_results = await asyncio.gather(*discover_tasks, return_exceptions=True) + discovered = [] + for (name, _), result in zip(provider_items, all_results): + if isinstance(result, BaseException): + logger.warning("Discovery failed for provider %s: %s", name, result) + continue + discovered.extend(result) elapsed_ms = (time.time() - start) * 1000 existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()} @@ -385,6 +417,21 @@ async def update_device( existing = store.get_device(device_id) is_group = existing.device_type == "group" + # Normalize a WLED URL the same way we do on create: infer http/https + # when the user typed a bare host. Done via a local rather than + # mutating the request DTO so the input is preserved for any future + # caller that inspects it. + normalized_url = update_data.url + if existing.device_type == "wled" and update_data.url: + raw_url = update_data.url.rstrip("/") + normalized_url = infer_http_scheme(raw_url) + if normalized_url != raw_url: + logger.debug("Inferred WLED URL scheme: %r -> %r", raw_url, normalized_url) + + # Group-only field overrides (led_count auto-recompute) are accumulated + # here too so the update_data Pydantic model is not mutated in place. + normalized_led_count = update_data.led_count + if is_group: new_children = update_data.group_device_ids new_mode = update_data.group_mode or existing.group_mode @@ -405,20 +452,20 @@ async def update_device( # Auto-recompute led_count for sequence mode if effective_mode == "sequence": - update_data.led_count = store.resolve_group_led_count(effective_children) + normalized_led_count = store.resolve_group_led_count(effective_children) elif ( - update_data.led_count is None + normalized_led_count is None and new_mode == "independent" and new_children is not None ): - update_data.led_count = store.resolve_group_max_led_count(effective_children) + normalized_led_count = store.resolve_group_max_led_count(effective_children) device = store.update_device( device_id=device_id, name=update_data.name, - url=update_data.url, + url=normalized_url, enabled=update_data.enabled, - led_count=update_data.led_count, + led_count=normalized_led_count, baud_rate=update_data.baud_rate, auto_shutdown=update_data.auto_shutdown, send_latency_ms=update_data.send_latency_ms, @@ -428,6 +475,9 @@ async def update_device( dmx_protocol=update_data.dmx_protocol, dmx_start_universe=update_data.dmx_start_universe, dmx_start_channel=update_data.dmx_start_channel, + ddp_port=update_data.ddp_port, + ddp_destination_id=update_data.ddp_destination_id, + ddp_color_order=update_data.ddp_color_order, espnow_peer_mac=update_data.espnow_peer_mac, espnow_channel=update_data.espnow_channel, hue_username=update_data.hue_username, @@ -449,13 +499,12 @@ async def update_device( try: manager.update_device_info( device_id, - device_url=update_data.url, - led_count=update_data.led_count, + device_url=normalized_url, + led_count=normalized_led_count, baud_rate=update_data.baud_rate, ) except ValueError as e: logger.debug("Processor manager device update skipped for %s: %s", device_id, e) - pass # Sync auto_shutdown and zone_mode in runtime state ds = manager.find_device_state(device_id) diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index f12500e..3b2fe0d 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -37,6 +37,19 @@ class DeviceCreate(BaseModel): dmx_start_channel: Optional[int] = Field( None, ge=1, le=512, description="DMX start channel (1-512)" ) + # DDP fields + ddp_port: Optional[int] = Field( + None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)" + ) + ddp_destination_id: Optional[int] = Field( + None, ge=0, le=255, description="DDP destination ID (default 1 = display)" + ) + ddp_color_order: Optional[int] = Field( + None, + ge=0, + le=5, + description="DDP color order: 0=GRB 1=RGB 2=BRG 3=RBG 4=BGR 5=GBR (most receivers expect RGB)", + ) # ESP-NOW fields espnow_peer_mac: Optional[str] = Field( None, description="ESP-NOW peer MAC address (e.g. AA:BB:CC:DD:EE:FF)" @@ -126,6 +139,11 @@ class DeviceUpdate(BaseModel): dmx_start_channel: Optional[int] = Field( None, ge=1, le=512, description="DMX start channel (1-512)" ) + ddp_port: Optional[int] = Field( + None, ge=0, le=65535, description="DDP UDP port (0 = protocol default 4048)" + ) + ddp_destination_id: Optional[int] = Field(None, ge=0, le=255, description="DDP destination ID") + ddp_color_order: Optional[int] = Field(None, ge=0, le=5, description="DDP color order code") espnow_peer_mac: Optional[str] = Field(None, description="ESP-NOW peer MAC address") espnow_channel: Optional[int] = Field(None, ge=1, le=14, description="ESP-NOW WiFi channel") hue_username: Optional[str] = Field(None, description="Hue bridge username") @@ -294,6 +312,9 @@ class DeviceResponse(BaseModel): dmx_protocol: str = Field(default="artnet", description="DMX protocol: artnet or sacn") dmx_start_universe: int = Field(default=0, description="DMX start universe") dmx_start_channel: int = Field(default=1, description="DMX start channel (1-512)") + ddp_port: int = Field(default=0, description="DDP UDP port (0 = protocol default 4048)") + ddp_destination_id: int = Field(default=1, description="DDP destination ID") + ddp_color_order: int = Field(default=1, description="DDP color order code (1 = RGB)") espnow_peer_mac: str = Field(default="", description="ESP-NOW peer MAC address") espnow_channel: int = Field(default=1, description="ESP-NOW WiFi channel") hue_username: str = Field(default="", description="Hue bridge username") diff --git a/server/src/ledgrab/core/devices/ddp_led_client.py b/server/src/ledgrab/core/devices/ddp_led_client.py new file mode 100644 index 0000000..3f9b2e3 --- /dev/null +++ b/server/src/ledgrab/core/devices/ddp_led_client.py @@ -0,0 +1,221 @@ +"""Standalone DDP (Distributed Display Protocol) LEDClient. + +Wraps the low-level ``DDPClient`` transport from ``ddp_client.py`` to expose +DDP as a first-class device type. Any receiver that speaks DDP — Pixelblaze, +ESPixelStick, xLights/Falcon endpoints, generic DDP firmware — can be driven +through this client without WLED in the path. + +URL scheme: ``ddp://host[:port]`` or a bare ``host[:port]``. Default port 4048. +""" + +from __future__ import annotations + +import asyncio +import socket +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.ddp_client import DDPClient +from ledgrab.core.devices.led_client import DeviceHealth, LEDClient +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +DEFAULT_DDP_PORT = 4048 +DEFAULT_DESTINATION_ID = 0x01 # 1 = display +DEFAULT_COLOR_ORDER = 1 # 1 = RGB (no reorder) + + +def parse_ddp_url(url: str) -> Tuple[str, int]: + """Parse a DDP URL into ``(host, port)``. + + Accepted forms: + ``ddp://192.168.1.50`` → ("192.168.1.50", 4048) + ``ddp://192.168.1.50:4048`` → ("192.168.1.50", 4048) + ``192.168.1.50`` → ("192.168.1.50", 4048) + ``192.168.1.50:4048`` → ("192.168.1.50", 4048) + ``ddp://[fe80::1]:4048`` → ("fe80::1", 4048) + """ + if not url: + raise ValueError("DDP URL is empty") + raw = url.strip() + if "://" in raw: + parsed = urlparse(raw) + host = parsed.hostname or "" + port = parsed.port or DEFAULT_DDP_PORT + else: + # Bare ``host`` or ``host:port`` — wrap into a URL so urlparse handles IPv6. + parsed = urlparse(f"ddp://{raw}") + host = parsed.hostname or "" + port = parsed.port or DEFAULT_DDP_PORT + if not host: + raise ValueError(f"DDP URL has no host: {url!r}") + return host, port + + +class DDPLEDClient(LEDClient): + """LEDClient for generic DDP receivers. + + Designed for the streaming hot loop: ``send_pixels_fast`` is a synchronous + fire-and-forget UDP push that delegates to the pre-allocated DDP transport. + """ + + def __init__( + self, + url: str, + led_count: int = 0, + *, + rgbw: bool = False, + port: Optional[int] = None, + destination_id: int = DEFAULT_DESTINATION_ID, + color_order: int = DEFAULT_COLOR_ORDER, + ): + parsed_host, parsed_port = parse_ddp_url(url) + self._host = parsed_host + self._port = port or parsed_port + self._led_count = led_count + self._rgbw = rgbw + self._destination_id = destination_id & 0xFF + self._color_order = color_order + self._ddp: Optional[DDPClient] = None + self._connected = False + + @property + def host(self) -> str: + return self._host + + @property + def port(self) -> int: + return self._port + + @property + def device_led_count(self) -> Optional[int]: + return self._led_count or None + + @property + def is_connected(self) -> bool: + return self._connected and self._ddp is not None + + async def connect(self) -> bool: + if self._connected and self._ddp is not None: + return True + ddp = DDPClient(self._host, port=self._port, rgbw=self._rgbw) + await ddp.connect() + # Use the BusConfig hook to encode color order across the full strip + # if the user picked something other than RGB. + if self._color_order != DEFAULT_COLOR_ORDER and self._led_count > 0: + from ledgrab.core.devices.ddp_client import BusConfig + + ddp.set_buses( + [ + BusConfig( + start=0, + length=self._led_count, + color_order=self._color_order, + ) + ] + ) + self._ddp = ddp + self._connected = True + logger.info( + "DDPLEDClient connected to %s:%d (led_count=%d, rgbw=%s, order=%d)", + self._host, + self._port, + self._led_count, + self._rgbw, + self._color_order, + ) + return True + + async def close(self) -> None: + if self._ddp is not None: + try: + await self._ddp.close() + finally: + self._ddp = None + self._connected = False + + @property + def supports_fast_send(self) -> bool: + return True + + @staticmethod + def _apply_brightness(pixels: np.ndarray, brightness: int) -> np.ndarray: + if brightness >= 255: + return pixels + if brightness <= 0: + return np.zeros_like(pixels) + # uint16 scratch avoids overflow; integer divide keeps everything in uint8. + return ((pixels.astype(np.uint16) * brightness) // 255).astype(np.uint8) + + def _as_numpy(self, pixels: Union[List[Tuple[int, int, int]], np.ndarray]) -> np.ndarray: + if isinstance(pixels, np.ndarray): + arr = pixels + else: + arr = np.asarray(pixels, dtype=np.uint8) + if arr.dtype != np.uint8: + arr = arr.astype(np.uint8) + if arr.ndim == 1 and arr.shape[0] % 3 == 0: + arr = arr.reshape(-1, 3) + return arr + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + if not self.is_connected: + raise RuntimeError("DDPLEDClient not connected") + arr = self._apply_brightness(self._as_numpy(pixels), brightness) + assert self._ddp is not None + self._ddp.send_pixels_numpy(arr) + return True + + def send_pixels_fast( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> None: + if not self.is_connected or self._ddp is None: + raise RuntimeError("DDPLEDClient not connected") + arr = self._apply_brightness(self._as_numpy(pixels), brightness) + self._ddp.send_pixels_numpy(arr) + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + """DDP is connectionless UDP — health = host resolves + port reachable. + + We don't get an ACK back from DDP receivers, so this is a best-effort + probe: resolve the host (cheap, async) and report online if it succeeds. + Anything more would require sending a frame and waiting for side effects + we can't observe. + """ + now = datetime.now(timezone.utc) + try: + host, _port = parse_ddp_url(url) + except ValueError as exc: + return DeviceHealth(online=False, last_checked=now, error=str(exc)) + loop = asyncio.get_running_loop() + try: + start = loop.time() + await loop.getaddrinfo(host, None, type=socket.SOCK_DGRAM) + latency_ms = (loop.time() - start) * 1000.0 + except (socket.gaierror, OSError) as exc: + return DeviceHealth( + online=False, + last_checked=now, + error=f"DNS resolution failed for {host}: {exc}", + ) + return DeviceHealth( + online=True, + latency_ms=latency_ms, + last_checked=now, + ) diff --git a/server/src/ledgrab/core/devices/ddp_provider.py b/server/src/ledgrab/core/devices/ddp_provider.py new file mode 100644 index 0000000..e6d8ddc --- /dev/null +++ b/server/src/ledgrab/core/devices/ddp_provider.py @@ -0,0 +1,66 @@ +"""DDP device provider — standalone UDP target for any DDP-speaking receiver.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +from ledgrab.core.devices.ddp_led_client import DDPLEDClient, parse_ddp_url +from ledgrab.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, + ProviderDeps, +) +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.devices.device_config import DDPConfig + +logger = get_logger(__name__) + + +class DDPDeviceProvider(LEDDeviceProvider): + """Provider for generic DDP receivers (Pixelblaze, ESPixelStick, Falcon, …). + + DDP has no native discovery protocol — callers must supply a manual IP/host. + LED count is also user-supplied since DDP receivers don't expose a metadata + channel back to the sender. + """ + + @property + def device_type(self) -> str: + return "ddp" + + @property + def capabilities(self) -> set: + # No power_control / brightness_control: DDP receivers don't define a + # standard reply channel, so we cannot read back state. Software + # brightness is still applied client-side before the frame is sent. + return {"manual_led_count", "health_check"} + + def create_client(self, config: "DDPConfig", *, deps: ProviderDeps) -> LEDClient: + return DDPLEDClient( + config.device_url, + led_count=config.led_count, + rgbw=config.rgbw, + port=config.ddp_port or None, + destination_id=config.ddp_destination_id, + color_order=config.ddp_color_order, + ) + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + return await DDPLEDClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Validate URL parses cleanly. DDP receivers don't report LED count.""" + try: + host, port = parse_ddp_url(url) + except ValueError as exc: + raise ValueError(f"Invalid DDP URL: {exc}") from exc + logger.info("DDP device URL validated: host=%s port=%d", host, port) + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + """DDP has no native discovery — returns empty list.""" + return [] diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index ff2d65a..ab27343 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -25,6 +25,21 @@ class WLEDConfig(BaseDeviceConfig): use_ddp: bool = False +@dataclass(frozen=True) +class DDPConfig(BaseDeviceConfig): + """Standalone DDP receiver (Pixelblaze, ESPixelStick, Falcon, …). + + ``ddp_port`` of 0 means "use the protocol default" (4048). ``ddp_color_order`` + follows the WLED enum (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR). Most + non-WLED DDP receivers expect raw RGB (1). + """ + + device_type: Literal["ddp"] = "ddp" + ddp_port: int = 0 + ddp_destination_id: int = 1 + ddp_color_order: int = 1 + + @dataclass(frozen=True) class AdalightConfig(BaseDeviceConfig): device_type: Literal["adalight"] = "adalight" @@ -130,6 +145,7 @@ class USBHIDConfig(BaseDeviceConfig): DeviceConfig = Union[ WLEDConfig, + DDPConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index 80c2106..e55b948 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -302,6 +302,10 @@ def _register_builtin_providers(): register_provider(AdalightDeviceProvider()) + from ledgrab.core.devices.ddp_provider import DDPDeviceProvider + + register_provider(DDPDeviceProvider()) + from ledgrab.core.devices.ambiled_provider import AmbiLEDDeviceProvider register_provider(AmbiLEDDeviceProvider()) diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 5f596ca..7b6ca30 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -147,6 +147,10 @@ export function isDmxDevice(type: string) { return type === 'dmx'; } +export function isDdpDevice(type: string) { + return type === 'ddp'; +} + export function isEspnowDevice(type: string) { return type === 'espnow'; } diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index b8a9085..46db3d8 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -47,7 +47,7 @@ const _audioSourceTypeIcons = { capture: _svg(P.volume2), processed: _svg(P.sl 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), mock: _svg(P.wrench), + dmx: _svg(P.radio), ddp: _svg(P.send), mock: _svg(P.wrench), espnow: _svg(P.radio), hue: _svg(P.lightbulb), usbhid: _svg(P.usb), 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 37355a8..dfd8e07 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -7,13 +7,13 @@ import { _discoveryCache, set_discoveryCache, csptCache, } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; +import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isEspnowDevice, isHueDevice, 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'; import { Modal } from '../core/modal.ts'; import { _computeMaxFps, _renderFpsHint } from './devices.ts'; -import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES } from '../core/icons.ts'; +import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts'; import { EntitySelect, EntityPalette } from '../core/entity-palette.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts'; @@ -36,6 +36,9 @@ class AddDeviceModal extends Modal { dmxProtocol: (document.getElementById('device-dmx-protocol') as HTMLSelectElement)?.value || 'artnet', dmxStartUniverse: (document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0', dmxStartChannel: (document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1', + ddpPort: (document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0', + ddpDestinationId: (document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', + ddpColorOrder: (document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', bleFamily: (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || '', bleGoveeKey: (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value || '', groupChildren: JSON.stringify(_getGroupChildIds('device')), @@ -48,7 +51,7 @@ const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ -const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'espnow', 'hue', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; +const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'espnow', 'hue', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; function _buildDeviceTypeItems() { return DEVICE_TYPE_KEYS.map(key => ({ @@ -125,6 +128,42 @@ export function destroyDmxProtocolIconSelect(selectId: any) { } } +/* ── Icon-grid DDP color-order selector ──────────────────────── */ + +function _buildDdpColorOrderItems() { + return [ + { value: '1', icon: ICON_PALETTE, label: 'RGB', desc: t('device.ddp.color_order.rgb.desc') }, + { value: '0', icon: ICON_PALETTE, label: 'GRB', desc: t('device.ddp.color_order.grb.desc') }, + { value: '2', icon: ICON_PALETTE, label: 'BRG', desc: t('device.ddp.color_order.brg.desc') }, + { value: '3', icon: ICON_PALETTE, label: 'RBG', desc: t('device.ddp.color_order.rbg.desc') }, + { value: '4', icon: ICON_PALETTE, label: 'BGR', desc: t('device.ddp.color_order.bgr.desc') }, + { value: '5', icon: ICON_PALETTE, label: 'GBR', desc: t('device.ddp.color_order.gbr.desc') }, + ]; +} + +const _ddpColorOrderIconSelects: Record = {}; + +export function ensureDdpColorOrderIconSelect(selectId: any) { + const sel = document.getElementById(selectId); + if (!sel) return; + if (_ddpColorOrderIconSelects[selectId]) { + _ddpColorOrderIconSelects[selectId].updateItems(_buildDdpColorOrderItems()); + return; + } + _ddpColorOrderIconSelects[selectId] = new IconSelect({ + target: sel, + items: _buildDdpColorOrderItems(), + columns: 3, + } as any); +} + +export function destroyDdpColorOrderIconSelect(selectId: any) { + if (_ddpColorOrderIconSelects[selectId]) { + _ddpColorOrderIconSelects[selectId].destroy(); + delete _ddpColorOrderIconSelects[selectId]; + } +} + /* ── Icon-grid SPI LED chipset selector ──────────────────────── */ function _buildSpiLedTypeItems() { @@ -217,6 +256,9 @@ export function onDeviceTypeChanged() { const dmxProtocolGroup = document.getElementById('device-dmx-protocol-group') as HTMLElement; const dmxStartUniverseGroup = document.getElementById('device-dmx-start-universe-group') as HTMLElement; const dmxStartChannelGroup = document.getElementById('device-dmx-start-channel-group') as HTMLElement; + const ddpPortGroup = document.getElementById('device-ddp-port-group') as HTMLElement; + const ddpDestinationIdGroup = document.getElementById('device-ddp-destination-id-group') as HTMLElement; + const ddpColorOrderGroup = document.getElementById('device-ddp-color-order-group') as HTMLElement; // Hide zone group + mode group by default (shown only for openrgb) if (zoneGroup) zoneGroup.style.display = 'none'; @@ -228,6 +270,11 @@ export function onDeviceTypeChanged() { if (dmxStartUniverseGroup) dmxStartUniverseGroup.style.display = 'none'; if (dmxStartChannelGroup) dmxStartChannelGroup.style.display = 'none'; + // Hide DDP fields by default + if (ddpPortGroup) ddpPortGroup.style.display = 'none'; + if (ddpDestinationIdGroup) ddpDestinationIdGroup.style.display = 'none'; + if (ddpColorOrderGroup) ddpColorOrderGroup.style.display = 'none'; + // Hide new device type fields by default _showEspnowFields(false); _showHueFields(false); @@ -321,6 +368,28 @@ export function onDeviceTypeChanged() { if (urlLabel) urlLabel.textContent = t('device.dmx.url'); if (urlHint) urlHint.textContent = t('device.dmx.url.hint'); urlInput.placeholder = t('device.dmx.url.placeholder') || '192.168.1.50'; + } else if (isDdpDevice(deviceType)) { + // DDP: show URL (IP address), LED count, DDP-specific fields; hide + // serial/baud/discovery (no native discovery — user enters IP manually). + 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 (discoverySection) discoverySection.style.display = 'none'; + if (scanBtn) scanBtn.style.display = 'none'; + // Show DDP-specific fields + if (ddpPortGroup) ddpPortGroup.style.display = ''; + if (ddpDestinationIdGroup) ddpDestinationIdGroup.style.display = ''; + if (ddpColorOrderGroup) ddpColorOrderGroup.style.display = ''; + ensureDdpColorOrderIconSelect('device-ddp-color-order'); + // Relabel URL field as IP Address + if (urlLabel) urlLabel.textContent = t('device.ddp.url'); + if (urlHint) urlHint.textContent = t('device.ddp.url.hint'); + urlInput.placeholder = t('device.ddp.url.placeholder') || '192.168.1.50'; } else if (isOpenrgbDevice(deviceType)) { urlGroup.style.display = ''; urlInput.setAttribute('required', ''); @@ -712,6 +781,19 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) { const dmxChannel = document.getElementById('device-dmx-start-channel') as HTMLInputElement; if (dmxChannel && cloneData.dmx_start_channel != null) dmxChannel.value = cloneData.dmx_start_channel; } + // Prefill DDP fields + if (isDdpDevice(presetType)) { + const ddpPort = document.getElementById('device-ddp-port') as HTMLInputElement; + if (ddpPort && cloneData.ddp_port != null) ddpPort.value = cloneData.ddp_port; + const ddpDest = document.getElementById('device-ddp-destination-id') as HTMLInputElement; + if (ddpDest && cloneData.ddp_destination_id != null) ddpDest.value = cloneData.ddp_destination_id; + const ddpColorOrder = document.getElementById('device-ddp-color-order') as HTMLSelectElement; + if (ddpColorOrder && cloneData.ddp_color_order != null) { + ddpColorOrder.value = String(cloneData.ddp_color_order); + const iconSelect = _ddpColorOrderIconSelects['device-ddp-color-order']; + if (iconSelect) iconSelect.setValue(String(cloneData.ddp_color_order)); + } + } // Prefill CSPT template selector (after fetch completes) if (cloneData.default_css_processing_template_id) { csptCache.fetch().then(() => { @@ -898,6 +980,11 @@ export async function handleAddDevice(event: any) { body.dmx_start_universe = parseInt((document.getElementById('device-dmx-start-universe') as HTMLInputElement)?.value || '0', 10); body.dmx_start_channel = parseInt((document.getElementById('device-dmx-start-channel') as HTMLInputElement)?.value || '1', 10); } + if (isDdpDevice(deviceType)) { + body.ddp_port = parseInt((document.getElementById('device-ddp-port') as HTMLInputElement)?.value || '0', 10); + body.ddp_destination_id = parseInt((document.getElementById('device-ddp-destination-id') as HTMLInputElement)?.value || '1', 10); + body.ddp_color_order = parseInt((document.getElementById('device-ddp-color-order') as HTMLSelectElement)?.value || '1', 10); + } if (isEspnowDevice(deviceType)) { body.espnow_peer_mac = (document.getElementById('device-espnow-peer-mac') as HTMLInputElement)?.value || ''; body.espnow_channel = parseInt((document.getElementById('device-espnow-channel') as HTMLInputElement)?.value || '1', 10); diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 966c2b5..fc75248 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -6,9 +6,9 @@ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; import { devicesCache } from '../core/state.ts'; -import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts'; +import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -599,6 +599,33 @@ export async function showSettings(deviceId: any) { if (dmxStartChannelGroup) (dmxStartChannelGroup as HTMLElement).style.display = 'none'; } + // DDP-specific fields + const ddpPortGroup = document.getElementById('settings-ddp-port-group'); + const ddpDestinationIdGroup = document.getElementById('settings-ddp-destination-id-group'); + const ddpColorOrderGroup = document.getElementById('settings-ddp-color-order-group'); + if (isDdpDevice(device.device_type)) { + if (ddpPortGroup) (ddpPortGroup as HTMLElement).style.display = ''; + if (ddpDestinationIdGroup) (ddpDestinationIdGroup as HTMLElement).style.display = ''; + if (ddpColorOrderGroup) (ddpColorOrderGroup as HTMLElement).style.display = ''; + (document.getElementById('settings-ddp-port') as HTMLInputElement).value = device.ddp_port ?? 0; + (document.getElementById('settings-ddp-destination-id') as HTMLInputElement).value = device.ddp_destination_id ?? 1; + const colorOrderSel = document.getElementById('settings-ddp-color-order') as HTMLSelectElement; + const colorOrderVal = String(device.ddp_color_order ?? 1); + if (colorOrderSel) colorOrderSel.value = colorOrderVal; + ensureDdpColorOrderIconSelect('settings-ddp-color-order'); + // Relabel URL field as IP Address + const urlLabel3 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; + const urlHint3 = urlGroup.querySelector('.input-hint') as HTMLElement | null; + if (urlLabel3) urlLabel3.textContent = t('device.ddp.url'); + if (urlHint3) urlHint3.textContent = t('device.ddp.url.hint'); + urlInput.placeholder = t('device.ddp.url.placeholder') || '192.168.1.50'; + } else { + destroyDdpColorOrderIconSelect('settings-ddp-color-order'); + if (ddpPortGroup) (ddpPortGroup as HTMLElement).style.display = 'none'; + if (ddpDestinationIdGroup) (ddpDestinationIdGroup as HTMLElement).style.display = 'none'; + if (ddpColorOrderGroup) (ddpColorOrderGroup 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 @@ -687,7 +714,7 @@ export async function showSettings(deviceId: any) { } export function isSettingsDirty() { return settingsModal.isDirty(); } -export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); settingsModal.forceClose(); } +export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } destroyBleFamilyIconSelect('settings-ble-family'); destroyDdpColorOrderIconSelect('settings-ddp-color-order'); destroyDmxProtocolIconSelect('settings-dmx-protocol'); settingsModal.forceClose(); } export function closeDeviceSettingsModal() { settingsModal.close(); } export async function saveDeviceSettings() { @@ -730,6 +757,11 @@ export async function saveDeviceSettings() { body.dmx_start_universe = parseInt((document.getElementById('settings-dmx-start-universe') as HTMLInputElement | null)?.value || '0', 10); body.dmx_start_channel = parseInt((document.getElementById('settings-dmx-start-channel') as HTMLInputElement | null)?.value || '1', 10); } + if (isDdpDevice(settingsModal.deviceType)) { + body.ddp_port = parseInt((document.getElementById('settings-ddp-port') as HTMLInputElement | null)?.value || '0', 10); + body.ddp_destination_id = parseInt((document.getElementById('settings-ddp-destination-id') as HTMLInputElement | null)?.value || '1', 10); + body.ddp_color_order = parseInt((document.getElementById('settings-ddp-color-order') as HTMLSelectElement | null)?.value || '1', 10); + } 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 ebffea2..d0e83e0 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -67,6 +67,9 @@ export interface Device { dmx_protocol: string; dmx_start_universe: number; dmx_start_channel: number; + ddp_port: number; + ddp_destination_id: number; + ddp_color_order: number; espnow_peer_mac: string; espnow_channel: number; hue_username: string; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index be66a56..13de47f 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -189,6 +189,8 @@ "device.type.openrgb.desc": "Control RGB peripherals via OpenRGB", "device.type.dmx": "DMX", "device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "Direct UDP pixel push (Pixelblaze, ESPixelStick, Falcon)", "device.type.mock": "Mock", "device.type.mock.desc": "Virtual device for testing", "device.type.espnow": "ESP-NOW", @@ -283,6 +285,21 @@ "device.dmx.url": "IP Address:", "device.dmx.url.hint": "IP address of the DMX node (e.g. 192.168.1.50)", "device.dmx.url.placeholder": "192.168.1.50", + "device.ddp.url": "IP Address:", + "device.ddp.url.hint": "DDP receiver address. Port defaults to 4048.", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_port": "DDP Port:", + "device.ddp_port.hint": "UDP port (0 = protocol default 4048).", + "device.ddp_destination_id": "Destination ID:", + "device.ddp_destination_id.hint": "DDP destination identifier (1 = display).", + "device.ddp_color_order": "Color Order:", + "device.ddp_color_order.hint": "Channel byte order on the wire. Most DDP receivers expect RGB.", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", "device.serial_port": "Serial Port:", "device.serial_port.hint": "Select the COM port of the Adalight device", "device.serial_port.none": "No serial ports found", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 7feb4c3..a7c550e 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -244,6 +244,8 @@ "device.type.openrgb.desc": "Управление RGB через OpenRGB", "device.type.dmx": "DMX", "device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "Прямая отправка пикселей по UDP (Pixelblaze, ESPixelStick, Falcon)", "device.type.mock": "Mock", "device.type.mock.desc": "Виртуальное устройство для тестов", "device.type.espnow": "ESP-NOW", @@ -336,6 +338,21 @@ "device.dmx.url": "IP адрес:", "device.dmx.url.hint": "IP адрес DMX-узла (напр. 192.168.1.50)", "device.dmx.url.placeholder": "192.168.1.50", + "device.ddp.url": "IP-адрес:", + "device.ddp.url.hint": "Адрес DDP-приёмника. Порт по умолчанию — 4048.", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_port": "Порт DDP:", + "device.ddp_port.hint": "UDP-порт (0 = протокольный по умолчанию 4048).", + "device.ddp_destination_id": "ID назначения:", + "device.ddp_destination_id.hint": "Идентификатор назначения DDP (1 = дисплей).", + "device.ddp_color_order": "Порядок цветов:", + "device.ddp_color_order.hint": "Порядок байт каналов на линии. Большинство DDP-приёмников ожидают RGB.", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", "device.serial_port": "Серийный порт:", "device.serial_port.hint": "Выберите COM порт устройства Adalight", "device.serial_port.none": "Серийные порты не найдены", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 5f9e0f3..d005617 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -242,6 +242,8 @@ "device.type.openrgb.desc": "通过OpenRGB控制RGB外设", "device.type.dmx": "DMX", "device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "直接UDP像素推送 (Pixelblaze、ESPixelStick、Falcon)", "device.type.mock": "Mock", "device.type.mock.desc": "用于测试的虚拟设备", "device.type.espnow": "ESP-NOW", @@ -334,6 +336,21 @@ "device.dmx.url": "IP 地址:", "device.dmx.url.hint": "DMX 节点的 IP 地址(例如 192.168.1.50)", "device.dmx.url.placeholder": "192.168.1.50", + "device.ddp.url": "IP 地址:", + "device.ddp.url.hint": "DDP 接收器地址。端口默认为 4048。", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_port": "DDP 端口:", + "device.ddp_port.hint": "UDP 端口(0 = 协议默认值 4048)。", + "device.ddp_destination_id": "目标 ID:", + "device.ddp_destination_id.hint": "DDP 目标标识符(1 = 显示)。", + "device.ddp_color_order": "颜色顺序:", + "device.ddp_color_order.hint": "线路上的通道字节顺序。大多数 DDP 接收器期望 RGB。", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", "device.serial_port": "串口:", "device.serial_port.hint": "选择 Adalight 设备的 COM 端口", "device.serial_port.none": "未找到串口", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 3013056..566dc4b 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, List, Optional from ledgrab.storage.base_sqlite_store import BaseSqliteStore from ledgrab.storage.database import Database -from ledgrab.utils import get_logger +from ledgrab.utils import get_logger, secret_box if TYPE_CHECKING: from ledgrab.core.devices.device_config import DeviceConfig @@ -14,6 +14,16 @@ if TYPE_CHECKING: logger = get_logger(__name__) +def _enc(value: str) -> str: + """Encrypt a non-empty secret for at-rest storage; pass-through empty.""" + return secret_box.encrypt(value) if value else "" + + +def _dec(value: str) -> str: + """Decrypt an envelope; legacy plaintext passes through unchanged.""" + return secret_box.decrypt(value) if value else "" + + class Device: """Represents a WLED device configuration. @@ -41,6 +51,10 @@ class Device: dmx_protocol: str = "artnet", dmx_start_universe: int = 0, dmx_start_channel: int = 1, + # DDP (Distributed Display Protocol) fields + ddp_port: int = 0, + ddp_destination_id: int = 1, + ddp_color_order: int = 1, # ESP-NOW fields espnow_peer_mac: str = "", espnow_channel: int = 1, @@ -87,6 +101,9 @@ class Device: self.dmx_protocol = dmx_protocol self.dmx_start_universe = dmx_start_universe self.dmx_start_channel = dmx_start_channel + self.ddp_port = ddp_port + self.ddp_destination_id = ddp_destination_id + self.ddp_color_order = ddp_color_order self.espnow_peer_mac = espnow_peer_mac self.espnow_channel = espnow_channel self.hue_username = hue_username @@ -119,6 +136,7 @@ class Device: AmbiLEDConfig, BLEConfig, ChromaConfig, + DDPConfig, DemoConfig, DMXConfig, ESPNowConfig, @@ -156,6 +174,13 @@ class Device: dmx_start_universe=self.dmx_start_universe, dmx_start_channel=self.dmx_start_channel, ) + if dt == "ddp": + return DDPConfig( + **base, + ddp_port=self.ddp_port, + ddp_destination_id=self.ddp_destination_id, + ddp_color_order=self.ddp_color_order, + ) if dt == "espnow": return ESPNowConfig( **base, @@ -230,14 +255,22 @@ class Device: d["dmx_start_universe"] = self.dmx_start_universe if self.dmx_start_channel != 1: d["dmx_start_channel"] = self.dmx_start_channel + if self.ddp_port: + d["ddp_port"] = self.ddp_port + if self.ddp_destination_id != 1: + d["ddp_destination_id"] = self.ddp_destination_id + if self.ddp_color_order != 1: + d["ddp_color_order"] = self.ddp_color_order if self.espnow_peer_mac: d["espnow_peer_mac"] = self.espnow_peer_mac if self.espnow_channel != 1: d["espnow_channel"] = self.espnow_channel if self.hue_username: - d["hue_username"] = self.hue_username + # Hue bridge credentials act as long-lived API keys for the entire + # bridge — encrypt at rest like HA tokens / MQTT passwords. + d["hue_username"] = _enc(self.hue_username) if self.hue_client_key: - d["hue_client_key"] = self.hue_client_key + d["hue_client_key"] = _enc(self.hue_client_key) if self.hue_entertainment_group_id: d["hue_entertainment_group_id"] = self.hue_entertainment_group_id if self.spi_speed_hz != 800000: @@ -251,7 +284,8 @@ class Device: if self.ble_family: d["ble_family"] = self.ble_family if self.ble_govee_key: - d["ble_govee_key"] = self.ble_govee_key + # Govee BLE AES key — encrypt at rest. + d["ble_govee_key"] = _enc(self.ble_govee_key) if self.mqtt_source_id: d["mqtt_source_id"] = self.mqtt_source_id if self.default_css_processing_template_id: @@ -286,17 +320,20 @@ class Device: dmx_protocol=data.get("dmx_protocol", "artnet"), dmx_start_universe=data.get("dmx_start_universe", 0), dmx_start_channel=data.get("dmx_start_channel", 1), + ddp_port=data.get("ddp_port", 0), + ddp_destination_id=data.get("ddp_destination_id", 1), + ddp_color_order=data.get("ddp_color_order", 1), espnow_peer_mac=data.get("espnow_peer_mac", ""), espnow_channel=data.get("espnow_channel", 1), - hue_username=data.get("hue_username", ""), - hue_client_key=data.get("hue_client_key", ""), + hue_username=_dec(data.get("hue_username", "")), + hue_client_key=_dec(data.get("hue_client_key", "")), hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), 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"), gamesense_device_type=data.get("gamesense_device_type", "keyboard"), ble_family=data.get("ble_family", ""), - ble_govee_key=data.get("ble_govee_key", ""), + ble_govee_key=_dec(data.get("ble_govee_key", "")), mqtt_source_id=data.get("mqtt_source_id", ""), default_css_processing_template_id=data.get("default_css_processing_template_id", ""), group_device_ids=data.get("group_device_ids", []), @@ -330,6 +367,9 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "dmx_protocol", "dmx_start_universe", "dmx_start_channel", + "ddp_port", + "ddp_destination_id", + "ddp_color_order", "espnow_peer_mac", "espnow_channel", "hue_username", @@ -360,6 +400,50 @@ class DeviceStore(BaseSqliteStore[Device]): def __init__(self, db: Database): super().__init__(db, Device.from_dict) logger.info(f"Device store initialized with {len(self._items)} devices") + self._migrate_plaintext_credentials() + + def _migrate_plaintext_credentials(self) -> None: + """Encrypt Hue and Govee credentials still in plaintext at rest. + + Mirrors the HA-token and MQTT-password migrations. ``hue_username`` + and ``hue_client_key`` together let an attacker control every light + on a Hue bridge; ``ble_govee_key`` is the per-device AES key for + Govee BLE devices. All three were stored plaintext before this + change. + """ + migrated = 0 + try: + rows = self._db.load_all(self._table_name) + except Exception as exc: + logger.error("Could not inspect rows for device credential migration: %s", exc) + return + for row in rows: + did = row.get("id") + if not did: + continue + fields = ( + row.get("hue_username", "") or "", + row.get("hue_client_key", "") or "", + row.get("ble_govee_key", "") or "", + ) + # Need migration if at least one secret field is non-empty AND + # not already in envelope form. + needs_migrate = any(v and not secret_box.is_encrypted(v) for v in fields) + if not needs_migrate: + continue + device = self._items.get(did) + if device is None: + continue + try: + self._save_item(did, device) + migrated += 1 + except Exception as exc: + logger.error("Failed to migrate device credentials for %s: %s", did, exc) + if migrated: + logger.warning( + "MIGRATION: encrypted plaintext credentials at rest for %d device(s).", + migrated, + ) # ── Backward-compat aliases (thin re-export of base methods) ─ get_device = BaseSqliteStore.get @@ -383,6 +467,9 @@ class DeviceStore(BaseSqliteStore[Device]): dmx_protocol: str = "artnet", dmx_start_universe: int = 0, dmx_start_channel: int = 1, + ddp_port: int = 0, + ddp_destination_id: int = 1, + ddp_color_order: int = 1, espnow_peer_mac: str = "", espnow_channel: int = 1, hue_username: str = "", @@ -423,6 +510,9 @@ class DeviceStore(BaseSqliteStore[Device]): dmx_protocol=dmx_protocol, dmx_start_universe=dmx_start_universe, dmx_start_channel=dmx_start_channel, + ddp_port=ddp_port, + ddp_destination_id=ddp_destination_id, + ddp_color_order=ddp_color_order, espnow_peer_mac=espnow_peer_mac, espnow_channel=espnow_channel, hue_username=hue_username, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index 4a8e70b..a539537 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -43,6 +43,7 @@ + @@ -182,6 +183,38 @@ + + + + +
+ + +
+ +