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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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() || '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Серийные порты не найдены",
|
||||
|
||||
@@ -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": "未找到串口",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user