diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index a090721..338a9bc 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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: diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 9584d07..50a304e 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -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, diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 0656f37..e58adc5 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index b2da08b..1e523af 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -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): diff --git a/server/src/wled_controller/core/calibration.py b/server/src/wled_controller/core/calibration.py index c7a0e83..a0d2850 100644 --- a/server/src/wled_controller/core/calibration.py +++ b/server/src/wled_controller/core/calibration.py @@ -79,6 +79,8 @@ class CalibrationConfig: # Skip LEDs: black out N LEDs at the start/end of the strip skip_leds_start: int = 0 skip_leds_end: int = 0 + # Border width: how many pixels from the screen edge to sample + border_width: int = 10 def build_segments(self) -> List[CalibrationSegment]: """Derive segment list from core parameters.""" @@ -463,6 +465,7 @@ def calibration_from_dict(data: dict) -> CalibrationConfig: span_left_end=data.get("span_left_end", 1.0), skip_leds_start=data.get("skip_leds_start", 0), skip_leds_end=data.get("skip_leds_end", 0), + border_width=data.get("border_width", 10), ) config.validate() @@ -506,4 +509,6 @@ def calibration_to_dict(config: CalibrationConfig) -> dict: result["skip_leds_start"] = config.skip_leds_start if config.skip_leds_end > 0: result["skip_leds_end"] = config.skip_leds_end + if config.border_width != 10: + result["border_width"] = config.border_width return result diff --git a/server/src/wled_controller/core/led_client.py b/server/src/wled_controller/core/led_client.py new file mode 100644 index 0000000..04bd185 --- /dev/null +++ b/server/src/wled_controller/core/led_client.py @@ -0,0 +1,180 @@ +"""Abstract base class for LED device communication clients.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Tuple + + +@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 + error: Optional[str] = None + + +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: List[Tuple[int, int, int]], + 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() + + +# Per-device-type capability sets. +# Used by API routes to gate type-specific features (e.g. brightness control). +DEVICE_TYPE_CAPABILITIES = { + "wled": {"brightness_control"}, +} + + +def get_device_capabilities(device_type: str) -> set: + """Return the capability set for a device type.""" + return DEVICE_TYPE_CAPABILITIES.get(device_type, set()) + + +def create_led_client(device_type: str, url: str, **kwargs) -> LEDClient: + """Factory: create the right LEDClient subclass for a device type. + + Args: + device_type: Device type identifier (e.g. "wled") + url: Device URL + **kwargs: Passed to the client constructor + + Returns: + LEDClient instance + + Raises: + ValueError: If device_type is unknown + """ + if device_type == "wled": + from wled_controller.core.wled_client import WLEDClient + return WLEDClient(url, **kwargs) + raise ValueError(f"Unknown LED device type: {device_type}") + + +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 LEDClient subclass. + + Args: + device_type: Device type identifier + url: Device URL + http_client: Shared httpx.AsyncClient + prev_health: Previous health result + """ + if device_type == "wled": + 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()) diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 3e4d644..b3e83a3 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -26,7 +26,12 @@ from wled_controller.core.screen_capture import ( calculate_median_color, extract_border_pixels, ) -from wled_controller.core.wled_client import WLEDClient +from wled_controller.core.led_client import ( + DeviceHealth, + LEDClient, + check_device_health, + create_led_client, +) from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -87,27 +92,12 @@ def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing): ) return colors -# 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 ProcessingSettings: """Settings for screen processing.""" display_index: int = 0 fps: int = 30 - border_width: int = 10 brightness: float = 1.0 gamma: float = 2.2 saturation: float = 1.0 @@ -117,21 +107,6 @@ class ProcessingSettings: state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL -@dataclass -class DeviceHealth: - """Health check result for a WLED device (GET /json/info).""" - - online: bool = False - latency_ms: Optional[float] = None - last_checked: Optional[datetime] = None - wled_name: Optional[str] = None - wled_version: Optional[str] = None - wled_led_count: Optional[int] = None - wled_rgbw: Optional[bool] = None - wled_led_type: Optional[str] = None - error: Optional[str] = None - - @dataclass class ProcessingMetrics: """Metrics for processing performance.""" @@ -150,12 +125,13 @@ class ProcessingMetrics: @dataclass class DeviceState: - """State for a registered WLED device (health monitoring + calibration).""" + """State for a registered LED device (health monitoring + calibration).""" device_id: str device_url: str led_count: int calibration: CalibrationConfig + device_type: str = "wled" health: DeviceHealth = field(default_factory=DeviceHealth) health_task: Optional[asyncio.Task] = None # Calibration test mode (works independently of target processing) @@ -174,7 +150,7 @@ class TargetState: settings: ProcessingSettings calibration: CalibrationConfig picture_source_id: str = "" - wled_client: Optional[WLEDClient] = None + led_client: Optional[LEDClient] = None pixel_mapper: Optional[PixelMapper] = None is_running: bool = False task: Optional[asyncio.Task] = None @@ -187,8 +163,8 @@ class TargetState: resolved_engine_config: Optional[dict] = None # LiveStream: runtime frame source (shared via LiveStreamManager) live_stream: Optional[LiveStream] = None - # WLED state snapshot taken before streaming starts (to restore on stop) - wled_state_before: Optional[dict] = None + # Device state snapshot taken before streaming starts (to restore on stop) + device_state_before: Optional[dict] = None @dataclass @@ -245,14 +221,16 @@ class ProcessorManager: device_url: str, led_count: int, calibration: Optional[CalibrationConfig] = None, + device_type: str = "wled", ): """Register a device for health monitoring. Args: device_id: Unique device identifier - device_url: WLED device URL + device_url: Device URL led_count: Number of LEDs calibration: Calibration config (creates default if None) + device_type: LED device type (e.g. "wled") """ if device_id in self._devices: raise ValueError(f"Device {device_id} already registered") @@ -265,6 +243,7 @@ class ProcessorManager: device_url=device_url, led_count=led_count, calibration=calibration, + device_type=device_type, ) self._devices[device_id] = state @@ -356,11 +335,11 @@ class ProcessorManager: "online": h.online, "latency_ms": h.latency_ms, "last_checked": h.last_checked, - "wled_name": h.wled_name, - "wled_version": h.wled_version, - "wled_led_count": h.wled_led_count, - "wled_rgbw": h.wled_rgbw, - "wled_led_type": h.wled_led_type, + "device_name": h.device_name, + "device_version": h.device_version, + "device_led_count": h.device_led_count, + "device_rgbw": h.device_rgbw, + "device_led_type": h.device_led_type, "error": h.error, } @@ -373,15 +352,15 @@ class ProcessorManager: h = ds.health return { "device_id": device_id, - "wled_online": h.online, - "wled_latency_ms": h.latency_ms, - "wled_name": h.wled_name, - "wled_version": h.wled_version, - "wled_led_count": h.wled_led_count, - "wled_rgbw": h.wled_rgbw, - "wled_led_type": h.wled_led_type, - "wled_last_checked": h.last_checked, - "wled_error": h.error, + "device_online": h.online, + "device_latency_ms": h.latency_ms, + "device_name": h.device_name, + "device_version": h.device_version, + "device_led_count": h.device_led_count, + "device_rgbw": h.device_rgbw, + "device_led_type": h.device_led_type, + "device_last_checked": h.last_checked, + "device_error": h.error, "test_mode": ds.test_mode_active, "test_mode_edges": list(ds.test_mode_edges.keys()), } @@ -548,31 +527,22 @@ class ProcessorManager: # Resolve stream settings self._resolve_stream_settings(state) - # Snapshot WLED state before streaming changes it - try: - async with httpx.AsyncClient(timeout=5) as http: - resp = await http.get(f"{state.device_url}/json/state") - resp.raise_for_status() - wled_state = resp.json() - state.wled_state_before = { - "on": wled_state.get("on", True), - "lor": wled_state.get("lor", 0), - } - if "AudioReactive" in wled_state: - state.wled_state_before["AudioReactive"] = wled_state["AudioReactive"] - logger.info(f"Saved WLED state before streaming: {state.wled_state_before}") - except Exception as e: - logger.warning(f"Could not snapshot WLED state: {e}") - state.wled_state_before = None + # Determine device type from device state + device_type = "wled" + if state.device_id in self._devices: + device_type = self._devices[state.device_id].device_type - # Connect to WLED device (always use DDP for low-latency UDP streaming) + # Connect to LED device via factory try: - state.wled_client = WLEDClient(state.device_url, use_ddp=True) - await state.wled_client.connect() - logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)") + state.led_client = create_led_client(device_type, state.device_url, use_ddp=True) + await state.led_client.connect() + logger.info(f"Target {target_id} connected to {device_type} device ({state.led_count} LEDs)") + + # Snapshot device state before streaming (type-specific, e.g. WLED saves on/lor/AudioReactive) + state.device_state_before = await state.led_client.snapshot_device_state() except Exception as e: - logger.error(f"Failed to connect to WLED device for target {target_id}: {e}") - raise RuntimeError(f"Failed to connect to WLED device: {e}") + logger.error(f"Failed to connect to LED device for target {target_id}: {e}") + raise RuntimeError(f"Failed to connect to LED device: {e}") # Acquire live stream via LiveStreamManager (shared across targets) try: @@ -589,8 +559,8 @@ class ProcessorManager: ) except Exception as e: logger.error(f"Failed to initialize live stream for target {target_id}: {e}") - if state.wled_client: - await state.wled_client.disconnect() + if state.led_client: + await state.led_client.close() raise RuntimeError(f"Failed to initialize live stream: {e}") # Initialize pixel mapper @@ -632,23 +602,15 @@ class ProcessorManager: pass state.task = None - # Restore WLED state - if state.wled_state_before: - try: - async with httpx.AsyncClient(timeout=5) as http: - await http.post( - f"{state.device_url}/json/state", - json=state.wled_state_before, - ) - logger.info(f"Restored WLED state: {state.wled_state_before}") - except Exception as e: - logger.warning(f"Could not restore WLED state: {e}") - state.wled_state_before = None + # Restore device state (type-specific, e.g. WLED restores on/lor/AudioReactive) + if state.led_client and state.device_state_before: + await state.led_client.restore_device_state(state.device_state_before) + state.device_state_before = None - # Close WLED connection - if state.wled_client: - await state.wled_client.close() - state.wled_client = None + # Close LED connection + if state.led_client: + await state.led_client.close() + state.led_client = None # Release live stream if state.live_stream: @@ -667,8 +629,8 @@ class ProcessorManager: target_fps = settings.fps smoothing = settings.smoothing - border_width = settings.border_width - wled_brightness = settings.brightness + border_width = state.calibration.border_width + led_brightness = settings.brightness logger.info( f"Processing loop started for target {target_id} " @@ -708,15 +670,15 @@ class ProcessorManager: # Skip processing + send if the frame hasn't changed if capture is prev_capture: - # Keepalive: resend last colors to prevent WLED exiting live mode + # Keepalive: resend last colors to prevent device exiting live mode if state.previous_colors and (loop_start - last_send_time) >= standby_interval: - if not state.is_running or state.wled_client is None: + if not state.is_running or state.led_client is None: break - brightness_value = int(wled_brightness * 255) - if state.wled_client.use_ddp: - state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value) + brightness_value = int(led_brightness * 255) + if state.led_client.supports_fast_send: + state.led_client.send_pixels_fast(state.previous_colors, brightness=brightness_value) else: - await state.wled_client.send_pixels(state.previous_colors, brightness=brightness_value) + await state.led_client.send_pixels(state.previous_colors, brightness=brightness_value) last_send_time = time.time() send_timestamps.append(last_send_time) state.metrics.frames_keepalive += 1 @@ -737,14 +699,14 @@ class ProcessorManager: state.pixel_mapper, state.previous_colors, smoothing, ) - # Send to WLED with device brightness - if not state.is_running or state.wled_client is None: + # Send to LED device with brightness + if not state.is_running or state.led_client is None: break - brightness_value = int(wled_brightness * 255) - if state.wled_client.use_ddp: - state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value) + brightness_value = int(led_brightness * 255) + if state.led_client.supports_fast_send: + state.led_client.send_pixels_fast(led_colors, brightness=brightness_value) else: - await state.wled_client.send_pixels(led_colors, brightness=brightness_value) + await state.led_client.send_pixels(led_colors, brightness=brightness_value) last_send_time = time.time() send_timestamps.append(last_send_time) @@ -802,20 +764,20 @@ class ProcessorManager: state = self._targets[target_id] metrics = state.metrics - # Include WLED health info from the device + # Include device health info health_info = {} if state.device_id in self._devices: h = self._devices[state.device_id].health health_info = { - "wled_online": h.online, - "wled_latency_ms": h.latency_ms, - "wled_name": h.wled_name, - "wled_version": h.wled_version, - "wled_led_count": h.wled_led_count, - "wled_rgbw": h.wled_rgbw, - "wled_led_type": h.wled_led_type, - "wled_last_checked": h.last_checked, - "wled_error": h.error, + "device_online": h.online, + "device_latency_ms": h.latency_ms, + "device_name": h.device_name, + "device_version": h.device_version, + "device_led_count": h.device_led_count, + "device_rgbw": h.device_rgbw, + "device_led_type": h.device_led_type, + "device_last_checked": h.last_checked, + "device_error": h.error, } return { @@ -913,38 +875,38 @@ class ProcessorManager: break try: - # Check if a target is running for this device (use its WLED client) + # Check if a target is running for this device (use its LED client) active_client = None for ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running and ts.wled_client: - active_client = ts.wled_client + if ts.device_id == device_id and ts.is_running and ts.led_client: + active_client = ts.led_client break if active_client: await active_client.send_pixels(pixels) else: - async with WLEDClient(ds.device_url, use_ddp=True) as wled: - await wled.send_pixels(pixels) + async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client: + await client.send_pixels(pixels) except Exception as e: logger.error(f"Failed to send test pixels for {device_id}: {e}") async def _send_clear_pixels(self, device_id: str) -> None: - """Send all-black pixels to clear WLED output.""" + """Send all-black pixels to clear LED output.""" ds = self._devices[device_id] pixels = [(0, 0, 0)] * ds.led_count try: active_client = None for ts in self._targets.values(): - if ts.device_id == device_id and ts.is_running and ts.wled_client: - active_client = ts.wled_client + if ts.device_id == device_id and ts.is_running and ts.led_client: + active_client = ts.led_client break if active_client: await active_client.send_pixels(pixels) else: - async with WLEDClient(ds.device_url, use_ddp=True) as wled: - await wled.send_pixels(pixels) + async with create_led_client(ds.device_type, ds.device_url, use_ddp=True) as client: + await client.send_pixels(pixels) except Exception as e: logger.error(f"Failed to clear pixels for {device_id}: {e}") @@ -1050,58 +1012,14 @@ class ProcessorManager: logger.error(f"Fatal error in health check loop for {device_id}: {e}") async def _check_device_health(self, device_id: str): - """Check device health via GET /json/info.""" + """Check device health via the LED client abstraction.""" state = self._devices.get(device_id) if not state: return - url = state.device_url.rstrip("/") - start = time.time() - try: - client = await self._get_http_client() - response = await client.get(f"{url}/json/info") - response.raise_for_status() - data = response.json() - latency = (time.time() - start) * 1000 - leds_info = data.get("leds", {}) - wled_led_count = leds_info.get("count") - - # Fetch LED type from /json/cfg once (it's static config) - wled_led_type = state.health.wled_led_type - if wled_led_type is None: - try: - cfg = await 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) - wled_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 for {device_id}: {cfg_err}") - - state.health = DeviceHealth( - online=True, - latency_ms=round(latency, 1), - last_checked=datetime.utcnow(), - wled_name=data.get("name"), - wled_version=data.get("ver"), - wled_led_count=wled_led_count, - wled_rgbw=leds_info.get("rgbw", False), - wled_led_type=wled_led_type, - error=None, - ) - except Exception as e: - state.health = DeviceHealth( - online=False, - latency_ms=None, - last_checked=datetime.utcnow(), - wled_name=state.health.wled_name, - wled_version=state.health.wled_version, - wled_led_count=state.health.wled_led_count, - wled_rgbw=state.health.wled_rgbw, - wled_led_type=state.health.wled_led_type, - error=str(e), - ) + client = await self._get_http_client() + state.health = await check_device_health( + state.device_type, state.device_url, client, state.health, + ) # ===== KEY COLORS TARGET MANAGEMENT ===== diff --git a/server/src/wled_controller/core/wled_client.py b/server/src/wled_controller/core/wled_client.py index ae8b096..ef93864 100644 --- a/server/src/wled_controller/core/wled_client.py +++ b/server/src/wled_controller/core/wled_client.py @@ -1,7 +1,9 @@ """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 @@ -10,9 +12,23 @@ import numpy as np from wled_controller.utils import get_logger from wled_controller.core.ddp_client import BusConfig, DDPClient +from wled_controller.core.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: @@ -30,7 +46,7 @@ class WLEDInfo: buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config -class WLEDClient: +class WLEDClient(LEDClient): """Client for WLED devices supporting both HTTP and DDP protocols.""" # HTTP JSON API has ~10KB limit, ~500 LEDs max @@ -67,15 +83,6 @@ class WLEDClient: self._ddp_client: Optional[DDPClient] = None self._connected = False - 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() - async def connect(self) -> bool: """Establish connection to WLED device. @@ -161,6 +168,11 @@ class WLEDClient: """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, @@ -445,6 +457,96 @@ class WLEDClient: 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, + 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, + error=str(e), + ) + + # ===== WLED-specific methods ===== + async def set_power(self, on: bool) -> bool: """Turn WLED device on or off. diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index fa01ffe..55a52c9 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -154,6 +154,7 @@ async def lifespan(app: FastAPI): device_url=device.url, led_count=device.led_count, calibration=device.calibration, + device_type=device.device_type, ) logger.info(f"Registered device: {device.name} ({device.id})") except Exception as e: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 596082f..45e83a0 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -614,33 +614,33 @@ async function loadDevices() { function createDeviceCard(device) { const state = device.state || {}; - // WLED device health indicator - const wledOnline = state.wled_online || false; - const wledLatency = state.wled_latency_ms; - const wledName = state.wled_name; - const wledVersion = state.wled_version; - const wledLastChecked = state.wled_last_checked; + // Device health indicator + const devOnline = state.device_online || false; + const devLatency = state.device_latency_ms; + const devName = state.device_name; + const devVersion = state.device_version; + const devLastChecked = state.device_last_checked; let healthClass, healthTitle, healthLabel; - if (wledLastChecked === null || wledLastChecked === undefined) { + if (devLastChecked === null || devLastChecked === undefined) { healthClass = 'health-unknown'; healthTitle = t('device.health.checking'); healthLabel = ''; - } else if (wledOnline) { + } else if (devOnline) { healthClass = 'health-online'; healthTitle = `${t('device.health.online')}`; - if (wledName) healthTitle += ` - ${wledName}`; - if (wledVersion) healthTitle += ` v${wledVersion}`; - if (wledLatency !== null && wledLatency !== undefined) healthTitle += ` (${Math.round(wledLatency)}ms)`; + if (devName) healthTitle += ` - ${devName}`; + if (devVersion) healthTitle += ` v${devVersion}`; + if (devLatency !== null && devLatency !== undefined) healthTitle += ` (${Math.round(devLatency)}ms)`; healthLabel = ''; } else { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); - if (state.wled_error) healthTitle += `: ${state.wled_error}`; + if (state.device_error) healthTitle += `: ${state.device_error}`; healthLabel = `${t('device.health.offline')}`; } - const ledCount = state.wled_led_count || device.led_count; + const ledCount = state.device_led_count || device.led_count; return `
@@ -654,9 +654,10 @@ function createDeviceCard(device) {
+ ${(device.device_type || 'wled').toUpperCase()} ${ledCount ? `💡 ${ledCount}` : ''} - ${state.wled_led_type ? `🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}` : ''} - ${state.wled_rgbw ? '' : ''} + ${state.device_led_type ? `🔌 ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} + ${state.device_rgbw ? '' : ''}
0; + const hasSkip = parseInt(skipStartEl.value || 0) > 0 || parseInt(skipEndEl.value || 0) > 0; + skipStartEl.disabled = hasOffset; + skipEndEl.disabled = hasOffset; + offsetEl.disabled = hasSkip; +} + function updateCalibrationPreview() { // Calculate total from edge inputs const total = parseInt(document.getElementById('cal-top-leds').value || 0) + @@ -1774,6 +1793,7 @@ async function saveCalibration() { span_left_end: spans.left?.end ?? 1, skip_leds_start: parseInt(document.getElementById('cal-skip-start').value || 0), skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0), + border_width: parseInt(document.getElementById('cal-border-width').value) || 10, }; try { @@ -1935,11 +1955,13 @@ const calibrationTutorialSteps = [ { selector: '#cal-top-leds', textKey: 'calibration.tip.led_count', position: 'bottom' }, { selector: '.corner-bottom-left', textKey: 'calibration.tip.start_corner', position: 'right' }, { selector: '.direction-toggle', textKey: 'calibration.tip.direction', position: 'bottom' }, - { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' }, { selector: '.edge-span-bar[data-edge="top"]', textKey: 'calibration.tip.span', position: 'bottom' }, { selector: '.toggle-top', textKey: 'calibration.tip.test', position: 'top' }, { selector: '.preview-screen-total', textKey: 'calibration.tip.toggle_inputs', position: 'top' }, - { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds', position: 'top' } + { selector: '.preview-screen-border-width', textKey: 'calibration.tip.border_width', position: 'bottom' }, + { selector: '#cal-offset', textKey: 'calibration.tip.offset', position: 'top' }, + { selector: '#cal-skip-start', textKey: 'calibration.tip.skip_leds_start', position: 'top' }, + { selector: '#cal-skip-end', textKey: 'calibration.tip.skip_leds_end', position: 'top' } ]; const deviceTutorialSteps = [ @@ -3820,13 +3842,14 @@ async function showTargetEditor(targetId = null) { const opt = document.createElement('option'); opt.value = d.id; const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : ''; - opt.textContent = `${d.name}${shortUrl ? ' (' + shortUrl + ')' : ''}`; + const devType = (d.device_type || 'wled').toUpperCase(); + opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`; deviceSelect.appendChild(opt); }); // Populate source select const sourceSelect = document.getElementById('target-editor-source'); - sourceSelect.innerHTML = ''; + sourceSelect.innerHTML = ''; sources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; @@ -3847,7 +3870,6 @@ async function showTargetEditor(targetId = null) { sourceSelect.value = target.picture_source_id || ''; document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30; document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30; - document.getElementById('target-editor-border-width').value = target.settings?.border_width ?? 10; document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3; document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3; @@ -3862,7 +3884,6 @@ async function showTargetEditor(targetId = null) { sourceSelect.value = ''; document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps-value').textContent = '30'; - document.getElementById('target-editor-border-width').value = 10; document.getElementById('target-editor-interpolation').value = 'average'; document.getElementById('target-editor-smoothing').value = 0.3; document.getElementById('target-editor-smoothing-value').textContent = '0.3'; @@ -3876,7 +3897,6 @@ async function showTargetEditor(targetId = null) { device: deviceSelect.value, source: sourceSelect.value, fps: document.getElementById('target-editor-fps').value, - border_width: document.getElementById('target-editor-border-width').value, interpolation: document.getElementById('target-editor-interpolation').value, smoothing: document.getElementById('target-editor-smoothing').value, standby_interval: document.getElementById('target-editor-standby-interval').value, @@ -3901,7 +3921,6 @@ function isTargetEditorDirty() { document.getElementById('target-editor-device').value !== targetEditorInitialValues.device || document.getElementById('target-editor-source').value !== targetEditorInitialValues.source || document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps || - document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width || document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation || document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing || document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval @@ -3929,7 +3948,6 @@ async function saveTargetEditor() { const deviceId = document.getElementById('target-editor-device').value; const sourceId = document.getElementById('target-editor-source').value; const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; - const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10; const interpolation = document.getElementById('target-editor-interpolation').value; const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value); const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); @@ -3947,7 +3965,6 @@ async function saveTargetEditor() { picture_source_id: sourceId, settings: { fps: fps, - border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing, standby_interval: standbyInterval, @@ -3963,7 +3980,7 @@ async function saveTargetEditor() { body: JSON.stringify(payload), }); } else { - payload.target_type = 'wled'; + payload.target_type = 'led'; response = await fetch(`${API_BASE}/picture-targets`, { method: 'POST', headers: getHeaders(), @@ -4083,14 +4100,16 @@ async function loadTargetsTab() { devicesWithState.forEach(d => { deviceMap[d.id] = d; }); // Group by type - const wledDevices = devicesWithState; - const wledTargets = targetsWithState.filter(t => t.target_type === 'wled'); + const ledDevices = devicesWithState; + const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled'); const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors'); - const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled'; + // Backward compat: map stored "wled" sub-tab to "led" + let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led'; + if (activeSubTab === 'wled') activeSubTab = 'led'; const subTabs = [ - { key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length }, + { key: 'led', icon: '💡', titleKey: 'targets.subtab.led', count: ledDevices.length + ledTargets.length }, { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length }, ]; @@ -4098,13 +4117,13 @@ async function loadTargetsTab() { `` ).join('')}
`; - // WLED panel: devices section + targets section - const wledPanel = ` -
+ // LED panel: devices section + targets section + const ledPanel = ` +

