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:
@@ -25,6 +25,7 @@ from wled_controller.api.schemas.devices import (
|
|||||||
DeviceUpdate,
|
DeviceUpdate,
|
||||||
DiscoveredDeviceResponse,
|
DiscoveredDeviceResponse,
|
||||||
DiscoverDevicesResponse,
|
DiscoverDevicesResponse,
|
||||||
|
StaticColorUpdate,
|
||||||
)
|
)
|
||||||
from wled_controller.core.capture.calibration import (
|
from wled_controller.core.capture.calibration import (
|
||||||
calibration_from_dict,
|
calibration_from_dict,
|
||||||
@@ -51,6 +52,7 @@ def _device_to_response(device) -> DeviceResponse:
|
|||||||
enabled=device.enabled,
|
enabled=device.enabled,
|
||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
auto_shutdown=device.auto_shutdown,
|
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)),
|
capabilities=sorted(get_device_capabilities(device.device_type)),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
created_at=device.created_at,
|
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}")
|
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 =====
|
# ===== CALIBRATION ENDPOINTS =====
|
||||||
|
|
||||||
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ class DeviceUpdate(BaseModel):
|
|||||||
auto_shutdown: Optional[bool] = Field(None, description="Turn off device when server stops")
|
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):
|
class Calibration(BaseModel):
|
||||||
"""Calibration configuration for pixel-to-LED mapping."""
|
"""Calibration configuration for pixel-to-LED mapping."""
|
||||||
|
|
||||||
@@ -92,7 +101,8 @@ class DeviceResponse(BaseModel):
|
|||||||
led_count: int = Field(description="Total number of LEDs")
|
led_count: int = Field(description="Total number of LEDs")
|
||||||
enabled: bool = Field(description="Whether device is enabled")
|
enabled: bool = Field(description="Whether device is enabled")
|
||||||
baud_rate: Optional[int] = Field(None, description="Serial baud rate")
|
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")
|
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
|
||||||
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Adalight device provider — serial LED controller support."""
|
"""Adalight device provider — serial LED controller support."""
|
||||||
|
|
||||||
from typing import List
|
from typing import List, Tuple
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ class AdalightDeviceProvider(LEDDeviceProvider):
|
|||||||
# manual_led_count: user must specify LED count (can't auto-detect)
|
# manual_led_count: user must specify LED count (can't auto-detect)
|
||||||
# power_control: can blank LEDs by sending all-black pixels
|
# power_control: can blank LEDs by sending all-black pixels
|
||||||
# brightness_control: software brightness (multiplies pixel values before sending)
|
# 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:
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
from wled_controller.core.devices.adalight_client import AdalightClient
|
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}")
|
logger.info(f"Adalight power off: sent black frame to {url}")
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
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."""
|
"""Set device power state. Override if capabilities include power_control."""
|
||||||
raise NotImplementedError
|
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 =====
|
# ===== PROVIDER REGISTRY =====
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ class DeviceState:
|
|||||||
health_task: Optional[asyncio.Task] = None
|
health_task: Optional[asyncio.Task] = None
|
||||||
# Software brightness for devices without hardware brightness (e.g. Adalight)
|
# Software brightness for devices without hardware brightness (e.g. Adalight)
|
||||||
software_brightness: int = 255
|
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
|
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)
|
# Calibration test mode (works independently of target processing)
|
||||||
test_mode_active: bool = False
|
test_mode_active: bool = False
|
||||||
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
|
||||||
@@ -151,6 +153,7 @@ class ProcessorManager:
|
|||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
software_brightness: int = 255,
|
software_brightness: int = 255,
|
||||||
auto_shutdown: bool = False,
|
auto_shutdown: bool = False,
|
||||||
|
static_color: Optional[Tuple[int, int, int]] = None,
|
||||||
):
|
):
|
||||||
"""Register a device for health monitoring."""
|
"""Register a device for health monitoring."""
|
||||||
if device_id in self._devices:
|
if device_id in self._devices:
|
||||||
@@ -168,6 +171,7 @@ class ProcessorManager:
|
|||||||
baud_rate=baud_rate,
|
baud_rate=baud_rate,
|
||||||
software_brightness=software_brightness,
|
software_brightness=software_brightness,
|
||||||
auto_shutdown=auto_shutdown,
|
auto_shutdown=auto_shutdown,
|
||||||
|
static_color=static_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._devices[device_id] = state
|
self._devices[device_id] = state
|
||||||
@@ -378,10 +382,19 @@ class ProcessorManager:
|
|||||||
await proc.start()
|
await proc.start()
|
||||||
|
|
||||||
async def stop_processing(self, target_id: str):
|
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)
|
proc = self._get_processor(target_id)
|
||||||
await proc.stop()
|
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:
|
def get_target_state(self, target_id: str) -> dict:
|
||||||
"""Get current processing state for a target (any type).
|
"""Get current processing state for a target (any type).
|
||||||
|
|
||||||
@@ -430,29 +443,6 @@ class ProcessorManager:
|
|||||||
return proc.target_id
|
return proc.target_id
|
||||||
return None
|
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) =====
|
# ===== OVERLAY VISUALIZATION (delegates to processor) =====
|
||||||
|
|
||||||
async def start_overlay(self, target_id: str, target_name: str = None) -> None:
|
async def start_overlay(self, target_id: str, target_name: str = None) -> None:
|
||||||
@@ -564,6 +554,45 @@ class ProcessorManager:
|
|||||||
return proc.device_id
|
return proc.device_id
|
||||||
return None
|
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 =====
|
# ===== LIFECYCLE =====
|
||||||
|
|
||||||
async def stop_all(self):
|
async def stop_all(self):
|
||||||
@@ -578,19 +607,9 @@ class ProcessorManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping target {target_id}: {e}")
|
logger.error(f"Error stopping target {target_id}: {e}")
|
||||||
|
|
||||||
# Auto-shutdown devices that have the flag enabled
|
# Restore idle state for devices that have auto-restore enabled
|
||||||
for device_id, ds in self._devices.items():
|
for device_id in self._devices:
|
||||||
if not ds.auto_shutdown:
|
await self._restore_device_idle_state(device_id)
|
||||||
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}")
|
|
||||||
|
|
||||||
# Safety net: release any remaining managed live streams
|
# Safety net: release any remaining managed live streams
|
||||||
self._live_stream_manager.release_all()
|
self._live_stream_manager.release_all()
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ async def lifespan(app: FastAPI):
|
|||||||
baud_rate=device.baud_rate,
|
baud_rate=device.baud_rate,
|
||||||
software_brightness=device.software_brightness,
|
software_brightness=device.software_brightness,
|
||||||
auto_shutdown=device.auto_shutdown,
|
auto_shutdown=device.auto_shutdown,
|
||||||
|
static_color=device.static_color,
|
||||||
)
|
)
|
||||||
logger.info(f"Registered device: {device.name} ({device.id})")
|
logger.info(f"Registered device: {device.name} ({device.id})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -667,6 +667,7 @@ function createDeviceCard(device) {
|
|||||||
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
${ledCount ? `<span class="card-meta" title="${t('device.led_count')}">💡 ${ledCount}</span>` : ''}
|
||||||
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
${state.device_led_type ? `<span class="card-meta">🔌 ${state.device_led_type.replace(/ RGBW$/, '')}</span>` : ''}
|
||||||
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
<span class="card-meta" title="${state.device_rgbw ? 'RGBW' : 'RGB'}"><span class="channel-indicator"><span class="ch" style="background:#e53935"></span><span class="ch" style="background:#43a047"></span><span class="ch" style="background:#1e88e5"></span>${state.device_rgbw ? '<span class="ch" style="background:#eee"></span>' : ''}</span></span>
|
||||||
|
${(device.capabilities || []).includes('static_color') ? `<span class="card-meta static-color-control" data-color-wrap="${device.id}"><input type="color" class="static-color-picker" value="${device.static_color ? rgbToHex(...device.static_color) : '#000000'}" data-device-color="${device.id}" onchange="saveDeviceStaticColor('${device.id}', this.value)" title="${t('device.static_color.hint')}"><button class="btn-clear-color" onclick="clearDeviceStaticColor('${device.id}')" title="${t('device.static_color.clear')}" ${!device.static_color ? 'style="display:none"' : ''}>✕</button></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${(device.capabilities || []).includes('brightness_control') ? `
|
${(device.capabilities || []).includes('brightness_control') ? `
|
||||||
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
|
||||||
@@ -814,6 +815,9 @@ async function showSettings(deviceId) {
|
|||||||
baudRateGroup.style.display = 'none';
|
baudRateGroup.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate auto shutdown toggle
|
||||||
|
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
|
||||||
|
|
||||||
// Snapshot initial values for dirty checking
|
// Snapshot initial values for dirty checking
|
||||||
settingsInitialValues = {
|
settingsInitialValues = {
|
||||||
name: device.name,
|
name: device.name,
|
||||||
@@ -823,6 +827,7 @@ async function showSettings(deviceId) {
|
|||||||
device_type: device.device_type,
|
device_type: device.device_type,
|
||||||
capabilities: caps,
|
capabilities: caps,
|
||||||
state_check_interval: '30',
|
state_check_interval: '30',
|
||||||
|
auto_shutdown: !!device.auto_shutdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
@@ -855,6 +860,7 @@ function isSettingsDirty() {
|
|||||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||||
_getSettingsUrl() !== settingsInitialValues.url ||
|
_getSettingsUrl() !== settingsInitialValues.url ||
|
||||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
|
||||||
|
document.getElementById('settings-auto-shutdown').checked !== settingsInitialValues.auto_shutdown ||
|
||||||
ledCountDirty
|
ledCountDirty
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -890,8 +896,8 @@ async function saveDeviceSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update device info (name, url, optionally led_count, baud_rate)
|
// Update device info (name, url, auto_shutdown, optionally led_count, baud_rate)
|
||||||
const body = { name, url };
|
const body = { name, url, auto_shutdown: document.getElementById('settings-auto-shutdown').checked };
|
||||||
const ledCountInput = document.getElementById('settings-led-count');
|
const ledCountInput = document.getElementById('settings-led-count');
|
||||||
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||||
body.led_count = parseInt(ledCountInput.value, 10);
|
body.led_count = parseInt(ledCountInput.value, 10);
|
||||||
@@ -973,6 +979,56 @@ async function fetchDeviceBrightness(deviceId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static color helpers
|
||||||
|
function rgbToHex(r, g, b) {
|
||||||
|
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||||
|
return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDeviceStaticColor(deviceId, hexValue) {
|
||||||
|
const rgb = hexToRgb(hexValue);
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ color: rgb })
|
||||||
|
});
|
||||||
|
// Show clear button
|
||||||
|
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
|
||||||
|
if (wrap) {
|
||||||
|
const clearBtn = wrap.querySelector('.btn-clear-color');
|
||||||
|
if (clearBtn) clearBtn.style.display = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set static color:', err);
|
||||||
|
showToast('Failed to set static color', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearDeviceStaticColor(deviceId) {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/devices/${deviceId}/color`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ color: null })
|
||||||
|
});
|
||||||
|
// Reset picker to black and hide clear button
|
||||||
|
const picker = document.querySelector(`[data-device-color="${deviceId}"]`);
|
||||||
|
if (picker) picker.value = '#000000';
|
||||||
|
const wrap = document.querySelector(`[data-color-wrap="${deviceId}"]`);
|
||||||
|
if (wrap) {
|
||||||
|
const clearBtn = wrap.querySelector('.btn-clear-color');
|
||||||
|
if (clearBtn) clearBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear static color:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add device modal
|
// Add device modal
|
||||||
let _discoveryScanRunning = false;
|
let _discoveryScanRunning = false;
|
||||||
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
|
let _discoveryCache = {}; // { deviceType: [...devices] } — per-type discovery cache
|
||||||
|
|||||||
@@ -297,6 +297,18 @@
|
|||||||
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group settings-toggle-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="settings.auto_shutdown">Auto Restore:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="settings.auto_shutdown.hint">Restore device to idle state when targets stop or server shuts down</small>
|
||||||
|
<label class="settings-toggle">
|
||||||
|
<input type="checkbox" id="settings-auto-shutdown">
|
||||||
|
<span class="settings-toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settings-error" class="error-message" style="display: none;"></div>
|
<div id="settings-error" class="error-message" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,6 +158,9 @@
|
|||||||
"device.health.online": "Online",
|
"device.health.online": "Online",
|
||||||
"device.health.offline": "Offline",
|
"device.health.offline": "Offline",
|
||||||
"device.health.checking": "Checking...",
|
"device.health.checking": "Checking...",
|
||||||
|
"device.static_color": "Idle Color",
|
||||||
|
"device.static_color.hint": "Color shown when device is idle",
|
||||||
|
"device.static_color.clear": "Clear idle color",
|
||||||
"device.tutorial.start": "Start tutorial",
|
"device.tutorial.start": "Start tutorial",
|
||||||
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
"device.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
|
||||||
"device.tip.brightness": "Slide to adjust device brightness",
|
"device.tip.brightness": "Slide to adjust device brightness",
|
||||||
@@ -184,6 +187,8 @@
|
|||||||
"settings.button.cancel": "Cancel",
|
"settings.button.cancel": "Cancel",
|
||||||
"settings.health_interval": "Health Check Interval (s):",
|
"settings.health_interval": "Health Check Interval (s):",
|
||||||
"settings.health_interval.hint": "How often to check the device status (5-600 seconds)",
|
"settings.health_interval.hint": "How often to check the device status (5-600 seconds)",
|
||||||
|
"settings.auto_shutdown": "Auto Restore:",
|
||||||
|
"settings.auto_shutdown.hint": "Restore device to idle state when targets stop or server shuts down",
|
||||||
"settings.button.save": "Save Changes",
|
"settings.button.save": "Save Changes",
|
||||||
"settings.saved": "Settings saved successfully",
|
"settings.saved": "Settings saved successfully",
|
||||||
"settings.failed": "Failed to save settings",
|
"settings.failed": "Failed to save settings",
|
||||||
|
|||||||
@@ -158,6 +158,9 @@
|
|||||||
"device.health.online": "Онлайн",
|
"device.health.online": "Онлайн",
|
||||||
"device.health.offline": "Недоступен",
|
"device.health.offline": "Недоступен",
|
||||||
"device.health.checking": "Проверка...",
|
"device.health.checking": "Проверка...",
|
||||||
|
"device.static_color": "Цвет ожидания",
|
||||||
|
"device.static_color.hint": "Цвет, когда устройство в режиме ожидания",
|
||||||
|
"device.static_color.clear": "Очистить цвет ожидания",
|
||||||
"device.tutorial.start": "Начать обучение",
|
"device.tutorial.start": "Начать обучение",
|
||||||
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
|
||||||
"device.tip.brightness": "Перетащите для регулировки яркости",
|
"device.tip.brightness": "Перетащите для регулировки яркости",
|
||||||
@@ -184,6 +187,8 @@
|
|||||||
"settings.button.cancel": "Отмена",
|
"settings.button.cancel": "Отмена",
|
||||||
"settings.health_interval": "Интервал Проверки (с):",
|
"settings.health_interval": "Интервал Проверки (с):",
|
||||||
"settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)",
|
"settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)",
|
||||||
|
"settings.auto_shutdown": "Авто-восстановление:",
|
||||||
|
"settings.auto_shutdown.hint": "Восстанавливать устройство в режим ожидания при остановке целей или сервера",
|
||||||
"settings.button.save": "Сохранить Изменения",
|
"settings.button.save": "Сохранить Изменения",
|
||||||
"settings.saved": "Настройки успешно сохранены",
|
"settings.saved": "Настройки успешно сохранены",
|
||||||
"settings.failed": "Не удалось сохранить настройки",
|
"settings.failed": "Не удалось сохранить настройки",
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ section {
|
|||||||
padding: 12px 20px 20px;
|
padding: 12px 20px 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
@@ -739,6 +741,40 @@ section {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Static color picker — inline in card-subtitle */
|
||||||
|
.static-color-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.static-color-picker {
|
||||||
|
width: 22px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-color {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-color:hover {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -779,6 +815,54 @@ ul.section-tip li {
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-toggle-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 9px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-slider::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle input:checked + .settings-toggle-slider {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle input:checked + .settings-toggle-slider::after {
|
||||||
|
transform: translateX(16px);
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from wled_controller.core.capture.calibration import (
|
from wled_controller.core.capture.calibration import (
|
||||||
CalibrationConfig,
|
CalibrationConfig,
|
||||||
@@ -35,6 +35,7 @@ class Device:
|
|||||||
baud_rate: Optional[int] = None,
|
baud_rate: Optional[int] = None,
|
||||||
software_brightness: int = 255,
|
software_brightness: int = 255,
|
||||||
auto_shutdown: bool = False,
|
auto_shutdown: bool = False,
|
||||||
|
static_color: Optional[Tuple[int, int, int]] = None,
|
||||||
calibration: Optional[CalibrationConfig] = None,
|
calibration: Optional[CalibrationConfig] = None,
|
||||||
created_at: Optional[datetime] = None,
|
created_at: Optional[datetime] = None,
|
||||||
updated_at: Optional[datetime] = None,
|
updated_at: Optional[datetime] = None,
|
||||||
@@ -48,6 +49,7 @@ class Device:
|
|||||||
self.baud_rate = baud_rate
|
self.baud_rate = baud_rate
|
||||||
self.software_brightness = software_brightness
|
self.software_brightness = software_brightness
|
||||||
self.auto_shutdown = auto_shutdown
|
self.auto_shutdown = auto_shutdown
|
||||||
|
self.static_color = static_color
|
||||||
self.calibration = calibration or create_default_calibration(led_count)
|
self.calibration = calibration or create_default_calibration(led_count)
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_at or datetime.utcnow()
|
self.updated_at = updated_at or datetime.utcnow()
|
||||||
@@ -71,6 +73,8 @@ class Device:
|
|||||||
d["software_brightness"] = self.software_brightness
|
d["software_brightness"] = self.software_brightness
|
||||||
if self.auto_shutdown:
|
if self.auto_shutdown:
|
||||||
d["auto_shutdown"] = True
|
d["auto_shutdown"] = True
|
||||||
|
if self.static_color is not None:
|
||||||
|
d["static_color"] = list(self.static_color)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -87,6 +91,9 @@ class Device:
|
|||||||
else create_default_calibration(data["led_count"])
|
else create_default_calibration(data["led_count"])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
static_color_raw = data.get("static_color")
|
||||||
|
static_color = tuple(static_color_raw) if static_color_raw else None
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
device_id=data["id"],
|
device_id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -97,6 +104,7 @@ class Device:
|
|||||||
baud_rate=data.get("baud_rate"),
|
baud_rate=data.get("baud_rate"),
|
||||||
software_brightness=data.get("software_brightness", 255),
|
software_brightness=data.get("software_brightness", 255),
|
||||||
auto_shutdown=data.get("auto_shutdown", False),
|
auto_shutdown=data.get("auto_shutdown", False),
|
||||||
|
static_color=static_color,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
@@ -256,6 +264,18 @@ class DeviceStore:
|
|||||||
logger.info(f"Updated device {device_id}")
|
logger.info(f"Updated device {device_id}")
|
||||||
return device
|
return device
|
||||||
|
|
||||||
|
def set_static_color(
|
||||||
|
self, device_id: str, color: Optional[Tuple[int, int, int]]
|
||||||
|
) -> "Device":
|
||||||
|
"""Set or clear the static idle color for a device."""
|
||||||
|
device = self._devices.get(device_id)
|
||||||
|
if not device:
|
||||||
|
raise ValueError(f"Device {device_id} not found")
|
||||||
|
device.static_color = color
|
||||||
|
device.updated_at = datetime.utcnow()
|
||||||
|
self.save()
|
||||||
|
return device
|
||||||
|
|
||||||
def delete_device(self, device_id: str):
|
def delete_device(self, device_id: str):
|
||||||
"""Delete device."""
|
"""Delete device."""
|
||||||
if device_id not in self._devices:
|
if device_id not in self._devices:
|
||||||
|
|||||||
@@ -251,9 +251,11 @@ def test_get_target_metrics(processor_manager):
|
|||||||
assert metrics["errors_count"] == 0
|
assert metrics["errors_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_is_kc_target(processor_manager):
|
def test_target_type_detection(processor_manager):
|
||||||
"""Test KC target type detection."""
|
"""Test target type detection via processor instances."""
|
||||||
from wled_controller.storage.key_colors_picture_target import KeyColorsSettings
|
from wled_controller.storage.key_colors_picture_target import KeyColorsSettings
|
||||||
|
from wled_controller.core.processing.kc_target_processor import KCTargetProcessor
|
||||||
|
from wled_controller.core.processing.wled_target_processor import WledTargetProcessor
|
||||||
|
|
||||||
processor_manager.add_device(
|
processor_manager.add_device(
|
||||||
device_id="test_device",
|
device_id="test_device",
|
||||||
@@ -272,8 +274,8 @@ def test_is_kc_target(processor_manager):
|
|||||||
settings=KeyColorsSettings(),
|
settings=KeyColorsSettings(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert processor_manager.is_kc_target("kc_target") is True
|
assert isinstance(processor_manager._processors["kc_target"], KCTargetProcessor)
|
||||||
assert processor_manager.is_kc_target("wled_target") is False
|
assert isinstance(processor_manager._processors["wled_target"], WledTargetProcessor)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user