Refactor core/ into logical sub-packages and split filter files
Reorganize the flat core/ directory (17 files) into three sub-packages: - core/devices/ — LED device communication (led_client, wled/adalight clients, providers, DDP) - core/processing/ — target processing pipeline (processor_manager, target processors, live streams, settings) - core/capture/ — screen capture & calibration (screen_capture, calibration, pixel_processor, overlay) Also split the monolithic filters/builtin.py (460 lines, 8 filters) into individual files: brightness, saturation, gamma, downscaler, pixelate, auto_crop, flip, color_correction. Includes the ProcessorManager refactor from target-centric architecture: ProcessorManager slimmed from ~1600 to ~490 lines with unified _processors dict replacing duplicate _targets/_kc_targets dicts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
25
server/src/wled_controller/core/devices/__init__.py
Normal file
25
server/src/wled_controller/core/devices/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""LED device communication layer."""
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
check_device_health,
|
||||
create_led_client,
|
||||
get_all_providers,
|
||||
get_device_capabilities,
|
||||
get_provider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeviceHealth",
|
||||
"DiscoveredDevice",
|
||||
"LEDClient",
|
||||
"LEDDeviceProvider",
|
||||
"check_device_health",
|
||||
"create_led_client",
|
||||
"get_all_providers",
|
||||
"get_device_capabilities",
|
||||
"get_provider",
|
||||
]
|
||||
198
server/src/wled_controller/core/devices/adalight_client.py
Normal file
198
server/src/wled_controller/core/devices/adalight_client.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Adalight serial LED client — sends pixel data over serial using the Adalight protocol."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_BAUD_RATE = 115200
|
||||
ARDUINO_RESET_DELAY = 2.0 # seconds to wait after opening serial for Arduino bootloader
|
||||
|
||||
|
||||
def parse_adalight_url(url: str) -> Tuple[str, int]:
|
||||
"""Parse an Adalight URL into (port, baud_rate).
|
||||
|
||||
Formats:
|
||||
"COM3" -> ("COM3", 115200)
|
||||
"COM3:230400" -> ("COM3", 230400)
|
||||
"/dev/ttyUSB0" -> ("/dev/ttyUSB0", 115200)
|
||||
"""
|
||||
url = url.strip()
|
||||
if ":" in url and not url.startswith("/"):
|
||||
# Windows COM port with baud: "COM3:230400"
|
||||
parts = url.rsplit(":", 1)
|
||||
try:
|
||||
baud = int(parts[1])
|
||||
return parts[0], baud
|
||||
except ValueError:
|
||||
pass
|
||||
elif ":" in url and url.startswith("/"):
|
||||
# Unix path with baud: "/dev/ttyUSB0:230400"
|
||||
parts = url.rsplit(":", 1)
|
||||
try:
|
||||
baud = int(parts[1])
|
||||
return parts[0], baud
|
||||
except ValueError:
|
||||
pass
|
||||
return url, DEFAULT_BAUD_RATE
|
||||
|
||||
|
||||
def _build_adalight_header(led_count: int) -> bytes:
|
||||
"""Build the 6-byte Adalight protocol header.
|
||||
|
||||
Format: 'A' 'd' 'a' <count_hi> <count_lo> <checksum>
|
||||
where count = led_count - 1 (zero-indexed).
|
||||
"""
|
||||
count = led_count - 1
|
||||
hi = (count >> 8) & 0xFF
|
||||
lo = count & 0xFF
|
||||
checksum = hi ^ lo ^ 0x55
|
||||
return bytes([ord("A"), ord("d"), ord("a"), hi, lo, checksum])
|
||||
|
||||
|
||||
class AdalightClient(LEDClient):
|
||||
"""LED client for Arduino Adalight serial devices."""
|
||||
|
||||
def __init__(self, url: str, led_count: int = 0, baud_rate: Optional[int] = None, **kwargs):
|
||||
"""Initialize Adalight client.
|
||||
|
||||
Args:
|
||||
url: Serial port string, e.g. "COM3" or "COM3:230400"
|
||||
led_count: Number of LEDs on the strip (required for Adalight header)
|
||||
baud_rate: Override baud rate (if None, parsed from url or default 115200)
|
||||
"""
|
||||
self._port, url_baud = parse_adalight_url(url)
|
||||
self._baud_rate = baud_rate or url_baud
|
||||
self._led_count = led_count
|
||||
self._serial = None
|
||||
self._connected = False
|
||||
|
||||
# Pre-compute Adalight header if led_count is known
|
||||
self._header = _build_adalight_header(led_count) if led_count > 0 else b""
|
||||
|
||||
# Pre-allocate numpy buffer for brightness scaling
|
||||
self._pixel_buf = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Open serial port and wait for Arduino reset."""
|
||||
import serial
|
||||
|
||||
try:
|
||||
self._serial = await asyncio.to_thread(
|
||||
serial.Serial, port=self._port, baudrate=self._baud_rate, timeout=1
|
||||
)
|
||||
# Wait for Arduino to finish bootloader reset (non-blocking)
|
||||
await asyncio.sleep(ARDUINO_RESET_DELAY)
|
||||
self._connected = True
|
||||
logger.info(
|
||||
f"Adalight connected: {self._port} @ {self._baud_rate} baud "
|
||||
f"({self._led_count} LEDs)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open serial port {self._port}: {e}")
|
||||
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the serial port."""
|
||||
self._connected = False
|
||||
if self._serial and self._serial.is_open:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing serial port: {e}")
|
||||
self._serial = None
|
||||
logger.info(f"Adalight disconnected: {self._port}")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and self._serial is not None and self._serial.is_open
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels,
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Send pixel data over serial using Adalight protocol (non-blocking).
|
||||
|
||||
Args:
|
||||
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return False
|
||||
|
||||
try:
|
||||
frame = self._build_frame(pixels, brightness)
|
||||
await asyncio.to_thread(self._serial.write, frame)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Adalight send_pixels error: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
# Serial write is blocking — use async send_pixels path instead
|
||||
return False
|
||||
|
||||
def _build_frame(self, pixels, brightness: int) -> bytes:
|
||||
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
|
||||
if isinstance(pixels, np.ndarray):
|
||||
arr = pixels.astype(np.uint16)
|
||||
else:
|
||||
arr = np.array(pixels, dtype=np.uint16)
|
||||
|
||||
if brightness < 255:
|
||||
arr = arr * brightness // 255
|
||||
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||
return self._header + rgb_bytes
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check if the serial port exists without opening it.
|
||||
|
||||
Enumerates COM ports to avoid exclusive-access conflicts on Windows.
|
||||
"""
|
||||
port, _baud = parse_adalight_url(url)
|
||||
|
||||
try:
|
||||
import serial.tools.list_ports
|
||||
|
||||
available_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||
port_upper = port.upper()
|
||||
found = any(p.upper() == port_upper for p in available_ports)
|
||||
|
||||
if found:
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=0.0,
|
||||
last_checked=datetime.utcnow(),
|
||||
device_name=prev_health.device_name if prev_health else None,
|
||||
device_version=None,
|
||||
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||
)
|
||||
else:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=datetime.utcnow(),
|
||||
error=f"Serial port {port} not found",
|
||||
)
|
||||
except Exception as e:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
last_checked=datetime.utcnow(),
|
||||
error=str(e),
|
||||
)
|
||||
125
server/src/wled_controller/core/devices/adalight_provider.py
Normal file
125
server/src/wled_controller/core/devices/adalight_provider.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Adalight device provider — serial LED controller support."""
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AdalightDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for Adalight serial LED controllers."""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "adalight"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
# manual_led_count: user must specify LED count (can't auto-detect)
|
||||
# power_control: can blank LEDs by sending all-black pixels
|
||||
# brightness_control: software brightness (multiplies pixel values before sending)
|
||||
return {"manual_led_count", "power_control", "brightness_control"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
led_count = kwargs.pop("led_count", 0)
|
||||
baud_rate = kwargs.pop("baud_rate", None)
|
||||
kwargs.pop("use_ddp", None) # Not applicable for serial
|
||||
return AdalightClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
return await AdalightClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate that the serial port exists.
|
||||
|
||||
Returns:
|
||||
Empty dict — Adalight devices don't report LED count,
|
||||
so it must be provided by the user.
|
||||
"""
|
||||
from wled_controller.core.devices.adalight_client import parse_adalight_url
|
||||
|
||||
port, _baud = parse_adalight_url(url)
|
||||
|
||||
try:
|
||||
import serial.tools.list_ports
|
||||
|
||||
available_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||
port_upper = port.upper()
|
||||
if not any(p.upper() == port_upper for p in available_ports):
|
||||
raise ValueError(
|
||||
f"Serial port {port} not found. "
|
||||
f"Available ports: {', '.join(available_ports) or 'none'}"
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to enumerate serial ports: {e}")
|
||||
|
||||
logger.info(f"Adalight device validated: port {port}")
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover serial ports that could be Adalight devices."""
|
||||
try:
|
||||
import serial.tools.list_ports
|
||||
|
||||
ports = serial.tools.list_ports.comports()
|
||||
results = []
|
||||
for port_info in ports:
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=port_info.description or port_info.device,
|
||||
url=port_info.device,
|
||||
device_type="adalight",
|
||||
ip=port_info.device,
|
||||
mac="",
|
||||
led_count=None,
|
||||
version=None,
|
||||
)
|
||||
)
|
||||
logger.info(f"Serial port scan found {len(results)} port(s)")
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Serial port discovery failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
# Adalight has no hardware power query; assume on
|
||||
return True
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Turn Adalight device on/off by sending an all-black frame (off) or no-op (on).
|
||||
|
||||
Requires kwargs: led_count (int), baud_rate (int | None).
|
||||
"""
|
||||
if on:
|
||||
return # "on" is a no-op — next processing frame lights LEDs up
|
||||
|
||||
led_count = kwargs.get("led_count", 0)
|
||||
baud_rate = kwargs.get("baud_rate")
|
||||
if led_count <= 0:
|
||||
raise ValueError("led_count is required to send black frame to Adalight device")
|
||||
|
||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||
|
||||
client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||
try:
|
||||
await client.connect()
|
||||
black = np.zeros((led_count, 3), dtype=np.uint8)
|
||||
await client.send_pixels(black, brightness=255)
|
||||
logger.info(f"Adalight power off: sent black frame to {url}")
|
||||
finally:
|
||||
await client.close()
|
||||
292
server/src/wled_controller/core/devices/ddp_client.py
Normal file
292
server/src/wled_controller/core/devices/ddp_client.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""DDP (Distributed Display Protocol) client for WLED."""
|
||||
import asyncio
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# DDP color order codes (matches WLED's order enum)
|
||||
COLOR_ORDER_MAP: Dict[int, Tuple[int, int, int]] = {
|
||||
0: (1, 0, 2), # GRB → indices into (R,G,B)
|
||||
1: (0, 1, 2), # RGB → no reorder
|
||||
2: (2, 0, 1), # BRG
|
||||
3: (0, 2, 1), # RBG
|
||||
4: (2, 1, 0), # BGR
|
||||
5: (1, 2, 0), # GBR
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BusConfig:
|
||||
"""Physical LED bus/output configuration from WLED."""
|
||||
start: int # First LED index
|
||||
length: int # Number of LEDs on this bus
|
||||
color_order: int # Color order code (0=GRB, 1=RGB, etc.)
|
||||
|
||||
|
||||
class DDPClient:
|
||||
"""UDP DDP client for sending pixel data to WLED devices."""
|
||||
|
||||
DDP_PORT = 4048
|
||||
DDP_FLAGS_VER1 = 0x40 # VER=1, all other flags 0
|
||||
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
|
||||
DDP_TYPE_RGB = 0x01
|
||||
|
||||
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
|
||||
"""Initialize DDP client.
|
||||
|
||||
Args:
|
||||
host: WLED device IP address or hostname
|
||||
port: DDP port (default 4048)
|
||||
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.rgbw = rgbw
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
self._sequence = 0
|
||||
self._buses: List[BusConfig] = []
|
||||
|
||||
async def connect(self):
|
||||
"""Establish UDP connection."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
self._transport, self._protocol = await loop.create_datagram_endpoint(
|
||||
asyncio.DatagramProtocol,
|
||||
remote_addr=(self.host, self.port)
|
||||
)
|
||||
logger.info(f"DDP client connected to {self.host}:{self.port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect DDP client: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection."""
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._protocol = None
|
||||
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
|
||||
|
||||
def set_buses(self, buses: List[BusConfig]) -> None:
|
||||
"""Set WLED bus configurations for per-bus color order reordering.
|
||||
|
||||
Args:
|
||||
buses: List of BusConfig from WLED device
|
||||
"""
|
||||
self._buses = buses
|
||||
for bus in buses:
|
||||
order_name = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||
logger.info(
|
||||
f"DDP bus: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
|
||||
)
|
||||
|
||||
def _build_ddp_packet(
|
||||
self,
|
||||
rgb_data: bytes,
|
||||
offset: int = 0,
|
||||
sequence: int = 1,
|
||||
push: bool = False,
|
||||
) -> bytes:
|
||||
"""Build a DDP packet.
|
||||
|
||||
DDP packet format (10-byte header + data):
|
||||
- Byte 0: Flags (VER1 | PUSH on last packet)
|
||||
- Byte 1: Sequence number
|
||||
- Byte 2: Data type (0x01 = RGB)
|
||||
- Byte 3: Source/Destination ID
|
||||
- Bytes 4-7: Data offset (4 bytes, big-endian)
|
||||
- Bytes 8-9: Data length (2 bytes, big-endian)
|
||||
- Bytes 10+: Pixel data
|
||||
|
||||
Args:
|
||||
rgb_data: RGB pixel data as bytes
|
||||
offset: Byte offset (pixel_index * 3)
|
||||
sequence: Sequence number (0-255)
|
||||
push: True for the last packet of a frame
|
||||
|
||||
Returns:
|
||||
Complete DDP packet as bytes
|
||||
"""
|
||||
flags = self.DDP_FLAGS_VER1
|
||||
if push:
|
||||
flags |= self.DDP_FLAGS_PUSH
|
||||
data_type = self.DDP_TYPE_RGB
|
||||
source_id = 0x01
|
||||
data_len = len(rgb_data)
|
||||
|
||||
# Build header (10 bytes)
|
||||
header = struct.pack(
|
||||
'!BBB B I H', # Network byte order (big-endian)
|
||||
flags, # Flags
|
||||
sequence, # Sequence
|
||||
data_type, # Data type
|
||||
source_id, # Source/Destination
|
||||
offset, # Data offset (4 bytes)
|
||||
data_len # Data length (2 bytes)
|
||||
)
|
||||
|
||||
return header + rgb_data
|
||||
|
||||
def _reorder_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
) -> List[Tuple[int, int, int]]:
|
||||
"""Apply per-bus color order reordering.
|
||||
|
||||
WLED may not apply per-bus color order conversion for DDP data on
|
||||
all buses (observed in multi-bus setups). We reorder pixel channels
|
||||
here so the hardware receives the correct byte order directly.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples in standard RGB order
|
||||
|
||||
Returns:
|
||||
List of reordered tuples matching each bus's hardware color order
|
||||
"""
|
||||
if not self._buses:
|
||||
return pixels
|
||||
|
||||
result = list(pixels)
|
||||
for bus in self._buses:
|
||||
order_map = COLOR_ORDER_MAP.get(bus.color_order)
|
||||
if not order_map or order_map == (0, 1, 2):
|
||||
continue # RGB order = no reordering needed
|
||||
|
||||
start = bus.start
|
||||
end = min(bus.start + bus.length, len(result))
|
||||
for i in range(start, end):
|
||||
r, g, b = result[i]
|
||||
rgb = (r, g, b)
|
||||
result[i] = (rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]])
|
||||
|
||||
return result
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
max_packet_size: int = 1400
|
||||
) -> bool:
|
||||
"""Send pixel data via DDP.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
RuntimeError: If send fails
|
||||
"""
|
||||
if not self._transport:
|
||||
raise RuntimeError("DDP client not connected")
|
||||
|
||||
try:
|
||||
# Send plain RGB — WLED handles per-bus color order conversion
|
||||
# internally when outputting to hardware.
|
||||
bpp = 4 if self.rgbw else 3 # bytes per pixel
|
||||
pixel_bytes = bytearray()
|
||||
for r, g, b in pixels:
|
||||
pixel_bytes.extend((int(r), int(g), int(b)))
|
||||
if self.rgbw:
|
||||
pixel_bytes.append(0) # White channel = 0
|
||||
|
||||
total_bytes = len(pixel_bytes)
|
||||
# Align payload to full pixels (multiple of bpp) to avoid splitting
|
||||
# a pixel's channels across packets
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
|
||||
# Split into multiple packets if needed
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
|
||||
logger.debug(
|
||||
f"DDP: Sending {len(pixels)} pixels ({total_bytes} bytes) "
|
||||
f"in {num_packets} packet(s) to {self.host}:{self.port}"
|
||||
)
|
||||
|
||||
for i in range(num_packets):
|
||||
start = i * bytes_per_packet
|
||||
end = min(start + bytes_per_packet, total_bytes)
|
||||
chunk = bytes(pixel_bytes[start:end])
|
||||
is_last = (i == num_packets - 1)
|
||||
|
||||
# Increment sequence number
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
|
||||
# Set PUSH flag on the last packet to signal frame completion
|
||||
packet = self._build_ddp_packet(
|
||||
chunk, offset=start,
|
||||
sequence=self._sequence, push=is_last,
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
logger.debug(
|
||||
f"Sent DDP packet {i+1}/{num_packets}: "
|
||||
f"{len(chunk)} bytes at offset {start}"
|
||||
f"{' [PUSH]' if is_last else ''}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DDP pixels: {e}")
|
||||
raise RuntimeError(f"DDP send failed: {e}")
|
||||
|
||||
def send_pixels_numpy(self, pixel_array: np.ndarray, max_packet_size: int = 1400) -> bool:
|
||||
"""Send pixel data via DDP from a numpy array — no per-pixel Python loops.
|
||||
|
||||
Args:
|
||||
pixel_array: (N, 3) uint8 numpy array of RGB values
|
||||
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
if not self._transport:
|
||||
raise RuntimeError("DDP client not connected")
|
||||
|
||||
# Handle RGBW: insert zero white channel column
|
||||
if self.rgbw:
|
||||
white = np.zeros((pixel_array.shape[0], 1), dtype=np.uint8)
|
||||
pixel_array = np.hstack((pixel_array, white))
|
||||
|
||||
pixel_bytes = pixel_array.tobytes()
|
||||
|
||||
bpp = 4 if self.rgbw else 3
|
||||
total_bytes = len(pixel_bytes)
|
||||
max_payload = max_packet_size - 10 # 10-byte header
|
||||
bytes_per_packet = (max_payload // bpp) * bpp
|
||||
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
|
||||
|
||||
for i in range(num_packets):
|
||||
start = i * bytes_per_packet
|
||||
end = min(start + bytes_per_packet, total_bytes)
|
||||
chunk = pixel_bytes[start:end]
|
||||
is_last = (i == num_packets - 1)
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
packet = self._build_ddp_packet(
|
||||
chunk, offset=start,
|
||||
sequence=self._sequence, push=is_last,
|
||||
)
|
||||
self._transport.sendto(packet)
|
||||
|
||||
return True
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
276
server/src/wled_controller/core/devices/led_client.py
Normal file
276
server/src/wled_controller/core/devices/led_client.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""Abstract base class for LED device communication clients and provider registry."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceHealth:
|
||||
"""Health check result for an LED device."""
|
||||
|
||||
online: bool = False
|
||||
latency_ms: Optional[float] = None
|
||||
last_checked: Optional[datetime] = None
|
||||
# Device-reported metadata (populated by type-specific health check)
|
||||
device_name: Optional[str] = None
|
||||
device_version: Optional[str] = None
|
||||
device_led_count: Optional[int] = None
|
||||
device_rgbw: Optional[bool] = None
|
||||
device_led_type: Optional[str] = None
|
||||
device_fps: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredDevice:
|
||||
"""A device found via network discovery."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
device_type: str
|
||||
ip: str
|
||||
mac: str
|
||||
led_count: Optional[int]
|
||||
version: Optional[str]
|
||||
|
||||
|
||||
class LEDClient(ABC):
|
||||
"""Abstract base for LED device communication.
|
||||
|
||||
Lifecycle:
|
||||
client = SomeLEDClient(url, ...)
|
||||
await client.connect()
|
||||
state = await client.snapshot_device_state() # save before streaming
|
||||
client.send_pixels_fast(pixels, brightness) # if supports_fast_send
|
||||
await client.send_pixels(pixels, brightness)
|
||||
await client.restore_device_state(state) # restore after streaming
|
||||
await client.close()
|
||||
|
||||
Or as async context manager:
|
||||
async with SomeLEDClient(url, ...) as client:
|
||||
...
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection. Returns True on success, raises on failure."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""Close the connection and release resources."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Send pixel colors to the LED device (async).
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
"""Whether send_pixels_fast() is available (e.g. DDP UDP)."""
|
||||
return False
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Synchronous fire-and-forget send for the hot loop.
|
||||
|
||||
Override in subclasses that support a fast protocol (e.g. DDP).
|
||||
"""
|
||||
raise NotImplementedError("send_pixels_fast not supported for this device type")
|
||||
|
||||
async def snapshot_device_state(self) -> Optional[dict]:
|
||||
"""Snapshot device state before streaming starts.
|
||||
|
||||
Override in subclasses that need to save/restore state around streaming.
|
||||
Returns a state dict to pass to restore_device_state(), or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def restore_device_state(self, state: Optional[dict]) -> None:
|
||||
"""Restore device state after streaming stops.
|
||||
|
||||
Args:
|
||||
state: State dict returned by snapshot_device_state(), or None.
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Check device health without a full client connection.
|
||||
|
||||
Override in subclasses for type-specific health probes.
|
||||
Default: mark as online with no metadata.
|
||||
|
||||
Args:
|
||||
url: Device URL
|
||||
http_client: Shared httpx.AsyncClient for HTTP requests
|
||||
prev_health: Previous health result (for preserving cached metadata)
|
||||
"""
|
||||
return DeviceHealth(online=True, last_checked=datetime.utcnow())
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
|
||||
|
||||
# ===== LED DEVICE PROVIDER =====
|
||||
|
||||
class LEDDeviceProvider(ABC):
|
||||
"""Encapsulates everything about a specific LED device type.
|
||||
|
||||
Implement one subclass per device type (WLED, etc.) and register it
|
||||
via register_provider(). All type-specific dispatch (client creation,
|
||||
health checks, discovery, validation, brightness) goes through the provider.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def device_type(self) -> str:
|
||||
"""Type identifier string, e.g. 'wled'."""
|
||||
...
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
"""Capability set for this device type. Override to add capabilities."""
|
||||
return set()
|
||||
|
||||
@abstractmethod
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
"""Create a connected-ready LEDClient for this device type."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
"""Check device health without a full client connection."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate a device URL before adding it.
|
||||
|
||||
Returns:
|
||||
dict with at least {'led_count': int}
|
||||
|
||||
Raises:
|
||||
Exception on validation failure (caller converts to HTTP error).
|
||||
"""
|
||||
...
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
"""Discover devices on the network.
|
||||
|
||||
Override in providers that support discovery. Default: empty list.
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
"""Get device brightness (0-255). Override if capabilities include brightness_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_brightness(self, url: str, brightness: int) -> None:
|
||||
"""Set device brightness (0-255). Override if capabilities include brightness_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
"""Get device power state. Override if capabilities include power_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
"""Set device power state. Override if capabilities include power_control."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ===== PROVIDER REGISTRY =====
|
||||
|
||||
_provider_registry: Dict[str, LEDDeviceProvider] = {}
|
||||
|
||||
|
||||
def register_provider(provider: LEDDeviceProvider) -> None:
|
||||
"""Register a device provider."""
|
||||
_provider_registry[provider.device_type] = provider
|
||||
|
||||
|
||||
def get_provider(device_type: str) -> LEDDeviceProvider:
|
||||
"""Get the provider for a device type.
|
||||
|
||||
Raises:
|
||||
ValueError: If device_type is unknown.
|
||||
"""
|
||||
provider = _provider_registry.get(device_type)
|
||||
if not provider:
|
||||
raise ValueError(f"Unknown LED device type: {device_type}")
|
||||
return provider
|
||||
|
||||
|
||||
def get_all_providers() -> Dict[str, LEDDeviceProvider]:
|
||||
"""Return all registered providers."""
|
||||
return dict(_provider_registry)
|
||||
|
||||
|
||||
# ===== FACTORY FUNCTIONS (delegate to providers) =====
|
||||
|
||||
def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient:
|
||||
"""Factory: create the right LEDClient subclass for a device type."""
|
||||
return get_provider(device_type).create_client(url, **kwargs)
|
||||
|
||||
|
||||
async def check_device_health(
|
||||
device_type: str,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""Factory: dispatch health check to the right provider."""
|
||||
return await get_provider(device_type).check_health(url, http_client, prev_health)
|
||||
|
||||
|
||||
def get_device_capabilities(device_type: str) -> set:
|
||||
"""Return the capability set for a device type."""
|
||||
try:
|
||||
return get_provider(device_type).capabilities
|
||||
except ValueError:
|
||||
return set()
|
||||
|
||||
|
||||
# ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
|
||||
|
||||
def _register_builtin_providers():
|
||||
from wled_controller.core.devices.wled_provider import WLEDDeviceProvider
|
||||
register_provider(WLEDDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
|
||||
register_provider(AdalightDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
660
server/src/wled_controller/core/devices/wled_client.py
Normal file
660
server/src/wled_controller/core/devices/wled_client.py
Normal file
@@ -0,0 +1,660 @@
|
||||
"""WLED client for controlling LED devices via HTTP or DDP."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple, Optional, Dict, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.core.devices.ddp_client import BusConfig, DDPClient
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# WLED LED bus type codes from const.h → human-readable names
|
||||
WLED_LED_TYPES: Dict[int, str] = {
|
||||
18: "WS2812 1ch", 19: "WS2812 1ch x3", 20: "WS2812 CCT", 21: "WS2812 WWA",
|
||||
22: "WS2812B", 23: "GS8608", 24: "WS2811 400kHz", 25: "TM1829",
|
||||
26: "UCS8903", 27: "APA106", 28: "FW1906", 29: "UCS8904",
|
||||
30: "SK6812 RGBW", 31: "TM1814", 32: "WS2805", 33: "TM1914", 34: "SM16825",
|
||||
40: "On/Off", 41: "PWM 1ch", 42: "PWM 2ch", 43: "PWM 3ch",
|
||||
44: "PWM 4ch", 45: "PWM 5ch", 46: "PWM 6ch",
|
||||
50: "WS2801", 51: "APA102", 52: "LPD8806", 53: "P9813", 54: "LPD6803",
|
||||
65: "HUB75 HS", 66: "HUB75 QS",
|
||||
80: "DDP RGB", 81: "E1.31", 82: "Art-Net", 88: "DDP RGBW", 89: "Art-Net RGBW",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WLEDInfo:
|
||||
"""WLED device information."""
|
||||
|
||||
name: str
|
||||
version: str
|
||||
led_count: int
|
||||
brand: str
|
||||
product: str
|
||||
mac: str
|
||||
ip: str
|
||||
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
|
||||
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
|
||||
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
|
||||
|
||||
|
||||
class WLEDClient(LEDClient):
|
||||
"""Client for WLED devices supporting both HTTP and DDP protocols."""
|
||||
|
||||
# HTTP JSON API has ~10KB limit, ~500 LEDs max
|
||||
HTTP_MAX_LEDS = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
timeout: int = 5,
|
||||
retry_attempts: int = 3,
|
||||
retry_delay: int = 1,
|
||||
use_ddp: bool = False,
|
||||
):
|
||||
"""Initialize WLED client.
|
||||
|
||||
Args:
|
||||
url: WLED device URL (e.g., http://192.168.1.100)
|
||||
timeout: Request timeout in seconds
|
||||
retry_attempts: Number of retry attempts on failure
|
||||
retry_delay: Delay between retries in seconds
|
||||
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
||||
"""
|
||||
self.url = url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.retry_attempts = retry_attempts
|
||||
self.retry_delay = retry_delay
|
||||
self.use_ddp = use_ddp
|
||||
|
||||
# Extract hostname/IP from URL for DDP
|
||||
parsed = urlparse(self.url)
|
||||
self.host = parsed.hostname or parsed.netloc.split(':')[0]
|
||||
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._ddp_client: Optional[DDPClient] = None
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Establish connection to WLED device.
|
||||
|
||||
Returns:
|
||||
True if connection successful
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection fails
|
||||
"""
|
||||
try:
|
||||
# Always create HTTP client for info/control
|
||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||
|
||||
# Test connection by getting device info
|
||||
info = await self.get_info()
|
||||
|
||||
# Auto-enable DDP for large LED counts
|
||||
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
|
||||
logger.info(
|
||||
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
|
||||
"auto-enabling DDP protocol"
|
||||
)
|
||||
self.use_ddp = True
|
||||
|
||||
# Create DDP client if needed
|
||||
if self.use_ddp:
|
||||
self._ddp_client = DDPClient(self.host, rgbw=False)
|
||||
# Pass per-bus config so DDP client can apply per-bus color reordering
|
||||
if info.buses:
|
||||
self._ddp_client.set_buses(info.buses)
|
||||
await self._ddp_client.connect()
|
||||
|
||||
# Configure device for DDP streaming:
|
||||
# - Turn on, set lor=0 (live data overrides effects),
|
||||
# and disable Audio Reactive.
|
||||
# - Do NOT set live — it's read-only and causes issues on WLED 0.15.x.
|
||||
# DDP packets automatically enter realtime mode.
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data={
|
||||
"on": True,
|
||||
"lor": 0,
|
||||
"AudioReactive": {"on": False}
|
||||
})
|
||||
logger.info("Configured device for DDP streaming")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not configure device for DDP: {e}")
|
||||
|
||||
logger.info(f"DDP protocol enabled for pixel data transmission (RGB mode)")
|
||||
|
||||
self._connected = True
|
||||
|
||||
protocol = "DDP" if self.use_ddp else "HTTP"
|
||||
logger.info(
|
||||
f"Connected to WLED device: {info.name} ({info.version}) "
|
||||
f"with {info.led_count} LEDs via {protocol}"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to WLED device at {self.url}: {e}")
|
||||
self._connected = False
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
if self._ddp_client:
|
||||
await self._ddp_client.close()
|
||||
self._ddp_client = None
|
||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection to WLED device."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
if self._ddp_client:
|
||||
await self._ddp_client.close()
|
||||
self._ddp_client = None
|
||||
self._connected = False
|
||||
logger.debug(f"Closed connection to {self.url}")
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected to WLED device."""
|
||||
return self._connected and self._client is not None
|
||||
|
||||
@property
|
||||
def supports_fast_send(self) -> bool:
|
||||
"""True when DDP is active and ready for fire-and-forget sends."""
|
||||
return self.use_ddp and self._ddp_client is not None
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
retry: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request to WLED device with retry logic.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
endpoint: API endpoint
|
||||
json_data: JSON data for request body
|
||||
retry: Whether to retry on failure
|
||||
|
||||
Returns:
|
||||
Response JSON data
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails after retries
|
||||
"""
|
||||
if not self._client:
|
||||
raise RuntimeError("Client not connected. Call connect() first.")
|
||||
|
||||
url = f"{self.url}{endpoint}"
|
||||
attempts = self.retry_attempts if retry else 1
|
||||
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = await self._client.get(url)
|
||||
elif method == "POST":
|
||||
response = await self._client.post(url, json=json_data)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error {e.response.status_code} on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
raise RuntimeError(f"HTTP request failed: {e}")
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
self._connected = False
|
||||
raise RuntimeError(f"Request to WLED device failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error on attempt {attempt + 1}: {e}")
|
||||
if attempt < attempts - 1:
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
else:
|
||||
raise RuntimeError(f"WLED request failed: {e}")
|
||||
|
||||
raise RuntimeError("Request failed after all retry attempts")
|
||||
|
||||
async def get_info(self) -> WLEDInfo:
|
||||
"""Get WLED device information.
|
||||
|
||||
Returns:
|
||||
WLEDInfo object with device details
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
try:
|
||||
# Get basic info
|
||||
data = await self._request("GET", "/json/info")
|
||||
leds_info = data.get("leds", {})
|
||||
|
||||
# Get LED configuration for color order and per-bus info
|
||||
cfg_data = await self._request("GET", "/json/cfg")
|
||||
color_order = 1 # Default to RGB
|
||||
buses: List[BusConfig] = []
|
||||
if "hw" in cfg_data and "led" in cfg_data["hw"]:
|
||||
led_cfg = cfg_data["hw"]["led"]
|
||||
ins_list = led_cfg.get("ins", [])
|
||||
if ins_list:
|
||||
# Use color order from first LED strip
|
||||
color_order = ins_list[0].get("order", 1)
|
||||
|
||||
# Parse all buses for per-segment color reordering
|
||||
order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
|
||||
for idx, bus_cfg in enumerate(ins_list):
|
||||
bus = BusConfig(
|
||||
start=bus_cfg.get("start", 0),
|
||||
length=bus_cfg.get("len", 0),
|
||||
color_order=bus_cfg.get("order", 1),
|
||||
)
|
||||
buses.append(bus)
|
||||
logger.info(
|
||||
f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
|
||||
f"pin={bus_cfg.get('pin', '?')}, "
|
||||
f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
|
||||
f"type={bus_cfg.get('type', '?')}"
|
||||
)
|
||||
|
||||
return WLEDInfo(
|
||||
name=data.get("name", "Unknown"),
|
||||
version=data.get("ver", "Unknown"),
|
||||
led_count=leds_info.get("count", 0),
|
||||
brand=data.get("brand", "WLED"),
|
||||
product=data.get("product", "FOSS"),
|
||||
mac=data.get("mac", ""),
|
||||
ip=data.get("ip", ""),
|
||||
rgbw=leds_info.get("rgbw", False),
|
||||
color_order=color_order,
|
||||
buses=buses,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get device info: {e}")
|
||||
raise
|
||||
|
||||
async def get_state(self) -> Dict[str, Any]:
|
||||
"""Get current WLED device state.
|
||||
|
||||
Returns:
|
||||
State dictionary
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
try:
|
||||
return await self._request("GET", "/json/state")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get device state: {e}")
|
||||
raise
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
segment_id: int = 0,
|
||||
) -> bool:
|
||||
"""Send pixel colors to WLED device.
|
||||
|
||||
Uses DDP for large LED counts (>500), HTTP JSON API for smaller counts.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples for each LED
|
||||
brightness: Global brightness (0-255)
|
||||
segment_id: Segment ID to update (HTTP only)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValueError: If pixel values are invalid
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
# Validate inputs
|
||||
if not pixels:
|
||||
raise ValueError("Pixels list cannot be empty")
|
||||
|
||||
if not 0 <= brightness <= 255:
|
||||
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
||||
|
||||
# Validate pixel values
|
||||
validated_pixels = []
|
||||
for i, (r, g, b) in enumerate(pixels):
|
||||
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
|
||||
raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})")
|
||||
validated_pixels.append((int(r), int(g), int(b)))
|
||||
|
||||
# Use DDP protocol if enabled
|
||||
if self.use_ddp and self._ddp_client:
|
||||
return await self._send_pixels_ddp(validated_pixels, brightness)
|
||||
else:
|
||||
return await self._send_pixels_http(validated_pixels, brightness, segment_id)
|
||||
|
||||
async def _send_pixels_ddp(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
"""Send pixels via DDP protocol.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Apply brightness to pixels
|
||||
if brightness < 255:
|
||||
brightness_factor = brightness / 255.0
|
||||
pixels = [
|
||||
(
|
||||
int(r * brightness_factor),
|
||||
int(g * brightness_factor),
|
||||
int(b * brightness_factor)
|
||||
)
|
||||
for r, g, b in pixels
|
||||
]
|
||||
|
||||
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
|
||||
await self._ddp_client.send_pixels(pixels)
|
||||
logger.debug(f"Successfully sent pixel colors via DDP")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send pixels via DDP: {e}")
|
||||
raise
|
||||
|
||||
async def _send_pixels_http(
|
||||
self,
|
||||
pixels: List[Tuple[int, int, int]],
|
||||
brightness: int = 255,
|
||||
segment_id: int = 0,
|
||||
) -> bool:
|
||||
"""Send pixels via HTTP JSON API.
|
||||
|
||||
Args:
|
||||
pixels: List of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
segment_id: Segment ID to update
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Build indexed pixel array: [led_index, r, g, b, ...]
|
||||
indexed_pixels = []
|
||||
for i, (r, g, b) in enumerate(pixels):
|
||||
indexed_pixels.extend([i, int(r), int(g), int(b)])
|
||||
|
||||
# Build WLED JSON state
|
||||
payload = {
|
||||
"on": True,
|
||||
"bri": int(brightness),
|
||||
"seg": [
|
||||
{
|
||||
"id": segment_id,
|
||||
"i": indexed_pixels,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)")
|
||||
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
|
||||
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.debug(f"Successfully sent pixel colors via HTTP")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send pixels via HTTP: {e}")
|
||||
raise
|
||||
|
||||
def send_pixels_fast(
|
||||
self,
|
||||
pixels,
|
||||
brightness: int = 255,
|
||||
) -> None:
|
||||
"""Optimized send for the hot loop — fire-and-forget DDP.
|
||||
|
||||
Accepts numpy array (N,3) uint8 directly to avoid conversion overhead.
|
||||
Synchronous (no await). Only works for DDP path.
|
||||
|
||||
Args:
|
||||
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||
brightness: Global brightness (0-255)
|
||||
"""
|
||||
if not self.use_ddp or not self._ddp_client:
|
||||
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_array = pixels
|
||||
else:
|
||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||
|
||||
if brightness < 255:
|
||||
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||
|
||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||
|
||||
# ===== LEDClient abstraction methods =====
|
||||
|
||||
async def snapshot_device_state(self) -> Optional[dict]:
|
||||
"""Snapshot WLED state before streaming (on, lor, AudioReactive)."""
|
||||
if not self._client:
|
||||
return None
|
||||
try:
|
||||
resp = await self._client.get(f"{self.url}/json/state")
|
||||
resp.raise_for_status()
|
||||
wled_state = resp.json()
|
||||
state = {
|
||||
"on": wled_state.get("on", True),
|
||||
"lor": wled_state.get("lor", 0),
|
||||
}
|
||||
if "AudioReactive" in wled_state:
|
||||
state["AudioReactive"] = wled_state["AudioReactive"]
|
||||
logger.info(f"Saved WLED state before streaming: {state}")
|
||||
return state
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not snapshot WLED state: {e}")
|
||||
return None
|
||||
|
||||
async def restore_device_state(self, state: Optional[dict]) -> None:
|
||||
"""Restore WLED state after streaming."""
|
||||
if not state:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as http:
|
||||
await http.post(f"{self.url}/json/state", json=state)
|
||||
logger.info(f"Restored WLED state: {state}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not restore WLED state: {e}")
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health: Optional[DeviceHealth] = None,
|
||||
) -> DeviceHealth:
|
||||
"""WLED health check via GET /json/info (+ /json/cfg for LED type)."""
|
||||
url = url.rstrip("/")
|
||||
start = time.time()
|
||||
try:
|
||||
response = await http_client.get(f"{url}/json/info")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
latency = (time.time() - start) * 1000
|
||||
leds_info = data.get("leds", {})
|
||||
|
||||
# Fetch LED type from /json/cfg once (it's static config)
|
||||
device_led_type = prev_health.device_led_type if prev_health else None
|
||||
if device_led_type is None:
|
||||
try:
|
||||
cfg = await http_client.get(f"{url}/json/cfg")
|
||||
cfg.raise_for_status()
|
||||
cfg_data = cfg.json()
|
||||
ins = cfg_data.get("hw", {}).get("led", {}).get("ins", [])
|
||||
if ins:
|
||||
type_code = ins[0].get("type", 0)
|
||||
device_led_type = WLED_LED_TYPES.get(type_code, f"Type {type_code}")
|
||||
except Exception as cfg_err:
|
||||
logger.debug(f"Could not fetch LED type: {cfg_err}")
|
||||
|
||||
return DeviceHealth(
|
||||
online=True,
|
||||
latency_ms=round(latency, 1),
|
||||
last_checked=datetime.utcnow(),
|
||||
device_name=data.get("name"),
|
||||
device_version=data.get("ver"),
|
||||
device_led_count=leds_info.get("count"),
|
||||
device_rgbw=leds_info.get("rgbw", False),
|
||||
device_led_type=device_led_type,
|
||||
device_fps=leds_info.get("fps"),
|
||||
error=None,
|
||||
)
|
||||
except Exception as e:
|
||||
return DeviceHealth(
|
||||
online=False,
|
||||
latency_ms=None,
|
||||
last_checked=datetime.utcnow(),
|
||||
device_name=prev_health.device_name if prev_health else None,
|
||||
device_version=prev_health.device_version if prev_health else None,
|
||||
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||
device_rgbw=prev_health.device_rgbw if prev_health else None,
|
||||
device_led_type=prev_health.device_led_type if prev_health else None,
|
||||
device_fps=prev_health.device_fps if prev_health else None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
# ===== WLED-specific methods =====
|
||||
|
||||
async def set_power(self, on: bool) -> bool:
|
||||
"""Turn WLED device on or off.
|
||||
|
||||
Args:
|
||||
on: True to turn on, False to turn off
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
payload = {"on": on}
|
||||
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.info(f"Set WLED power: {'ON' if on else 'OFF'}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set power: {e}")
|
||||
raise
|
||||
|
||||
async def set_brightness(self, brightness: int) -> bool:
|
||||
"""Set global brightness.
|
||||
|
||||
Args:
|
||||
brightness: Brightness value (0-255)
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
|
||||
Raises:
|
||||
ValueError: If brightness is out of range
|
||||
RuntimeError: If request fails
|
||||
"""
|
||||
if not 0 <= brightness <= 255:
|
||||
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
||||
|
||||
payload = {"bri": brightness}
|
||||
|
||||
try:
|
||||
await self._request("POST", "/json/state", json_data=payload)
|
||||
logger.debug(f"Set brightness to {brightness}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set brightness: {e}")
|
||||
raise
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Test connection to WLED device.
|
||||
|
||||
Returns:
|
||||
True if device is reachable
|
||||
|
||||
Raises:
|
||||
RuntimeError: If connection test fails
|
||||
"""
|
||||
try:
|
||||
await self.get_info()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Connection test failed: {e}")
|
||||
raise
|
||||
|
||||
async def send_test_pattern(self, led_count: int, duration: float = 2.0):
|
||||
"""Send a test pattern to verify LED configuration.
|
||||
|
||||
Cycles through red, green, blue on all LEDs.
|
||||
|
||||
Args:
|
||||
led_count: Number of LEDs
|
||||
duration: Duration for each color in seconds
|
||||
|
||||
Raises:
|
||||
RuntimeError: If test pattern fails
|
||||
"""
|
||||
logger.info(f"Sending test pattern to {led_count} LEDs")
|
||||
|
||||
try:
|
||||
# Red
|
||||
pixels = [(255, 0, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Green
|
||||
pixels = [(0, 255, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Blue
|
||||
pixels = [(0, 0, 255)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
await asyncio.sleep(duration)
|
||||
|
||||
# Off
|
||||
pixels = [(0, 0, 0)] * led_count
|
||||
await self.send_pixels(pixels)
|
||||
|
||||
logger.info("Test pattern complete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Test pattern failed: {e}")
|
||||
raise
|
||||
188
server/src/wled_controller/core/devices/wled_provider.py
Normal file
188
server/src/wled_controller/core/devices/wled_provider.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""WLED device provider — consolidates all WLED-specific dispatch logic."""
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from zeroconf import ServiceStateChange
|
||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
WLED_MDNS_TYPE = "_wled._tcp.local."
|
||||
DEFAULT_SCAN_TIMEOUT = 3.0
|
||||
|
||||
|
||||
class WLEDDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for WLED LED controllers."""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "wled"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"brightness_control", "power_control", "standby_required"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
kwargs.pop("led_count", None)
|
||||
kwargs.pop("baud_rate", None)
|
||||
return WLEDClient(url, **kwargs)
|
||||
|
||||
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||
from wled_controller.core.devices.wled_client import WLEDClient
|
||||
return await WLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate a WLED device URL by probing /json/info.
|
||||
|
||||
Returns:
|
||||
dict with 'led_count' key.
|
||||
|
||||
Raises:
|
||||
httpx.ConnectError: Device unreachable.
|
||||
httpx.TimeoutException: Connection timed out.
|
||||
ValueError: Invalid LED count.
|
||||
"""
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{url}/json/info")
|
||||
response.raise_for_status()
|
||||
wled_info = response.json()
|
||||
led_count = wled_info.get("leds", {}).get("count")
|
||||
if not led_count or led_count < 1:
|
||||
raise ValueError(
|
||||
f"WLED device at {url} reported invalid LED count: {led_count}"
|
||||
)
|
||||
logger.info(
|
||||
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
||||
f"v{wled_info.get('ver', '?')} ({led_count} LEDs)"
|
||||
)
|
||||
return {"led_count": led_count}
|
||||
|
||||
# ===== DISCOVERY =====
|
||||
|
||||
async def discover(self, timeout: float = DEFAULT_SCAN_TIMEOUT) -> List[DiscoveredDevice]:
|
||||
"""Scan the local network for WLED devices via mDNS."""
|
||||
discovered: dict[str, AsyncServiceInfo] = {}
|
||||
|
||||
def on_state_change(**kwargs):
|
||||
service_type = kwargs.get("service_type", "")
|
||||
name = kwargs.get("name", "")
|
||||
state_change = kwargs.get("state_change")
|
||||
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
||||
discovered[name] = AsyncServiceInfo(service_type, name)
|
||||
|
||||
aiozc = AsyncZeroconf()
|
||||
browser = AsyncServiceBrowser(
|
||||
aiozc.zeroconf, WLED_MDNS_TYPE, handlers=[on_state_change]
|
||||
)
|
||||
|
||||
await asyncio.sleep(timeout)
|
||||
|
||||
# Resolve all discovered services
|
||||
for info in discovered.values():
|
||||
await info.async_request(aiozc.zeroconf, timeout=2000)
|
||||
|
||||
await browser.async_cancel()
|
||||
|
||||
# Build raw list with IPs, then enrich in parallel
|
||||
raw: list[tuple[str, str, str]] = [] # (service_name, ip, url)
|
||||
for name, info in discovered.items():
|
||||
addrs = info.parsed_addresses()
|
||||
if not addrs:
|
||||
continue
|
||||
ip = addrs[0]
|
||||
port = info.port or 80
|
||||
url = f"http://{ip}" if port == 80 else f"http://{ip}:{port}"
|
||||
service_name = name.replace(f".{WLED_MDNS_TYPE}", "")
|
||||
raw.append((service_name, ip, url))
|
||||
|
||||
# Enrich all devices in parallel
|
||||
enrichment = await asyncio.gather(
|
||||
*[self._enrich_device(url, sname) for sname, _, url in raw]
|
||||
)
|
||||
|
||||
results: List[DiscoveredDevice] = []
|
||||
for (service_name, ip, url), (wled_name, version, led_count, mac) in zip(
|
||||
raw, enrichment
|
||||
):
|
||||
results.append(
|
||||
DiscoveredDevice(
|
||||
name=wled_name,
|
||||
url=url,
|
||||
device_type="wled",
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
led_count=led_count,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
|
||||
await aiozc.async_close()
|
||||
logger.info(f"mDNS scan found {len(results)} WLED device(s)")
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
async def _enrich_device(
|
||||
url: str, fallback_name: str
|
||||
) -> tuple[str, Optional[str], Optional[int], str]:
|
||||
"""Probe a WLED device's /json/info to get name, version, LED count, MAC."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2) as client:
|
||||
resp = await client.get(f"{url}/json/info")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return (
|
||||
data.get("name", fallback_name),
|
||||
data.get("ver"),
|
||||
data.get("leds", {}).get("count"),
|
||||
data.get("mac", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch WLED info from {url}: {e}")
|
||||
return fallback_name, None, None, ""
|
||||
|
||||
# ===== BRIGHTNESS =====
|
||||
|
||||
async def get_brightness(self, url: str) -> int:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
state = resp.json()
|
||||
return state.get("bri", 255)
|
||||
|
||||
async def set_brightness(self, url: str, brightness: int) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{url}/json/state",
|
||||
json={"bri": brightness},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
async def get_power(self, url: str, **kwargs) -> bool:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.get(f"{url}/json/state")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("on", False)
|
||||
|
||||
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||
url = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
||||
resp = await http_client.post(
|
||||
f"{url}/json/state",
|
||||
json={"on": on},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
Reference in New Issue
Block a user