From cc91ccd75a53ac2581cafb624217bd5d4875ccfc Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 17 Feb 2026 19:18:39 +0300 Subject: [PATCH] Add power toggle button to LED device cards WLED: native on/off via JSON API. Adalight: sends all-black frame to blank LEDs (uses existing client if target is running, otherwise opens temporary serial connection). Toggle button placed next to delete button in card top-right corner. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 59 ++++++++++++++++++- .../wled_controller/core/adalight_provider.py | 12 +++- server/src/wled_controller/core/led_client.py | 8 +++ .../wled_controller/core/processor_manager.py | 29 +++++++++ .../src/wled_controller/core/wled_provider.py | 18 +++++- server/src/wled_controller/static/app.js | 32 +++++++++- .../wled_controller/static/locales/en.json | 3 + .../wled_controller/static/locales/ru.json | 3 + server/src/wled_controller/static/style.css | 33 +++++++++++ 9 files changed, 193 insertions(+), 4 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index e5717ae..642cd25 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -1,4 +1,4 @@ -"""Device routes: CRUD, health state, brightness, calibration.""" +"""Device routes: CRUD, health state, brightness, power, calibration.""" import httpx from fastapi import APIRouter, HTTPException, Depends @@ -367,6 +367,63 @@ async def set_device_brightness( raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") +# ===== POWER ENDPOINTS ===== + +@router.get("/api/v1/devices/{device_id}/power", tags=["Settings"]) +async def get_device_power( + device_id: str, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), +): + """Get current power state from the device.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + if "power_control" not in get_device_capabilities(device.device_type): + raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices") + + try: + provider = get_provider(device.device_type) + on = await provider.get_power(device.url) + return {"on": on} + except Exception as e: + logger.error(f"Failed to get power for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") + + +@router.put("/api/v1/devices/{device_id}/power", tags=["Settings"]) +async def set_device_power( + device_id: str, + body: dict, + _auth: AuthRequired, + store: DeviceStore = Depends(get_device_store), + manager = Depends(get_processor_manager), +): + """Turn device on or off.""" + device = store.get_device(device_id) + if not device: + raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + if "power_control" not in get_device_capabilities(device.device_type): + raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices") + + on = body.get("on") + if on is None or not isinstance(on, bool): + raise HTTPException(status_code=400, detail="'on' must be a boolean") + + try: + if device.device_type == "adalight": + if not on: + await manager.send_black_frame(device_id) + # "on" is a no-op for Adalight — next processing frame lights them up + else: + provider = get_provider(device.device_type) + await provider.set_power(device.url, on) + return {"on": on} + except Exception as e: + logger.error(f"Failed to set power for {device_id}: {e}") + raise HTTPException(status_code=502, detail=f"Failed to reach device: {e}") + + # ===== CALIBRATION ENDPOINTS ===== @router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"]) diff --git a/server/src/wled_controller/core/adalight_provider.py b/server/src/wled_controller/core/adalight_provider.py index ad01687..f8246b9 100644 --- a/server/src/wled_controller/core/adalight_provider.py +++ b/server/src/wled_controller/core/adalight_provider.py @@ -24,7 +24,8 @@ class AdalightDeviceProvider(LEDDeviceProvider): def capabilities(self) -> set: # No hardware brightness control, no standby required # manual_led_count: user must specify LED count (can't auto-detect) - return {"manual_led_count"} + # power_control: can blank LEDs by sending all-black pixels + return {"manual_led_count", "power_control"} def create_client(self, url: str, **kwargs) -> LEDClient: from wled_controller.core.adalight_client import AdalightClient @@ -92,3 +93,12 @@ class AdalightDeviceProvider(LEDDeviceProvider): except Exception as e: logger.error(f"Serial port discovery failed: {e}") return [] + + async def get_power(self, url: str) -> bool: + # Adalight has no hardware power query; assume on + return True + + async def set_power(self, url: str, on: bool) -> None: + # Adalight power control is handled at the API layer via processor manager + # because it needs access to the active serial client or device info. + raise NotImplementedError("Use API-level set_power for Adalight") diff --git a/server/src/wled_controller/core/led_client.py b/server/src/wled_controller/core/led_client.py index 55a11ca..154f3ef 100644 --- a/server/src/wled_controller/core/led_client.py +++ b/server/src/wled_controller/core/led_client.py @@ -200,6 +200,14 @@ class LEDDeviceProvider(ABC): """Set device brightness (0-255). Override if capabilities include brightness_control.""" raise NotImplementedError + async def get_power(self, url: str) -> bool: + """Get device power state. Override if capabilities include power_control.""" + raise NotImplementedError + + async def set_power(self, url: str, on: bool) -> None: + """Set device power state. Override if capabilities include power_control.""" + raise NotImplementedError + # ===== PROVIDER REGISTRY ===== diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index cd33ea7..7ebe11e 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -382,6 +382,35 @@ class ProcessorManager: logger.info(f"Updated calibration for device {device_id}") + async def send_black_frame(self, device_id: str) -> None: + """Send an all-black frame to an Adalight device to blank its LEDs. + + Uses the existing client from a running target if available, + otherwise opens a temporary serial connection. + """ + if device_id not in self._devices: + raise ValueError(f"Device {device_id} not found") + + ds = self._devices[device_id] + black = np.zeros((ds.led_count, 3), dtype=np.uint8) + + # Try to use existing client from a running target + for ts in self._targets.values(): + if ts.device_id == device_id and ts.is_running and ts.led_client: + await ts.led_client.send_pixels(black, brightness=255) + return + + # No running target — open a temporary connection + client = create_led_client( + ds.device_type, ds.device_url, + led_count=ds.led_count, baud_rate=ds.baud_rate, + ) + try: + await client.connect() + await client.send_pixels(black, brightness=255) + finally: + await client.close() + def get_device_state(self, device_id: str) -> DeviceState: """Get device state (for health/calibration info).""" if device_id not in self._devices: diff --git a/server/src/wled_controller/core/wled_provider.py b/server/src/wled_controller/core/wled_provider.py index 6ca0d27..9b42338 100644 --- a/server/src/wled_controller/core/wled_provider.py +++ b/server/src/wled_controller/core/wled_provider.py @@ -30,7 +30,7 @@ class WLEDDeviceProvider(LEDDeviceProvider): @property def capabilities(self) -> set: - return {"brightness_control", "standby_required"} + return {"brightness_control", "power_control", "standby_required"} def create_client(self, url: str, **kwargs) -> LEDClient: from wled_controller.core.wled_client import WLEDClient @@ -170,3 +170,19 @@ class WLEDDeviceProvider(LEDDeviceProvider): json={"bri": brightness}, ) resp.raise_for_status() + + async def get_power(self, url: str) -> bool: + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.get(f"{url}/json/state") + resp.raise_for_status() + return resp.json().get("on", False) + + async def set_power(self, url: str, on: bool) -> None: + url = url.rstrip("/") + async with httpx.AsyncClient(timeout=5.0) as http_client: + resp = await http_client.post( + f"{url}/json/state", + json={"on": on}, + ) + resp.raise_for_status() diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 76c19cc..dcaa812 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -650,7 +650,10 @@ function createDeviceCard(device) { return `
- +
+ ${(device.capabilities || []).includes('power_control') ? `` : ''} + +
@@ -686,6 +689,33 @@ function createDeviceCard(device) { `; } +async function toggleDevicePower(deviceId) { + try { + // Get current power state + const getResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { headers: getHeaders() }); + if (getResp.status === 401) { handle401Error(); return; } + if (!getResp.ok) { showToast('Failed to get power state', 'error'); return; } + const current = await getResp.json(); + const newState = !current.on; + + // Toggle + const setResp = await fetch(`${API_BASE}/devices/${deviceId}/power`, { + method: 'PUT', + headers: { ...getHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ on: newState }) + }); + if (setResp.status === 401) { handle401Error(); return; } + if (setResp.ok) { + showToast(t(newState ? 'device.power.on_success' : 'device.power.off_success'), 'success'); + } else { + const error = await setResp.json(); + showToast(error.detail || 'Failed', 'error'); + } + } catch (error) { + showToast('Failed to toggle power', 'error'); + } +} + function attachDeviceListeners(deviceId) { // Add any specific event listeners here if needed } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index e0cf8c3..0c53bdc 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -130,6 +130,9 @@ "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.power.off_success": "Device turned off", "device.status.connected": "Connected", "device.status.disconnected": "Disconnected", "device.status.error": "Error", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index e78ed5e..fc45f7e 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -130,6 +130,9 @@ "device.button.calibrate": "Калибровка", "device.button.remove": "Удалить", "device.button.webui": "Открыть веб-интерфейс устройства", + "device.button.power_toggle": "Вкл/Выкл", + "device.power.on_success": "Устройство включено", + "device.power.off_success": "Устройство выключено", "device.status.connected": "Подключено", "device.status.disconnected": "Отключено", "device.status.error": "Ошибка", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 8319f67..a301ab4 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -263,6 +263,39 @@ section { } +.card-top-actions { + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 2px; +} + +.card-top-actions .card-remove-btn { + position: static; +} + +.card-power-btn { + background: none; + border: none; + color: #777; + font-size: 1rem; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: color 0.2s, background 0.2s; +} + +.card-power-btn:hover { + color: var(--primary-color); + background: rgba(76, 175, 80, 0.1); +} + .card-remove-btn { position: absolute; top: 10px;