diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 1e13d93..3e12183 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -25,6 +25,7 @@ from wled_controller.api.schemas.devices import ( DeviceUpdate, DiscoveredDeviceResponse, DiscoverDevicesResponse, + StaticColorUpdate, ) from wled_controller.core.capture.calibration import ( calibration_from_dict, @@ -51,6 +52,7 @@ def _device_to_response(device) -> DeviceResponse: enabled=device.enabled, baud_rate=device.baud_rate, auto_shutdown=device.auto_shutdown, + static_color=list(device.static_color) if device.static_color else None, capabilities=sorted(get_device_capabilities(device.device_type)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), created_at=device.created_at, @@ -446,6 +448,66 @@ async def set_device_power( raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") +# ===== STATIC COLOR ENDPOINTS ===== + + +@router.get("/api/v1/devices/{device_id}/color", tags=["Settings"]) +async def get_device_color( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Get the static idle color for a device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + if "static_color" not in get_device_capabilities(device.device_type): + raise HTTPException(status_code=400, detail="Static color is not supported for this device type") + return {"color": list(device.static_color) if device.static_color else None} + + +@router.put("/api/v1/devices/{device_id}/color", tags=["Settings"]) +async def set_device_color( + device_id: str, + body: StaticColorUpdate, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Set or clear the static idle color for a device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + if "static_color" not in get_device_capabilities(device.device_type): + raise HTTPException(status_code=400, detail="Static color is not supported for this device type") + + color = None + if body.color is not None: + if len(body.color) != 3 or not all(isinstance(c, int) and 0 <= c <= 255 for c in body.color): + raise HTTPException(status_code=400, detail="color must be [R, G, B] with values 0-255") + color = tuple(body.color) + + store.set_static_color(device_id, color) + + # Update runtime state + ds = manager._devices.get(device_id) + if ds: + ds.static_color = color + + # If device is idle, apply the color immediately + if color is not None and not manager.is_device_processing(device_id): + try: + provider = get_provider(device.device_type) + await provider.set_color( + device.url, color, + led_count=device.led_count, baud_rate=device.baud_rate, + ) + except Exception as e: + logger.warning(f"Failed to apply static color immediately: {e}") + + return {"color": list(color) if color else None} + + # ===== CALIBRATION ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index de2ae88..fe626ad 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -28,6 +28,15 @@ class DeviceUpdate(BaseModel): auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops") +class StaticColorUpdate(BaseModel): + """Request to set or clear the static idle color.""" + + color: Optional[List[int]] = Field( + None, + description="RGB color [R, G, B] with values 0-255, or null to clear", + ) + + class Calibration(BaseModel): """Calibration configuration for pixel-to-LED mapping.""" @@ -92,7 +101,8 @@ class DeviceResponse(BaseModel): led_count: int = Field(description="Total number of LEDs") enabled: bool = Field(description="Whether device is enabled") baud_rate: Optional[int] = Field(None, description="Serial baud rate") - auto_shutdown: bool = Field(default=False, description="Turn off device when server stops") + auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop") + static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") calibration: Optional[Calibration] = Field(None, description="Calibration configuration") created_at: datetime = Field(description="Creation timestamp") diff --git a/server/src/wled_controller/core/devices/adalight_provider.py b/server/src/wled_controller/core/devices/adalight_provider.py index 85e269f..9964515 100644 --- a/server/src/wled_controller/core/devices/adalight_provider.py +++ b/server/src/wled_controller/core/devices/adalight_provider.py @@ -1,6 +1,6 @@ """Adalight device provider — serial LED controller support.""" -from typing import List +from typing import List, Tuple import numpy as np @@ -27,7 +27,7 @@ class AdalightDeviceProvider(LEDDeviceProvider): # manual_led_count: user must specify LED count (can't auto-detect) # power_control: can blank LEDs by sending all-black pixels # brightness_control: software brightness (multiplies pixel values before sending) - return {"manual_led_count", "power_control", "brightness_control"} + return {"manual_led_count", "power_control", "brightness_control", "static_color"} def create_client(self, url: str, **kwargs) -> LEDClient: from wled_controller.core.devices.adalight_client import AdalightClient @@ -123,3 +123,24 @@ class AdalightDeviceProvider(LEDDeviceProvider): logger.info(f"Adalight power off: sent black frame to {url}") finally: await client.close() + + async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: + """Send a solid color frame to the Adalight device. + + Requires kwargs: led_count (int), baud_rate (int | None). + """ + led_count = kwargs.get("led_count", 0) + baud_rate = kwargs.get("baud_rate") + if led_count <= 0: + raise ValueError("led_count is required to send color frame to Adalight device") + + from wled_controller.core.devices.adalight_client import AdalightClient + + client = AdalightClient(url, led_count=led_count, baud_rate=baud_rate) + try: + await client.connect() + frame = np.full((led_count, 3), color, dtype=np.uint8) + await client.send_pixels(frame, brightness=255) + logger.info(f"Adalight set_color: sent solid {color} to {url}") + finally: + await client.close() diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index ecbe5c6..33695fa 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -210,6 +210,10 @@ class LEDDeviceProvider(ABC): """Set device power state. Override if capabilities include power_control.""" raise NotImplementedError + async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None: + """Set all LEDs to a solid color. Override if capabilities include static_color.""" + raise NotImplementedError + # ===== PROVIDER REGISTRY ===== diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 161ca39..5790d8f 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -48,8 +48,10 @@ class DeviceState: health_task: Optional[asyncio.Task] = None # Software brightness for devices without hardware brightness (e.g. Adalight) software_brightness: int = 255 - # Auto-shutdown: turn off device when server stops + # Auto-restore: restore device to idle state when targets stop auto_shutdown: bool = False + # Static idle color for devices without a rich editor (e.g. Adalight) + static_color: Optional[Tuple[int, int, int]] = None # Calibration test mode (works independently of target processing) test_mode_active: bool = False test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) @@ -151,6 +153,7 @@ class ProcessorManager: baud_rate: Optional[int] = None, software_brightness: int = 255, auto_shutdown: bool = False, + static_color: Optional[Tuple[int, int, int]] = None, ): """Register a device for health monitoring.""" if device_id in self._devices: @@ -168,6 +171,7 @@ class ProcessorManager: baud_rate=baud_rate, software_brightness=software_brightness, auto_shutdown=auto_shutdown, + static_color=static_color, ) self._devices[device_id] = state @@ -378,10 +382,19 @@ class ProcessorManager: await proc.start() async def stop_processing(self, target_id: str): - """Stop processing for a target (any type).""" + """Stop processing for a target (any type). + + For WLED targets, if the associated device has auto_shutdown enabled + and no other targets are still actively processing on it, the device + is restored to its idle state (static color or pre-streaming snapshot). + """ proc = self._get_processor(target_id) await proc.stop() + # Auto-shutdown device if applicable + if isinstance(proc, WledTargetProcessor): + await self._restore_device_idle_state(proc.device_id) + def get_target_state(self, target_id: str) -> dict: """Get current processing state for a target (any type). @@ -430,29 +443,6 @@ class ProcessorManager: return proc.target_id return None - # Backward-compat aliases for KC-specific operations - def update_kc_target_settings(self, target_id: str, settings) -> None: - self.update_target_settings(target_id, settings) - - def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None: - self.update_target_source(target_id, picture_source_id) - - async def start_kc_processing(self, target_id: str) -> None: - await self.start_processing(target_id) - - async def stop_kc_processing(self, target_id: str) -> None: - await self.stop_processing(target_id) - - def get_kc_target_state(self, target_id: str) -> dict: - return self.get_target_state(target_id) - - def get_kc_target_metrics(self, target_id: str) -> dict: - return self.get_target_metrics(target_id) - - def is_kc_target(self, target_id: str) -> bool: - """Check if a target ID belongs to a KC target.""" - return isinstance(self._processors.get(target_id), KCTargetProcessor) - # ===== OVERLAY VISUALIZATION (delegates to processor) ===== async def start_overlay(self, target_id: str, target_name: str = None) -> None: @@ -564,6 +554,45 @@ class ProcessorManager: return proc.device_id return None + async def _restore_device_idle_state(self, device_id: str) -> None: + """Restore a device to its idle state when all targets stop. + + - If a static color is configured, send it. + - For WLED: do nothing — stop() already restored the snapshot. + - For other devices without static color: power off (black frame). + """ + ds = self._devices.get(device_id) + if not ds or not ds.auto_shutdown: + return + + if self.is_device_processing(device_id): + return + + try: + provider = get_provider(ds.device_type) + + if ds.static_color is not None: + await provider.set_color( + ds.device_url, ds.static_color, + led_count=ds.led_count, baud_rate=ds.baud_rate, + ) + logger.info( + f"Auto-restore: sent static color {ds.static_color} " + f"to {ds.device_type} device {device_id}" + ) + elif ds.device_type != "wled": + # Non-WLED without static color: power off (send black frame) + await provider.set_power( + ds.device_url, False, + led_count=ds.led_count, baud_rate=ds.baud_rate, + ) + logger.info(f"Auto-restore: powered off {ds.device_type} device {device_id}") + else: + # WLED: stop() already called restore_device_state() via snapshot + logger.info(f"Auto-restore: WLED device {device_id} restored by snapshot") + except Exception as e: + logger.error(f"Auto-restore failed for device {device_id}: {e}") + # ===== LIFECYCLE ===== async def stop_all(self): @@ -578,19 +607,9 @@ class ProcessorManager: except Exception as e: logger.error(f"Error stopping target {target_id}: {e}") - # Auto-shutdown devices that have the flag enabled - for device_id, ds in self._devices.items(): - if not ds.auto_shutdown: - continue - try: - provider = get_provider(ds.device_type) - await provider.set_power( - ds.device_url, False, - led_count=ds.led_count, baud_rate=ds.baud_rate, - ) - logger.info(f"Auto-shutdown: powered off {ds.device_type} device {device_id}") - except Exception as e: - logger.error(f"Auto-shutdown failed for device {device_id}: {e}") + # Restore idle state for devices that have auto-restore enabled + for device_id in self._devices: + await self._restore_device_idle_state(device_id) # Safety net: release any remaining managed live streams self._live_stream_manager.release_all() diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index fde4722..f13d858 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -160,6 +160,7 @@ async def lifespan(app: FastAPI): baud_rate=device.baud_rate, software_brightness=device.software_brightness, auto_shutdown=device.auto_shutdown, + static_color=device.static_color, ) 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 dcaa812..940b67d 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -667,6 +667,7 @@ function createDeviceCard(device) { ${ledCount ? `` : ''} ${state.device_led_type ? `` : ''} + ${(device.capabilities || []).includes('static_color') ? `` : ''} ${(device.capabilities || []).includes('brightness_control') ? `