${t('targets.section.devices')}

- ${wledDevices.map(device => createDeviceCard(device)).join('')} + ${ledDevices.map(device => createDeviceCard(device)).join('')}
+
@@ -4113,7 +4132,7 @@ async function loadTargetsTab() {

${t('targets.section.targets')}

- ${wledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')} + ${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).join('')}
+
@@ -4144,9 +4163,9 @@ async function loadTargetsTab() {
`; - container.innerHTML = tabBar + wledPanel + kcPanel; + container.innerHTML = tabBar + ledPanel + kcPanel; - // Attach event listeners and fetch WLED brightness for device cards + // Attach event listeners and fetch brightness for device cards devicesWithState.forEach(device => { attachDeviceListeners(device.id); fetchDeviceBrightness(device.id); @@ -4186,12 +4205,12 @@ function createTargetCard(target, deviceMap, sourceMap) { const sourceName = source ? source.name : (target.picture_source_id || 'No source'); // Health info from target state (forwarded from device) - const wledOnline = state.wled_online || false; + const devOnline = state.device_online || false; let healthClass = 'health-unknown'; let healthTitle = ''; - if (state.wled_last_checked !== null && state.wled_last_checked !== undefined) { - healthClass = wledOnline ? 'health-online' : 'health-offline'; - healthTitle = wledOnline ? t('device.health.online') : t('device.health.offline'); + if (state.device_last_checked !== null && state.device_last_checked !== undefined) { + healthClass = devOnline ? 'health-online' : 'health-offline'; + healthTitle = devOnline ? t('device.health.online') : t('device.health.offline'); } return ` diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 00984e8..dbb6e5c 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -95,6 +95,10 @@ CW
0 / 0
+
+ + +
@@ -171,7 +175,7 @@
- +
@@ -179,7 +183,7 @@
- +
@@ -187,7 +191,7 @@
- +
@@ -235,10 +239,10 @@
- +
- +
@@ -247,7 +251,7 @@ - + @@ -279,10 +283,10 @@
- +
- +
@@ -307,15 +311,6 @@ -
-
- - -
- - -
-
@@ -349,7 +344,7 @@
- +
@@ -516,7 +511,7 @@