feat(devices): standalone DDP target type

Promotes the existing DDP packet layer (previously WLED-internal) to a
first-class device type so any DDP-speaking receiver (Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic firmware) can be driven
directly without WLED in the path.

Backend:
- New DDPLEDClient wraps the DDPClient transport as a proper LEDClient
  with supports_fast_send=True (synchronous UDP push on the hot loop).
- New DDPDeviceProvider — no native discovery, manual LED count,
  capabilities = {manual_led_count, health_check}.
- DDPConfig joins the typed config union; Device storage gains
  ddp_port / ddp_destination_id / ddp_color_order fields with safe
  defaults (0/1/1 -> port 4048, destination 1=display, RGB byte order).
- URL scheme: ddp://host[:port] or bare host[:port] (default 4048).
- Health check resolves the host via async DNS; UDP has no reply
  channel so reachability is best-effort by design.
- 29 new tests in test_ddp_led_client.py cover URL parsing, packet
  hot path (brightness, list/numpy input shapes, fast vs async send),
  provider validate/discover/capabilities, config round-trip via
  Device.to_config() and to_dict/from_dict.

Frontend:
- 'ddp' in DEVICE_TYPE_KEYS (next to 'dmx'), paper-plane icon.
- isDdpDevice predicate + per-type field show/hide in the create &
  settings modals.
- Color-order picker uses IconSelect (project rule bans plain select).
- Locale strings added in en/ru/zh.

Note: this commit also carries two pre-existing in-flight hunks that
were intermixed in the same files and could not be split out
non-interactively:
- api/routes/devices.py: URL-scheme inference for bare WLED hosts,
  safer error messages, exception-isolated parallel discovery.
- storage/device_store.py: secret_box helpers + at-rest encryption of
  Hue / BLE-Govee / MQTT credentials.
