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