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:
2026-02-18 13:42:05 +03:00
parent fc779eef39
commit d6cf45c873
13 changed files with 349 additions and 48 deletions

View File

@@ -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"])

View File

@@ -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")

View File

@@ -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()

View File

@@ -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 =====

View File

@@ -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()

View File

@@ -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:

View File

@@ -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"' : ''}>&#x2715;</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

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": "Не удалось сохранить настройки",

View File

@@ -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;

View File

@@ -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:

View File

@@ -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