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:
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -650,7 +650,10 @@ 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>` : ''}
|
||||
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">✕</button>
|
||||
</div>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<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) {
|
||||
// Add any specific event listeners here if needed
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ошибка",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user