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
|
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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 =====
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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')}">✕</button>
|
<button class="card-remove-btn" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">✕</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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Ошибка",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user