Add LED device abstraction layer for multi-controller support

Introduce abstract LEDClient base class with factory pattern so new
LED controller types can plug in alongside WLED. ProcessorManager is
now fully type-agnostic — all device-specific logic (health checks,
state snapshot/restore, fast send) lives behind the LEDClient interface.

- New led_client.py: LEDClient ABC, DeviceHealth, factory functions
- WLEDClient inherits LEDClient, encapsulates WLED health checks and state management
- device_type field on Device storage model (defaults to "wled")
- Rename target_type "wled" → "led" with backward-compat migration
- Frontend: "WLED" tab → "LED", device type badge, type selector in
  add-device modal, device type shown in target device dropdown
- All wled_* API fields renamed to device_* for generic naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 12:41:02 +03:00
parent afce183f79
commit b5a6885126
18 changed files with 667 additions and 346 deletions

View File

@@ -4,6 +4,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.core.led_client import get_device_capabilities
from wled_controller.api.dependencies import (
get_device_store,
get_picture_target_store,
@@ -39,6 +40,7 @@ def _device_to_response(device) -> DeviceResponse:
id=device.id,
name=device.name,
url=device.url,
device_type=device.device_type,
led_count=device.led_count,
enabled=device.enabled,
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
@@ -56,50 +58,60 @@ async def create_device(
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Create and attach a new WLED device."""
"""Create and attach a new LED device."""
try:
logger.info(f"Creating device: {device_data.name}")
device_type = device_data.device_type
logger.info(f"Creating {device_type} device: {device_data.name}")
# Validate WLED device is reachable before adding
device_url = device_data.url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=5) as client:
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}"
wled_led_count = 0
if device_type == "wled":
# Validate WLED device is reachable before adding
try:
async with httpx.AsyncClient(timeout=5) as client:
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)"
)
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.ConnectError:
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(
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}"
status_code=400,
detail=f"Unsupported device type: {device_type}"
)
# Create device in storage (LED count auto-detected from WLED)
# Create device in storage
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=wled_led_count,
device_type=device_type,
)
# Register in processor manager for health monitoring
@@ -108,6 +120,7 @@ async def create_device(
device_url=device.url,
led_count=device.led_count,
calibration=device.calibration,
device_type=device.device_type,
)
return _device_to_response(device)
@@ -234,6 +247,7 @@ async def get_device_state(
try:
state = manager.get_device_health_dict(device_id)
state["device_type"] = device.device_type
return DeviceStateResponse(**state)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -251,6 +265,8 @@ async def get_device_brightness(
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
try:
async with httpx.AsyncClient(timeout=5.0) as http_client:
@@ -275,6 +291,8 @@ async def set_device_brightness(
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "brightness_control" not in get_device_capabilities(device.device_type):
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
bri = body.get("brightness")
if bri is None or not isinstance(bri, int) or not 0 <= bri <= 255:

View File

@@ -65,7 +65,6 @@ def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
settings = ProcessingSettings(
display_index=schema.display_index,
fps=schema.fps,
border_width=schema.border_width,
interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness,
smoothing=schema.smoothing,
@@ -86,7 +85,6 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
return ProcessingSettingsSchema(
display_index=settings.display_index,
fps=settings.fps,
border_width=settings.border_width,
interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness,
smoothing=settings.smoothing,
@@ -468,7 +466,6 @@ async def update_target_settings(
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps,
border_width=settings.border_width if 'border_width' in sent else existing.border_width,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
gamma=existing.gamma,

View File

@@ -7,10 +7,11 @@ from pydantic import BaseModel, Field
class DeviceCreate(BaseModel):
"""Request to create/attach a WLED device."""
"""Request to create/attach an LED device."""
name: str = Field(description="Device name", min_length=1, max_length=100)
url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)")
url: str = Field(description="Device URL (e.g., http://192.168.1.100)")
device_type: str = Field(default="wled", description="LED device type (e.g., wled)")
class DeviceUpdate(BaseModel):
@@ -53,6 +54,7 @@ class Calibration(BaseModel):
# Skip LEDs at start/end of strip
skip_leds_start: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the start of the strip")
skip_leds_end: int = Field(default=0, ge=0, description="LEDs to skip (black out) at the end of the strip")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for edge sampling")
class CalibrationTestModeRequest(BaseModel):
@@ -79,7 +81,8 @@ class DeviceResponse(BaseModel):
id: str = Field(description="Device ID")
name: str = Field(description="Device name")
url: str = Field(description="WLED device URL")
url: str = Field(description="Device URL")
device_type: str = Field(default="wled", description="LED device type")
led_count: int = Field(description="Total number of LEDs")
enabled: bool = Field(description="Whether device is enabled")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
@@ -98,14 +101,15 @@ class DeviceStateResponse(BaseModel):
"""Device health/connection state response."""
device_id: str = Field(description="Device ID")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
wled_version: Optional[str] = Field(None, description="WLED firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
wled_error: Optional[str] = Field(None, description="Last health check error")
device_type: str = Field(default="wled", description="LED device type")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
test_mode_edges: List[str] = Field(default_factory=list, description="Currently lit edges in test mode")

View File

@@ -21,7 +21,6 @@ class ProcessingSettings(BaseModel):
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
@@ -76,8 +75,8 @@ class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="wled", description="Target type (wled, key_colors)")
device_id: str = Field(default="", description="WLED device ID")
target_type: str = Field(default="led", description="Target type (led, key_colors)")
device_id: str = Field(default="", description="LED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
@@ -132,15 +131,15 @@ class TargetProcessingState(BaseModel):
display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms")
wled_name: Optional[str] = Field(None, description="WLED device name")
wled_version: Optional[str] = Field(None, description="WLED firmware version")
wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device")
wled_rgbw: Optional[bool] = Field(None, description="Whether WLED device uses RGBW LEDs")
wled_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
wled_last_checked: Optional[datetime] = Field(None, description="Last health check time")
wled_error: Optional[str] = Field(None, description="Last health check error")
device_online: bool = Field(default=False, description="Whether device is reachable")
device_latency_ms: Optional[float] = Field(None, description="Health check latency in ms")
device_name: Optional[str] = Field(None, description="Device name reported by firmware")
device_version: Optional[str] = Field(None, description="Firmware version")
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
device_error: Optional[str] = Field(None, description="Last health check error")
class TargetMetricsResponse(BaseModel):