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:
2026-03-15 02:16:59 +03:00
parent 7e78323c9c
commit 294d704eb0
72 changed files with 2992 additions and 1416 deletions

View File

@@ -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:

View File

@@ -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),

View File

@@ -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

View File

@@ -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

View 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}")

View File

@@ -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:

View File

@@ -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":

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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))

View File

@@ -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