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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user