Both are independent of DDP and intentional per the user.
This commit is contained in:
2026-05-16 01:26:45 +03:00
parent 337984c618
commit 8f1140abad
19 changed files with 1109 additions and 28 deletions
+76
View File
@@ -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)
+63 -14
View File
@@ -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)
+21
View File
@@ -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")
@@ -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,
)
@@ -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 []
@@ -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,
@@ -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())
+4
View File
@@ -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';
}
+1 -1
View File
@@ -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),
@@ -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<string, any> = {};
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);
@@ -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() || '';
+3
View File
@@ -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;
+17
View File
@@ -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",
+17
View File
@@ -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": "Серийные порты не найдены",
+17
View File
@@ -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": "未找到串口",
+97 -7
View File
@@ -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,
@@ -43,6 +43,7 @@
<option value="ws">WebSocket</option>
<option value="openrgb">OpenRGB</option>
<option value="dmx">DMX</option>
<option value="ddp">DDP</option>
<option value="espnow">ESP-NOW</option>
<option value="hue">Philips Hue</option>
<option value="ble">BLE LED Controller</option>
@@ -182,6 +183,38 @@
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
<input type="number" id="device-dmx-start-channel" min="1" max="512" value="1">
</div>
<!-- DDP fields -->
<div class="form-group" id="device-ddp-port-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-port" data-i18n="device.ddp_port">DDP Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_port.hint">UDP port (0 = protocol default 4048).</small>
<input type="number" id="device-ddp-port" min="0" max="65535" value="0">
</div>
<div class="form-group" id="device-ddp-destination-id-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-destination-id" data-i18n="device.ddp_destination_id">Destination ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_destination_id.hint">DDP destination identifier (1 = display).</small>
<input type="number" id="device-ddp-destination-id" min="0" max="255" value="1">
</div>
<div class="form-group" id="device-ddp-color-order-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-color-order" data-i18n="device.ddp_color_order">Color Order:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_color_order.hint">Channel byte order on the wire. Most DDP receivers expect RGB.</small>
<select id="device-ddp-color-order">
<option value="0">GRB</option>
<option value="1" selected>RGB</option>
<option value="2">BRG</option>
<option value="3">RBG</option>
<option value="4">BGR</option>
<option value="5">GBR</option>
</select>
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
@@ -216,6 +216,40 @@
</div>
</div>
<div class="ds-pair-row">
<div class="form-group" id="settings-ddp-port-group" style="display: none;">
<div class="label-row">
<label for="settings-ddp-port" data-i18n="device.ddp_port">DDP Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_port.hint">UDP port (0 = protocol default 4048).</small>
<input type="number" id="settings-ddp-port" min="0" max="65535" value="0">
</div>
<div class="form-group" id="settings-ddp-destination-id-group" style="display: none;">
<div class="label-row">
<label for="settings-ddp-destination-id" data-i18n="device.ddp_destination_id">Destination ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_destination_id.hint">DDP destination identifier (1 = display).</small>
<input type="number" id="settings-ddp-destination-id" min="0" max="255" value="1">
</div>
</div>
<div class="form-group" id="settings-ddp-color-order-group" style="display: none;">
<div class="label-row">
<label for="settings-ddp-color-order" data-i18n="device.ddp_color_order">Color Order:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_color_order.hint">Channel byte order on the wire. Most DDP receivers expect RGB.</small>
<select id="settings-ddp-color-order">
<option value="0">GRB</option>
<option value="1" selected>RGB</option>
<option value="2">BRG</option>
<option value="3">RBG</option>
<option value="4">BGR</option>
<option value="5">GBR</option>
</select>
</div>
<div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
+294
View File
@@ -0,0 +1,294 @@
"""Tests for the standalone DDP LEDClient wrapper and provider."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import numpy as np
import pytest
from ledgrab.core.devices.ddp_led_client import (
DEFAULT_DDP_PORT,
DDPLEDClient,
parse_ddp_url,
)
from ledgrab.core.devices.ddp_provider import DDPDeviceProvider
from ledgrab.core.devices.device_config import DDPConfig
from ledgrab.core.devices.led_client import ProviderDeps
# ============================================================================
# parse_ddp_url
# ============================================================================
@pytest.mark.parametrize(
"url,expected",
[
("ddp://192.168.1.50", ("192.168.1.50", DEFAULT_DDP_PORT)),
("ddp://192.168.1.50:4048", ("192.168.1.50", 4048)),
("ddp://192.168.1.50:5000", ("192.168.1.50", 5000)),
("192.168.1.50", ("192.168.1.50", DEFAULT_DDP_PORT)),
("192.168.1.50:4048", ("192.168.1.50", 4048)),
("pixelblaze.local", ("pixelblaze.local", DEFAULT_DDP_PORT)),
("ddp://pixelblaze.local:9999", ("pixelblaze.local", 9999)),
],
)
def test_parse_ddp_url_accepts_common_forms(url, expected):
assert parse_ddp_url(url) == expected
@pytest.mark.parametrize("url", ["", " ", "ddp://", "://192.168.1.1"])
def test_parse_ddp_url_rejects_empty_or_hostless(url):
with pytest.raises(ValueError):
parse_ddp_url(url)
# ============================================================================
# DDPLEDClient
# ============================================================================
def _make_connected_client(led_count: int = 3, rgbw: bool = False) -> DDPLEDClient:
"""Build a DDPLEDClient with its underlying DDPClient transport mocked."""
client = DDPLEDClient("ddp://127.0.0.1", led_count=led_count, rgbw=rgbw)
# Avoid real UDP socket creation — stub the DDPClient layer.
inner = MagicMock()
inner.send_pixels_numpy = MagicMock()
inner.close = AsyncMock()
client._ddp = inner
client._connected = True
return client
def test_supports_fast_send_is_true():
client = DDPLEDClient("ddp://127.0.0.1", led_count=1)
assert client.supports_fast_send is True
def test_send_pixels_fast_pushes_pixels():
client = _make_connected_client(led_count=3)
pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
client.send_pixels_fast(pixels)
client._ddp.send_pixels_numpy.assert_called_once()
sent = client._ddp.send_pixels_numpy.call_args[0][0]
np.testing.assert_array_equal(sent, pixels)
def test_send_pixels_fast_applies_brightness():
client = _make_connected_client(led_count=1)
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
client.send_pixels_fast(pixels, brightness=128)
sent = client._ddp.send_pixels_numpy.call_args[0][0]
# 200 * 128 // 255 = 100, 100 * 128 // 255 = 50, 50 * 128 // 255 = 25
np.testing.assert_array_equal(sent, np.array([[100, 50, 25]], dtype=np.uint8))
def test_send_pixels_fast_brightness_full_is_passthrough():
client = _make_connected_client(led_count=1)
pixels = np.array([[200, 100, 50]], dtype=np.uint8)
client.send_pixels_fast(pixels, brightness=255)
sent = client._ddp.send_pixels_numpy.call_args[0][0]
np.testing.assert_array_equal(sent, pixels)
def test_send_pixels_fast_brightness_zero_blanks_all():
client = _make_connected_client(led_count=2)
pixels = np.array([[200, 100, 50], [255, 255, 255]], dtype=np.uint8)
client.send_pixels_fast(pixels, brightness=0)
sent = client._ddp.send_pixels_numpy.call_args[0][0]
np.testing.assert_array_equal(sent, np.zeros_like(pixels))
@pytest.mark.asyncio
async def test_async_send_pixels_accepts_list():
client = _make_connected_client(led_count=2)
await client.send_pixels([(1, 2, 3), (4, 5, 6)])
sent = client._ddp.send_pixels_numpy.call_args[0][0]
np.testing.assert_array_equal(sent, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8))
def test_send_pixels_fast_when_not_connected_raises():
client = DDPLEDClient("ddp://127.0.0.1", led_count=1)
with pytest.raises(RuntimeError, match="not connected"):
client.send_pixels_fast([(1, 2, 3)])
def test_send_pixels_fast_accepts_flat_array():
"""A flat 1-D RGB byte array reshapes to (N, 3) before sending."""
client = _make_connected_client(led_count=2)
flat = np.array([1, 2, 3, 4, 5, 6], dtype=np.uint8)
client.send_pixels_fast(flat)
sent = client._ddp.send_pixels_numpy.call_args[0][0]
np.testing.assert_array_equal(sent, np.array([[1, 2, 3], [4, 5, 6]], dtype=np.uint8))
# ============================================================================
# DDPDeviceProvider
# ============================================================================
def test_provider_device_type():
provider = DDPDeviceProvider()
assert provider.device_type == "ddp"
def test_provider_capabilities():
provider = DDPDeviceProvider()
caps = provider.capabilities
assert "manual_led_count" in caps
assert "health_check" in caps
# No native power / brightness — DDP has no reply channel.
assert "power_control" not in caps
assert "brightness_control" not in caps
@pytest.mark.asyncio
async def test_provider_validate_device_accepts_bare_host():
provider = DDPDeviceProvider()
result = await provider.validate_device("192.168.1.50")
# validate_device returns the empty dict (no LED count to report)
assert result == {}
@pytest.mark.asyncio
async def test_provider_validate_device_rejects_empty_url():
provider = DDPDeviceProvider()
with pytest.raises(ValueError, match="Invalid DDP URL"):
await provider.validate_device("")
@pytest.mark.asyncio
async def test_provider_discover_returns_empty():
"""DDP has no native discovery — provider must return an empty list, not raise."""
provider = DDPDeviceProvider()
assert await provider.discover() == []
def test_provider_create_client_threads_config_fields():
provider = DDPDeviceProvider()
config = DDPConfig(
device_id="device_test",
device_url="ddp://192.168.1.50:9000",
led_count=144,
rgbw=True,
ddp_port=9000,
ddp_destination_id=2,
ddp_color_order=4, # BGR
)
client = provider.create_client(config, deps=ProviderDeps())
assert isinstance(client, DDPLEDClient)
assert client.host == "192.168.1.50"
assert client.port == 9000
assert client._led_count == 144
assert client._rgbw is True
assert client._destination_id == 2
assert client._color_order == 4
# ============================================================================
# DDPConfig round-trip via Device.to_config()
# ============================================================================
def test_device_to_config_round_trip_ddp():
"""A Device with device_type='ddp' yields a DDPConfig from to_config()."""
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="My Pixelblaze",
url="ddp://192.168.1.42",
led_count=300,
device_type="ddp",
rgbw=False,
ddp_port=4048,
ddp_destination_id=1,
ddp_color_order=1,
)
config = device.to_config()
assert isinstance(config, DDPConfig)
assert config.device_type == "ddp"
assert config.device_url == "ddp://192.168.1.42"
assert config.led_count == 300
assert config.ddp_port == 4048
assert config.ddp_destination_id == 1
assert config.ddp_color_order == 1
def test_device_to_dict_omits_ddp_defaults():
"""A DDP device with default DDP fields shouldn't pollute the persisted JSON."""
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Defaults",
url="ddp://192.168.1.42",
led_count=10,
device_type="ddp",
)
payload = device.to_dict()
# Defaults: ddp_port=0, ddp_destination_id=1, ddp_color_order=1 — all omitted.
assert "ddp_port" not in payload
assert "ddp_destination_id" not in payload
assert "ddp_color_order" not in payload
def test_device_to_dict_preserves_non_default_ddp_fields():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Custom",
url="ddp://192.168.1.42",
led_count=10,
device_type="ddp",
ddp_port=4048,
ddp_destination_id=2,
ddp_color_order=4,
)
payload = device.to_dict()
assert payload["ddp_port"] == 4048
assert payload["ddp_destination_id"] == 2
assert payload["ddp_color_order"] == 4
def test_device_from_dict_round_trip_ddp_fields():
from ledgrab.storage.device_store import Device
payload = {
"id": "device_abc12345",
"name": "Roundtrip",
"url": "ddp://192.168.1.42",
"led_count": 50,
"device_type": "ddp",
"ddp_port": 9999,
"ddp_destination_id": 5,
"ddp_color_order": 2,
}
restored = Device.from_dict(payload)
assert restored.ddp_port == 9999
assert restored.ddp_destination_id == 5
assert restored.ddp_color_order == 2