From 8a0730d91bcd02317c9bb15ea3aa1222b641eb55 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 21 Feb 2026 04:04:28 +0300 Subject: [PATCH] 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 --- .../src/wled_controller/api/routes/devices.py | 73 +------------------ .../wled_controller/api/schemas/devices.py | 10 --- .../core/devices/adalight_client.py | 9 ++- .../core/devices/serial_provider.py | 35 +-------- .../core/processing/processor_manager.py | 32 +------- server/src/wled_controller/main.py | 1 - .../src/wled_controller/static/css/cards.css | 33 --------- server/src/wled_controller/static/js/app.js | 7 +- .../static/js/features/devices.js | 73 +++---------------- .../wled_controller/static/locales/en.json | 6 +- .../wled_controller/static/locales/ru.json | 6 +- .../wled_controller/storage/device_store.py | 22 +----- 12 files changed, 29 insertions(+), 278 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 88cc2ff..92ff800 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -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} - diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index dac9c92..dd2f9df 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -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") diff --git a/server/src/wled_controller/core/devices/adalight_client.py b/server/src/wled_controller/core/devices/adalight_client.py index b5d7646..d781679 100644 --- a/server/src/wled_controller/core/devices/adalight_client.py +++ b/server/src/wled_controller/core/devices/adalight_client.py @@ -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: diff --git a/server/src/wled_controller/core/devices/serial_provider.py b/server/src/wled_controller/core/devices/serial_provider.py index 23c581b..fd32930 100644 --- a/server/src/wled_controller/core/devices/serial_provider.py +++ b/server/src/wled_controller/core/devices/serial_provider.py @@ -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}") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 1820af4..9f86eac 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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: diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 2b9dbd0..1f3333d 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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: diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 48dcdc5..1941903 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -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; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 76a8db2..9977368 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index a97845a..755a4c5 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -69,7 +69,7 @@ export function createDeviceCard(device) { return `
- ${(device.capabilities || []).includes('power_control') ? `` : ''} + ${(device.capabilities || []).includes('power_control') ? `` : ''}
@@ -85,7 +85,6 @@ export function createDeviceCard(device) { ${ledCount ? `💡 ${ledCount}` : ''} ${state.device_led_type ? `🔌 ${state.device_led_type.replace(/ RGBW$/, '')}` : ''} ${state.device_rgbw ? '' : ''} - ${(device.capabilities || []).includes('static_color') ? `` : ''}
${(device.capabilities || []).includes('brightness_control') ? `
@@ -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; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 49b338a..4dabca8 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index e953312..333bdd5 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Перетащите для регулировки яркости", diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index 1d4edc3..2fca70b 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -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: