Add CSPT entity, processed CSS source type, reverse filter, and UI improvements
- Add Color Strip Processing Template (CSPT) entity: reusable filter chains for 1D LED strip postprocessing (backend, storage, API, frontend CRUD) - Add "processed" color strip source type that wraps another CSS source and applies a CSPT filter chain (dataclass, stream, schema, modal, cards) - Add Reverse filter for strip LED order reversal - Add CSPT and processed CSS nodes/edges to visual graph editor - Add CSPT test preview WS endpoint with input source selection - Add device settings CSPT template selector (add + edit modals with hints) - Use icon grids for palette quantization preset selector in filter lists - Use EntitySelect for template references and test modal source selectors - Fix filters.css_filter_template.desc missing localization - Fix icon grid cell height inequality (grid-auto-rows: 1fr) - Rename "Processed" subtab to "Processing Templates" - Localize all new strings (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,13 +26,12 @@ PREVIEW_JPEG_QUALITY = 70
|
||||
|
||||
|
||||
def authenticate_ws_token(token: str) -> bool:
|
||||
"""Check a WebSocket query-param token against configured API keys."""
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
return True
|
||||
return False
|
||||
"""Check a WebSocket query-param token against configured API keys.
|
||||
|
||||
Delegates to the canonical implementation in auth module.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
return verify_ws_token(token)
|
||||
|
||||
|
||||
def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Audio device routes: enumerate available audio devices."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
@@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired):
|
||||
filter by the selected audio template's engine type.
|
||||
"""
|
||||
try:
|
||||
devices = AudioCaptureManager.enumerate_devices()
|
||||
by_engine = AudioCaptureManager.enumerate_devices_by_engine()
|
||||
devices, by_engine = await asyncio.to_thread(
|
||||
lambda: (
|
||||
AudioCaptureManager.enumerate_devices(),
|
||||
AudioCaptureManager.enumerate_devices_by_engine(),
|
||||
)
|
||||
)
|
||||
return {
|
||||
"devices": devices,
|
||||
"count": len(devices),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Audio source routes: CRUD for audio sources + real-time test WebSocket."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -21,7 +20,6 @@ from wled_controller.api.schemas.audio_sources import (
|
||||
AudioSourceResponse,
|
||||
AudioSourceUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.storage.audio_source import AudioSource
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
@@ -169,16 +167,8 @@ async def test_audio_source_ws(
|
||||
(ref-counted — shares with running targets), and streams AudioAnalysis
|
||||
snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from starlette.websockets import WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -17,7 +15,6 @@ from wled_controller.api.schemas.audio_templates import (
|
||||
AudioTemplateResponse,
|
||||
AudioTemplateUpdate,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||
from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.audio_source_store import AudioSourceStore
|
||||
@@ -189,16 +186,8 @@ async def test_audio_template_ws(
|
||||
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
|
||||
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
265
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
265
server/src/wled_controller/api/routes/color_strip_processing.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Color strip processing template routes."""
|
||||
|
||||
import asyncio
|
||||
import json as _json
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_color_strip_store,
|
||||
get_cspt_store,
|
||||
get_device_store,
|
||||
get_processor_manager,
|
||||
)
|
||||
from wled_controller.api.schemas.filters import FilterInstanceSchema
|
||||
from wled_controller.api.schemas.color_strip_processing import (
|
||||
ColorStripProcessingTemplateCreate,
|
||||
ColorStripProcessingTemplateListResponse,
|
||||
ColorStripProcessingTemplateResponse,
|
||||
ColorStripProcessingTemplateUpdate,
|
||||
)
|
||||
from wled_controller.core.filters import FilterInstance
|
||||
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage import DeviceStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
|
||||
"""Convert a ColorStripProcessingTemplate to its API response."""
|
||||
return ColorStripProcessingTemplateResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters],
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
description=t.description,
|
||||
tags=getattr(t, 'tags', []),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
|
||||
async def list_cspt(
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""List all color strip processing templates."""
|
||||
try:
|
||||
templates = store.get_all_templates()
|
||||
responses = [_cspt_to_response(t) for t in templates]
|
||||
return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list color strip processing templates: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
|
||||
async def create_cspt(
|
||||
data: ColorStripProcessingTemplateCreate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Create a new color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters]
|
||||
template = store.create_template(
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "created", template.id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def get_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Get color strip processing template by ID."""
|
||||
try:
|
||||
template = store.get_template(template_id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
|
||||
|
||||
|
||||
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
|
||||
async def update_cspt(
|
||||
template_id: str,
|
||||
data: ColorStripProcessingTemplateUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
):
|
||||
"""Update a color strip processing template."""
|
||||
try:
|
||||
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
|
||||
template = store.update_template(
|
||||
template_id=template_id,
|
||||
name=data.name,
|
||||
filters=filters,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
fire_entity_event("cspt", "updated", template_id)
|
||||
return _cspt_to_response(template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
|
||||
async def delete_cspt(
|
||||
template_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
|
||||
device_store: DeviceStore = Depends(get_device_store),
|
||||
css_store: ColorStripStore = Depends(get_color_strip_store),
|
||||
):
|
||||
"""Delete a color strip processing template."""
|
||||
try:
|
||||
refs = store.get_references(template_id, device_store=device_store, css_store=css_store)
|
||||
if refs:
|
||||
names = ", ".join(refs)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot delete: template is referenced by: {names}. "
|
||||
"Please reassign before deleting.",
|
||||
)
|
||||
store.delete_template(template_id)
|
||||
fire_entity_event("cspt", "deleted", template_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete color strip processing template: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ── Test / Preview WebSocket ──────────────────────────────────────────
|
||||
|
||||
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
|
||||
async def test_cspt_ws(
|
||||
websocket: WebSocket,
|
||||
template_id: str,
|
||||
token: str = Query(""),
|
||||
input_source_id: str = Query(""),
|
||||
led_count: int = Query(100),
|
||||
fps: int = Query(20),
|
||||
):
|
||||
"""WebSocket for real-time CSPT preview.
|
||||
|
||||
Takes an input CSS source, applies the CSPT filter chain, and streams
|
||||
the processed RGB frames. Auth via ``?token=<api_key>``.
|
||||
"""
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
from wled_controller.core.filters import FilterRegistry
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
# Validate template exists
|
||||
cspt_store = get_cspt_store()
|
||||
try:
|
||||
template = cspt_store.get_template(template_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
if not input_source_id:
|
||||
await websocket.close(code=4003, reason="input_source_id is required")
|
||||
return
|
||||
|
||||
# Validate input source exists
|
||||
css_store = get_color_strip_store()
|
||||
try:
|
||||
input_source = css_store.get_source(input_source_id)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
await websocket.close(code=4004, reason=str(e))
|
||||
return
|
||||
|
||||
# Resolve filter chain
|
||||
try:
|
||||
resolved = cspt_store.resolve_filter_instances(template.filters)
|
||||
filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved]
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Acquire input stream
|
||||
manager: ProcessorManager = get_processor_manager()
|
||||
csm = manager.color_strip_stream_manager
|
||||
consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__"
|
||||
try:
|
||||
stream = csm.acquire(input_source_id, consumer_id)
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}")
|
||||
await websocket.close(code=4003, reason=str(e))
|
||||
return
|
||||
|
||||
# Configure LED count for auto-sizing streams
|
||||
if hasattr(stream, "configure"):
|
||||
stream.configure(max(1, led_count))
|
||||
|
||||
fps = max(1, min(60, fps))
|
||||
frame_interval = 1.0 / fps
|
||||
|
||||
await websocket.accept()
|
||||
logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}")
|
||||
|
||||
try:
|
||||
# Send metadata
|
||||
meta = {
|
||||
"type": "meta",
|
||||
"source_type": input_source.source_type,
|
||||
"source_name": input_source.name,
|
||||
"template_name": template.name,
|
||||
"led_count": stream.led_count,
|
||||
"filter_count": len(filters),
|
||||
}
|
||||
await websocket.send_text(_json.dumps(meta))
|
||||
|
||||
# Stream processed frames
|
||||
while True:
|
||||
colors = stream.get_latest_colors()
|
||||
if colors is not None:
|
||||
# Apply CSPT filters
|
||||
for flt in filters:
|
||||
try:
|
||||
result = flt.process_strip(colors)
|
||||
if result is not None:
|
||||
colors = result
|
||||
except Exception:
|
||||
pass
|
||||
await websocket.send_bytes(colors.tobytes())
|
||||
await asyncio.sleep(frame_interval)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"CSPT test WS error: {e}")
|
||||
finally:
|
||||
csm.release(input_source_id, consumer_id)
|
||||
logger.info(f"CSPT test WS disconnected: template={template_id}")
|
||||
@@ -36,7 +36,7 @@ from wled_controller.core.capture.calibration import (
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource
|
||||
from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource, ProcessedColorStripSource
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||
@@ -238,6 +238,14 @@ async def delete_color_strip_source(
|
||||
detail=f"Color strip source is used as a zone in mapped source(s): {names}. "
|
||||
"Remove it from the mapped source(s) first.",
|
||||
)
|
||||
processed_names = store.get_processed_referencing(source_id)
|
||||
if processed_names:
|
||||
names = ", ".join(processed_names)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Color strip source is used as input in processed source(s): {names}. "
|
||||
"Delete or reassign the processed source(s) first.",
|
||||
)
|
||||
store.delete_source(source_id)
|
||||
fire_entity_event("color_strip_source", "deleted", source_id)
|
||||
except HTTPException:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Device routes: CRUD, health state, brightness, power, calibration, WS stream."""
|
||||
|
||||
import secrets
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -157,8 +155,7 @@ async def create_device(
|
||||
|
||||
# WS devices: auto-set URL to ws://{device_id}
|
||||
if device_type == "ws":
|
||||
store.update_device(device_id=device.id, url=f"ws://{device.id}")
|
||||
device = store.get_device(device.id)
|
||||
device = store.update_device(device.id, url=f"ws://{device.id}")
|
||||
|
||||
# Register in processor manager for health monitoring
|
||||
manager.add_device(
|
||||
@@ -309,9 +306,10 @@ async def get_device(
|
||||
store: DeviceStore = Depends(get_device_store),
|
||||
):
|
||||
"""Get device details by ID."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
return _device_to_response(device)
|
||||
|
||||
|
||||
@@ -430,9 +428,10 @@ async def get_device_state(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Get device health/connection state."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = manager.get_device_health_dict(device_id)
|
||||
@@ -450,9 +449,10 @@ async def ping_device(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Force an immediate health check on a device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
try:
|
||||
state = await manager.force_device_health_check(device_id)
|
||||
@@ -477,9 +477,10 @@ async def get_device_brightness(
|
||||
frontend request — hitting the ESP32 over WiFi in the async event loop
|
||||
causes ~150 ms jitter in the processing loop.
|
||||
"""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -512,9 +513,10 @@ async def set_device_brightness(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""Set brightness on the device."""
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
if "brightness_control" not in get_device_capabilities(device.device_type):
|
||||
raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices")
|
||||
|
||||
@@ -528,10 +530,7 @@ async def set_device_brightness(
|
||||
await provider.set_brightness(device.url, bri)
|
||||
except NotImplementedError:
|
||||
# Provider has no hardware brightness; use software brightness
|
||||
device.software_brightness = bri
|
||||
from datetime import datetime, timezone
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
store.save()
|
||||
store.update_device(device_id=device_id, software_brightness=bri)
|
||||
ds = manager.find_device_state(device_id)
|
||||
if ds:
|
||||
ds.software_brightness = bri
|
||||
@@ -557,9 +556,10 @@ async def get_device_power(
|
||||
manager: ProcessorManager = Depends(get_processor_manager),
|
||||
):
|
||||
"""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")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
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")
|
||||
|
||||
@@ -586,9 +586,10 @@ async def set_device_power(
|
||||
manager: ProcessorManager = 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")
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
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")
|
||||
|
||||
@@ -628,23 +629,15 @@ async def device_ws_stream(
|
||||
Wire format: [brightness_byte][R G B R G B ...]
|
||||
Auth via ?token=<api_key>.
|
||||
"""
|
||||
from wled_controller.config import get_config
|
||||
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
store = get_device_store()
|
||||
device = store.get_device(device_id)
|
||||
if not device:
|
||||
try:
|
||||
device = store.get_device(device_id)
|
||||
except ValueError:
|
||||
await websocket.close(code=4004, reason="Device not found")
|
||||
return
|
||||
if device.device_type != "ws":
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
@@ -35,7 +34,6 @@ from wled_controller.api.schemas.output_targets import (
|
||||
TargetMetricsResponse,
|
||||
TargetProcessingState,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
from wled_controller.core.filters import FilterRegistry, ImagePool
|
||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
||||
@@ -151,8 +149,9 @@ async def create_target(
|
||||
try:
|
||||
# Validate device exists if provided
|
||||
if data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
|
||||
@@ -250,8 +249,9 @@ async def update_target(
|
||||
try:
|
||||
# Validate device exists if changing
|
||||
if data.device_id is not None and data.device_id:
|
||||
device = device_store.get_device(data.device_id)
|
||||
if not device:
|
||||
try:
|
||||
device_store.get_device(data.device_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
|
||||
|
||||
# Build KC settings with partial-update support: only apply fields that were
|
||||
@@ -697,16 +697,8 @@ async def target_colors_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -737,15 +729,8 @@ async def led_preview_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
@@ -777,15 +762,8 @@ async def events_ws(
|
||||
token: str = Query(""),
|
||||
):
|
||||
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Picture source routes."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
@@ -97,23 +98,26 @@ async def validate_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
def _process_image(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
width, height = pil_image.size
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
return width, height, preview
|
||||
|
||||
# Create thumbnail preview (max 320px wide)
|
||||
thumb = pil_image.copy()
|
||||
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
thumb.save(buf, format="JPEG", quality=80)
|
||||
buf.seek(0)
|
||||
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
|
||||
|
||||
return ImageValidateResponse(
|
||||
valid=True, width=width, height=height, preview=preview
|
||||
@@ -140,18 +144,22 @@ async def get_full_image(
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
response = await client.get(source)
|
||||
response.raise_for_status()
|
||||
pil_image = Image.open(io.BytesIO(response.content))
|
||||
img_bytes = response.content
|
||||
else:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
pil_image = Image.open(path)
|
||||
img_bytes = path
|
||||
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
buf.seek(0)
|
||||
return Response(content=buf.getvalue(), media_type="image/jpeg")
|
||||
def _encode_full(src):
|
||||
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
pil_image.save(buf, format="JPEG", quality=90)
|
||||
return buf.getvalue()
|
||||
|
||||
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
|
||||
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -326,7 +334,7 @@ async def test_picture_source(
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
|
||||
pil_image = Image.open(path).convert("RGB")
|
||||
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
|
||||
|
||||
actual_duration = time.perf_counter() - start_time
|
||||
frame_count = 1
|
||||
@@ -393,48 +401,50 @@ async def test_picture_source(
|
||||
else:
|
||||
raise ValueError("Unexpected image format from engine")
|
||||
|
||||
# Create thumbnail
|
||||
thumbnail_width = 640
|
||||
aspect_ratio = pil_image.height / pil_image.width
|
||||
thumbnail_height = int(thumbnail_width * aspect_ratio)
|
||||
thumbnail = pil_image.copy()
|
||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Apply postprocessing filters if this is a processed stream
|
||||
# Create thumbnail + encode (CPU-bound — run in thread)
|
||||
pp_template_ids = chain["postprocessing_template_ids"]
|
||||
flat_filters = None
|
||||
if pp_template_ids:
|
||||
try:
|
||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||
if flat_filters:
|
||||
pool = ImagePool()
|
||||
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in flat_filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
|
||||
thumbnail = apply_filters(thumbnail)
|
||||
pil_image = apply_filters(pil_image)
|
||||
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
|
||||
except ValueError:
|
||||
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
|
||||
|
||||
# Encode thumbnail
|
||||
img_buffer = io.BytesIO()
|
||||
thumbnail.save(img_buffer, format='JPEG', quality=85)
|
||||
img_buffer.seek(0)
|
||||
thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
def _create_thumbnails_and_encode(pil_img, filters):
|
||||
thumbnail_w = 640
|
||||
aspect_ratio = pil_img.height / pil_img.width
|
||||
thumbnail_h = int(thumbnail_w * aspect_ratio)
|
||||
thumb = pil_img.copy()
|
||||
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
|
||||
|
||||
# Encode full-resolution image
|
||||
full_buffer = io.BytesIO()
|
||||
pil_image.save(full_buffer, format='JPEG', quality=90)
|
||||
full_buffer.seek(0)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
if filters:
|
||||
pool = ImagePool()
|
||||
def apply_filters(img):
|
||||
arr = np.array(img)
|
||||
for fi in filters:
|
||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
result = f.process_image(arr, pool)
|
||||
if result is not None:
|
||||
arr = result
|
||||
return Image.fromarray(arr)
|
||||
thumb = apply_filters(thumb)
|
||||
pil_img = apply_filters(pil_img)
|
||||
|
||||
img_buffer = io.BytesIO()
|
||||
thumb.save(img_buffer, format='JPEG', quality=85)
|
||||
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
full_buffer = io.BytesIO()
|
||||
pil_img.save(full_buffer, format='JPEG', quality=90)
|
||||
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
|
||||
|
||||
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
|
||||
_create_thumbnails_and_encode, pil_image, flat_filters
|
||||
)
|
||||
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
|
||||
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
|
||||
|
||||
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
|
||||
|
||||
@@ -59,19 +59,8 @@ logger = get_logger(__name__)
|
||||
# Prime psutil CPU counter (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
# Try to initialize NVIDIA GPU monitoring
|
||||
_nvml_available = False
|
||||
try:
|
||||
import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated)
|
||||
|
||||
_pynvml_mod.nvmlInit()
|
||||
_nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0)
|
||||
_nvml_available = True
|
||||
_nvml = _pynvml_mod
|
||||
logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}")
|
||||
except Exception:
|
||||
_nvml = None
|
||||
logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)")
|
||||
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
|
||||
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
|
||||
|
||||
|
||||
def _get_cpu_name() -> str | None:
|
||||
@@ -156,17 +145,9 @@ async def list_all_tags(_: AuthRequired):
|
||||
store = getter()
|
||||
except RuntimeError:
|
||||
continue
|
||||
# Each store has a different "get all" method name
|
||||
items = None
|
||||
for method_name in (
|
||||
"get_all_devices", "get_all_targets", "get_all_sources",
|
||||
"get_all_streams", "get_all_clocks", "get_all_automations",
|
||||
"get_all_presets", "get_all_templates",
|
||||
):
|
||||
fn = getattr(store, method_name, None)
|
||||
if fn is not None:
|
||||
items = fn()
|
||||
break
|
||||
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
|
||||
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
|
||||
items = fn() if fn else None
|
||||
if items:
|
||||
for item in items:
|
||||
all_tags.update(getattr(item, 'tags', []))
|
||||
@@ -191,9 +172,9 @@ async def get_displays(
|
||||
from wled_controller.core.capture_engines import EngineRegistry
|
||||
|
||||
engine_cls = EngineRegistry.get_engine(engine_type)
|
||||
display_dataclasses = engine_cls.get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
|
||||
else:
|
||||
display_dataclasses = get_available_displays()
|
||||
display_dataclasses = await asyncio.to_thread(get_available_displays)
|
||||
|
||||
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
|
||||
displays = [
|
||||
@@ -321,6 +302,7 @@ STORE_MAP = {
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"color_strip_processing_templates": "color_strip_processing_templates_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
@@ -579,11 +561,13 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Connecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "connect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "connect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
output = (result.stdout + result.stderr).strip()
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
output = (stdout.decode() + stderr.decode()).strip()
|
||||
if "connected" in output.lower():
|
||||
return {"status": "connected", "address": address, "message": output}
|
||||
raise HTTPException(status_code=400, detail=output or "Connection failed")
|
||||
@@ -592,7 +576,7 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
|
||||
status_code=500,
|
||||
detail="adb not found on PATH. Install Android SDK Platform-Tools.",
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB connect timed out")
|
||||
|
||||
|
||||
@@ -606,12 +590,14 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
|
||||
adb = _get_adb_path()
|
||||
logger.info(f"Disconnecting ADB device: {address}")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[adb, "disconnect", address],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
adb, "disconnect", address,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
return {"status": "disconnected", "message": result.stdout.strip()}
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
|
||||
return {"status": "disconnected", "message": stdout.decode().strip()}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="adb not found on PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail="ADB disconnect timed out")
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_cspt_store,
|
||||
get_picture_source_store,
|
||||
get_pp_template_store,
|
||||
get_template_store,
|
||||
@@ -479,3 +480,48 @@ async def list_filter_types(
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
|
||||
@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"])
|
||||
async def list_strip_filter_types(
|
||||
_auth: AuthRequired,
|
||||
cspt_store=Depends(get_cspt_store),
|
||||
):
|
||||
"""List filter types that support 1D LED strip processing."""
|
||||
all_filters = FilterRegistry.get_all()
|
||||
|
||||
# Pre-build template choices for the css_filter_template filter
|
||||
cspt_choices = None
|
||||
if cspt_store:
|
||||
try:
|
||||
templates = cspt_store.get_all_templates()
|
||||
cspt_choices = [{"value": t.id, "label": t.name} for t in templates]
|
||||
except Exception:
|
||||
cspt_choices = []
|
||||
|
||||
responses = []
|
||||
for filter_id, filter_cls in all_filters.items():
|
||||
if not getattr(filter_cls, "supports_strip", True):
|
||||
continue
|
||||
schema = filter_cls.get_options_schema()
|
||||
opt_schemas = []
|
||||
for opt in schema:
|
||||
choices = opt.choices
|
||||
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
|
||||
choices = cspt_choices
|
||||
opt_schemas.append(FilterOptionDefSchema(
|
||||
key=opt.key,
|
||||
label=opt.label,
|
||||
type=opt.option_type,
|
||||
default=opt.default,
|
||||
min_value=opt.min_value,
|
||||
max_value=opt.max_value,
|
||||
step=opt.step,
|
||||
choices=choices,
|
||||
))
|
||||
responses.append(FilterTypeResponse(
|
||||
filter_id=filter_cls.filter_id,
|
||||
filter_name=filter_cls.filter_name,
|
||||
options_schema=opt_schemas,
|
||||
))
|
||||
return FilterTypeListResponse(filters=responses, count=len(responses))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Value source routes: CRUD for value sources."""
|
||||
|
||||
import asyncio
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||
@@ -13,7 +12,6 @@ from wled_controller.api.dependencies import (
|
||||
get_processor_manager,
|
||||
get_value_source_store,
|
||||
)
|
||||
from wled_controller.config import get_config
|
||||
from wled_controller.api.schemas.value_sources import (
|
||||
ValueSourceCreate,
|
||||
ValueSourceListResponse,
|
||||
@@ -202,16 +200,8 @@ async def test_value_source_ws(
|
||||
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
|
||||
and streams {value: float} JSON to the client.
|
||||
"""
|
||||
# Authenticate
|
||||
authenticated = False
|
||||
cfg = get_config()
|
||||
if token and cfg.auth.api_keys:
|
||||
for _label, api_key in cfg.auth.api_keys.items():
|
||||
if secrets.compare_digest(token, api_key):
|
||||
authenticated = True
|
||||
break
|
||||
|
||||
if not authenticated:
|
||||
from wled_controller.api.auth import verify_ws_token
|
||||
if not verify_ws_token(token):
|
||||
await websocket.close(code=4001, reason="Unauthorized")
|
||||
return
|
||||
|
||||
|
||||
Reference in New Issue
Block a user