feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
226
server/src/wled_controller/api/routes/assets.py
Normal file
226
server/src/wled_controller/api/routes/assets.py
Normal 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],
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user