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:
2026-02-18 12:03:29 +03:00
parent 77dd342c4c
commit fc779eef39
50 changed files with 2740 additions and 2267 deletions

View 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",
]

View 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),
)

View 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()

View 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()

View 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()

View 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

View 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()