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 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 19:18:39 +03:00
parent f4503d36b4
commit cc91ccd75a
9 changed files with 193 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
"""Device routes: CRUD, health state, brightness, calibration.""" """Device routes: CRUD, health state, brightness, power, calibration."""
import httpx import httpx
from fastapi import APIRouter, HTTPException, Depends 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}") 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 ===== # ===== 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

@@ -24,7 +24,8 @@ class AdalightDeviceProvider(LEDDeviceProvider):
def capabilities(self) -> set: def capabilities(self) -> set:
# No hardware brightness control, no standby required # No hardware brightness control, no standby required
# manual_led_count: user must specify LED count (can't auto-detect) # 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: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.adalight_client import AdalightClient from wled_controller.core.adalight_client import AdalightClient
@@ -92,3 +93,12 @@ class AdalightDeviceProvider(LEDDeviceProvider):
except Exception as e: except Exception as e:
logger.error(f"Serial port discovery failed: {e}") logger.error(f"Serial port discovery failed: {e}")
return [] 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")

View File

@@ -200,6 +200,14 @@ class LEDDeviceProvider(ABC):
"""Set device brightness (0-255). Override if capabilities include brightness_control.""" """Set device brightness (0-255). Override if capabilities include brightness_control."""
raise NotImplementedError 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 ===== # ===== PROVIDER REGISTRY =====

View File

@@ -382,6 +382,35 @@ class ProcessorManager:
logger.info(f"Updated calibration for device {device_id}") 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: def get_device_state(self, device_id: str) -> DeviceState:
"""Get device state (for health/calibration info).""" """Get device state (for health/calibration info)."""
if device_id not in self._devices: if device_id not in self._devices:

View File

@@ -30,7 +30,7 @@ class WLEDDeviceProvider(LEDDeviceProvider):
@property @property
def capabilities(self) -> set: def capabilities(self) -> set:
return {"brightness_control", "standby_required"} return {"brightness_control", "power_control", "standby_required"}
def create_client(self, url: str, **kwargs) -> LEDClient: def create_client(self, url: str, **kwargs) -> LEDClient:
from wled_controller.core.wled_client import WLEDClient from wled_controller.core.wled_client import WLEDClient
@@ -170,3 +170,19 @@ class WLEDDeviceProvider(LEDDeviceProvider):
json={"bri": brightness}, json={"bri": brightness},
) )
resp.raise_for_status() 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()

View File

@@ -650,7 +650,10 @@ function createDeviceCard(device) {
return ` return `
<div class="card" data-device-id="${device.id}"> <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>` : ''}
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button> <button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">&#x2715;</button>
</div>
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span> <span class="health-dot ${healthClass}" title="${healthTitle}"></span>
@@ -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) { function attachDeviceListeners(deviceId) {
// Add any specific event listeners here if needed // Add any specific event listeners here if needed
} }

View File

@@ -130,6 +130,9 @@
"device.button.calibrate": "Calibrate", "device.button.calibrate": "Calibrate",
"device.button.remove": "Remove", "device.button.remove": "Remove",
"device.button.webui": "Open Device Web UI", "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.connected": "Connected",
"device.status.disconnected": "Disconnected", "device.status.disconnected": "Disconnected",
"device.status.error": "Error", "device.status.error": "Error",

View File

@@ -130,6 +130,9 @@
"device.button.calibrate": "Калибровка", "device.button.calibrate": "Калибровка",
"device.button.remove": "Удалить", "device.button.remove": "Удалить",
"device.button.webui": "Открыть веб-интерфейс устройства", "device.button.webui": "Открыть веб-интерфейс устройства",
"device.button.power_toggle": "Вкл/Выкл",
"device.power.on_success": "Устройство включено",
"device.power.off_success": "Устройство выключено",
"device.status.connected": "Подключено", "device.status.connected": "Подключено",
"device.status.disconnected": "Отключено", "device.status.disconnected": "Отключено",
"device.status.error": "Ошибка", "device.status.error": "Ошибка",

View File

@@ -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 { .card-remove-btn {
position: absolute; position: absolute;
top: 10px; top: 10px;