Add LEDDeviceProvider abstraction and standby capability flag
Consolidate all device-type-specific logic into LEDDeviceProvider ABC with provider registry. WLEDDeviceProvider handles client creation, health checks, validation, mDNS discovery, and brightness control. Routes now delegate to providers instead of using if/else type checks. Add standby_required capability and expose device capabilities in API. Target editor conditionally shows standby interval based on selected device's capabilities. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,11 @@ import httpx
|
|||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
from wled_controller.api.auth import AuthRequired
|
from wled_controller.api.auth import AuthRequired
|
||||||
from wled_controller.core.led_client import get_device_capabilities
|
from wled_controller.core.led_client import (
|
||||||
|
get_all_providers,
|
||||||
|
get_device_capabilities,
|
||||||
|
get_provider,
|
||||||
|
)
|
||||||
from wled_controller.api.dependencies import (
|
from wled_controller.api.dependencies import (
|
||||||
get_device_store,
|
get_device_store,
|
||||||
get_picture_target_store,
|
get_picture_target_store,
|
||||||
@@ -45,6 +49,7 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
device_type=device.device_type,
|
device_type=device.device_type,
|
||||||
led_count=device.led_count,
|
led_count=device.led_count,
|
||||||
enabled=device.enabled,
|
enabled=device.enabled,
|
||||||
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
@@ -66,53 +71,44 @@ async def create_device(
|
|||||||
logger.info(f"Creating {device_type} device: {device_data.name}")
|
logger.info(f"Creating {device_type} device: {device_data.name}")
|
||||||
|
|
||||||
device_url = device_data.url.rstrip("/")
|
device_url = device_data.url.rstrip("/")
|
||||||
wled_led_count = 0
|
|
||||||
|
|
||||||
if device_type == "wled":
|
# Validate via provider
|
||||||
# Validate WLED device is reachable before adding
|
try:
|
||||||
try:
|
provider = get_provider(device_type)
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
except ValueError:
|
||||||
response = await client.get(f"{device_url}/json/info")
|
|
||||||
response.raise_for_status()
|
|
||||||
wled_info = response.json()
|
|
||||||
wled_led_count = wled_info.get("leds", {}).get("count")
|
|
||||||
if not wled_led_count or wled_led_count < 1:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
|
|
||||||
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
|
|
||||||
)
|
|
||||||
except httpx.ConnectError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
|
|
||||||
)
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Connection to {device_url} timed out. Check network connectivity."
|
|
||||||
)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Failed to connect to WLED device at {device_url}: {e}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Unsupported device type: {device_type}"
|
detail=f"Unsupported device type: {device_type}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await provider.validate_device(device_url)
|
||||||
|
led_count = result["led_count"]
|
||||||
|
except httpx.ConnectError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Cannot reach {device_type} device at {device_url}. Check the URL and ensure the device is powered on."
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Connection to {device_url} timed out. Check network connectivity."
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=422, detail=str(e))
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"Failed to connect to {device_type} device at {device_url}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create device in storage
|
# Create device in storage
|
||||||
device = store.create_device(
|
device = store.create_device(
|
||||||
name=device_data.name,
|
name=device_data.name,
|
||||||
url=device_data.url,
|
url=device_data.url,
|
||||||
led_count=wled_led_count,
|
led_count=led_count,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -151,12 +147,17 @@ async def discover_devices(
|
|||||||
store: DeviceStore = Depends(get_device_store),
|
store: DeviceStore = Depends(get_device_store),
|
||||||
timeout: float = 3.0,
|
timeout: float = 3.0,
|
||||||
):
|
):
|
||||||
"""Scan the local network for WLED devices via mDNS."""
|
"""Scan the local network for LED devices via all registered providers."""
|
||||||
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from wled_controller.core.discovery import discover_wled_devices
|
|
||||||
|
|
||||||
start = time.time()
|
start = time.time()
|
||||||
discovered = await discover_wled_devices(timeout=min(timeout, 10.0))
|
capped_timeout = min(timeout, 10.0)
|
||||||
|
# Discover from all providers in parallel
|
||||||
|
providers = get_all_providers()
|
||||||
|
discover_tasks = [p.discover(timeout=capped_timeout) for p in providers.values()]
|
||||||
|
all_results = await asyncio.gather(*discover_tasks)
|
||||||
|
discovered = [d for batch in all_results for d in batch]
|
||||||
elapsed_ms = (time.time() - start) * 1000
|
elapsed_ms = (time.time() - start) * 1000
|
||||||
|
|
||||||
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
existing_urls = {d.url.rstrip("/").lower() for d in store.get_all_devices()}
|
||||||
@@ -310,15 +311,12 @@ async def get_device_brightness(
|
|||||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
provider = get_provider(device.device_type)
|
||||||
resp = await http_client.get(f"{device.url}/json/state")
|
bri = await provider.get_brightness(device.url)
|
||||||
resp.raise_for_status()
|
return {"brightness": bri}
|
||||||
state = resp.json()
|
|
||||||
bri = state.get("bri", 255)
|
|
||||||
return {"brightness": bri}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get WLED brightness for {device_id}: {e}")
|
logger.error(f"Failed to get brightness for {device_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
|
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
@router.put("/api/v1/devices/{device_id}/brightness", tags=["Settings"])
|
||||||
@@ -340,16 +338,12 @@ async def set_device_brightness(
|
|||||||
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
raise HTTPException(status_code=400, detail="brightness must be an integer 0-255")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as http_client:
|
provider = get_provider(device.device_type)
|
||||||
resp = await http_client.post(
|
await provider.set_brightness(device.url, bri)
|
||||||
f"{device.url}/json/state",
|
return {"brightness": bri}
|
||||||
json={"bri": bri},
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return {"brightness": bri}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to set WLED brightness for {device_id}: {e}")
|
logger.error(f"Failed to set brightness for {device_id}: {e}")
|
||||||
raise HTTPException(status_code=502, detail=f"Failed to reach WLED device: {e}")
|
raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}")
|
||||||
|
|
||||||
|
|
||||||
# ===== CALIBRATION ENDPOINTS =====
|
# ===== CALIBRATION ENDPOINTS =====
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class DeviceResponse(BaseModel):
|
|||||||
device_type: str = Field(default="wled", description="LED device type")
|
device_type: str = Field(default="wled", description="LED device type")
|
||||||
led_count: int = Field(description="Total number of LEDs")
|
led_count: int = Field(description="Total number of LEDs")
|
||||||
enabled: bool = Field(description="Whether device is enabled")
|
enabled: bool = Field(description="Whether device is enabled")
|
||||||
|
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Network discovery for LED devices (mDNS/DNS-SD)."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from zeroconf import ServiceStateChange, Zeroconf
|
|
||||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
|
||||||
|
|
||||||
WLED_MDNS_TYPE = "_wled._tcp.local."
|
|
||||||
DEFAULT_SCAN_TIMEOUT = 3.0
|
|
||||||
|
|
||||||
|
|
||||||
@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]
|
|
||||||
|
|
||||||
|
|
||||||
async def _enrich_wled_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, ""
|
|
||||||
|
|
||||||
|
|
||||||
async def discover_wled_devices(
|
|
||||||
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(
|
|
||||||
*[_enrich_wled_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
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Abstract base class for LED device communication clients."""
|
"""Abstract base class for LED device communication clients and provider registry."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -22,6 +22,19 @@ class DeviceHealth:
|
|||||||
error: Optional[str] = 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):
|
class LEDClient(ABC):
|
||||||
"""Abstract base for LED device communication.
|
"""Abstract base for LED device communication.
|
||||||
|
|
||||||
@@ -128,36 +141,97 @@ class LEDClient(ABC):
|
|||||||
await self.close()
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
# Per-device-type capability sets.
|
# ===== LED DEVICE PROVIDER =====
|
||||||
# Used by API routes to gate type-specific features (e.g. brightness control).
|
|
||||||
DEVICE_TYPE_CAPABILITIES = {
|
class LEDDeviceProvider(ABC):
|
||||||
"wled": {"brightness_control"},
|
"""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
|
||||||
|
|
||||||
|
|
||||||
def get_device_capabilities(device_type: str) -> set:
|
# ===== PROVIDER REGISTRY =====
|
||||||
"""Return the capability set for a device type."""
|
|
||||||
return DEVICE_TYPE_CAPABILITIES.get(device_type, set())
|
_provider_registry: Dict[str, LEDDeviceProvider] = {}
|
||||||
|
|
||||||
|
|
||||||
def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient:
|
def register_provider(provider: LEDDeviceProvider) -> None:
|
||||||
"""Factory: create the right LEDClient subclass for a device type.
|
"""Register a device provider."""
|
||||||
|
_provider_registry[provider.device_type] = provider
|
||||||
|
|
||||||
Args:
|
|
||||||
device_type: Device type identifier (e.g. "wled")
|
|
||||||
url: Device URL
|
|
||||||
**kwargs: Passed to the client constructor
|
|
||||||
|
|
||||||
Returns:
|
def get_provider(device_type: str) -> LEDDeviceProvider:
|
||||||
LEDClient instance
|
"""Get the provider for a device type.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If device_type is unknown
|
ValueError: If device_type is unknown.
|
||||||
"""
|
"""
|
||||||
if device_type == "wled":
|
provider = _provider_registry.get(device_type)
|
||||||
from wled_controller.core.wled_client import WLEDClient
|
if not provider:
|
||||||
return WLEDClient(url, **kwargs)
|
raise ValueError(f"Unknown LED device type: {device_type}")
|
||||||
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(
|
async def check_device_health(
|
||||||
@@ -166,15 +240,23 @@ async def check_device_health(
|
|||||||
http_client,
|
http_client,
|
||||||
prev_health: Optional[DeviceHealth] = None,
|
prev_health: Optional[DeviceHealth] = None,
|
||||||
) -> DeviceHealth:
|
) -> DeviceHealth:
|
||||||
"""Factory: dispatch health check to the right LEDClient subclass.
|
"""Factory: dispatch health check to the right provider."""
|
||||||
|
return await get_provider(device_type).check_health(url, http_client, prev_health)
|
||||||
|
|
||||||
Args:
|
|
||||||
device_type: Device type identifier
|
def get_device_capabilities(device_type: str) -> set:
|
||||||
url: Device URL
|
"""Return the capability set for a device type."""
|
||||||
http_client: Shared httpx.AsyncClient
|
try:
|
||||||
prev_health: Previous health result
|
return get_provider(device_type).capabilities
|
||||||
"""
|
except ValueError:
|
||||||
if device_type == "wled":
|
return set()
|
||||||
from wled_controller.core.wled_client import WLEDClient
|
|
||||||
return await WLEDClient.check_health(url, http_client, prev_health)
|
|
||||||
return DeviceHealth(online=True, last_checked=datetime.utcnow())
|
# ===== AUTO-REGISTER BUILT-IN PROVIDERS =====
|
||||||
|
|
||||||
|
def _register_builtin_providers():
|
||||||
|
from wled_controller.core.wled_provider import WLEDDeviceProvider
|
||||||
|
register_provider(WLEDDeviceProvider())
|
||||||
|
|
||||||
|
|
||||||
|
_register_builtin_providers()
|
||||||
|
|||||||
170
server/src/wled_controller/core/wled_provider.py
Normal file
170
server/src/wled_controller/core/wled_provider.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""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.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", "standby_required"}
|
||||||
|
|
||||||
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
from wled_controller.core.wled_client import WLEDClient
|
||||||
|
return WLEDClient(url, **kwargs)
|
||||||
|
|
||||||
|
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
|
||||||
|
from wled_controller.core.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()
|
||||||
@@ -873,6 +873,8 @@ async function fetchDeviceBrightness(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add device modal
|
// Add device modal
|
||||||
|
let _discoveryScanRunning = false;
|
||||||
|
|
||||||
function showAddDevice() {
|
function showAddDevice() {
|
||||||
const modal = document.getElementById('add-device-modal');
|
const modal = document.getElementById('add-device-modal');
|
||||||
const form = document.getElementById('add-device-form');
|
const form = document.getElementById('add-device-form');
|
||||||
@@ -892,6 +894,8 @@ function showAddDevice() {
|
|||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||||
|
// Auto-start discovery on open
|
||||||
|
scanForDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAddDeviceModal() {
|
function closeAddDeviceModal() {
|
||||||
@@ -901,6 +905,9 @@ function closeAddDeviceModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function scanForDevices() {
|
async function scanForDevices() {
|
||||||
|
if (_discoveryScanRunning) return;
|
||||||
|
_discoveryScanRunning = true;
|
||||||
|
|
||||||
const loading = document.getElementById('discovery-loading');
|
const loading = document.getElementById('discovery-loading');
|
||||||
const list = document.getElementById('discovery-list');
|
const list = document.getElementById('discovery-list');
|
||||||
const empty = document.getElementById('discovery-empty');
|
const empty = document.getElementById('discovery-empty');
|
||||||
@@ -962,6 +969,8 @@ async function scanForDevices() {
|
|||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
empty.querySelector('small').textContent = t('device.scan.error');
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
console.error('Device scan failed:', err);
|
console.error('Device scan failed:', err);
|
||||||
|
} finally {
|
||||||
|
_discoveryScanRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3906,6 +3915,15 @@ function closePPTemplateModal() {
|
|||||||
|
|
||||||
// ===== TARGET EDITOR MODAL =====
|
// ===== TARGET EDITOR MODAL =====
|
||||||
let targetEditorInitialValues = {};
|
let targetEditorInitialValues = {};
|
||||||
|
let _targetEditorDevices = []; // cached devices list for capability checks
|
||||||
|
|
||||||
|
function _updateStandbyVisibility() {
|
||||||
|
const deviceSelect = document.getElementById('target-editor-device');
|
||||||
|
const standbyGroup = document.getElementById('target-editor-standby-group');
|
||||||
|
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
||||||
|
const caps = selectedDevice?.capabilities || [];
|
||||||
|
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
async function showTargetEditor(targetId = null) {
|
async function showTargetEditor(targetId = null) {
|
||||||
try {
|
try {
|
||||||
@@ -3917,6 +3935,7 @@ async function showTargetEditor(targetId = null) {
|
|||||||
|
|
||||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||||
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
|
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
|
||||||
|
_targetEditorDevices = devices;
|
||||||
|
|
||||||
// Populate device select
|
// Populate device select
|
||||||
const deviceSelect = document.getElementById('target-editor-device');
|
const deviceSelect = document.getElementById('target-editor-device');
|
||||||
@@ -3929,6 +3948,7 @@ async function showTargetEditor(targetId = null) {
|
|||||||
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
||||||
deviceSelect.appendChild(opt);
|
deviceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
deviceSelect.onchange = _updateStandbyVisibility;
|
||||||
|
|
||||||
// Populate source select
|
// Populate source select
|
||||||
const sourceSelect = document.getElementById('target-editor-source');
|
const sourceSelect = document.getElementById('target-editor-source');
|
||||||
@@ -3975,6 +3995,9 @@ async function showTargetEditor(targetId = null) {
|
|||||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show/hide standby interval based on selected device capabilities
|
||||||
|
_updateStandbyVisibility();
|
||||||
|
|
||||||
targetEditorInitialValues = {
|
targetEditorInitialValues = {
|
||||||
name: document.getElementById('target-editor-name').value,
|
name: document.getElementById('target-editor-name').value,
|
||||||
device: deviceSelect.value,
|
device: deviceSelect.value,
|
||||||
|
|||||||
@@ -336,7 +336,7 @@
|
|||||||
<input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value">
|
<input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group" id="target-editor-standby-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="target-editor-standby-interval">
|
<label for="target-editor-standby-interval">
|
||||||
<span data-i18n="targets.standby_interval">Standby Interval:</span>
|
<span data-i18n="targets.standby_interval">Standby Interval:</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user