Remove idle color feature, simplify power to turn-off only, fix settings serial port bug

- Remove static/idle color from entire stack (storage, API, processing, UI, CSS, locales)
- Simplify device power button to turn-off only (send black frame, no toggle)
- Send black frame on serial port close (AdalightClient.close)
- Fix settings modal serial port dropdown showing WLED devices due to stale deviceType

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 04:04:28 +03:00
parent 1f6c913343
commit 8a0730d91b
12 changed files with 29 additions and 278 deletions

View File

@@ -22,7 +22,6 @@ from wled_controller.api.schemas.devices import (
DeviceUpdate,
DiscoveredDeviceResponse,
DiscoverDevicesResponse,
StaticColorUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
@@ -45,7 +44,6 @@ 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)),
created_at=device.created_at,
updated_at=device.updated_at,
@@ -399,13 +397,6 @@ async def set_device_brightness(
if ds:
ds.hardware_brightness = bri
# If device is idle with a static color, re-send it at the new brightness
if ds and ds.static_color is not None and not manager.is_device_processing(device_id):
try:
await manager.send_static_color(device_id, ds.static_color)
except Exception:
pass
return {"brightness": bri}
except Exception as e:
logger.error(f"Failed to set brightness for {device_id}: {e}")
@@ -465,11 +456,7 @@ async def set_device_power(
# For serial devices, use the cached idle client to avoid port conflicts
ds = manager._devices.get(device_id)
if device.device_type in ("adalight", "ambiled") and ds:
if on:
# Restore idle state (static color or stay dark)
if ds.static_color is not None:
await manager.send_static_color(device_id, ds.static_color)
else:
if not on:
await manager._send_clear_pixels(device_id)
ds.power_on = on
else:
@@ -484,61 +471,3 @@ 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 change immediately
if not manager.is_device_processing(device_id):
try:
if color is not None:
await manager.send_static_color(device_id, color)
else:
await manager.clear_device(device_id)
except Exception as e:
logger.warning(f"Failed to apply color change immediately: {e}")
return {"color": list(color) if color else None}

View File

@@ -28,15 +28,6 @@ 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."""
@@ -102,7 +93,6 @@ class DeviceResponse(BaseModel):
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="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")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -100,7 +100,14 @@ class AdalightClient(LEDClient):
raise RuntimeError(f"Failed to open serial port {self._port}: {e}")
async def close(self) -> None:
"""Close the serial port."""
"""Send black frame and close the serial port."""
if self._connected and self._serial and self._serial.is_open and self._led_count > 0:
try:
black = np.zeros((self._led_count, 3), dtype=np.uint8)
frame = self._build_frame(black, brightness=255)
await asyncio.to_thread(self._serial.write, frame)
except Exception as e:
logger.debug(f"Failed to send black frame on close: {e}")
self._connected = False
if self._serial and self._serial.is_open:
try:

View File

@@ -2,10 +2,10 @@
Subclasses only need to override ``device_type`` and ``create_client()``.
All common serial-device logic (COM port validation, discovery, health
checks, power control via black frames, static colour) lives here.
checks, power control via black frames) lives here.
"""
from typing import List, Tuple
from typing import List
import numpy as np
@@ -28,8 +28,7 @@ class SerialDeviceProvider(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)
# static_color: can send a solid colour frame
return {"manual_led_count", "power_control", "brightness_control", "static_color"}
return {"manual_led_count", "power_control", "brightness_control"}
async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth:
# Generic serial port health check — enumerate COM ports
@@ -116,31 +115,3 @@ class SerialDeviceProvider(LEDDeviceProvider):
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 device.
Accepts optional kwargs:
client: An already-connected LEDClient (e.g. cached idle client).
brightness (int): Software brightness 0-255 (default 255).
led_count (int), baud_rate (int | None).
"""
led_count = kwargs.get("led_count", 0)
if led_count <= 0:
raise ValueError(f"led_count is required to send color frame to {self.device_type} device")
brightness = kwargs.get("brightness", 255)
frame = np.full((led_count, 3), color, dtype=np.uint8)
existing_client = kwargs.get("client")
if existing_client:
await existing_client.send_pixels(frame, brightness=brightness)
else:
baud_rate = kwargs.get("baud_rate")
client = self.create_client(url, led_count=led_count, baud_rate=baud_rate)
try:
await client.connect()
await client.send_pixels(frame, brightness=brightness)
finally:
await client.close()
logger.info(f"{self.device_type} set_color: sent solid {color} to {url}")

View File

@@ -47,8 +47,6 @@ class DeviceState:
hardware_brightness: Optional[int] = None
# 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)
@@ -159,7 +157,6 @@ 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:
@@ -173,7 +170,6 @@ class ProcessorManager:
baud_rate=baud_rate,
software_brightness=software_brightness,
auto_shutdown=auto_shutdown,
static_color=static_color,
)
self._devices[device_id] = state
@@ -634,22 +630,6 @@ class ProcessorManager:
return proc.device_id
return None
async def send_static_color(self, device_id: str, color: Tuple[int, int, int]) -> None:
"""Send a solid color to a device via its provider."""
ds = self._devices.get(device_id)
if not ds:
raise ValueError(f"Device {device_id} not found")
try:
provider = get_provider(ds.device_type)
client = await self._get_idle_client(device_id)
await provider.set_color(
ds.device_url, color,
led_count=ds.led_count, baud_rate=ds.baud_rate, client=client,
brightness=ds.software_brightness,
)
except Exception as e:
logger.error(f"Failed to send static color for {device_id}: {e}")
async def clear_device(self, device_id: str) -> None:
"""Clear LED output on a device (send black / power off)."""
ds = self._devices.get(device_id)
@@ -663,9 +643,8 @@ class ProcessorManager:
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).
- For other devices: power off (send black frame).
"""
ds = self._devices.get(device_id)
if not ds or not ds.auto_shutdown:
@@ -675,14 +654,7 @@ class ProcessorManager:
return
try:
if ds.static_color is not None:
await self.send_static_color(device_id, ds.static_color)
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)
if ds.device_type != "wled":
await self._send_clear_pixels(device_id)
logger.info(f"Auto-restore: powered off {ds.device_type} device {device_id}")
else:

View File

@@ -213,7 +213,6 @@ 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:

View File

@@ -423,39 +423,6 @@ section {
}
/* 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 {
display: flex;
align-items: center;

View File

@@ -30,8 +30,7 @@ import {
import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
saveDeviceStaticColor, clearDeviceStaticColor,
toggleDevicePower, removeDevice, loadDevices,
turnOffDevice, removeDevice, loadDevices,
updateSettingsBaudFpsHint,
} from './features/devices.js';
import {
@@ -149,9 +148,7 @@ Object.assign(window, {
saveDeviceSettings,
updateBrightnessLabel,
saveCardBrightness,
saveDeviceStaticColor,
clearDeviceStaticColor,
toggleDevicePower,
turnOffDevice,
removeDevice,
loadDevices,
updateSettingsBaudFpsHint,

View File

@@ -69,7 +69,7 @@ export function createDeviceCard(device) {
return `
<div class="card" data-device-id="${device.id}">
<div class="card-top-actions">
${(device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="toggleDevicePower('${device.id}')" title="${t('device.button.power_toggle')}"></button>` : ''}
${(device.capabilities || []).includes('power_control') ? `<button class="card-top-btn card-power-btn" onclick="turnOffDevice('${device.id}')" title="${t('device.button.power_off')}"></button>` : ''}
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button>
</div>
<div class="card-header">
@@ -85,7 +85,6 @@ export function createDeviceCard(device) {
${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>` : ''}
<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>
${(device.capabilities || []).includes('brightness_control') ? `
<div class="brightness-control${_deviceBrightnessCache[device.id] == null ? ' brightness-loading' : ''}" data-brightness-wrap="${device.id}">
@@ -106,26 +105,21 @@ export function createDeviceCard(device) {
`;
}
export async function toggleDevicePower(deviceId) {
export async function turnOffDevice(deviceId) {
try {
const getResp = await fetchWithAuth(`/devices/${deviceId}/power`);
if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; }
const current = await getResp.json();
const newState = !current.on;
const setResp = await fetchWithAuth(`/devices/${deviceId}/power`, {
method: 'PUT',
body: JSON.stringify({ on: newState })
body: JSON.stringify({ on: false })
});
if (setResp.ok) {
showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success');
showToast(t('device.power.off_success'), 'success');
} else {
const error = await setResp.json();
showToast(error.detail || 'Failed', 'error');
}
} catch (error) {
if (error.isAuth) return;
showToast('Failed to toggle power', 'error');
showToast('Failed to turn off device', 'error');
}
}
@@ -162,6 +156,11 @@ export async function showSettings(deviceId) {
const device = await deviceResponse.json();
const isAdalight = isSerialDevice(device.device_type);
const caps = device.capabilities || [];
// Set modal state before populating fields (so async helpers read correct type)
settingsModal.deviceType = device.device_type;
settingsModal.capabilities = caps;
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
@@ -181,7 +180,6 @@ export async function showSettings(deviceId) {
serialGroup.style.display = 'none';
}
const caps = device.capabilities || [];
const ledCountGroup = document.getElementById('settings-led-count-group');
if (caps.includes('manual_led_count')) {
ledCountGroup.style.display = '';
@@ -205,9 +203,6 @@ export async function showSettings(deviceId) {
}
document.getElementById('settings-auto-shutdown').checked = !!device.auto_shutdown;
settingsModal.deviceType = device.device_type;
settingsModal.capabilities = caps;
settingsModal.snapshot();
settingsModal.open();
@@ -314,54 +309,6 @@ export async function fetchDeviceBrightness(deviceId) {
}
}
// Static color helpers
export function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
}
export 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;
}
export 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 })
});
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');
}
}
export 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 })
});
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);
}
}
// FPS hint helpers (shared with device-discovery)
export function _computeMaxFps(baudRate, ledCount, deviceType) {
if (!baudRate || !ledCount || ledCount < 1) return null;

View File

@@ -131,8 +131,7 @@
"device.button.calibrate": "Calibrate",
"device.button.remove": "Remove",
"device.button.webui": "Open Device Web UI",
"device.button.power_toggle": "Toggle Power",
"device.power.on_success": "Device turned on",
"device.button.power_off": "Turn Off",
"device.power.off_success": "Device turned off",
"device.status.connected": "Connected",
"device.status.disconnected": "Disconnected",
@@ -159,9 +158,6 @@
"device.health.online": "Online",
"device.health.offline": "Offline",
"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.tip.metadata": "Device info (LED count, type, color channels) is auto-detected from the device",
"device.tip.brightness": "Slide to adjust device brightness",

View File

@@ -131,8 +131,7 @@
"device.button.calibrate": "Калибровка",
"device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс устройства",
"device.button.power_toggle": "Вкл/Выкл",
"device.power.on_success": "Устройство включено",
"device.button.power_off": "Выключить",
"device.power.off_success": "Устройство выключено",
"device.status.connected": "Подключено",
"device.status.disconnected": "Отключено",
@@ -159,9 +158,6 @@
"device.health.online": "Онлайн",
"device.health.offline": "Недоступен",
"device.health.checking": "Проверка...",
"device.static_color": "Цвет ожидания",
"device.static_color.hint": "Цвет, когда устройство в режиме ожидания",
"device.static_color.clear": "Очистить цвет ожидания",
"device.tutorial.start": "Начать обучение",
"device.tip.metadata": "Информация об устройстве (кол-во LED, тип, цветовые каналы) определяется автоматически",
"device.tip.brightness": "Перетащите для регулировки яркости",

View File

@@ -4,7 +4,7 @@ import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional
from wled_controller.utils import get_logger
@@ -30,7 +30,6 @@ class Device:
baud_rate: Optional[int] = None,
software_brightness: int = 255,
auto_shutdown: bool = False,
static_color: Optional[Tuple[int, int, int]] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -43,7 +42,6 @@ class Device:
self.baud_rate = baud_rate
self.software_brightness = software_brightness
self.auto_shutdown = auto_shutdown
self.static_color = static_color
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
# Preserved from old JSON for migration — not written back
@@ -67,8 +65,6 @@ class Device:
d["software_brightness"] = self.software_brightness
if self.auto_shutdown:
d["auto_shutdown"] = True
if self.static_color is not None:
d["static_color"] = list(self.static_color)
return d
@classmethod
@@ -78,9 +74,6 @@ class Device:
Backward-compatible: reads legacy 'calibration' field and stores it
in _legacy_calibration for migration use only.
"""
static_color_raw = data.get("static_color")
static_color = tuple(static_color_raw) if static_color_raw else None
device = cls(
device_id=data["id"],
name=data["name"],
@@ -91,7 +84,6 @@ class Device:
baud_rate=data.get("baud_rate"),
software_brightness=data.get("software_brightness", 255),
auto_shutdown=data.get("auto_shutdown", False),
static_color=static_color,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
@@ -250,18 +242,6 @@ class DeviceStore:
logger.info(f"Updated device {device_id}")
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):
"""Delete device."""
if device_id not in self._devices: