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 `