Add static color for simple devices, change auto-shutdown to auto-restore
- Add `static_color` capability to Adalight provider with `set_color()` method
- Add `static_color` field to Device model, DeviceState, and API schemas
- Add GET/PUT `/devices/{id}/color` API endpoints
- Change auto-shutdown behavior: restore device to idle state instead of
powering off (WLED uses snapshot/restore, Adalight sends static color
or black frame)
- Rename `_auto_shutdown_device_if_idle` to `_restore_device_idle_state`
- Add inline color picker on device cards for devices with static_color
- Add auto_shutdown toggle to device settings modal
- Update labels from "Auto Shutdown" to "Auto Restore" (en + ru)
- Remove backward-compat KC aliases from ProcessorManager
- Align card action buttons to bottom with flex column layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user