feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled

- Replace URL-based image_source/url fields with image_asset_id/video_asset_id
  on StaticImagePictureSource and VideoCaptureSource (clean break, no migration)
- Resolve asset IDs to file paths at runtime via AssetStore.get_file_path()
- Add EntitySelect asset pickers for image/video in stream editor modal
- Add notification sound configuration (global sound + per-app overrides)
- Unify per-app color and sound overrides into single "Per-App Overrides" section
- Persist notification history between server restarts
- Add asset management system (upload, edit, delete, soft-delete)
- Replace emoji buttons with SVG icons throughout UI
- Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
2026-03-26 20:40:25 +03:00
parent c0853ce184
commit e2e1107df7
100 changed files with 2935 additions and 992 deletions

View File

@@ -123,7 +123,8 @@ async def stream_capture_test(
if stream:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("Capture stream cleanup error: %s", e)
pass
done_event.set()
@@ -210,8 +211,9 @@ async def stream_capture_test(
"avg_capture_ms": round(avg_ms, 1),
})
except Exception:
except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop
logger.debug("Capture preview WS error, stopping capture thread: %s", e)
stop_event.set()
await capture_future
raise

View File

@@ -0,0 +1,226 @@
"""Asset routes: CRUD, file upload/download, prebuilt restore."""
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_asset_store
from wled_controller.api.schemas.assets import (
AssetListResponse,
AssetResponse,
AssetUpdate,
)
from wled_controller.config import get_config
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# Prebuilt sounds directory (shipped with the app)
_PREBUILT_SOUNDS_DIR = Path(__file__).resolve().parents[2] / "data" / "prebuilt_sounds"
def _asset_to_response(asset) -> AssetResponse:
d = asset.to_dict()
return AssetResponse(**d)
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets",
response_model=AssetListResponse,
tags=["Assets"],
)
async def list_assets(
_: AuthRequired,
asset_type: str | None = Query(None, description="Filter by type: sound, image, video, other"),
store: AssetStore = Depends(get_asset_store),
):
"""List all assets, optionally filtered by type."""
if asset_type:
assets = store.get_assets_by_type(asset_type)
else:
assets = store.get_visible_assets()
return AssetListResponse(
assets=[_asset_to_response(a) for a in assets],
count=len(assets),
)
@router.get(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def get_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Get asset metadata by ID."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
if asset.deleted:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return _asset_to_response(asset)
@router.post(
"/api/v1/assets",
response_model=AssetResponse,
status_code=201,
tags=["Assets"],
)
async def upload_asset(
_: AuthRequired,
file: UploadFile = File(...),
name: str | None = Query(None, description="Display name (defaults to filename)"),
description: str | None = Query(None, description="Optional description"),
store: AssetStore = Depends(get_asset_store),
):
"""Upload a new asset file."""
config = get_config()
max_size = getattr(getattr(config, "assets", None), "max_file_size_mb", 50) * 1024 * 1024
data = await file.read()
if len(data) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large (max {max_size // (1024 * 1024)} MB)",
)
if not data:
raise HTTPException(status_code=400, detail="Empty file")
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
try:
asset = store.create_asset(
name=display_name,
filename=file.filename or "unnamed",
file_data=data,
mime_type=file.content_type,
description=description,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "created", asset.id)
return _asset_to_response(asset)
@router.put(
"/api/v1/assets/{asset_id}",
response_model=AssetResponse,
tags=["Assets"],
)
async def update_asset(
asset_id: str,
body: AssetUpdate,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Update asset metadata."""
try:
asset = store.update_asset(
asset_id,
name=body.name,
description=body.description,
tags=body.tags,
)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
fire_entity_event("asset", "updated", asset.id)
return _asset_to_response(asset)
@router.delete(
"/api/v1/assets/{asset_id}",
tags=["Assets"],
)
async def delete_asset(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Delete an asset. Prebuilt assets are soft-deleted and can be restored."""
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
store.delete_asset(asset_id)
fire_entity_event("asset", "deleted", asset_id)
return {
"status": "deleted",
"id": asset_id,
"restorable": asset.prebuilt,
}
# ---------------------------------------------------------------------------
# File serving
# ---------------------------------------------------------------------------
@router.get(
"/api/v1/assets/{asset_id}/file",
tags=["Assets"],
)
async def serve_asset_file(
asset_id: str,
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Serve the actual asset file for playback/display."""
file_path = store.get_file_path(asset_id)
if file_path is None:
raise HTTPException(status_code=404, detail=f"Asset file not found: {asset_id}")
try:
asset = store.get_asset(asset_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Asset not found: {asset_id}")
return FileResponse(
path=str(file_path),
media_type=asset.mime_type,
filename=asset.filename,
)
# ---------------------------------------------------------------------------
# Prebuilt restore
# ---------------------------------------------------------------------------
@router.post(
"/api/v1/assets/restore-prebuilt",
tags=["Assets"],
)
async def restore_prebuilt_assets(
_: AuthRequired,
store: AssetStore = Depends(get_asset_store),
):
"""Re-import any deleted prebuilt assets."""
restored = store.restore_prebuilt(_PREBUILT_SOUNDS_DIR)
return {
"status": "ok",
"restored_count": len(restored),
"restored": [_asset_to_response(a) for a in restored],
}

View File

@@ -221,7 +221,8 @@ async def test_audio_source_ws(
template = template_store.get_template(audio_template_id)
engine_type = template.engine_type
engine_config = template.engine_config
except ValueError:
except ValueError as e:
logger.debug("Audio template not found, falling back to best available engine: %s", e)
pass # Fall back to best available engine
# Acquire shared audio stream
@@ -268,6 +269,7 @@ async def test_audio_source_ws(
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio test WebSocket disconnected for source %s", source_id)
pass
except Exception as e:
logger.error(f"Audio test WebSocket error for {source_id}: {e}")

View File

@@ -46,8 +46,8 @@ async def list_audio_templates(
]
return AudioTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list audio templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list audio templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
@@ -76,8 +76,8 @@ async def create_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@@ -126,8 +126,8 @@ async def update_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/audio-templates/{template_id}", status_code=204, tags=["Audio Templates"])
@@ -149,8 +149,8 @@ async def delete_audio_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete audio template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete audio template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== AUDIO ENGINE ENDPOINTS =====
@@ -175,8 +175,8 @@ async def list_audio_engines(_auth: AuthRequired):
return AudioEngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list audio engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list audio engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== REAL-TIME AUDIO TEMPLATE TEST WEBSOCKET =====
@@ -237,6 +237,7 @@ async def test_audio_template_ws(
})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"Audio template test WS error: {e}")

View File

@@ -1,6 +1,7 @@
"""System routes: backup, restore, auto-backup.
All backups are SQLite database snapshots (.db files).
Backups are ZIP files containing a SQLite database snapshot (.db)
and any uploaded asset files from data/assets/.
"""
import asyncio
@@ -8,13 +9,14 @@ import io
import subprocess
import sys
import threading
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import StreamingResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_auto_backup_engine, get_database
from wled_controller.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
@@ -22,7 +24,9 @@ from wled_controller.api.schemas.system import (
BackupListResponse,
RestoreResponse,
)
from wled_controller.config import get_config
from wled_controller.core.backup.auto_backup import AutoBackupEngine
from wled_controller.storage.asset_store import AssetStore
from wled_controller.storage.database import Database, freeze_writes
from wled_controller.utils import get_logger
@@ -60,25 +64,43 @@ def _schedule_restart() -> None:
@router.get("/api/v1/system/backup", tags=["System"])
def backup_config(_: AuthRequired, db: Database = Depends(get_database)):
"""Download a full database backup as a .db file."""
def backup_config(
_: AuthRequired,
db: Database = Depends(get_database),
asset_store: AssetStore = Depends(get_asset_store),
):
"""Download a full backup as a .zip containing the database and asset files."""
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
db.backup_to(tmp_path)
content = tmp_path.read_bytes()
db_content = tmp_path.read_bytes()
finally:
tmp_path.unlink(missing_ok=True)
# Build ZIP: database + asset files
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("ledgrab.db", db_content)
# Include all asset files
assets_dir = Path(get_config().assets.assets_dir)
if assets_dir.exists():
for asset_file in assets_dir.iterdir():
if asset_file.is_file():
zf.write(asset_file, f"assets/{asset_file.name}")
zip_buffer.seek(0)
from datetime import datetime, timezone
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S")
filename = f"ledgrab-backup-{timestamp}.db"
filename = f"ledgrab-backup-{timestamp}.zip"
return StreamingResponse(
io.BytesIO(content),
media_type="application/octet-stream",
zip_buffer,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@@ -89,21 +111,52 @@ async def restore_config(
file: UploadFile = File(...),
db: Database = Depends(get_database),
):
"""Upload a .db backup file to restore all configuration. Triggers server restart."""
"""Upload a .db or .zip backup file to restore all configuration. Triggers server restart.
ZIP backups contain the database and asset files. Plain .db backups are
also supported for backward compatibility (assets are not restored).
"""
raw = await file.read()
if len(raw) > 50 * 1024 * 1024: # 50 MB limit
raise HTTPException(status_code=400, detail="Backup file too large (max 50 MB)")
if len(raw) > 200 * 1024 * 1024: # 200 MB limit (ZIP may contain assets)
raise HTTPException(status_code=400, detail="Backup file too large (max 200 MB)")
if len(raw) < 100:
raise HTTPException(status_code=400, detail="File too small to be a valid SQLite database")
# SQLite files start with "SQLite format 3\000"
if not raw[:16].startswith(b"SQLite format 3"):
raise HTTPException(status_code=400, detail="Not a valid SQLite database file")
raise HTTPException(status_code=400, detail="File too small to be a valid backup")
import tempfile
is_zip = raw[:4] == b"PK\x03\x04"
is_sqlite = raw[:16].startswith(b"SQLite format 3")
if not is_zip and not is_sqlite:
raise HTTPException(status_code=400, detail="Not a valid backup file (expected .zip or .db)")
if is_zip:
# Extract DB and assets from ZIP
try:
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
names = zf.namelist()
if "ledgrab.db" not in names:
raise HTTPException(status_code=400, detail="ZIP backup missing ledgrab.db")
db_bytes = zf.read("ledgrab.db")
# Restore asset files
assets_dir = Path(get_config().assets.assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
for name in names:
if name.startswith("assets/") and not name.endswith("/"):
asset_filename = name.split("/", 1)[1]
dest = assets_dir / asset_filename
dest.write_bytes(zf.read(name))
logger.info(f"Restored asset file: {asset_filename}")
except zipfile.BadZipFile:
raise HTTPException(status_code=400, detail="Invalid ZIP file")
else:
db_bytes = raw
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp.write(raw)
tmp.write(db_bytes)
tmp_path = Path(tmp.name)
try:

View File

@@ -57,8 +57,8 @@ async def list_cspt(
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))
logger.error("Failed to list color strip processing templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201)
@@ -84,8 +84,8 @@ async def create_cspt(
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))
logger.error("Failed to create color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
@@ -127,8 +127,8 @@ async def update_cspt(
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))
logger.error("Failed to update color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"])
@@ -159,8 +159,8 @@ async def delete_cspt(
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))
logger.error("Failed to delete color strip processing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Test / Preview WebSocket ──────────────────────────────────────────
@@ -259,12 +259,14 @@ async def test_cspt_ws(
result = flt.process_strip(colors)
if result is not None:
colors = result
except Exception:
except Exception as e:
logger.debug("Strip filter processing error: %s", e)
pass
await websocket.send_bytes(colors.tobytes())
await asyncio.sleep(frame_interval)
except WebSocketDisconnect:
logger.debug("Color strip processing test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"CSPT test WS error: {e}")

View File

@@ -96,7 +96,8 @@ def _resolve_display_index(picture_source_id: str, picture_source_store: Picture
return 0
try:
ps = picture_source_store.get_stream(picture_source_id)
except Exception:
except Exception as e:
logger.debug("Failed to resolve display index for picture source %s: %s", picture_source_id, e)
return 0
if isinstance(ps, ScreenCapturePictureSource):
return ps.display_index
@@ -160,8 +161,8 @@ async def create_color_strip_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
@@ -204,8 +205,8 @@ async def update_color_strip_source(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
@@ -256,8 +257,8 @@ async def delete_color_strip_source(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete color strip source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== CALIBRATION TEST =====
@@ -332,8 +333,8 @@ async def test_css_calibration(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to set CSS calibration test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to set CSS calibration test mode: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== OVERLAY VISUALIZATION =====
@@ -372,8 +373,8 @@ async def start_css_overlay(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to start CSS overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to start CSS overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/color-strip-sources/{source_id}/overlay/stop", tags=["Color Strip Sources"])
@@ -387,8 +388,8 @@ async def stop_css_overlay(
await manager.stop_css_overlay(source_id)
return {"status": "stopped", "source_id": source_id}
except Exception as e:
logger.error(f"Failed to stop CSS overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to stop CSS overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/color-strip-sources/{source_id}/overlay/status", tags=["Color Strip Sources"])
@@ -549,7 +550,8 @@ async def preview_color_strip_ws(
try:
mgr = get_processor_manager()
return getattr(mgr, "_sync_clock_manager", None)
except Exception:
except Exception as e:
logger.debug("SyncClockManager not available: %s", e)
return None
def _build_source(config: dict):

View File

@@ -177,8 +177,8 @@ async def create_device(
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/devices", response_model=DeviceListResponse, tags=["Devices"])
@@ -360,7 +360,8 @@ async def update_device(
led_count=update_data.led_count,
baud_rate=update_data.baud_rate,
)
except ValueError:
except ValueError as e:
logger.debug("Processor manager device update skipped for %s: %s", device_id, e)
pass
# Sync auto_shutdown and zone_mode in runtime state
@@ -377,8 +378,8 @@ async def update_device(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/devices/{device_id}", status_code=204, tags=["Devices"])
@@ -417,8 +418,8 @@ async def delete_device(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete device: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete device: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== DEVICE STATE (health only) =====
@@ -654,6 +655,7 @@ async def device_ws_stream(
while True:
await websocket.receive_text()
except WebSocketDisconnect:
logger.debug("Device event WebSocket disconnected for %s", device_id)
pass
finally:
broadcaster.remove_client(device_id, websocket)

View File

@@ -163,8 +163,8 @@ async def create_target(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets", response_model=OutputTargetListResponse, tags=["Targets"])
@@ -291,14 +291,16 @@ async def update_target(
css_changed=data.color_strip_source_id is not None,
brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed),
)
except ValueError:
except ValueError as e:
logger.debug("Processor config update skipped for target %s: %s", target_id, e)
pass
# Device change requires async stop -> swap -> start cycle
if data.device_id is not None:
try:
await manager.update_target_device(target_id, target.device_id)
except ValueError:
except ValueError as e:
logger.debug("Device update skipped for target %s: %s", target_id, e)
pass
fire_entity_event("output_target", "updated", target_id)
@@ -309,8 +311,8 @@ async def update_target(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/output-targets/{target_id}", status_code=204, tags=["Targets"])
@@ -325,13 +327,15 @@ async def delete_target(
# Stop processing if running
try:
await manager.stop_processing(target_id)
except ValueError:
except ValueError as e:
logger.debug("Stop processing skipped for target %s (not running): %s", target_id, e)
pass
# Remove from manager
try:
manager.remove_target(target_id)
except (ValueError, RuntimeError):
except (ValueError, RuntimeError) as e:
logger.debug("Remove target from manager skipped for %s: %s", target_id, e)
pass
# Delete from store
@@ -343,5 +347,5 @@ async def delete_target(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete target: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -120,8 +120,8 @@ async def start_processing(
msg = msg.replace(t.id, f"'{t.name}'")
raise HTTPException(status_code=409, detail=msg)
except Exception as e:
logger.error(f"Failed to start processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to start processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/stop", tags=["Processing"])
@@ -140,8 +140,8 @@ async def stop_processing(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop processing: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to stop processing: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE & METRICS ENDPOINTS =====
@@ -160,8 +160,8 @@ async def get_target_state(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target state: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to get target state: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
@@ -178,8 +178,8 @@ async def get_target_metrics(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to get target metrics: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ===== STATE CHANGE EVENT STREAM =====
@@ -268,8 +268,8 @@ async def start_target_overlay(
except RuntimeError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"Failed to start overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to start overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/output-targets/{target_id}/overlay/stop", tags=["Visualization"])
@@ -286,8 +286,8 @@ async def stop_target_overlay(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to stop overlay: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to stop overlay: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/output-targets/{target_id}/overlay/status", tags=["Visualization"])

View File

@@ -88,7 +88,6 @@ async def test_kc_target(
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key-colors target: capture a frame, extract colors from each rectangle."""
import httpx
stream = None
try:
@@ -130,21 +129,16 @@ async def test_kc_target(
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
from wled_controller.utils.image_codec import load_image_file
if isinstance(raw_stream, StaticImagePictureSource):
source = raw_stream.image_source
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
from pathlib import Path
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
image = load_image_file(image_path)
elif isinstance(raw_stream, ScreenCapturePictureSource):
try:
@@ -264,10 +258,11 @@ async def test_kc_target(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}")
logger.error("Capture error during KC target test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error(f"Failed to test KC target: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to test KC target: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
@@ -420,7 +415,8 @@ async def test_kc_target_ws(
for pp_id in pp_template_ids:
try:
pp_template = pp_template_store_inst.get_template(pp_id)
except ValueError:
except ValueError as e:
logger.debug("PP template %s not found during KC test: %s", pp_id, e)
continue
flat_filters = pp_template_store_inst.resolve_filter_instances(pp_template.filters)
for fi in flat_filters:
@@ -429,7 +425,8 @@ async def test_kc_target_ws(
result = f.process_image(cur_image, image_pool)
if result is not None:
cur_image = result
except ValueError:
except ValueError as e:
logger.debug("Filter processing error during KC test: %s", e)
pass
# Extract colors
@@ -492,7 +489,8 @@ async def test_kc_target_ws(
await asyncio.to_thread(
live_stream_mgr.release, target.picture_source_id
)
except Exception:
except Exception as e:
logger.debug("Live stream release during KC test cleanup: %s", e)
pass
logger.info(f"KC test WS closed for {target_id}")
@@ -524,6 +522,7 @@ async def target_colors_ws(
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
logger.debug("KC live WebSocket disconnected for target %s", target_id)
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -53,8 +53,8 @@ async def list_pattern_templates(
responses = [_pat_template_to_response(t) for t in templates]
return PatternTemplateListResponse(templates=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list pattern templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list pattern templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
@@ -83,8 +83,8 @@ async def create_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
@@ -131,8 +131,8 @@ async def update_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
@@ -162,5 +162,5 @@ async def delete_pattern_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete pattern template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete pattern template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")

View File

@@ -56,13 +56,13 @@ def _stream_to_response(s) -> PictureSourceResponse:
target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_source=getattr(s, "image_source", None),
image_asset_id=getattr(s, "image_asset_id", None),
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=s.tags,
# Video fields
url=getattr(s, "url", None),
video_asset_id=getattr(s, "video_asset_id", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
@@ -83,8 +83,8 @@ async def list_picture_sources(
responses = [_stream_to_response(s) for s in streams]
return PictureSourceListResponse(streams=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list picture sources: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list picture sources: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
@@ -94,19 +94,21 @@ async def validate_image(
):
"""Validate an image source (URL or file path) and return a preview thumbnail."""
try:
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
source = data.image_source.strip()
if not source:
return ImageValidateResponse(valid=False, error="Image source is empty")
if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
path = validate_image_path(source)
if not path.exists():
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
img_bytes = path
@@ -147,16 +149,18 @@ async def get_full_image(
source: str = Query(..., description="Image URL or local file path"),
):
"""Serve the full-resolution image for lightbox preview."""
from pathlib import Path
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
try:
if source.startswith(("http://", "https://")):
validate_image_url(source)
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
path = validate_image_path(source)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
img_bytes = path
@@ -215,11 +219,11 @@ async def create_picture_source(
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
@@ -237,8 +241,8 @@ async def create_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
@@ -272,11 +276,11 @@ async def update_picture_source(
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source,
image_asset_id=data.image_asset_id,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
video_asset_id=data.video_asset_id,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
@@ -292,8 +296,8 @@ async def update_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
@@ -324,8 +328,8 @@ async def delete_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
@@ -344,8 +348,15 @@ async def get_video_thumbnail(
if not isinstance(source, VideoCaptureSource):
raise HTTPException(status_code=400, detail="Not a video source")
# Resolve video asset to file path
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = asset_store.get_file_path(source.video_asset_id) if source.video_asset_id else None
if not video_path:
raise HTTPException(status_code=400, detail="Video asset not found or missing file")
frame = await asyncio.get_event_loop().run_in_executor(
None, extract_thumbnail, source.url, source.resolution_limit
None, extract_thumbnail, str(video_path), source.resolution_limit
)
if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
@@ -360,8 +371,8 @@ async def get_video_thumbnail(
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to extract video thumbnail: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to extract video thumbnail: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
@@ -394,24 +405,17 @@ async def test_picture_source(
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image directly, no engine needed
from pathlib import Path
# Static image stream: load image from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from wled_controller.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter()
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = await asyncio.to_thread(load_image_file, path)
image = await asyncio.to_thread(load_image_file, image_path)
actual_duration = time.perf_counter() - start_time
frame_count = 1
@@ -543,10 +547,11 @@ async def test_picture_source(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
logger.error("Engine error during picture source test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error(f"Failed to test picture source: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to test picture source: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
@@ -602,12 +607,19 @@ async def test_picture_source_ws(
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
from wled_controller.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = asset_store.get_file_path(raw_stream.video_asset_id) if raw_stream.video_asset_id else None
if not video_path:
await websocket.close(code=4004, reason="Video asset not found or missing file")
return
await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream(
url=raw_stream.url,
url=str(video_path),
loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time,
@@ -663,12 +675,14 @@ async def test_picture_source_ws(
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
logger.debug("Video source test WebSocket disconnected for %s", stream_id)
pass
except Exception as e:
logger.error(f"Video source test WS error for {stream_id}: {e}")
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
except Exception as e2:
logger.debug("Failed to send error to video test WS: %s", e2)
pass
finally:
video_stream.stop()
@@ -697,7 +711,8 @@ async def test_picture_source_ws(
try:
pp_template = pp_store.get_template(pp_template_ids[0])
pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
except ValueError as e:
logger.debug("PP template not found for picture source test: %s", e)
pass
# Engine factory — creates + initializes engine inside the capture thread
@@ -721,6 +736,7 @@ async def test_picture_source_ws(
preview_width=preview_width or None,
)
except WebSocketDisconnect:
logger.debug("Picture source test WebSocket disconnected for %s", stream_id)
pass
except Exception as e:
logger.error(f"Picture source test WS error for {stream_id}: {e}")

View File

@@ -2,7 +2,6 @@
import time
import httpx
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
@@ -87,8 +86,8 @@ async def create_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@@ -130,8 +129,8 @@ async def update_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
@@ -162,8 +161,8 @@ async def delete_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete postprocessing template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete postprocessing template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
@@ -197,28 +196,21 @@ async def test_pp_template(
from wled_controller.utils.image_codec import (
encode_jpeg_data_uri,
load_image_bytes,
load_image_file,
thumbnail as make_thumbnail,
)
if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load directly
from pathlib import Path
# Static image: load from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = asset_store.get_file_path(raw_stream.image_asset_id) if raw_stream.image_asset_id else None
if not image_path:
raise HTTPException(status_code=400, detail="Image asset not found or missing file")
source = raw_stream.image_source
start_time = time.perf_counter()
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
resp = await client.get(source)
resp.raise_for_status()
image = load_image_bytes(resp.content)
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
image = load_image_file(path)
image = load_image_file(image_path)
actual_duration = time.perf_counter() - start_time
frame_count = 1
@@ -330,13 +322,14 @@ async def test_pp_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Postprocessing template test failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Postprocessing template test failed: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("PP test capture stream cleanup: %s", e)
pass
@@ -434,6 +427,7 @@ async def test_pp_template_ws(
preview_width=preview_width or None,
)
except WebSocketDisconnect:
logger.debug("PP template test WebSocket disconnected for %s", template_id)
pass
except Exception as e:
logger.error(f"PP template test WS error for {template_id}: {e}")

View File

@@ -130,17 +130,19 @@ async def get_version():
async def list_all_tags(_: AuthRequired):
"""Get all tags used across all entities."""
all_tags: set[str] = set()
from wled_controller.api.dependencies import get_asset_store
store_getters = [
get_device_store, get_output_target_store, get_color_strip_store,
get_picture_source_store, get_audio_source_store, get_value_source_store,
get_sync_clock_store, get_automation_store, get_scene_preset_store,
get_template_store, get_audio_template_store, get_pp_template_store,
get_pattern_template_store,
get_pattern_template_store, get_asset_store,
]
for getter in store_getters:
try:
store = getter()
except RuntimeError:
except RuntimeError as e:
logger.debug("Store not available during entity count: %s", e)
continue
# BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices()
fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None)
@@ -211,10 +213,10 @@ async def get_displays(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to get displays: {e}")
logger.error("Failed to get displays: %s", e, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve display information: {str(e)}"
detail="Internal server error"
)
@@ -232,10 +234,10 @@ async def get_running_processes(_: AuthRequired):
sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e:
logger.error(f"Failed to get processes: {e}")
logger.error("Failed to get processes: %s", e, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve process list: {str(e)}"
detail="Internal server error"
)
@@ -300,7 +302,7 @@ def list_api_keys(_: AuthRequired):
"""List API key labels (read-only; keys are defined in the YAML config file)."""
config = get_config()
keys = [
{"label": label, "masked": key[:4] + "****" + key[-4:] if len(key) >= 8 else "****"}
{"label": label, "masked": key[:4] + "****" if len(key) >= 8 else "****"}
for label, key in config.auth.api_keys.items()
]
return {"keys": keys, "count": len(keys)}

View File

@@ -191,8 +191,10 @@ async def logs_ws(
except Exception:
break
except WebSocketDisconnect:
logger.debug("Log stream WebSocket disconnected")
pass
except Exception:
except Exception as e:
logger.debug("Log stream WebSocket error: %s", e)
pass
finally:
log_broadcaster.unsubscribe(queue)
@@ -287,6 +289,7 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
address = request.address.strip()
if not address:
raise HTTPException(status_code=400, detail="Address is required")
_validate_adb_address(address)
adb = _get_adb_path()
logger.info(f"Disconnecting ADB device: {address}")

View File

@@ -76,8 +76,8 @@ async def list_templates(
)
except Exception as e:
logger.error(f"Failed to list templates: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list templates: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
@@ -115,8 +115,8 @@ async def create_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to create template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@@ -180,8 +180,8 @@ async def update_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to update template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/capture-templates/{template_id}", status_code=204, tags=["Templates"])
@@ -222,8 +222,8 @@ async def delete_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete template: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to delete template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-engines", response_model=EngineListResponse, tags=["Templates"])
@@ -252,8 +252,8 @@ async def list_engines(_auth: AuthRequired):
return EngineListResponse(engines=engines, count=len(engines))
except Exception as e:
logger.error(f"Failed to list engines: {e}")
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to list engines: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
@@ -365,10 +365,11 @@ def test_template(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
logger.error("Engine error during template test: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
except Exception as e:
logger.error(f"Failed to test template: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
logger.error("Failed to test template: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if stream:
try:
@@ -432,6 +433,7 @@ async def test_template_ws(
try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
except WebSocketDisconnect:
logger.debug("Capture template test WebSocket disconnected")
pass
except Exception as e:
logger.error(f"Capture template test WS error: {e}")

View File

@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_update_service
from wled_controller.api.schemas.update import (
DismissRequest,
@@ -20,6 +21,7 @@ router = APIRouter(prefix="/api/v1/system/update", tags=["update"])
@router.get("/status", response_model=UpdateStatusResponse)
async def get_update_status(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return service.get_status()
@@ -27,6 +29,7 @@ async def get_update_status(
@router.post("/check", response_model=UpdateStatusResponse)
async def check_for_updates(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return await service.check_now()
@@ -34,6 +37,7 @@ async def check_for_updates(
@router.post("/dismiss")
async def dismiss_update(
_: AuthRequired,
body: DismissRequest,
service: UpdateService = Depends(get_update_service),
):
@@ -43,6 +47,7 @@ async def dismiss_update(
@router.post("/apply")
async def apply_update(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
"""Download (if needed) and apply the available update."""
@@ -59,11 +64,12 @@ async def apply_update(
return {"ok": True, "message": "Update applied, server shutting down"}
except Exception as exc:
logger.error("Failed to apply update: %s", exc, exc_info=True)
return JSONResponse(status_code=500, content={"detail": str(exc)})
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@router.get("/settings", response_model=UpdateSettingsResponse)
async def get_update_settings(
_: AuthRequired,
service: UpdateService = Depends(get_update_service),
):
return service.get_settings()
@@ -71,6 +77,7 @@ async def get_update_settings(
@router.put("/settings", response_model=UpdateSettingsResponse)
async def update_update_settings(
_: AuthRequired,
body: UpdateSettingsRequest,
service: UpdateService = Depends(get_update_service),
):

View File

@@ -245,6 +245,7 @@ async def test_value_source_ws(
await websocket.send_json({"value": round(value, 4)})
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Value source test WebSocket disconnected for %s", source_id)
pass
except Exception as e:
logger.error(f"Value source test WebSocket error for {source_id}: {e}")

View File

@@ -6,6 +6,7 @@ automations that have a webhook condition. No API-key auth is required —
the secret token itself authenticates the caller.
"""
import secrets
import time
from collections import defaultdict
@@ -43,6 +44,12 @@ def _check_rate_limit(client_ip: str) -> None:
)
_rate_hits[client_ip].append(now)
# Periodic cleanup: remove IPs with no recent hits to prevent unbounded growth
if len(_rate_hits) > 100:
stale = [ip for ip, ts in _rate_hits.items() if not ts or ts[-1] < window_start]
for ip in stale:
del _rate_hits[ip]
class WebhookPayload(BaseModel):
action: str = Field(description="'activate' or 'deactivate'")
@@ -68,7 +75,7 @@ async def handle_webhook(
# Find the automation that owns this token
for automation in store.get_all_automations():
for condition in automation.conditions:
if isinstance(condition, WebhookCondition) and condition.token == token:
if isinstance(condition, WebhookCondition) and secrets.compare_digest(condition.token, token):
active = body.action == "activate"
await engine.set_webhook_state(token, active)
logger.info(