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

@@ -7,7 +7,7 @@
- `src/wled_controller/api/schemas/` — Pydantic request/response models (one file per entity)
- `src/wled_controller/core/` — Core business logic (capture, devices, audio, processing, automations)
- `src/wled_controller/storage/` — Data models (dataclasses) and JSON persistence stores
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection)
- `src/wled_controller/utils/` — Utility functions (logging, monitor detection, SSRF validation, sound playback)
- `src/wled_controller/static/` — Frontend files (TypeScript, CSS, locales)
- `src/wled_controller/templates/` — Jinja2 HTML templates
- `config/` — Configuration files (YAML)

View File

@@ -8,7 +8,7 @@ The server component provides:
- 🎯 **Real-time Screen Capture** - Multi-monitor support with configurable FPS
- 🎨 **Advanced Processing** - Border pixel extraction with color correction
- 🔧 **Flexible Calibration** - Map screen edges to any LED layout
- 🌐 **REST API** - Complete control via 17 REST endpoints
- 🌐 **REST API** - Complete control via 25+ REST endpoints
- 💾 **Persistent Storage** - JSON-based device and configuration management
- 📊 **Metrics & Monitoring** - Real-time FPS, status, and performance data

View File

@@ -26,6 +26,7 @@ from .routes.color_strip_processing import router as cspt_router
from .routes.gradients import router as gradients_router
from .routes.weather_sources import router as weather_sources_router
from .routes.update import router as update_router
from .routes.assets import router as assets_router
router = APIRouter()
router.include_router(system_router)
@@ -52,5 +53,6 @@ router.include_router(cspt_router)
router.include_router(gradients_router)
router.include_router(weather_sources_router)
router.include_router(update_router)
router.include_router(assets_router)
__all__ = ["router"]

View File

@@ -24,6 +24,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -131,6 +132,10 @@ def get_weather_manager() -> WeatherManager:
return _get("weather_manager", "Weather manager")
def get_asset_store() -> AssetStore:
return _get("asset_store", "Asset store")
def get_database() -> Database:
return _get("database", "Database")
@@ -187,6 +192,7 @@ def init_dependencies(
weather_source_store: WeatherSourceStore | None = None,
weather_manager: WeatherManager | None = None,
update_service: UpdateService | None = None,
asset_store: AssetStore | None = None,
):
"""Initialize global dependencies."""
_deps.update({
@@ -213,4 +219,5 @@ def init_dependencies(
"weather_source_store": weather_source_store,
"weather_manager": weather_manager,
"update_service": update_service,
"asset_store": asset_store,
})

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(

View File

@@ -0,0 +1,37 @@
"""Asset schemas (CRUD)."""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class AssetUpdate(BaseModel):
"""Request to update asset metadata."""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="Display name")
description: Optional[str] = Field(None, max_length=500, description="Optional description")
tags: Optional[List[str]] = Field(None, description="User-defined tags")
class AssetResponse(BaseModel):
"""Asset response."""
id: str = Field(description="Asset ID")
name: str = Field(description="Display name")
filename: str = Field(description="Original upload filename")
mime_type: str = Field(description="MIME type")
asset_type: str = Field(description="Asset type: sound, image, video, other")
size_bytes: int = Field(description="File size in bytes")
description: Optional[str] = Field(None, description="Description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
prebuilt: bool = Field(False, description="Whether this is a shipped prebuilt asset")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class AssetListResponse(BaseModel):
"""List of assets."""
assets: List[AssetResponse] = Field(description="List of assets")
count: int = Field(description="Number of assets")

View File

@@ -8,6 +8,13 @@ from pydantic import BaseModel, Field, model_validator
from wled_controller.api.schemas.devices import Calibration
class AppSoundOverride(BaseModel):
"""Per-application sound override for notification sources."""
sound_asset_id: Optional[str] = Field(None, description="Asset ID for the sound (None = mute this app)")
volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Volume override (None = use global)")
class AnimationConfig(BaseModel):
"""Procedural animation configuration for static/gradient color strip sources."""
@@ -102,6 +109,9 @@ class ColorStripSourceCreate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
@@ -173,6 +183,9 @@ class ColorStripSourceUpdate(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, ge=0.0, le=1.0, description="Global notification sound volume")
app_sounds: Optional[Dict[str, AppSoundOverride]] = Field(None, description="Per-app sound overrides")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
@@ -245,6 +258,9 @@ class ColorStripSourceResponse(BaseModel):
app_filter_mode: Optional[str] = Field(None, description="App filter mode: off|whitelist|blacklist")
app_filter_list: Optional[List[str]] = Field(None, description="App names for filter")
os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications")
sound_asset_id: Optional[str] = Field(None, description="Global notification sound asset ID")
sound_volume: Optional[float] = Field(None, description="Global notification sound volume")
app_sounds: Optional[Dict[str, dict]] = Field(None, description="Per-app sound overrides")
# daylight-type fields
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")

View File

@@ -16,11 +16,11 @@ class PictureSourceCreate(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
# Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: bool = Field(True, description="Loop video playback")
playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
@@ -38,11 +38,11 @@ class PictureSourceUpdate(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, le=90)
source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)")
image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)")
image_asset_id: Optional[str] = Field(None, description="Image asset ID (static_image streams)")
description: Optional[str] = Field(None, description="Stream description", max_length=500)
tags: Optional[List[str]] = None
# Video fields
url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL")
video_asset_id: Optional[str] = Field(None, description="Video asset ID (video streams)")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0)
start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0)
@@ -62,13 +62,13 @@ class PictureSourceResponse(BaseModel):
target_fps: Optional[int] = Field(None, description="Target FPS")
source_stream_id: Optional[str] = Field(None, description="Source stream ID")
postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID")
image_source: Optional[str] = Field(None, description="Image URL or file path")
image_asset_id: Optional[str] = Field(None, description="Image asset ID")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Stream description")
# Video fields
url: Optional[str] = Field(None, description="Video URL")
video_asset_id: Optional[str] = Field(None, description="Video asset ID")
loop: Optional[bool] = Field(None, description="Loop video playback")
playback_speed: Optional[float] = Field(None, description="Playback speed multiplier")
start_time: Optional[float] = Field(None, description="Trim start time in seconds")

View File

@@ -15,7 +15,7 @@ class ServerConfig(BaseSettings):
host: str = "0.0.0.0"
port: int = 8080
log_level: str = "INFO"
cors_origins: List[str] = ["*"]
cors_origins: List[str] = ["http://localhost:8080"]
class AuthConfig(BaseSettings):
@@ -24,6 +24,13 @@ class AuthConfig(BaseSettings):
api_keys: dict[str, str] = {} # label: key mapping (empty = auth disabled)
class AssetsConfig(BaseSettings):
"""Assets configuration."""
max_file_size_mb: int = 50 # Max upload size in MB
assets_dir: str = "data/assets" # Directory for uploaded asset files
class StorageConfig(BaseSettings):
"""Storage configuration."""
@@ -65,16 +72,21 @@ class Config(BaseSettings):
server: ServerConfig = Field(default_factory=ServerConfig)
auth: AuthConfig = Field(default_factory=AuthConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
assets: AssetsConfig = Field(default_factory=AssetsConfig)
mqtt: MQTTConfig = Field(default_factory=MQTTConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
def model_post_init(self, __context: object) -> None:
"""Override storage paths when demo mode is active."""
"""Override storage and assets paths when demo mode is active."""
if self.demo:
for field_name in self.storage.model_fields:
for field_name in StorageConfig.model_fields:
value = getattr(self.storage, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.storage, field_name, value.replace("data/", "data/demo/", 1))
for field_name in AssetsConfig.model_fields:
value = getattr(self.assets, field_name)
if isinstance(value, str) and value.startswith("data/"):
setattr(self.assets, field_name, value.replace("data/", "data/demo/", 1))
@classmethod
def from_yaml(cls, config_path: str | Path) -> "Config":

View File

@@ -141,7 +141,8 @@ class ManagedAudioStream:
if stream is not None:
try:
stream.cleanup()
except Exception:
except Exception as e:
logger.debug("Audio stream cleanup error: %s", e)
pass
self._running = False
logger.info(

View File

@@ -75,7 +75,8 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
try:
self._sd_stream.stop()
self._sd_stream.close()
except Exception:
except Exception as e:
logger.debug("Sounddevice stream cleanup: %s", e)
pass
self._sd_stream = None
self._initialized = False
@@ -104,7 +105,8 @@ class SounddeviceEngine(AudioCaptureEngine):
try:
import sounddevice # noqa: F401
return True
except ImportError:
except ImportError as e:
logger.debug("Sounddevice engine unavailable: %s", e)
return False
@classmethod
@@ -118,7 +120,8 @@ class SounddeviceEngine(AudioCaptureEngine):
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try:
import sounddevice as sd
except ImportError:
except ImportError as e:
logger.debug("Cannot enumerate sounddevice devices: %s", e)
return []
try:

View File

@@ -85,13 +85,15 @@ class WasapiCaptureStream(AudioCaptureStreamBase):
try:
self._stream.stop_stream()
self._stream.close()
except Exception:
except Exception as e:
logger.debug("WASAPI stream cleanup: %s", e)
pass
self._stream = None
if self._pa is not None:
try:
self._pa.terminate()
except Exception:
except Exception as e:
logger.debug("PyAudio terminate during cleanup: %s", e)
pass
self._pa = None
self._initialized = False
@@ -139,7 +141,8 @@ class WasapiEngine(AudioCaptureEngine):
try:
import pyaudiowpatch # noqa: F401
return True
except ImportError:
except ImportError as e:
logger.debug("WASAPI engine unavailable (pyaudiowpatch not installed): %s", e)
return False
@classmethod
@@ -153,7 +156,8 @@ class WasapiEngine(AudioCaptureEngine):
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
try:
import pyaudiowpatch as pyaudio
except ImportError:
except ImportError as e:
logger.debug("Cannot enumerate WASAPI devices (pyaudiowpatch not installed): %s", e)
return []
pa = None
@@ -223,7 +227,8 @@ class WasapiEngine(AudioCaptureEngine):
if pa is not None:
try:
pa.terminate()
except Exception:
except Exception as e:
logger.debug("PyAudio terminate in enumerate cleanup: %s", e)
pass
@classmethod

View File

@@ -74,6 +74,7 @@ class AutomationEngine:
try:
await self._task
except asyncio.CancelledError:
logger.debug("Automation engine task cancelled")
pass
self._task = None
@@ -92,6 +93,7 @@ class AutomationEngine:
logger.error(f"Automation evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError:
logger.debug("Automation poll loop cancelled")
pass
async def _evaluate_all(self) -> None:
@@ -262,7 +264,8 @@ class AutomationEngine:
return False
try:
return matcher()
except re.error:
except re.error as e:
logger.debug("MQTT condition regex error: %s", e)
return False
def _evaluate_app_condition(

View File

@@ -75,7 +75,8 @@ class PlatformDetector:
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
value = setting.Data[0]
self._display_on = value != 0
except Exception:
except Exception as e:
logger.debug("Failed to parse display power setting: %s", e)
pass
return 0
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
@@ -309,7 +310,8 @@ class PlatformDetector:
and win_rect.right >= mr.right
and win_rect.bottom >= mr.bottom
)
except Exception:
except Exception as e:
logger.debug("Fullscreen check failed for hwnd: %s", e)
return False
def _get_fullscreen_processes_sync(self) -> Set[str]:

View File

@@ -108,6 +108,7 @@ class AutoBackupEngine:
except Exception as e:
logger.error(f"Auto-backup failed: {e}", exc_info=True)
except asyncio.CancelledError:
logger.debug("Auto-backup loop cancelled")
pass
# ─── Backup operations ─────────────────────────────────────

View File

@@ -39,7 +39,8 @@ class BetterCamCaptureStream(CaptureStream):
# Clear global camera cache for fresh DXGI state
try:
self._bettercam.__factory.clean_up()
except Exception:
except Exception as e:
logger.debug("BetterCam factory cleanup on init: %s", e)
pass
self._camera = self._bettercam.create(
@@ -59,7 +60,8 @@ class BetterCamCaptureStream(CaptureStream):
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
except Exception as e:
logger.debug("BetterCam camera stop during cleanup: %s", e)
pass
try:
self._camera.release()
@@ -70,7 +72,8 @@ class BetterCamCaptureStream(CaptureStream):
if self._bettercam:
try:
self._bettercam.__factory.clean_up()
except Exception:
except Exception as e:
logger.debug("BetterCam factory cleanup on teardown: %s", e)
pass
self._initialized = False

View File

@@ -408,7 +408,8 @@ class CameraEngine(CaptureEngine):
try:
import cv2 # noqa: F401
return True
except ImportError:
except ImportError as e:
logger.debug("Camera engine unavailable (cv2 not installed): %s", e)
return False
@classmethod

View File

@@ -39,7 +39,8 @@ class DXcamCaptureStream(CaptureStream):
# Clear global camera cache for fresh DXGI state
try:
self._dxcam.__factory.clean_up()
except Exception:
except Exception as e:
logger.debug("DXcam factory cleanup on init: %s", e)
pass
self._camera = self._dxcam.create(
@@ -59,7 +60,8 @@ class DXcamCaptureStream(CaptureStream):
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
except Exception as e:
logger.debug("DXcam camera stop during cleanup: %s", e)
pass
try:
self._camera.release()
@@ -70,7 +72,8 @@ class DXcamCaptureStream(CaptureStream):
if self._dxcam:
try:
self._dxcam.__factory.clean_up()
except Exception:
except Exception as e:
logger.debug("DXcam factory cleanup on teardown: %s", e)
pass
self._initialized = False

View File

@@ -115,7 +115,8 @@ class WGCCaptureStream(CaptureStream):
import platform
build = int(platform.version().split(".")[2])
return build >= 22621
except Exception:
except Exception as e:
logger.debug("Failed to detect WGC border toggle support: %s", e)
return False
def _cleanup_internal(self) -> None:
@@ -133,7 +134,8 @@ class WGCCaptureStream(CaptureStream):
if self._capture_instance:
try:
del self._capture_instance
except Exception:
except Exception as e:
logger.debug("WGC capture instance cleanup: %s", e)
pass
self._capture_instance = None
@@ -215,7 +217,8 @@ class WGCEngine(CaptureEngine):
build = int(parts[2])
if major < 10 or (major == 10 and minor == 0 and build < 17134):
return False
except Exception:
except Exception as e:
logger.debug("Failed to check Windows version for WGC availability: %s", e)
pass
try:

View File

@@ -201,8 +201,8 @@ def _build_picture_sources() -> dict:
"updated_at": _NOW,
"source_stream_id": None,
"postprocessing_template_id": None,
"image_source": None,
"url": None,
"image_asset_id": None,
"video_asset_id": None,
"loop": None,
"playback_speed": None,
"start_time": None,
@@ -223,8 +223,8 @@ def _build_picture_sources() -> dict:
"updated_at": _NOW,
"source_stream_id": None,
"postprocessing_template_id": None,
"image_source": None,
"url": None,
"image_asset_id": None,
"video_asset_id": None,
"loop": None,
"playback_speed": None,
"start_time": None,

View File

@@ -60,6 +60,7 @@ class MQTTService:
try:
await self._task
except asyncio.CancelledError:
logger.debug("MQTT background task cancelled")
pass
self._task = None
self._connected = False
@@ -79,6 +80,7 @@ class MQTTService:
try:
self._publish_queue.put_nowait((topic, payload, retain, qos))
except asyncio.QueueFull:
logger.warning("MQTT publish queue full, dropping message for topic %s", topic)
pass
async def subscribe(self, topic: str, callback: Callable) -> None:

View File

@@ -115,7 +115,8 @@ class AudioColorStripStream(ColorStripStream):
tpl = self._audio_template_store.get_template(resolved.audio_template_id)
self._audio_engine_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config
except ValueError:
except ValueError as e:
logger.warning("Audio template %s not found, using default engine: %s", resolved.audio_template_id, e)
pass
except ValueError as e:
logger.warning(f"Failed to resolve audio source {audio_source_id}: {e}")

View File

@@ -69,7 +69,7 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None):
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None, gradient_store=None, weather_manager=None, asset_store=None):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
@@ -91,6 +91,7 @@ class ColorStripStreamManager:
self._cspt_store = cspt_store
self._gradient_store = gradient_store
self._weather_manager = weather_manager
self._asset_store = asset_store
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -125,7 +126,8 @@ class ColorStripStreamManager:
clock_id = getattr(source, "clock_id", None)
if clock_id:
self._sync_clock_manager.release(clock_id)
except Exception:
except Exception as e:
logger.debug("Sync clock release during stream cleanup: %s", e)
pass # source may have been deleted already
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
@@ -186,6 +188,9 @@ class ColorStripStreamManager:
# Inject gradient store for palette resolution
if self._gradient_store and hasattr(css_stream, "set_gradient_store"):
css_stream.set_gradient_store(self._gradient_store)
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
# Inject sync clock runtime if source references a clock
acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start()

View File

@@ -55,6 +55,8 @@ class CompositeColorStripStream(ColorStripStream):
self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
@@ -560,9 +562,16 @@ class CompositeColorStripStream(ColorStripStream):
continue
# Resize to zone length
if len(colors) != zone_len:
src_x = np.linspace(0, 1, len(colors))
dst_x = np.linspace(0, 1, zone_len)
resized = np.empty((zone_len, 3), dtype=np.uint8)
rkey = (len(colors), zone_len)
cached = self._resize_cache.get(rkey)
if cached is None:
cached = (
np.linspace(0, 1, len(colors)),
np.linspace(0, 1, zone_len),
np.empty((zone_len, 3), dtype=np.uint8),
)
self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached
for ch in range(3):
np.copyto(resized[:, ch], np.interp(dst_x, src_x, colors[:, ch]), casting="unsafe")
colors = resized

View File

@@ -100,6 +100,7 @@ class DeviceHealthMixin:
interval = ACTIVE_INTERVAL if self._is_device_streaming(device_id) else IDLE_INTERVAL
await asyncio.sleep(interval)
except asyncio.CancelledError:
logger.debug("Device health monitor cancelled for %s", device_id)
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")

View File

@@ -193,6 +193,7 @@ class KCTargetProcessor(TargetProcessor):
try:
await self._task
except asyncio.CancelledError:
logger.debug("KC target processor task cancelled")
pass
self._task = None
@@ -476,7 +477,8 @@ class KCTargetProcessor(TargetProcessor):
try:
await ws.send_text(message)
return True
except Exception:
except Exception as e:
logger.debug("KC WS send failed: %s", e)
return False
clients = list(self._ws_clients)

View File

@@ -11,7 +11,6 @@ releases them.
from dataclasses import dataclass
from typing import Dict, Optional
import httpx
import numpy as np
from wled_controller.core.capture_engines import EngineRegistry
@@ -54,17 +53,19 @@ class LiveStreamManager:
enabling sharing at every level of the stream chain.
"""
def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None):
def __init__(self, picture_source_store, capture_template_store=None, pp_template_store=None, asset_store=None):
"""Initialize the live stream manager.
Args:
picture_source_store: PictureSourceStore for resolving stream configs
capture_template_store: TemplateStore for resolving capture engine settings
pp_template_store: PostprocessingTemplateStore for resolving filter chains
asset_store: AssetStore for resolving asset IDs to file paths
"""
self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
self._asset_store = asset_store
self._streams: Dict[str, _LiveStreamEntry] = {}
def acquire(self, picture_source_id: str) -> LiveStream:
@@ -268,6 +269,21 @@ class LiveStreamManager:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
return resolved
def _resolve_asset_path(self, asset_id: str | None, label: str) -> str:
"""Resolve an asset ID to its on-disk file path string.
Raises:
ValueError: If asset not found, deleted, or file missing.
"""
if not asset_id:
raise ValueError(f"{label} has no asset ID configured")
if not self._asset_store:
raise ValueError(f"AssetStore not available to resolve {label}")
path = self._asset_store.get_file_path(asset_id)
if not path:
raise ValueError(f"Asset {asset_id} not found or file missing for {label}")
return str(path)
def _create_video_live_stream(self, config):
"""Create a VideoCaptureLiveStream from a VideoCaptureSource config."""
if not _has_video:
@@ -275,8 +291,9 @@ class LiveStreamManager:
"OpenCV is required for video stream support. "
"Install it with: pip install opencv-python-headless"
)
video_path = self._resolve_asset_path(config.video_asset_id, "video source")
stream = VideoCaptureLiveStream(
url=config.url,
url=video_path,
loop=config.loop,
playback_speed=config.playback_speed,
start_time=config.start_time,
@@ -300,27 +317,18 @@ class LiveStreamManager:
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
image = self._load_static_image(config.image_source)
image_path = self._resolve_asset_path(config.image_asset_id, "static image source")
image = self._load_static_image(image_path)
return StaticImageLiveStream(image)
@staticmethod
def _load_static_image(image_source: str) -> np.ndarray:
"""Load a static image from URL or file path, return as RGB numpy array.
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
This is acceptable because acquire() (the only caller chain) is always
invoked from background worker threads, never from the async event loop.
"""
def _load_static_image(file_path: str) -> np.ndarray:
"""Load a static image from a local file path, return as RGB numpy array."""
from pathlib import Path
from wled_controller.utils.image_codec import load_image_bytes, load_image_file
from wled_controller.utils.image_codec import load_image_file
if image_source.startswith(("http://", "https://")):
response = httpx.get(image_source, timeout=15.0, follow_redirects=True)
response.raise_for_status()
return load_image_bytes(response.content)
else:
path = Path(image_source)
if not path.exists():
raise FileNotFoundError(f"Image file not found: {image_source}")
return load_image_file(path)
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Image file not found: {file_path}")
return load_image_file(path)

View File

@@ -40,6 +40,8 @@ class MappedColorStripStream(ColorStripStream):
# zone_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing
self._resize_cache: Dict[tuple, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# ── ColorStripStream interface ──────────────────────────────
@@ -210,9 +212,16 @@ class MappedColorStripStream(ColorStripStream):
# Resize sub-stream output to zone length if needed
if len(colors) != zone_len:
src_x = np.linspace(0, 1, len(colors))
dst_x = np.linspace(0, 1, zone_len)
resized = np.empty((zone_len, 3), dtype=np.uint8)
rkey = (len(colors), zone_len)
cached = self._resize_cache.get(rkey)
if cached is None:
cached = (
np.linspace(0, 1, len(colors)),
np.linspace(0, 1, zone_len),
np.empty((zone_len, 3), dtype=np.uint8),
)
self._resize_cache[rkey] = cached
src_x, dst_x, resized = cached
for ch in range(3):
np.copyto(
resized[:, ch],

View File

@@ -38,7 +38,8 @@ def _collect_system_snapshot() -> dict:
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
snapshot["gpu_util"] = float(util.gpu)
snapshot["gpu_temp"] = float(temp)
except Exception:
except Exception as e:
logger.debug("GPU metrics collection failed: %s", e)
pass
return snapshot
@@ -67,6 +68,7 @@ class MetricsHistory:
try:
await self._task
except asyncio.CancelledError:
logger.debug("Metrics history collection task cancelled")
pass
self._task = None
logger.info("Metrics history sampling stopped")

View File

@@ -6,6 +6,7 @@ from any thread (REST handler) while get_latest_colors() is called from the
target processor thread.
Uses a background render loop at 30 FPS with double-buffered output.
Optionally plays a notification sound via the asset store and sound player.
"""
import collections
@@ -61,8 +62,15 @@ class NotificationColorStripStream(ColorStripStream):
# Active effect state
self._active_effect: Optional[dict] = None # {"color": (r,g,b), "start": float}
# Asset store for resolving sound file paths (injected via set_asset_store)
self._asset_store = None
self._update_from_source(source)
def set_asset_store(self, asset_store) -> None:
"""Inject asset store for resolving notification sound file paths."""
self._asset_store = asset_store
def _update_from_source(self, source) -> None:
"""Parse config from source dataclass."""
self._notification_effect = getattr(source, "notification_effect", "flash")
@@ -73,6 +81,11 @@ class NotificationColorStripStream(ColorStripStream):
self._app_filter_list = [a.lower() for a in getattr(source, "app_filter_list", [])]
self._auto_size = not getattr(source, "led_count", 0)
self._led_count = getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
# Sound config
self._sound_asset_id = getattr(source, "sound_asset_id", None)
self._sound_volume = float(getattr(source, "sound_volume", 1.0))
raw_app_sounds = dict(getattr(source, "app_sounds", {}))
self._app_sounds = {k.lower(): v for k, v in raw_app_sounds.items()}
with self._colors_lock:
self._colors: Optional[np.ndarray] = np.zeros((self._led_count, 3), dtype=np.uint8)
@@ -109,8 +122,52 @@ class NotificationColorStripStream(ColorStripStream):
# Priority: 0 = normal, 1 = high (high interrupts current effect)
priority = 1 if color_override else 0
self._event_queue.append({"color": color, "start": time.monotonic(), "priority": priority})
# Play notification sound
self._play_notification_sound(app_lower)
return True
def _play_notification_sound(self, app_lower: Optional[str]) -> None:
"""Resolve and play the notification sound for the given app."""
if self._asset_store is None:
return
# Resolve sound: per-app override > global sound_asset_id
sound_asset_id = None
volume = self._sound_volume
if app_lower and app_lower in self._app_sounds:
override = self._app_sounds[app_lower]
if isinstance(override, dict):
# sound_asset_id=None in override means mute this app
if "sound_asset_id" not in override:
# No override entry, fall through to global
sound_asset_id = self._sound_asset_id
else:
sound_asset_id = override.get("sound_asset_id")
if sound_asset_id is None:
return # Muted for this app
override_volume = override.get("volume")
if override_volume is not None:
volume = float(override_volume)
else:
sound_asset_id = self._sound_asset_id
if not sound_asset_id:
return
file_path = self._asset_store.get_file_path(sound_asset_id)
if file_path is None:
logger.debug(f"Sound asset not found: {sound_asset_id}")
return
try:
from wled_controller.utils.sound_player import play_sound_async
play_sound_async(file_path, volume=volume)
except Exception as e:
logger.error(f"Failed to play notification sound: {e}")
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start)."""
if self._auto_size and device_led_count > 0:

View File

@@ -12,9 +12,11 @@ Supported platforms:
import asyncio
import collections
import json
import platform
import threading
import time
from pathlib import Path
from typing import Dict, List, Optional, Set
from wled_controller.utils import get_logger
@@ -22,6 +24,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
_POLL_INTERVAL = 0.5 # seconds between polls (Windows only)
_HISTORY_FILE = Path("data/notification_history.json")
_HISTORY_MAX = 50
# Module-level singleton for dependency access
_instance: Optional["OsNotificationListener"] = None
@@ -48,7 +52,8 @@ def _import_winrt_notifications():
)
from winrt.windows.ui.notifications import NotificationKinds
return UserNotificationListener, UserNotificationListenerAccessStatus, NotificationKinds, "winrt"
except ImportError:
except ImportError as e:
logger.debug("winrt notification packages not available, trying winsdk: %s", e)
pass
# Fallback: winsdk (~35MB, may already be installed)
@@ -282,7 +287,8 @@ class OsNotificationListener:
self._available = False
self._backend = None
# Recent notification history (thread-safe deque, newest first)
self._history: collections.deque = collections.deque(maxlen=50)
self._history: collections.deque = collections.deque(maxlen=_HISTORY_MAX)
self._load_history()
@property
def available(self) -> bool:
@@ -308,6 +314,7 @@ class OsNotificationListener:
if self._backend:
self._backend.stop()
self._backend = None
self._save_history()
logger.info("OS notification listener stopped")
@property
@@ -315,6 +322,29 @@ class OsNotificationListener:
"""Return recent notification history (newest first)."""
return list(self._history)
def _load_history(self) -> None:
"""Load persisted notification history from disk."""
try:
if _HISTORY_FILE.exists():
data = json.loads(_HISTORY_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
for entry in data[:_HISTORY_MAX]:
self._history.append(entry)
logger.info(f"Loaded {len(self._history)} notification history entries")
except Exception as exc:
logger.warning(f"Failed to load notification history: {exc}")
def _save_history(self) -> None:
"""Persist notification history to disk."""
try:
_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
_HISTORY_FILE.write_text(
json.dumps(list(self._history), ensure_ascii=False),
encoding="utf-8",
)
except Exception as exc:
logger.warning(f"Failed to save notification history: {exc}")
def _on_new_notification(self, app_name: Optional[str]) -> None:
"""Handle a new OS notification — fire matching streams."""
from wled_controller.storage.color_strip_source import NotificationColorStripSource
@@ -347,5 +377,6 @@ class OsNotificationListener:
"filtered": filtered,
}
self._history.appendleft(entry)
self._save_history()
logger.info(f"OS notification captured: app={app_name!r}, fired={fired}, filtered={filtered}")

View File

@@ -28,6 +28,20 @@ from wled_controller.core.processing.auto_restart import (
RESTART_MAX_ATTEMPTS as _RESTART_MAX_ATTEMPTS,
RESTART_WINDOW_SEC as _RESTART_WINDOW_SEC,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.processing.device_health import DeviceHealthMixin
from wled_controller.core.processing.device_test_mode import DeviceTestModeMixin
from wled_controller.utils import get_logger
@@ -44,19 +58,20 @@ class ProcessorDependencies:
Keeps the constructor signature stable when new stores are added.
"""
picture_source_store: object = None
capture_template_store: object = None
pp_template_store: object = None
pattern_template_store: object = None
device_store: object = None
color_strip_store: object = None
audio_source_store: object = None
audio_template_store: object = None
value_source_store: object = None
sync_clock_manager: object = None
cspt_store: object = None
gradient_store: object = None
weather_manager: object = None
picture_source_store: Optional[PictureSourceStore] = None
capture_template_store: Optional[TemplateStore] = None
pp_template_store: Optional[PostprocessingTemplateStore] = None
pattern_template_store: Optional[PatternTemplateStore] = None
device_store: Optional[DeviceStore] = None
color_strip_store: Optional[ColorStripStore] = None
audio_source_store: Optional[AudioSourceStore] = None
audio_template_store: Optional[AudioTemplateStore] = None
value_source_store: Optional[ValueSourceStore] = None
sync_clock_manager: Optional[SyncClockManager] = None
cspt_store: Optional[ColorStripProcessingTemplateStore] = None
gradient_store: Optional[GradientStore] = None
weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
@dataclass
@@ -119,7 +134,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
self._value_source_store = deps.value_source_store
self._cspt_store = deps.cspt_store
self._live_stream_manager = LiveStreamManager(
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store
deps.picture_source_store, deps.capture_template_store, deps.pp_template_store,
asset_store=deps.asset_store,
)
self._audio_capture_manager = AudioCaptureManager()
self._sync_clock_manager = deps.sync_clock_manager
@@ -133,6 +149,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
cspt_store=deps.cspt_store,
gradient_store=deps.gradient_store,
weather_manager=deps.weather_manager,
asset_store=deps.asset_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=deps.value_source_store,
@@ -206,7 +223,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
dev = self._device_store.get_device(ds.device_id)
for key, default in self._DEVICE_FIELD_DEFAULTS.items():
extras[key] = getattr(dev, key, default)
except ValueError:
except ValueError as e:
logger.debug("Device %s not found in store, using defaults: %s", ds.device_id, e)
pass
return DeviceInfo(
@@ -348,7 +366,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
try:
dev = self._device_store.get_device(device_id)
rgbw = getattr(dev, "rgbw", False)
except ValueError:
except ValueError as e:
logger.debug("Device %s not found for RGBW lookup: %s", device_id, e)
pass
return {
"device_id": device_id,
@@ -523,7 +542,8 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
try:
dev = self._device_store.get_device(proc.device_id)
dev_name = dev.name
except ValueError:
except ValueError as e:
logger.debug("Device %s not found for name lookup: %s", proc.device_id, e)
pass
raise RuntimeError(
f"Device '{dev_name}' is already being processed by target {tgt_name}"

View File

@@ -43,11 +43,18 @@ class SyncClockRuntime:
"""Pause-aware elapsed seconds since creation/last reset.
Returns *real* (wall-clock) elapsed time, not speed-scaled.
Lock-free: under CPython's GIL, reading individual attributes is
atomic. pause()/resume() update _offset and _epoch under a lock,
so the reader sees a consistent pre- or post-update snapshot.
The worst case is a one-frame stale value, which is imperceptible.
"""
with self._lock:
if not self._running:
return self._offset
return self._offset + (time.perf_counter() - self._epoch)
running = self._running
offset = self._offset
epoch = self._epoch
if not running:
return offset
return offset + (time.perf_counter() - epoch)
# ── Control ────────────────────────────────────────────────────

View File

@@ -207,7 +207,8 @@ class AudioValueStream(ValueStream):
tpl = self._audio_template_store.get_template(template_id)
self._audio_engine_type = tpl.engine_type
self._audio_engine_config = tpl.engine_config
except ValueError:
except ValueError as e:
logger.warning("Audio template %s not found for value stream, using default engine: %s", template_id, e)
pass
except ValueError as e:
logger.warning(f"Failed to resolve audio source {self._audio_source_id}: {e}")
@@ -671,7 +672,8 @@ class ValueStreamManager:
"""Hot-update the shared stream for the given ValueSource."""
try:
source = self._value_source_store.get_source(vs_id)
except ValueError:
except ValueError as e:
logger.debug("Value source %s not found for hot-update: %s", vs_id, e)
return
stream = self._streams.get(vs_id)

View File

@@ -181,7 +181,8 @@ class WeatherColorStripStream(ColorStripStream):
if self._weather_source_id:
try:
self._weather_manager.release(self._weather_source_id)
except Exception:
except Exception as e:
logger.debug("Weather source release during update: %s", e)
pass
self._weather_source_id = new_ws_id
if new_ws_id:
@@ -208,7 +209,8 @@ class WeatherColorStripStream(ColorStripStream):
# are looked up at the ProcessorManager level when the stream
# is created. For now, return None and use wall time.
return None
except Exception:
except Exception as e:
logger.debug("Sync clock lookup failed for weather stream: %s", e)
return None
def _animate_loop(self) -> None:
@@ -278,5 +280,6 @@ class WeatherColorStripStream(ColorStripStream):
return DEFAULT_WEATHER
try:
return self._weather_manager.get_data(self._weather_source_id)
except Exception:
except Exception as e:
logger.debug("Weather data fetch failed, using default: %s", e)
return DEFAULT_WEATHER

View File

@@ -72,6 +72,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_cache_key: tuple = (0, 0)
self._fit_cache_src: Optional[np.ndarray] = None
self._fit_cache_dst: Optional[np.ndarray] = None
self._fit_result_buf: Optional[np.ndarray] = None
# LED preview WebSocket clients
self._preview_clients: list = []
@@ -207,6 +208,7 @@ class WledTargetProcessor(TargetProcessor):
try:
await self._task
except asyncio.CancelledError:
logger.debug("WLED target processor task cancelled")
pass
self._task = None
await asyncio.sleep(0.05)
@@ -341,7 +343,8 @@ class WledTargetProcessor(TargetProcessor):
try:
resp = await client.get(f"{device_url}/json/info")
return resp.status_code == 200
except Exception:
except Exception as e:
logger.debug("Device probe failed for %s: %s", device_url, e)
return False
def get_display_index(self) -> Optional[int]:
@@ -525,7 +528,8 @@ class WledTargetProcessor(TargetProcessor):
async def _send_preview_to(ws, data: bytes) -> None:
try:
await ws.send_bytes(data)
except Exception:
except Exception as e:
logger.debug("LED preview WS send failed: %s", e)
pass
def remove_led_preview_client(self, ws) -> None:
@@ -569,7 +573,8 @@ class WledTargetProcessor(TargetProcessor):
try:
await ws.send_bytes(data)
return True
except Exception:
except Exception as e:
logger.debug("LED preview broadcast WS send failed: %s", e)
return False
clients = list(self._preview_clients)
@@ -591,11 +596,62 @@ class WledTargetProcessor(TargetProcessor):
self._fit_cache_src = np.linspace(0, 1, n)
self._fit_cache_dst = np.linspace(0, 1, device_led_count)
self._fit_cache_key = key
result = np.column_stack([
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]).astype(np.uint8)
for ch in range(colors.shape[1])
])
return result
self._fit_result_buf = np.empty((device_led_count, 3), dtype=np.uint8)
buf = self._fit_result_buf
for ch in range(min(colors.shape[1], 3)):
np.copyto(
buf[:, ch],
np.interp(self._fit_cache_dst, self._fit_cache_src, colors[:, ch]),
casting="unsafe",
)
return buf
async def _send_to_device(self, send_colors: np.ndarray) -> float:
"""Send colors to LED device and return send time in ms."""
t_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
return (time.perf_counter() - t_start) * 1000
@staticmethod
def _emit_diagnostics(
target_id: str,
sleep_jitters: collections.deque,
iter_times: collections.deque,
slow_iters: collections.deque,
frame_time: float,
diag_interval: float,
) -> None:
"""Log periodic timing diagnostics and clear the deques."""
if sleep_jitters:
jitters = [a - r for r, a in sleep_jitters]
avg_j = sum(jitters) / len(jitters)
max_j = max(jitters)
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
logger.info(
f"[DIAG] {target_id} sleep jitter: "
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
f"(n={len(sleep_jitters)})"
)
if iter_times:
avg_iter = sum(iter_times) / len(iter_times)
max_iter = max(iter_times)
logger.info(
f"[DIAG] {target_id} iter: "
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
f"target={frame_time*1000:.1f}ms iters={len(iter_times)}"
)
if slow_iters:
logger.warning(
f"[DIAG] {target_id} slow iterations: "
f"{len(slow_iters)} in last {diag_interval}s — "
f"{list(slow_iters)[:5]}"
)
sleep_jitters.clear()
slow_iters.clear()
iter_times.clear()
async def _processing_loop(self) -> None:
"""Main processing loop — poll CSS stream -> brightness -> send."""
@@ -608,7 +664,9 @@ class WledTargetProcessor(TargetProcessor):
def _fps_current_from_timestamps():
"""Count timestamps within the last second."""
cutoff = time.perf_counter() - 1.0
return sum(1 for ts in send_timestamps if ts > cutoff)
while send_timestamps and send_timestamps[0] <= cutoff:
send_timestamps.popleft()
return len(send_timestamps)
last_send_time = 0.0
_last_preview_broadcast = 0.0
@@ -844,10 +902,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_to_device(prev_frame_ref, _total_leds),
cur_brightness,
)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
await self._send_to_device(send_colors)
now = time.perf_counter()
last_send_time = now
send_timestamps.append(now)
@@ -879,10 +934,7 @@ class WledTargetProcessor(TargetProcessor):
self._fit_to_device(prev_frame_ref, _total_leds),
cur_brightness,
)
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
await self._send_to_device(send_colors)
now = time.perf_counter()
last_send_time = now
send_timestamps.append(now)
@@ -910,12 +962,7 @@ class WledTargetProcessor(TargetProcessor):
# Send to LED device
if not self._is_running or self._led_client is None:
break
t_send_start = time.perf_counter()
if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors)
else:
await self._led_client.send_pixels(send_colors)
send_ms = (time.perf_counter() - t_send_start) * 1000
send_ms = await self._send_to_device(send_colors)
now = time.perf_counter()
last_send_time = now
@@ -984,33 +1031,11 @@ class WledTargetProcessor(TargetProcessor):
# Periodic diagnostics report
if iter_end >= _diag_next_report:
_diag_next_report = iter_end + _diag_interval
if _diag_sleep_jitters:
jitters = [a - r for r, a in _diag_sleep_jitters]
avg_j = sum(jitters) / len(jitters)
max_j = max(jitters)
p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j
logger.info(
f"[DIAG] {self._target_id} sleep jitter: "
f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms "
f"(n={len(_diag_sleep_jitters)})"
)
if _diag_iter_times:
avg_iter = sum(_diag_iter_times) / len(_diag_iter_times)
max_iter = max(_diag_iter_times)
logger.info(
f"[DIAG] {self._target_id} iter: "
f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms "
f"target={frame_time*1000:.1f}ms iters={len(_diag_iter_times)}"
)
if _diag_slow_iters:
logger.warning(
f"[DIAG] {self._target_id} slow iterations: "
f"{len(_diag_slow_iters)} in last {_diag_interval}s — "
f"{list(_diag_slow_iters)[:5]}"
)
_diag_sleep_jitters.clear()
_diag_slow_iters.clear()
_diag_iter_times.clear()
self._emit_diagnostics(
self._target_id, _diag_sleep_jitters,
_diag_iter_times, _diag_slow_iters,
frame_time, _diag_interval,
)
except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}")

View File

@@ -131,6 +131,7 @@ class UpdateService:
except Exception as exc:
logger.error("Update check failed: %s", exc, exc_info=True)
except asyncio.CancelledError:
logger.debug("Update check loop cancelled")
pass
# ── Core check logic ───────────────────────────────────────
@@ -172,7 +173,8 @@ class UpdateService:
continue
try:
normalize_version(release.version)
except Exception:
except Exception as e:
logger.debug("Skipping release with unparseable version %s: %s", release.version, e)
continue
if is_newer(release.version, __version__):
return release
@@ -317,6 +319,10 @@ class UpdateService:
shutil.rmtree(staging)
staging.mkdir(parents=True)
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.namelist():
target = (staging / member).resolve()
if not target.is_relative_to(staging.resolve()):
raise ValueError(f"Zip entry escapes target directory: {member}")
zf.extractall(staging)
await asyncio.to_thread(_extract)

View File

@@ -4,10 +4,13 @@ Normalizes Gitea-style tags (v0.3.0-alpha.1) to PEP 440 (0.3.0a1)
so that ``packaging.version.Version`` can compare them correctly.
"""
import logging
import re
from packaging.version import InvalidVersion, Version
logger = logging.getLogger(__name__)
_PRE_MAP = {
"alpha": "a",
@@ -41,5 +44,6 @@ def is_newer(candidate: str, current: str) -> bool:
"""
try:
return normalize_version(candidate) > normalize_version(current)
except InvalidVersion:
except InvalidVersion as e:
logger.debug("Unparseable version string in comparison: %s", e)
return False

View File

@@ -34,6 +34,7 @@ from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.storage.asset_store import AssetStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.automations.automation_engine import AutomationEngine
@@ -80,6 +81,12 @@ cspt_store = ColorStripProcessingTemplateStore(db)
gradient_store = GradientStore(db)
gradient_store.migrate_palette_references(color_strip_store)
weather_source_store = WeatherSourceStore(db)
asset_store = AssetStore(db, config.assets.assets_dir)
# Import prebuilt notification sounds on first run
_prebuilt_sounds_dir = Path(__file__).parent / "data" / "prebuilt_sounds"
asset_store.import_prebuilt_sounds(_prebuilt_sounds_dir)
sync_clock_manager = SyncClockManager(sync_clock_store)
weather_manager = WeatherManager(weather_source_store)
@@ -98,6 +105,7 @@ processor_manager = ProcessorManager(
cspt_store=cspt_store,
gradient_store=gradient_store,
weather_manager=weather_manager,
asset_store=asset_store,
)
)
@@ -190,6 +198,7 @@ async def lifespan(app: FastAPI):
weather_source_store=weather_source_store,
weather_manager=weather_manager,
update_service=update_service,
asset_store=asset_store,
)
# Register devices in processor manager for health monitoring

View File

@@ -7,6 +7,10 @@ mechanism the system tray "Shutdown" menu item uses.
from typing import Any, Optional
from wled_controller.utils import get_logger
logger = get_logger(__name__)
_server: Optional[Any] = None # uvicorn.Server
_tray: Optional[Any] = None # TrayManager
@@ -39,7 +43,8 @@ def request_shutdown() -> None:
try:
from wled_controller.main import _save_all_stores
_save_all_stores()
except Exception:
except Exception as e:
logger.debug("Best-effort store save on shutdown failed: %s", e)
pass # best-effort; lifespan handler is the backup
if _server is not None:
@@ -55,5 +60,6 @@ def _broadcast_restarting() -> None:
pm = _deps.get("processor_manager")
if pm is not None:
pm.fire_event({"type": "server_restarting"})
except Exception:
except Exception as e:
logger.debug("Failed to broadcast server_restarting event: %s", e)
pass

View File

@@ -215,21 +215,6 @@
align-items: center;
}
.btn-browse-apps {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.btn-browse-apps:hover {
border-color: var(--primary-color);
background: rgba(33, 150, 243, 0.1);
}
/* Webhook URL row */
.webhook-url-row {

View File

@@ -1466,64 +1466,42 @@
line-height: 1;
}
/* ── Notification app color mappings ─────────────────────────── */
/* ── Notification per-app overrides (unified color + sound) ──── */
.notif-app-color-row {
display: flex;
.notif-override-row {
display: grid;
grid-template-columns: 1fr auto auto auto;
gap: 4px 4px;
align-items: center;
gap: 6px;
margin-bottom: 4px;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.notif-app-color-row .notif-app-name {
flex: 1;
.notif-override-row .notif-override-name,
.notif-override-row .notif-override-sound {
min-width: 0;
}
.notif-app-color-row .notif-app-color {
width: 28px;
height: 28px;
/* Sound select spans the first column, volume spans browse+color columns */
.notif-override-row .notif-override-sound {
grid-column: 1;
}
.notif-override-row .notif-override-volume {
grid-column: 2 / 4;
width: 100%;
}
.notif-override-row .notif-override-color {
width: 26px;
height: 26px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 1px;
cursor: pointer;
background: transparent;
flex-shrink: 0;
}
.notif-app-browse,
.notif-app-color-remove {
background: none;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
min-width: unset;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color 0.2s, color 0.2s;
}
.notif-app-browse svg {
width: 14px;
height: 14px;
}
.notif-app-color-remove {
font-size: 0.75rem;
line-height: 1;
}
.notif-app-browse:hover,
.notif-app-color-remove:hover {
border-color: var(--primary-color);
color: var(--text-color);
}
/* ── Notification history list ─────────────────────────────────── */
@@ -1866,3 +1844,128 @@ body.composite-layer-dragging .composite-layer-drag-handle {
opacity: 0 !important;
}
/* ── File drop zone ── */
.file-dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 20px;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-color);
cursor: pointer;
transition: border-color var(--duration-normal) ease,
background var(--duration-normal) ease,
box-shadow var(--duration-normal) ease;
user-select: none;
outline: none;
}
.file-dropzone:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 5%, var(--bg-color));
}
.file-dropzone:focus-visible {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15);
}
.file-dropzone.dragover {
border-color: var(--primary-color);
border-style: solid;
background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-color));
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.15),
inset 0 0 20px rgba(76, 175, 80, 0.06);
}
.file-dropzone.has-file {
border-style: solid;
border-color: var(--primary-color);
padding: 16px 20px;
}
.file-dropzone-icon {
color: var(--text-muted);
transition: color var(--duration-normal) ease, transform var(--duration-normal) var(--ease-spring);
}
.file-dropzone:hover .file-dropzone-icon,
.file-dropzone.dragover .file-dropzone-icon {
color: var(--primary-color);
transform: translateY(-2px);
}
.file-dropzone.has-file .file-dropzone-icon {
color: var(--primary-color);
}
.file-dropzone-text {
text-align: center;
}
.file-dropzone-label {
font-size: 0.9rem;
color: var(--text-muted);
transition: color var(--duration-normal) ease;
}
.file-dropzone:hover .file-dropzone-label {
color: var(--text-secondary);
}
.file-dropzone-info {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: var(--radius-sm);
background: var(--bg-secondary);
width: 100%;
max-width: 100%;
overflow: hidden;
}
.file-dropzone-filename {
flex: 1;
font-size: 0.88rem;
font-weight: var(--weight-medium);
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-dropzone-filesize {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
font-family: var(--font-mono);
}
.file-dropzone-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
line-height: 1;
padding: 0;
}
.file-dropzone-remove:hover {
background: var(--danger-color);
color: white;
}

View File

@@ -137,7 +137,7 @@ import {
previewCSSFromEditor,
copyEndpointUrl,
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
@@ -464,7 +464,7 @@ Object.assign(window, {
previewCSSFromEditor,
copyEndpointUrl,
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,

View File

@@ -84,3 +84,6 @@ export const circleOff = '<path d="m2 2 20 20"/><path d="M8.35 2.69A10 10 0 0
export const externalLink = '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>';
export const thermometer = '<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z"/>';
export const xIcon = '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>';
export const fileUp = '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>';
export const fileAudio = '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><circle cx="10" cy="16" r="2"/><path d="M12 12v4"/>';
export const packageIcon = '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>';

View File

@@ -186,3 +186,17 @@ export const ICON_LIST_CHECKS = _svg(P.listChecks);
export const ICON_CIRCLE_OFF = _svg(P.circleOff);
export const ICON_EXTERNAL_LINK = _svg(P.externalLink);
export const ICON_X = _svg(P.xIcon);
export const ICON_FILE_UP = _svg(P.fileUp);
export const ICON_FILE_AUDIO = _svg(P.fileAudio);
export const ICON_ASSET = _svg(P.packageIcon);
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
const map: Record<string, string> = {
sound: _svg(P.volume2),
image: _svg(P.image),
video: _svg(P.film),
other: _svg(P.fileText),
};
return map[assetType] || _svg(P.fileText);
}

View File

@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
SyncClock, WeatherSource, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
} from '../types.ts';
@@ -226,6 +226,7 @@ export let _cachedValueSources: ValueSource[] = [];
// Sync clocks
export let _cachedSyncClocks: SyncClock[] = [];
export let _cachedWeatherSources: WeatherSource[] = [];
export let _cachedAssets: Asset[] = [];
// Automations
export let _automationsCache: Automation[] | null = null;
@@ -289,6 +290,12 @@ export const weatherSourcesCache = new DataCache<WeatherSource[]>({
});
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
export const assetsCache = new DataCache<Asset[]>({
endpoint: '/assets',
extractData: json => json.assets || [],
});
assetsCache.subscribe(v => { _cachedAssets = v; });
export const filtersCache = new DataCache<FilterDef[]>({
endpoint: '/filters',
extractData: json => json.filters || [],

View File

@@ -0,0 +1,475 @@
/**
* Assets — file upload/download, CRUD, cards, modal.
*/
import { _cachedAssets, assetsCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml, API_BASE, getHeaders } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { Asset } from '../types.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
const ICON_PLAY_SOUND = _icon(P.play);
const ICON_UPLOAD = _icon(P.fileUp);
const ICON_RESTORE = _icon(P.rotateCcw);
// ── Helpers ──
let _dropzoneInitialized = false;
/** Initialise the drop-zone wiring for the upload modal (once). */
function initUploadDropzone(): void {
if (_dropzoneInitialized) return;
_dropzoneInitialized = true;
const dropzone = document.getElementById('asset-upload-dropzone')!;
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
const infoEl = document.getElementById('asset-upload-file-info')!;
const nameEl = document.getElementById('asset-upload-file-name')!;
const sizeEl = document.getElementById('asset-upload-file-size')!;
const removeBtn = document.getElementById('asset-upload-file-remove')!;
const showFile = (file: File) => {
nameEl.textContent = file.name;
sizeEl.textContent = formatFileSize(file.size);
infoEl.style.display = '';
dropzone.classList.add('has-file');
// Hide the prompt text when a file is selected
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (labelEl) labelEl.style.display = 'none';
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (iconEl) iconEl.style.display = 'none';
};
const clearFile = () => {
fileInput.value = '';
infoEl.style.display = 'none';
dropzone.classList.remove('has-file');
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (labelEl) labelEl.style.display = '';
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (iconEl) iconEl.style.display = '';
};
// Click → open file picker
dropzone.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.file-dropzone-remove')) return;
fileInput.click();
});
// Keyboard: Enter/Space
dropzone.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
fileInput.click();
}
});
// File input change
fileInput.addEventListener('change', () => {
if (fileInput.files && fileInput.files.length > 0) {
showFile(fileInput.files[0]);
} else {
clearFile();
}
});
// Drag events
let dragCounter = 0;
dropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', () => {
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.remove('dragover');
}
});
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
});
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.remove('dragover');
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
fileInput.files = files;
showFile(files[0]);
}
});
// Remove button
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
clearFile();
});
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAssetTypeLabel(assetType: string): string {
const map: Record<string, string> = {
sound: t('asset.type.sound'),
image: t('asset.type.image'),
video: t('asset.type.video'),
other: t('asset.type.other'),
};
return map[assetType] || assetType;
}
// ── Card builder ──
export function createAssetCard(asset: Asset): string {
const icon = getAssetTypeIcon(asset.asset_type);
const sizeStr = formatFileSize(asset.size_bytes);
const prebuiltBadge = asset.prebuilt
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
: '';
let playBtn = '';
if (asset.asset_type === 'sound') {
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
}
return wrapCard({
dataAttr: 'data-id',
id: asset.id,
removeOnclick: `deleteAsset('${asset.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(asset.name)}">
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
${prebuiltBadge}
</div>
${renderTagChips(asset.tags)}`,
actions: `
${playBtn}
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
});
}
// ── Sound playback ──
let _currentAudio: HTMLAudioElement | null = null;
async function _playAssetSound(assetId: string) {
if (_currentAudio) {
_currentAudio.pause();
_currentAudio = null;
}
try {
const res = await fetchWithAuth(`/assets/${assetId}/file`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl);
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
audio.play().catch(() => {});
_currentAudio = audio;
} catch { /* ignore playback errors */ }
}
// ── Modal ──
let _assetTagsInput: TagInput | null = null;
let _uploadTagsInput: TagInput | null = null;
class AssetEditorModal extends Modal {
constructor() { super('asset-editor-modal'); }
snapshotValues() {
return {
name: (document.getElementById('asset-editor-name') as HTMLInputElement).value,
description: (document.getElementById('asset-editor-description') as HTMLInputElement).value,
tags: JSON.stringify(_assetTagsInput ? _assetTagsInput.getValue() : []),
};
}
onForceClose() {
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
}
}
const assetEditorModal = new AssetEditorModal();
class AssetUploadModal extends Modal {
constructor() { super('asset-upload-modal'); }
snapshotValues() {
return {
name: (document.getElementById('asset-upload-name') as HTMLInputElement).value,
file: (document.getElementById('asset-upload-file') as HTMLInputElement).value,
tags: JSON.stringify(_uploadTagsInput ? _uploadTagsInput.getValue() : []),
};
}
onForceClose() {
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
}
}
const assetUploadModal = new AssetUploadModal();
// ── CRUD: Upload ──
export async function showAssetUploadModal(): Promise<void> {
const titleEl = document.getElementById('asset-upload-title')!;
titleEl.innerHTML = `${ICON_UPLOAD} ${t('asset.upload')}`;
(document.getElementById('asset-upload-name') as HTMLInputElement).value = '';
(document.getElementById('asset-upload-description') as HTMLInputElement).value = '';
(document.getElementById('asset-upload-file') as HTMLInputElement).value = '';
document.getElementById('asset-upload-error')!.style.display = 'none';
// Tags
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
const tagsContainer = document.getElementById('asset-upload-tags-container')!;
_uploadTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
// Reset dropzone visual state
const dropzone = document.getElementById('asset-upload-dropzone')!;
dropzone.classList.remove('has-file', 'dragover');
const dzLabel = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
if (dzLabel) dzLabel.style.display = '';
const dzIcon = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
if (dzIcon) dzIcon.style.display = '';
document.getElementById('asset-upload-file-info')!.style.display = 'none';
initUploadDropzone();
assetUploadModal.open();
assetUploadModal.snapshot();
}
export async function uploadAsset(): Promise<void> {
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
const nameInput = document.getElementById('asset-upload-name') as HTMLInputElement;
const descInput = document.getElementById('asset-upload-description') as HTMLInputElement;
const errorEl = document.getElementById('asset-upload-error')!;
if (!fileInput.files || fileInput.files.length === 0) {
errorEl.textContent = t('asset.error.no_file');
errorEl.style.display = '';
return;
}
const file = fileInput.files[0];
const formData = new FormData();
formData.append('file', file);
let url = `${API_BASE}/assets`;
const params = new URLSearchParams();
const name = nameInput.value.trim();
if (name) params.set('name', name);
const desc = descInput.value.trim();
if (desc) params.set('description', desc);
if (params.toString()) url += `?${params.toString()}`;
try {
const headers = getHeaders();
delete headers['Content-Type']; // Let browser set multipart boundary
const res = await fetch(url, {
method: 'POST',
headers,
body: formData,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Upload failed');
}
// Set tags via metadata update if any were specified
const tags = _uploadTagsInput ? _uploadTagsInput.getValue() : [];
const result = await res.json();
if (tags.length > 0 && result.id) {
await fetchWithAuth(`/assets/${result.id}`, {
method: 'PUT',
body: JSON.stringify({ tags }),
});
}
showToast(t('asset.uploaded'), 'success');
assetsCache.invalidate();
assetUploadModal.forceClose();
await loadPictureSources();
} catch (e: any) {
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
export function closeAssetUploadModal() {
assetUploadModal.close();
}
// ── CRUD: Edit metadata ──
export async function showAssetEditor(editId: string): Promise<void> {
const titleEl = document.getElementById('asset-editor-title')!;
const idInput = document.getElementById('asset-editor-id') as HTMLInputElement;
const nameInput = document.getElementById('asset-editor-name') as HTMLInputElement;
const descInput = document.getElementById('asset-editor-description') as HTMLInputElement;
const errorEl = document.getElementById('asset-editor-error')!;
errorEl.style.display = 'none';
const assets = await assetsCache.fetch();
const asset = assets.find(a => a.id === editId);
if (!asset) return;
titleEl.innerHTML = `${ICON_ASSET} ${t('asset.edit')}`;
idInput.value = asset.id;
nameInput.value = asset.name;
descInput.value = asset.description || '';
// Tags
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
const tagsContainer = document.getElementById('asset-editor-tags-container')!;
_assetTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
_assetTagsInput.setValue(asset.tags || []);
assetEditorModal.open();
assetEditorModal.snapshot();
}
export async function saveAssetMetadata(): Promise<void> {
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
const errorEl = document.getElementById('asset-editor-error')!;
if (!name) {
errorEl.textContent = t('asset.error.name_required');
errorEl.style.display = '';
return;
}
const tags = _assetTagsInput ? _assetTagsInput.getValue() : [];
try {
const res = await fetchWithAuth(`/assets/${id}`, {
method: 'PUT',
body: JSON.stringify({ name, description: description || null, tags }),
});
if (!res!.ok) {
const err = await res!.json();
throw new Error(err.detail);
}
showToast(t('asset.updated'), 'success');
assetsCache.invalidate();
assetEditorModal.forceClose();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
errorEl.textContent = e.message;
errorEl.style.display = '';
}
}
export function closeAssetEditorModal() {
assetEditorModal.close();
}
// ── CRUD: Delete ──
export async function deleteAsset(assetId: string): Promise<void> {
const ok = await showConfirm(t('asset.confirm_delete'));
if (!ok) return;
try {
await fetchWithAuth(`/assets/${assetId}`, { method: 'DELETE' });
showToast(t('asset.deleted'), 'success');
assetsCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('asset.error.delete_failed'), 'error');
}
}
// ── Restore prebuilt ──
export async function restorePrebuiltAssets(): Promise<void> {
try {
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
if (!res!.ok) {
const err = await res!.json();
throw new Error(err.detail);
}
const data = await res!.json();
if (data.restored_count > 0) {
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
} else {
showToast(t('asset.prebuilt_none_to_restore'), 'info');
}
assetsCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Download ──
async function _downloadAsset(assetId: string) {
const asset = _cachedAssets.find(a => a.id === assetId);
const filename = asset ? asset.filename : 'download';
try {
const res = await fetchWithAuth(`/assets/${assetId}/file`);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
} catch { /* ignore */ }
}
// ── Event delegation ──
export function initAssetDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: Event) => {
const btn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!btn) return;
const card = btn.closest('[data-id]') as HTMLElement | null;
if (!card || !card.closest('#stream-tab-assets')) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-id');
if (!action || !id) return;
e.stopPropagation();
if (action === 'edit') showAssetEditor(id);
else if (action === 'delete') deleteAsset(id);
else if (action === 'download') _downloadAsset(id);
else if (action === 'play') _playAssetSound(id);
});
}
// ── Expose to global scope for HTML template onclick handlers ──
window.showAssetUploadModal = showAssetUploadModal;
window.closeAssetUploadModal = closeAssetUploadModal;
window.uploadAsset = uploadAsset;
window.showAssetEditor = showAssetEditor;
window.closeAssetEditorModal = closeAssetEditorModal;
window.saveAssetMetadata = saveAssetMetadata;
window.deleteAsset = deleteAsset;
window.restorePrebuiltAssets = restorePrebuiltAssets;

View File

@@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { CardSection } from '../core/card-sections.ts';
import { updateTabBadge, updateSubTabHash } from './tabs.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB } from '../core/icons.ts';
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE, ICON_TRASH, ICON_CIRCLE_OFF, ICON_UNDO, ICON_WEB, ICON_SEARCH } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -766,7 +766,7 @@ function addAutomationConditionRow(condition: any) {
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>

View File

@@ -7,23 +7,33 @@ import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import {
ICON_SEARCH, ICON_CLONE,
ICON_SEARCH, ICON_CLONE, getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { attachNotificationAppPicker, NotificationAppPalette } from '../core/process-picker.ts';
import { _cachedAssets, assetsCache } from '../core/state.ts';
import { getBaseOrigin } from './settings.ts';
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
/* ── Notification state ───────────────────────────────────────── */
let _notificationAppColors: any[] = []; // [{app: '', color: '#...'}]
/** Return current app colors array (for dirty-check snapshot). */
export function notificationGetRawAppColors() {
return _notificationAppColors;
interface AppOverride {
app: string;
color: string;
sound_asset_id: string | null;
volume: number; // 0100
}
let _notificationAppOverrides: AppOverride[] = [];
/** Return current overrides array (for dirty-check snapshot). */
export function notificationGetRawAppOverrides() {
return _notificationAppOverrides;
}
let _notificationEffectIconSelect: any = null;
let _notificationFilterModeIconSelect: any = null;
@@ -58,50 +68,162 @@ export function onNotificationFilterModeChange() {
(document.getElementById('css-editor-notification-filter-list-group') as HTMLElement).style.display = mode === 'off' ? 'none' : '';
}
function _notificationAppColorsRenderList() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
list.innerHTML = _notificationAppColors.map((entry, i) => `
<div class="notif-app-color-row">
<input type="text" class="notif-app-name" data-idx="${i}" value="${escapeHtml(entry.app)}" placeholder="App name">
<button type="button" class="notif-app-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-app-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="notif-app-color-remove"
onclick="notificationRemoveAppColor(${i})">&#x2715;</button>
</div>
`).join('');
/* ── Unified per-app overrides (color + sound) ────────────────── */
// Wire up browse buttons to open process palette
list.querySelectorAll<HTMLButtonElement>('.notif-app-browse').forEach(btn => {
let _overrideEntitySelects: EntitySelect[] = [];
function _getSoundAssetItems() {
return _cachedAssets
.filter(a => a.asset_type === 'sound')
.map(a => ({ value: a.id, label: a.name, icon: getAssetTypeIcon('sound'), desc: a.filename }));
}
function _overridesSyncFromDom() {
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-override-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-override-color');
const sounds = list.querySelectorAll<HTMLSelectElement>('.notif-override-sound');
const volumes = list.querySelectorAll<HTMLInputElement>('.notif-override-volume');
if (names.length === _notificationAppOverrides.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppOverrides[i].app = names[i].value;
_notificationAppOverrides[i].color = colors[i].value;
_notificationAppOverrides[i].sound_asset_id = sounds[i].value || null;
_notificationAppOverrides[i].volume = parseInt(volumes[i].value);
}
}
}
function _overridesRenderList() {
const list = document.getElementById('notification-app-overrides-list') as HTMLElement | null;
if (!list) return;
_overrideEntitySelects.forEach(es => es.destroy());
_overrideEntitySelects = [];
const soundAssets = _cachedAssets.filter(a => a.asset_type === 'sound');
list.innerHTML = _notificationAppOverrides.map((entry, i) => {
const soundOpts = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
soundAssets.map(a =>
`<option value="${a.id}"${a.id === entry.sound_asset_id ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
).join('');
const volPct = entry.volume ?? 100;
return `
<div class="notif-override-row">
<input type="text" class="notif-override-name" data-idx="${i}" value="${escapeHtml(entry.app)}"
placeholder="${t('color_strip.notification.app_overrides.app_placeholder') || 'App name'}">
<button type="button" class="btn btn-icon btn-secondary notif-override-browse" data-idx="${i}"
title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<input type="color" class="notif-override-color" data-idx="${i}" value="${entry.color}">
<button type="button" class="btn btn-icon btn-secondary"
onclick="notificationRemoveAppOverride(${i})">&#x2715;</button>
<select class="notif-override-sound" data-idx="${i}">${soundOpts}</select>
<input type="range" class="notif-override-volume" data-idx="${i}" min="0" max="100" step="5" value="${volPct}"
title="${volPct}%"
oninput="this.title = this.value + '%'">
</div>`;
}).join('');
// Wire browse buttons
list.querySelectorAll<HTMLButtonElement>('.notif-override-browse').forEach(btn => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.idx!);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-app-name[data-idx="${idx}"]`);
const nameInput = list.querySelector<HTMLInputElement>(`.notif-override-name[data-idx="${idx}"]`);
if (!nameInput) return;
const picked = await NotificationAppPalette.pick({
current: nameInput.value,
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps...',
placeholder: t('color_strip.notification.search_apps') || 'Search notification apps',
});
if (picked !== undefined) {
nameInput.value = picked;
_notificationAppColorsSyncFromDom();
_overridesSyncFromDom();
}
});
});
// Wire EntitySelects for sound dropdowns
list.querySelectorAll<HTMLSelectElement>('.notif-override-sound').forEach(sel => {
const items = _getSoundAssetItems();
if (items.length > 0) {
const es = new EntitySelect({
target: sel,
getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
});
_overrideEntitySelects.push(es);
}
});
}
export function notificationAddAppColor() {
_notificationAppColorsSyncFromDom();
_notificationAppColors.push({ app: '', color: '#ffffff' });
_notificationAppColorsRenderList();
export function notificationAddAppOverride() {
_overridesSyncFromDom();
_notificationAppOverrides.push({ app: '', color: '#ffffff', sound_asset_id: null, volume: 100 });
_overridesRenderList();
}
export function notificationRemoveAppColor(i: number) {
_notificationAppColorsSyncFromDom();
_notificationAppColors.splice(i, 1);
_notificationAppColorsRenderList();
export function notificationRemoveAppOverride(i: number) {
_overridesSyncFromDom();
_notificationAppOverrides.splice(i, 1);
_overridesRenderList();
}
/** Split overrides into app_colors dict for the API. */
export function notificationGetAppColorsDict() {
_overridesSyncFromDom();
const dict: Record<string, string> = {};
for (const entry of _notificationAppOverrides) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
/** Split overrides into app_sounds dict for the API. */
export function notificationGetAppSoundsDict() {
_overridesSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppOverrides) {
if (!entry.app.trim()) continue;
if (entry.sound_asset_id || entry.volume !== 100) {
dict[entry.app.trim()] = {
sound_asset_id: entry.sound_asset_id || null,
volume: (entry.volume ?? 100) / 100,
};
}
}
return dict;
}
/* ── Notification sound — global EntitySelect ─────────────────── */
let _notifSoundEntitySelect: EntitySelect | null = null;
function _populateSoundOptions(sel: HTMLSelectElement, selectedId?: string | null) {
const sounds = _cachedAssets.filter(a => a.asset_type === 'sound');
sel.innerHTML = `<option value="">${t('color_strip.notification.sound.none')}</option>` +
sounds.map(a =>
`<option value="${a.id}"${a.id === selectedId ? ' selected' : ''}>${escapeHtml(a.name)}</option>`
).join('');
}
export function ensureNotifSoundEntitySelect() {
const sel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement | null;
if (!sel) return;
_populateSoundOptions(sel);
if (_notifSoundEntitySelect) _notifSoundEntitySelect.destroy();
const items = _getSoundAssetItems();
if (items.length > 0) {
_notifSoundEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getSoundAssetItems(),
placeholder: t('color_strip.notification.sound.search') || 'Search sounds…',
});
}
}
/* ── Test notification ────────────────────────────────────────── */
export async function testNotification(sourceId: string) {
try {
const resp = (await fetchWithAuth(`/color-strip-sources/${sourceId}/notify`, { method: 'POST' }))!;
@@ -194,29 +316,24 @@ async function _loadNotificationHistory() {
}
}
function _notificationAppColorsSyncFromDom() {
const list = document.getElementById('notification-app-colors-list') as HTMLElement | null;
if (!list) return;
const names = list.querySelectorAll<HTMLInputElement>('.notif-app-name');
const colors = list.querySelectorAll<HTMLInputElement>('.notif-app-color');
if (names.length === _notificationAppColors.length) {
for (let i = 0; i < names.length; i++) {
_notificationAppColors[i].app = names[i].value;
_notificationAppColors[i].color = colors[i].value;
}
}
/* ── Load / Reset state ───────────────────────────────────────── */
/**
* Merge app_colors and app_sounds dicts into unified overrides list.
* app_colors: {app: color}
* app_sounds: {app: {sound_asset_id, volume}}
*/
function _mergeOverrides(appColors: Record<string, string>, appSounds: Record<string, any>): AppOverride[] {
const appNames = new Set([...Object.keys(appColors), ...Object.keys(appSounds)]);
return [...appNames].map(app => ({
app,
color: appColors[app] || '#ffffff',
sound_asset_id: appSounds[app]?.sound_asset_id || null,
volume: Math.round((appSounds[app]?.volume ?? 1.0) * 100),
}));
}
export function notificationGetAppColorsDict() {
_notificationAppColorsSyncFromDom();
const dict: Record<string, any> = {};
for (const entry of _notificationAppColors) {
if (entry.app.trim()) dict[entry.app.trim()] = entry.color;
}
return dict;
}
export function loadNotificationState(css: any) {
export async function loadNotificationState(css: any) {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = !!css.os_listener;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = css.notification_effect || 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue(css.notification_effect || 'flash');
@@ -230,15 +347,27 @@ export function loadNotificationState(css: any) {
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
// App colors dict -> list
const ac = css.app_colors || {};
_notificationAppColors = Object.entries(ac).map(([app, color]) => ({ app, color }));
_notificationAppColorsRenderList();
// Ensure assets are loaded before populating sound dropdowns
await assetsCache.fetch();
// Sound (global)
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
_populateSoundOptions(soundSel, css.sound_asset_id);
if (soundSel) soundSel.value = css.sound_asset_id || '';
ensureNotifSoundEntitySelect();
if (_notifSoundEntitySelect && css.sound_asset_id) _notifSoundEntitySelect.setValue(css.sound_asset_id);
const volPct = Math.round((css.sound_volume ?? 1.0) * 100);
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = volPct as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = `${volPct}%`;
// Unified per-app overrides (merge app_colors + app_sounds)
_notificationAppOverrides = _mergeOverrides(css.app_colors || {}, css.app_sounds || {});
_overridesRenderList();
showNotificationEndpoint(css.id);
}
export function resetNotificationState() {
export async function resetNotificationState() {
(document.getElementById('css-editor-notification-os-listener') as HTMLInputElement).checked = true;
(document.getElementById('css-editor-notification-effect') as HTMLInputElement).value = 'flash';
if (_notificationEffectIconSelect) _notificationEffectIconSelect.setValue('flash');
@@ -250,8 +379,19 @@ export function resetNotificationState() {
(document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value = '';
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
_notificationAppColors = [];
_notificationAppColorsRenderList();
// Sound reset
const soundSel = document.getElementById('css-editor-notification-sound') as HTMLSelectElement;
_populateSoundOptions(soundSel);
if (soundSel) soundSel.value = '';
ensureNotifSoundEntitySelect();
(document.getElementById('css-editor-notification-volume') as HTMLInputElement).value = 100 as any;
(document.getElementById('css-editor-notification-volume-val') as HTMLElement).textContent = '100%';
// Clear overrides
_notificationAppOverrides = [];
_overridesRenderList();
showNotificationEndpoint(null);
}

View File

@@ -15,7 +15,7 @@ import {
import { EntitySelect } from '../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from './css-gradient-editor.ts';
import { testNotification, _getAnimationPayload, _colorCycleGetColors } from './color-strips.ts';
import { notificationGetAppColorsDict } from './color-strips-notification.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict } from './color-strips-notification.ts';
/* ── Preview config builder ───────────────────────────────────── */
@@ -54,6 +54,9 @@ function _collectPreviewConfig() {
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
app_sounds: notificationGetAppSoundsDict(),
};
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;

View File

@@ -34,17 +34,19 @@ import {
} from './color-strips-composite.ts';
import {
ensureNotificationEffectIconSelect, ensureNotificationFilterModeIconSelect,
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
onNotificationFilterModeChange,
notificationAddAppOverride, notificationRemoveAppOverride,
notificationGetAppColorsDict, notificationGetAppSoundsDict, notificationGetRawAppOverrides,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
notificationGetAppColorsDict, loadNotificationState, resetNotificationState, showNotificationEndpoint,
notificationGetRawAppColors,
loadNotificationState, resetNotificationState, showNotificationEndpoint,
} from './color-strips-notification.ts';
// Re-export for app.js window global bindings
export { gradientInit, gradientRenderAll, gradientAddStop };
export { compositeAddLayer, compositeRemoveLayer };
export {
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
onNotificationFilterModeChange,
notificationAddAppOverride, notificationRemoveAppOverride,
testNotification, showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
};
export { _getAnimationPayload, _colorCycleGetColors };
@@ -97,7 +99,7 @@ class CSSEditorModal extends Modal {
notification_default_color: (document.getElementById('css-editor-notification-default-color') as HTMLInputElement).value,
notification_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
notification_filter_list: (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value,
notification_app_colors: JSON.stringify(notificationGetRawAppColors()),
notification_app_overrides: JSON.stringify(notificationGetRawAppOverrides()),
clock_id: (document.getElementById('css-editor-clock') as HTMLInputElement).value,
daylight_speed: (document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value,
daylight_use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
@@ -1473,6 +1475,9 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
app_filter_mode: (document.getElementById('css-editor-notification-filter-mode') as HTMLInputElement).value,
app_filter_list: filterList,
app_colors: notificationGetAppColorsDict(),
sound_asset_id: (document.getElementById('css-editor-notification-sound') as HTMLSelectElement).value || null,
sound_volume: parseInt((document.getElementById('css-editor-notification-volume') as HTMLInputElement).value) / 100,
app_sounds: notificationGetAppSoundsDict(),
};
},
},

View File

@@ -19,7 +19,6 @@ import {
currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
@@ -35,7 +34,7 @@ import {
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, assetsCache, _cachedAssets, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
@@ -51,6 +50,7 @@ import { updateSubTabHash } from './tabs.ts';
import { createValueSourceCard } from './value-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import {
@@ -58,7 +58,8 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
@@ -97,8 +98,61 @@ const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon:
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }];
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
/** Resolve an asset ID to its display name. */
function _getAssetName(assetId?: string | null): string {
if (!assetId) return '—';
const asset = _cachedAssets.find((a: any) => a.id === assetId);
return asset ? asset.name : assetId;
}
/** Get EntitySelect items for a given asset type (image/video). */
function _getAssetItems(assetType: string) {
return _cachedAssets
.filter((a: any) => a.asset_type === assetType)
.map((a: any) => ({ value: a.id, label: a.name, icon: getAssetTypeIcon(assetType), desc: a.filename }));
}
let _imageAssetEntitySelect: EntitySelect | null = null;
let _videoAssetEntitySelect: EntitySelect | null = null;
function _ensureImageAssetEntitySelect() {
const sel = document.getElementById('stream-image-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.destroy();
_imageAssetEntitySelect = null;
const items = _getAssetItems('image');
sel.innerHTML = `<option value="">${t('streams.image_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_imageAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('image'),
placeholder: t('streams.image_asset.search') || 'Search image assets…',
});
}
function _ensureVideoAssetEntitySelect() {
const sel = document.getElementById('stream-video-asset') as HTMLSelectElement | null;
if (!sel) return;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.destroy();
_videoAssetEntitySelect = null;
const items = _getAssetItems('video');
sel.innerHTML = `<option value="">${t('streams.video_asset.select')}</option>` +
items.map(a => `<option value="${a.value}">${escapeHtml(a.label)}</option>`).join('');
_videoAssetEntitySelect = new EntitySelect({
target: sel,
getItems: () => _getAssetItems('video'),
placeholder: t('streams.video_asset.search') || 'Search video assets…',
});
}
function _destroyAssetEntitySelects() {
if (_imageAssetEntitySelect) { _imageAssetEntitySelect.destroy(); _imageAssetEntitySelect = null; }
if (_videoAssetEntitySelect) { _videoAssetEntitySelect.destroy(); _videoAssetEntitySelect = null; }
}
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id', emptyKey: 'section.empty.picture_sources', bulkActions: _streamDeleteAction });
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id', emptyKey: 'section.empty.capture_templates', bulkActions: _captureTemplateDeleteAction });
@@ -114,6 +168,7 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
@@ -137,13 +192,14 @@ class StreamEditorModal extends Modal {
targetFps: (document.getElementById('stream-target-fps') as HTMLInputElement).value,
source: (document.getElementById('stream-source') as HTMLSelectElement).value,
ppTemplate: (document.getElementById('stream-pp-template') as HTMLSelectElement).value,
imageSource: (document.getElementById('stream-image-source') as HTMLInputElement).value,
imageAsset: (document.getElementById('stream-image-asset') as HTMLSelectElement).value,
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
};
}
onForceClose() {
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
_destroyAssetEntitySelects();
(document.getElementById('stream-type') as HTMLSelectElement).disabled = false;
set_streamNameManuallyEdited(false);
}
@@ -226,6 +282,7 @@ export async function loadPictureSources() {
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
weatherSourcesCache.fetch(),
assetsCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
@@ -316,16 +373,15 @@ const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
</div>`;
},
static_image: (stream) => {
const src = stream.image_source || '';
const assetName = _getAssetName(stream.image_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
</div>`;
},
video: (stream) => {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
const assetName = _getAssetName(stream.video_asset_id);
return `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(assetName)}">${ICON_ASSET} ${escapeHtml(assetName)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
@@ -510,6 +566,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
];
// Build tree navigation structure
@@ -563,6 +620,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'assets', titleKey: 'streams.group.assets', icon: ICON_ASSET, count: _cachedAssets.length },
]
}
];
@@ -723,6 +781,7 @@ function renderPictureSourcesList(streams: any) {
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) {
@@ -742,6 +801,7 @@ function renderPictureSourcesList(streams: any) {
value: _cachedValueSources.length,
sync: _cachedSyncClocks.length,
weather: _cachedWeatherSources.length,
assets: _cachedAssets.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -759,6 +819,7 @@ function renderPictureSourcesList(streams: any) {
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
csWeatherSources.reconcile(weatherSourceItems);
csAssets.reconcile(assetItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -777,18 +838,20 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csAssets]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
initWeatherSourceDelegation(container);
initAudioSourceDelegation(container);
initAssetDelegation(container);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -806,6 +869,7 @@ function renderPictureSourcesList(streams: any) {
'value-sources': 'value',
'sync-clocks': 'sync',
'weather-sources': 'weather',
'assets': 'assets',
});
}
}
@@ -867,14 +931,8 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
document.getElementById('stream-display-picker-label')!.textContent = t('displays.picker.select');
document.getElementById('stream-error')!.style.display = 'none';
(document.getElementById('stream-type') as HTMLSelectElement).value = streamType;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
imgSrcInput.value = '';
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
set_streamNameManuallyEdited(!!cloneData);
@@ -906,10 +964,15 @@ export async function showAddStreamModal(presetType: any, cloneData: any = null)
(document.getElementById('stream-source') as HTMLSelectElement).value = cloneData.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = cloneData.postprocessing_template_id || '';
} else if (streamType === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
if (cloneData.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = cloneData.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(cloneData.image_asset_id);
}
} else if (streamType === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = cloneData.url || '';
if (cloneData.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = cloneData.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(cloneData.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = cloneData.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = cloneData.playback_speed || 1.0;
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
@@ -951,13 +1014,8 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-description') as HTMLInputElement).value = stream.description || '';
(document.getElementById('stream-type') as HTMLSelectElement).value = stream.stream_type;
set_lastValidatedImageSource('');
const imgSrcInput = document.getElementById('stream-image-source') as HTMLInputElement;
document.getElementById('stream-image-preview-container')!.style.display = 'none';
document.getElementById('stream-image-validation-status')!.style.display = 'none';
imgSrcInput.onblur = () => validateStaticImage();
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
_ensureImageAssetEntitySelect();
_ensureVideoAssetEntitySelect();
onStreamTypeChange();
await populateStreamModalDropdowns();
@@ -976,10 +1034,15 @@ export async function editStream(streamId: any) {
(document.getElementById('stream-source') as HTMLSelectElement).value = stream.source_stream_id || '';
(document.getElementById('stream-pp-template') as HTMLSelectElement).value = stream.postprocessing_template_id || '';
} else if (stream.stream_type === 'static_image') {
(document.getElementById('stream-image-source') as HTMLInputElement).value = stream.image_source || '';
if (stream.image_source) validateStaticImage();
if (stream.image_asset_id) {
(document.getElementById('stream-image-asset') as HTMLSelectElement).value = stream.image_asset_id;
if (_imageAssetEntitySelect) _imageAssetEntitySelect.setValue(stream.image_asset_id);
}
} else if (stream.stream_type === 'video') {
(document.getElementById('stream-video-url') as HTMLInputElement).value = stream.url || '';
if (stream.video_asset_id) {
(document.getElementById('stream-video-asset') as HTMLSelectElement).value = stream.video_asset_id;
if (_videoAssetEntitySelect) _videoAssetEntitySelect.setValue(stream.video_asset_id);
}
(document.getElementById('stream-video-loop') as HTMLInputElement).checked = stream.loop !== false;
(document.getElementById('stream-video-speed') as HTMLInputElement).value = stream.playback_speed || 1.0;
const speedLabel = document.getElementById('stream-video-speed-value');
@@ -1164,13 +1227,13 @@ export async function saveStream() {
payload.source_stream_id = (document.getElementById('stream-source') as HTMLSelectElement).value;
payload.postprocessing_template_id = (document.getElementById('stream-pp-template') as HTMLSelectElement).value;
} else if (streamType === 'static_image') {
const imageSource = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
const imageAssetId = (document.getElementById('stream-image-asset') as HTMLSelectElement).value;
if (!imageAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_asset_id = imageAssetId;
} else if (streamType === 'video') {
const url = (document.getElementById('stream-video-url') as HTMLInputElement).value.trim();
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
payload.url = url;
const videoAssetId = (document.getElementById('stream-video-asset') as HTMLSelectElement).value;
if (!videoAssetId) { showToast(t('streams.error.required'), 'error'); return; }
payload.video_asset_id = videoAssetId;
payload.loop = (document.getElementById('stream-video-loop') as HTMLInputElement).checked;
payload.playback_speed = parseFloat((document.getElementById('stream-video-speed') as HTMLInputElement).value) || 1.0;
payload.target_fps = parseInt((document.getElementById('stream-video-fps') as HTMLInputElement).value) || 30;
@@ -1239,55 +1302,6 @@ export async function closeStreamModal() {
await streamModal.close();
}
async function validateStaticImage() {
const source = (document.getElementById('stream-image-source') as HTMLInputElement).value.trim();
const previewContainer = document.getElementById('stream-image-preview-container')!;
const previewImg = document.getElementById('stream-image-preview') as HTMLImageElement;
const infoEl = document.getElementById('stream-image-info')!;
const statusEl = document.getElementById('stream-image-validation-status')!;
if (!source) {
set_lastValidatedImageSource('');
previewContainer.style.display = 'none';
statusEl.style.display = 'none';
return;
}
if (source === _lastValidatedImageSource) return;
statusEl.textContent = t('streams.validate_image.validating');
statusEl.className = 'validation-status loading';
statusEl.style.display = 'block';
previewContainer.style.display = 'none';
try {
const response = await fetchWithAuth('/picture-sources/validate-image', {
method: 'POST',
body: JSON.stringify({ image_source: source }),
});
const data = await response.json();
set_lastValidatedImageSource(source);
if (data.valid) {
previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid');
statusEl.className = 'validation-status success';
} else {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
statusEl.className = 'validation-status error';
}
} catch (err) {
previewContainer.style.display = 'none';
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
statusEl.className = 'validation-status error';
}
}
// ===== Picture Source Test =====
export async function showTestStreamModal(streamId: any) {

View File

@@ -266,8 +266,8 @@ interface Window {
previewCSSFromEditor: (...args: any[]) => any;
copyEndpointUrl: (...args: any[]) => any;
onNotificationFilterModeChange: (...args: any[]) => any;
notificationAddAppColor: (...args: any[]) => any;
notificationRemoveAppColor: (...args: any[]) => any;
notificationAddAppOverride: (...args: any[]) => any;
notificationRemoveAppOverride: (...args: any[]) => any;
testNotification: (...args: any[]) => any;
showNotificationHistory: (...args: any[]) => any;
closeNotificationHistory: (...args: any[]) => any;

View File

@@ -345,10 +345,10 @@ export interface PictureSource {
postprocessing_template_id?: string;
// Static image
image_source?: string;
image_asset_id?: string;
// Video
url?: string;
video_asset_id?: string;
loop?: boolean;
playback_speed?: number;
start_time?: number;
@@ -413,6 +413,27 @@ export interface WeatherSourceListResponse {
count: number;
}
// ── Asset ────────────────────────────────────────────────────
export interface Asset {
id: string;
name: string;
filename: string;
mime_type: string;
asset_type: string;
size_bytes: number;
description?: string;
tags: string[];
prebuilt: boolean;
created_at: string;
updated_at: string;
}
export interface AssetListResponse {
assets: Asset[];
count: number;
}
// ── Automation ────────────────────────────────────────────────
export type ConditionType =

View File

@@ -593,22 +593,19 @@
"streams.add.video": "Add Video Source",
"streams.edit.video": "Edit Video Source",
"picture_source.type.video": "Video",
"picture_source.type.video.desc": "Stream frames from video file, URL, or YouTube",
"picture_source.video.url": "Video URL:",
"picture_source.video.url.hint": "Local file path, HTTP URL, or YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.type.video.desc": "Stream frames from an uploaded video asset",
"picture_source.video.loop": "Loop:",
"picture_source.video.speed": "Playback Speed:",
"picture_source.video.start_time": "Start Time (s):",
"picture_source.video.end_time": "End Time (s):",
"picture_source.video.resolution_limit": "Max Width (px):",
"picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance",
"streams.image_source": "Image Source:",
"streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png",
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image",
"streams.validate_image.validating": "Validating...",
"streams.validate_image.valid": "Image accessible",
"streams.validate_image.invalid": "Image not accessible",
"streams.image_asset": "Image Asset:",
"streams.image_asset.select": "Select image asset…",
"streams.image_asset.search": "Search image assets…",
"streams.video_asset": "Video Asset:",
"streams.video_asset.select": "Select video asset…",
"streams.video_asset.search": "Search video assets…",
"targets.title": "Targets",
"targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.",
"targets.subtab.wled": "LED",
@@ -1105,6 +1102,22 @@
"color_strip.notification.app_colors.label": "Color Mappings:",
"color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.",
"color_strip.notification.app_colors.add": "+ Add Mapping",
"color_strip.notification.app_overrides": "Per-App Overrides",
"color_strip.notification.app_overrides.label": "App Overrides:",
"color_strip.notification.app_overrides.hint": "Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.",
"color_strip.notification.app_overrides.add": "+ Add Override",
"color_strip.notification.app_overrides.app_placeholder": "App name",
"color_strip.notification.sound": "Sound",
"color_strip.notification.sound.asset": "Sound Asset:",
"color_strip.notification.sound.asset.hint": "Pick a sound asset to play when a notification fires. Leave empty for silent.",
"color_strip.notification.sound.none": "None (silent)",
"color_strip.notification.sound.search": "Search sounds…",
"color_strip.notification.sound.volume": "Volume:",
"color_strip.notification.sound.volume.hint": "Global volume for notification sounds (0100%).",
"color_strip.notification.sound.app_sounds": "Per-App Sounds:",
"color_strip.notification.sound.app_sounds.hint": "Override sound and volume for specific apps. Empty sound = mute that app.",
"color_strip.notification.sound.app_sounds.add": "+ Add Override",
"color_strip.notification.sound.app_name_placeholder": "App name",
"color_strip.notification.endpoint": "Webhook Endpoint:",
"color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.",
@@ -1963,9 +1976,35 @@
"update.install_type.docker": "Docker",
"update.install_type.dev": "Development",
"color_strip": {
"notification": {
"search_apps": "Search notification apps…"
}
}
"color_strip.notification.search_apps": "Search notification apps…",
"asset.group.title": "Assets",
"asset.upload": "Upload Asset",
"asset.edit": "Edit Asset",
"asset.name": "Name:",
"asset.name.hint": "Display name for this asset.",
"asset.description": "Description:",
"asset.description.hint": "Optional description for this asset.",
"asset.file": "File:",
"asset.file.hint": "Select a file to upload (sound, image, video, or other).",
"asset.drop_or_browse": "Drop file here or click to browse",
"asset.uploaded": "Asset uploaded",
"asset.updated": "Asset updated",
"asset.deleted": "Asset deleted",
"asset.confirm_delete": "Delete this asset?",
"asset.error.name_required": "Name is required",
"asset.error.no_file": "Please select a file to upload",
"asset.error.delete_failed": "Failed to delete asset",
"asset.play": "Play",
"asset.download": "Download",
"asset.prebuilt": "Prebuilt",
"asset.prebuilt_restored": "{count} prebuilt asset(s) restored",
"asset.prebuilt_none_to_restore": "All prebuilt assets are already available",
"asset.restore_prebuilt": "Restore Prebuilt Sounds",
"asset.type.sound": "Sound",
"asset.type.image": "Image",
"asset.type.video": "Video",
"asset.type.other": "Other",
"streams.group.assets": "Assets",
"section.empty.assets": "No assets yet. Click + to upload one."
}

View File

@@ -593,22 +593,19 @@
"streams.add.video": "Добавить видеоисточник",
"streams.edit.video": "Редактировать видеоисточник",
"picture_source.type.video": "Видео",
"picture_source.type.video.desc": "Потоковые кадры из видеофайла, URL или YouTube",
"picture_source.video.url": "URL видео:",
"picture_source.video.url.hint": "Локальный файл, HTTP URL или YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.type.video.desc": "Потоковые кадры из загруженного видео",
"picture_source.video.loop": "Зацикливание:",
"picture_source.video.speed": "Скорость воспроизведения:",
"picture_source.video.start_time": "Время начала (с):",
"picture_source.video.end_time": "Время окончания (с):",
"picture_source.video.resolution_limit": "Макс. ширина (px):",
"picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности",
"streams.image_source": сточник изображения:",
"streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png",
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению",
"streams.validate_image.validating": "Проверка...",
"streams.validate_image.valid": "Изображение доступно",
"streams.validate_image.invalid": "Изображение недоступно",
"streams.image_asset": "Изображение:",
"streams.image_asset.select": "Выберите изображение…",
"streams.image_asset.search": "Поиск изображений…",
"streams.video_asset": "Видео:",
"streams.video_asset.select": "Выберите видео…",
"streams.video_asset.search": "Поиск видео…",
"targets.title": "Цели",
"targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.",
"targets.subtab.wled": "LED",
@@ -1084,6 +1081,22 @@
"color_strip.notification.app_colors.label": "Назначения цветов:",
"color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.",
"color_strip.notification.app_colors.add": "+ Добавить",
"color_strip.notification.app_overrides": "Настройки приложений",
"color_strip.notification.app_overrides.label": "Переопределения:",
"color_strip.notification.app_overrides.hint": "Индивидуальные настройки цвета и звука для каждого приложения.",
"color_strip.notification.app_overrides.add": "+ Добавить",
"color_strip.notification.app_overrides.app_placeholder": "Имя приложения",
"color_strip.notification.sound": "Звук",
"color_strip.notification.sound.asset": "Звуковой ассет:",
"color_strip.notification.sound.asset.hint": "Выберите звуковой ассет для воспроизведения при уведомлении. Оставьте пустым для тишины.",
"color_strip.notification.sound.none": "Нет (без звука)",
"color_strip.notification.sound.search": "Поиск звуков…",
"color_strip.notification.sound.volume": "Громкость:",
"color_strip.notification.sound.volume.hint": "Общая громкость звуков уведомлений (0100%).",
"color_strip.notification.sound.app_sounds": "Звуки приложений:",
"color_strip.notification.sound.app_sounds.hint": "Переопределение звука и громкости для конкретных приложений. Пустой звук = отключить для этого приложения.",
"color_strip.notification.sound.app_sounds.add": "+ Добавить",
"color_strip.notification.sound.app_name_placeholder": "Имя приложения",
"color_strip.notification.endpoint": "Вебхук:",
"color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.",
"color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.",
@@ -1892,9 +1905,35 @@
"update.install_type.docker": "Docker",
"update.install_type.dev": "Разработка",
"color_strip": {
"notification": {
"search_apps": "Поиск приложений…"
}
}
"color_strip.notification.search_apps": "Поиск приложений…",
"asset.group.title": "Ресурсы",
"asset.upload": "Загрузить ресурс",
"asset.edit": "Редактировать ресурс",
"asset.name": "Название:",
"asset.name.hint": "Отображаемое название ресурса.",
"asset.description": "Описание:",
"asset.description.hint": "Необязательное описание ресурса.",
"asset.file": "Файл:",
"asset.file.hint": "Выберите файл для загрузки (звук, изображение, видео или другое).",
"asset.drop_or_browse": "Перетащите файл сюда или нажмите для выбора",
"asset.uploaded": "Ресурс загружен",
"asset.updated": "Ресурс обновлён",
"asset.deleted": "Ресурс удалён",
"asset.confirm_delete": "Удалить этот ресурс?",
"asset.error.name_required": "Название обязательно",
"asset.error.no_file": "Выберите файл для загрузки",
"asset.error.delete_failed": "Не удалось удалить ресурс",
"asset.play": "Воспроизвести",
"asset.download": "Скачать",
"asset.prebuilt": "Встроенный",
"asset.prebuilt_restored": "Восстановлено встроенных ресурсов: {count}",
"asset.prebuilt_none_to_restore": "Все встроенные ресурсы уже доступны",
"asset.restore_prebuilt": "Восстановить встроенные звуки",
"asset.type.sound": "Звук",
"asset.type.image": "Изображение",
"asset.type.video": "Видео",
"asset.type.other": "Другое",
"streams.group.assets": "Ресурсы",
"section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить."
}

View File

@@ -593,22 +593,19 @@
"streams.add.video": "添加视频源",
"streams.edit.video": "编辑视频源",
"picture_source.type.video": "视频",
"picture_source.type.video.desc": "从视频文件、URL或YouTube流式传输帧",
"picture_source.video.url": "视频URL",
"picture_source.video.url.hint": "本地文件路径、HTTP URL或YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.type.video.desc": "从上传的视频素材中流式传输帧",
"picture_source.video.loop": "循环:",
"picture_source.video.speed": "播放速度:",
"picture_source.video.start_time": "开始时间(秒):",
"picture_source.video.end_time": "结束时间(秒):",
"picture_source.video.resolution_limit": "最大宽度(像素):",
"picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能",
"streams.image_source": "图片",
"streams.image_source.placeholder": "https://example.com/image.jpg 或 C:\\path\\to\\image.png",
"streams.image_source.hint": "输入图片的 URLhttp/https或本地文件路径",
"streams.validate_image.validating": "正在验证...",
"streams.validate_image.valid": "图片可访问",
"streams.validate_image.invalid": "图片不可访问",
"streams.image_asset": "图片素材",
"streams.image_asset.select": "选择图片素材…",
"streams.image_asset.search": "搜索图片素材…",
"streams.video_asset": "视频素材:",
"streams.video_asset.select": "选择视频素材…",
"streams.video_asset.search": "搜索视频素材…",
"targets.title": "目标",
"targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。",
"targets.subtab.wled": "LED",
@@ -1084,6 +1081,22 @@
"color_strip.notification.app_colors.label": "颜色映射:",
"color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。",
"color_strip.notification.app_colors.add": "+ 添加映射",
"color_strip.notification.app_overrides": "按应用覆盖",
"color_strip.notification.app_overrides.label": "应用覆盖:",
"color_strip.notification.app_overrides.hint": "为特定应用自定义颜色和声音。每行可设置颜色、声音资源和音量。",
"color_strip.notification.app_overrides.add": "+ 添加覆盖",
"color_strip.notification.app_overrides.app_placeholder": "应用名称",
"color_strip.notification.sound": "声音",
"color_strip.notification.sound.asset": "声音资源:",
"color_strip.notification.sound.asset.hint": "选择通知触发时播放的声音资源。留空表示静音。",
"color_strip.notification.sound.none": "无(静音)",
"color_strip.notification.sound.search": "搜索声音…",
"color_strip.notification.sound.volume": "音量:",
"color_strip.notification.sound.volume.hint": "通知声音的全局音量0100%)。",
"color_strip.notification.sound.app_sounds": "按应用声音:",
"color_strip.notification.sound.app_sounds.hint": "为特定应用覆盖声音和音量。空声音 = 静音该应用。",
"color_strip.notification.sound.app_sounds.add": "+ 添加覆盖",
"color_strip.notification.sound.app_name_placeholder": "应用名称",
"color_strip.notification.endpoint": "Webhook 端点:",
"color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON{\"app\": \"AppName\", \"color\": \"#FF0000\"}。",
"color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。",
@@ -1890,9 +1903,35 @@
"update.install_type.docker": "Docker",
"update.install_type.dev": "开发环境",
"color_strip": {
"notification": {
"search_apps": "搜索通知应用…"
}
}
"color_strip.notification.search_apps": "搜索通知应用…",
"asset.group.title": "资源",
"asset.upload": "上传资源",
"asset.edit": "编辑资源",
"asset.name": "名称:",
"asset.name.hint": "资源的显示名称。",
"asset.description": "描述:",
"asset.description.hint": "资源的可选描述。",
"asset.file": "文件:",
"asset.file.hint": "选择要上传的文件(声音、图片、视频或其他)。",
"asset.drop_or_browse": "拖放文件到此处或点击浏览",
"asset.uploaded": "资源已上传",
"asset.updated": "资源已更新",
"asset.deleted": "资源已删除",
"asset.confirm_delete": "删除此资源?",
"asset.error.name_required": "名称为必填项",
"asset.error.no_file": "请选择要上传的文件",
"asset.error.delete_failed": "删除资源失败",
"asset.play": "播放",
"asset.download": "下载",
"asset.prebuilt": "内置",
"asset.prebuilt_restored": "已恢复 {count} 个内置资源",
"asset.prebuilt_none_to_restore": "所有内置资源均已可用",
"asset.restore_prebuilt": "恢复内置声音",
"asset.type.sound": "声音",
"asset.type.image": "图片",
"asset.type.video": "视频",
"asset.type.other": "其他",
"streams.group.assets": "资源",
"section.empty.assets": "暂无资源。点击 + 上传一个。"
}

View File

@@ -0,0 +1,80 @@
"""Asset data model.
An Asset represents an uploaded file (sound, image, video, or other)
stored on the server. Assets are referenced by ID from other entities
(e.g. NotificationColorStripSource uses sound assets for alert sounds).
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
# Map MIME type prefixes to asset_type categories
_MIME_TO_ASSET_TYPE = {
"audio/": "sound",
"image/": "image",
"video/": "video",
}
def asset_type_from_mime(mime_type: str) -> str:
"""Derive asset_type from a MIME type string."""
for prefix, asset_type in _MIME_TO_ASSET_TYPE.items():
if mime_type.startswith(prefix):
return asset_type
return "other"
@dataclass
class Asset:
"""Persistent metadata for an uploaded file asset."""
id: str
name: str
filename: str # original upload filename
stored_filename: str # on-disk filename (uuid-based)
mime_type: str # e.g. "audio/wav", "image/png"
asset_type: str # "sound" | "image" | "video" | "other"
size_bytes: int
created_at: datetime
updated_at: datetime
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
prebuilt: bool = False # True for shipped assets
deleted: bool = False # soft-delete for prebuilt assets
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"filename": self.filename,
"stored_filename": self.stored_filename,
"mime_type": self.mime_type,
"asset_type": self.asset_type,
"size_bytes": self.size_bytes,
"description": self.description,
"tags": self.tags,
"prebuilt": self.prebuilt,
"deleted": self.deleted,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@staticmethod
def from_dict(data: dict) -> "Asset":
return Asset(
id=data["id"],
name=data["name"],
filename=data.get("filename", ""),
stored_filename=data.get("stored_filename", ""),
mime_type=data.get("mime_type", "application/octet-stream"),
asset_type=data.get("asset_type", "other"),
size_bytes=int(data.get("size_bytes", 0)),
description=data.get("description"),
tags=data.get("tags", []),
prebuilt=bool(data.get("prebuilt", False)),
deleted=bool(data.get("deleted", False)),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
)

View File

@@ -0,0 +1,219 @@
"""Asset storage with file management.
Metadata is stored in SQLite via BaseSqliteStore; actual files live
in the assets directory on disk.
"""
import mimetypes
import shutil
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from wled_controller.storage.asset import Asset, asset_type_from_mime
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AssetStore(BaseSqliteStore[Asset]):
"""Persistent storage for uploaded file assets."""
_table_name = "assets"
_entity_name = "Asset"
def __init__(self, db: Database, assets_dir: str | Path):
self._assets_dir = Path(assets_dir)
self._assets_dir.mkdir(parents=True, exist_ok=True)
super().__init__(db, Asset.from_dict)
# -- Aliases for consistency with other stores ----------------------------
get_all_assets = BaseSqliteStore.get_all
get_asset = BaseSqliteStore.get
def get_visible_assets(self) -> List[Asset]:
"""Return all assets that are not soft-deleted."""
return [a for a in self._items.values() if not a.deleted]
def get_assets_by_type(self, asset_type: str) -> List[Asset]:
"""Return visible assets filtered by type."""
return [a for a in self._items.values()
if not a.deleted and a.asset_type == asset_type]
def get_file_path(self, asset_id: str) -> Optional[Path]:
"""Resolve the on-disk path for an asset's file. Returns None if missing."""
asset = self._items.get(asset_id)
if asset is None or asset.deleted:
return None
path = self._assets_dir / asset.stored_filename
return path if path.exists() else None
def create_asset(
self,
name: str,
filename: str,
file_data: bytes,
mime_type: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
prebuilt: bool = False,
) -> Asset:
"""Create a new asset from uploaded file data.
Args:
name: Display name for the asset.
filename: Original upload filename.
file_data: Raw file bytes.
mime_type: MIME type (auto-detected from filename if not provided).
description: Optional description.
tags: Optional tags.
prebuilt: Whether this is a shipped prebuilt asset.
Returns:
The created Asset.
"""
self._check_name_unique(name)
if not mime_type:
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
asset_type = asset_type_from_mime(mime_type)
# Generate unique stored filename
ext = Path(filename).suffix
stored_filename = f"{uuid.uuid4().hex}{ext}"
# Write file to disk
dest = self._assets_dir / stored_filename
dest.write_bytes(file_data)
asset_id = f"asset_{uuid.uuid4().hex[:8]}"
now = datetime.now(timezone.utc)
asset = Asset(
id=asset_id,
name=name,
filename=filename,
stored_filename=stored_filename,
mime_type=mime_type,
asset_type=asset_type,
size_bytes=len(file_data),
created_at=now,
updated_at=now,
description=description,
tags=tags or [],
prebuilt=prebuilt,
)
self._items[asset_id] = asset
self._save_item(asset_id, asset)
logger.info(f"Created asset: {name} ({asset_id}, type={asset_type}, {len(file_data)} bytes)")
return asset
def update_asset(
self,
asset_id: str,
name: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> Asset:
"""Update asset metadata (not the file itself)."""
asset = self.get(asset_id)
if name is not None:
self._check_name_unique(name, exclude_id=asset_id)
asset.name = name
if description is not None:
asset.description = description
if tags is not None:
asset.tags = tags
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset_id, asset)
logger.info(f"Updated asset: {asset_id}")
return asset
def delete_asset(self, asset_id: str) -> None:
"""Delete an asset. Prebuilt assets are soft-deleted; others are fully removed."""
asset = self.get(asset_id)
if asset.prebuilt:
# Soft-delete: mark as deleted, remove file but keep metadata
asset.deleted = True
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset_id, asset)
file_path = self._assets_dir / asset.stored_filename
if file_path.exists():
file_path.unlink()
logger.info(f"Soft-deleted prebuilt asset: {asset_id}")
else:
# Hard delete: remove file and metadata
file_path = self._assets_dir / asset.stored_filename
if file_path.exists():
file_path.unlink()
del self._items[asset_id]
self._delete_item(asset_id)
logger.info(f"Deleted asset: {asset_id}")
def restore_prebuilt(self, prebuilt_dir: Path) -> List[Asset]:
"""Re-import any soft-deleted prebuilt assets from the prebuilt directory.
Returns list of restored assets.
"""
restored = []
for asset in list(self._items.values()):
if asset.prebuilt and asset.deleted:
# Find original file in prebuilt dir
src = prebuilt_dir / asset.filename
if src.exists():
dest = self._assets_dir / asset.stored_filename
shutil.copy2(src, dest)
asset.deleted = False
asset.size_bytes = dest.stat().st_size
asset.updated_at = datetime.now(timezone.utc)
self._save_item(asset.id, asset)
restored.append(asset)
logger.info(f"Restored prebuilt asset: {asset.name} ({asset.id})")
return restored
def import_prebuilt_sounds(self, prebuilt_dir: Path) -> List[Asset]:
"""Import prebuilt sound files that don't already exist as assets.
Called on startup. Skips files that are already imported (by original
filename match with prebuilt=True), including soft-deleted ones.
Returns list of newly imported assets.
"""
if not prebuilt_dir.exists():
return []
# Build set of known prebuilt filenames (including deleted ones)
known_filenames = {
a.filename for a in self._items.values() if a.prebuilt
}
imported = []
for src in sorted(prebuilt_dir.iterdir()):
if not src.is_file():
continue
if src.name in known_filenames:
continue
file_data = src.read_bytes()
# Derive a friendly name from filename: "chime.wav" -> "Chime"
friendly_name = src.stem.replace("_", " ").replace("-", " ").title()
asset = self.create_asset(
name=friendly_name,
filename=src.name,
file_data=file_data,
prebuilt=True,
)
imported.append(asset)
logger.info(f"Imported prebuilt sound: {src.name} -> {asset.id}")
return imported

View File

@@ -1,9 +1,12 @@
"""Automation and Condition data models."""
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Dict, List, Optional, Type
logger = logging.getLogger(__name__)
@dataclass
class Condition:
@@ -226,7 +229,8 @@ class Automation:
for c_data in data.get("conditions", []):
try:
conditions.append(Condition.from_dict(c_data))
except ValueError:
except ValueError as e:
logger.warning("Skipping unknown condition type on load: %s", e)
pass # skip unknown condition types on load
return cls(

View File

@@ -838,6 +838,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode: str = "off" # off | whitelist | blacklist
app_filter_list: list = field(default_factory=list) # app names for filter
os_listener: bool = False # whether to listen for OS notifications
sound_asset_id: Optional[str] = None # global notification sound (asset ID)
sound_volume: float = 1.0 # global volume 0.0-1.0
app_sounds: dict = field(default_factory=dict) # app name -> {"sound_asset_id": str|None, "volume": float|None}
def to_dict(self) -> dict:
d = super().to_dict()
@@ -848,6 +851,9 @@ class NotificationColorStripSource(ColorStripSource):
d["app_filter_mode"] = self.app_filter_mode
d["app_filter_list"] = list(self.app_filter_list)
d["os_listener"] = self.os_listener
d["sound_asset_id"] = self.sound_asset_id
d["sound_volume"] = self.sound_volume
d["app_sounds"] = dict(self.app_sounds)
return d
@classmethod
@@ -855,6 +861,7 @@ class NotificationColorStripSource(ColorStripSource):
common = _parse_css_common(data)
raw_app_colors = data.get("app_colors")
raw_app_filter_list = data.get("app_filter_list")
raw_app_sounds = data.get("app_sounds")
return cls(
**common, source_type="notification",
notification_effect=data.get("notification_effect") or "flash",
@@ -864,6 +871,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode=data.get("app_filter_mode") or "off",
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
os_listener=bool(data.get("os_listener", False)),
sound_asset_id=data.get("sound_asset_id"),
sound_volume=float(data.get("sound_volume", 1.0)),
app_sounds=raw_app_sounds if isinstance(raw_app_sounds, dict) else {},
)
@classmethod
@@ -873,7 +883,9 @@ class NotificationColorStripSource(ColorStripSource):
notification_effect=None, duration_ms=None,
default_color=None, app_colors=None,
app_filter_mode=None, app_filter_list=None,
os_listener=None, **_kwargs):
os_listener=None, sound_asset_id=None,
sound_volume=None, app_sounds=None,
**_kwargs):
return cls(
id=id, name=name, source_type="notification",
created_at=created_at, updated_at=updated_at,
@@ -885,6 +897,9 @@ class NotificationColorStripSource(ColorStripSource):
app_filter_mode=app_filter_mode or "off",
app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [],
os_listener=bool(os_listener) if os_listener is not None else False,
sound_asset_id=sound_asset_id,
sound_volume=float(sound_volume) if sound_volume is not None else 1.0,
app_sounds=app_sounds if isinstance(app_sounds, dict) else {},
)
def apply_update(self, **kwargs) -> None:
@@ -904,6 +919,13 @@ class NotificationColorStripSource(ColorStripSource):
self.app_filter_list = app_filter_list
if kwargs.get("os_listener") is not None:
self.os_listener = bool(kwargs["os_listener"])
if "sound_asset_id" in kwargs:
self.sound_asset_id = kwargs["sound_asset_id"]
if kwargs.get("sound_volume") is not None:
self.sound_volume = float(kwargs["sound_volume"])
app_sounds = kwargs.get("app_sounds")
if app_sounds is not None and isinstance(app_sounds, dict):
self.app_sounds = app_sounds
@dataclass

View File

@@ -55,9 +55,19 @@ _ENTITY_TABLES = [
"color_strip_processing_templates",
"gradients",
"weather_sources",
"assets",
]
_VALID_TABLES = frozenset(_ENTITY_TABLES) | {"settings", "schema_version"}
def _check_table(table: str) -> None:
"""Raise ValueError if *table* is not a known entity table."""
if table not in _VALID_TABLES:
raise ValueError(f"Invalid table name: {table!r}")
class Database:
"""Thread-safe SQLite connection wrapper with WAL mode.
@@ -169,6 +179,7 @@ class Database:
Returns list of dicts parsed from the ``data`` JSON column.
"""
_check_table(table)
with self._lock:
rows = self._conn.execute(
f"SELECT id, data FROM [{table}]"
@@ -187,6 +198,7 @@ class Database:
Skipped silently when writes are frozen.
"""
_check_table(table)
if _writes_frozen:
return
json_data = json.dumps(data, ensure_ascii=False)
@@ -202,6 +214,7 @@ class Database:
Skipped silently when writes are frozen.
"""
_check_table(table)
if _writes_frozen:
return
with self._lock:
@@ -215,6 +228,7 @@ class Database:
Skipped silently when writes are frozen.
"""
_check_table(table)
if _writes_frozen:
return
with self._lock:
@@ -226,6 +240,7 @@ class Database:
Skipped silently when writes are frozen.
"""
_check_table(table)
if _writes_frozen:
return
with self._lock:
@@ -237,6 +252,7 @@ class Database:
def count(self, table: str) -> int:
"""Count rows in an entity table."""
_check_table(table)
with self._lock:
row = self._conn.execute(
f"SELECT COUNT(*) as cnt FROM [{table}]"
@@ -245,13 +261,15 @@ class Database:
def table_exists_with_data(self, table: str) -> bool:
"""Check if a table exists and has at least one row."""
_check_table(table)
with self._lock:
try:
row = self._conn.execute(
f"SELECT COUNT(*) as cnt FROM [{table}]"
).fetchone()
return row["cnt"] > 0
except sqlite3.OperationalError:
except sqlite3.OperationalError as e:
logger.debug("Table %s does not exist or is inaccessible: %s", table, e)
return False
# -- Settings (key-value) ------------------------------------------------
@@ -266,7 +284,8 @@ class Database:
return None
try:
return json.loads(row["value"])
except json.JSONDecodeError:
except json.JSONDecodeError as e:
logger.warning("Corrupt JSON in setting '%s': %s", key, e)
return None
def set_setting(self, key: str, value: dict) -> None:

View File

@@ -12,8 +12,8 @@ class PictureSource:
A picture source is either:
- "raw": captures from a display using a capture engine template at a target FPS
- "processed": applies postprocessing to another picture source
- "static_image": returns a static frame from a URL or local file path
- "video": decodes frames from a video file, URL, or YouTube link
- "static_image": returns a static frame from an uploaded asset
- "video": decodes frames from an uploaded video asset
"""
id: str
@@ -40,9 +40,9 @@ class PictureSource:
"target_fps": None,
"source_stream_id": None,
"postprocessing_template_id": None,
"image_source": None,
"image_asset_id": None,
# Video fields
"url": None,
"video_asset_id": None,
"loop": None,
"playback_speed": None,
"start_time": None,
@@ -138,13 +138,13 @@ class ProcessedPictureSource(PictureSource):
@dataclass
class StaticImagePictureSource(PictureSource):
"""A static image stream from a URL or file path."""
"""A static image stream from an uploaded asset."""
image_source: str = ""
image_asset_id: Optional[str] = None
def to_dict(self) -> dict:
d = super().to_dict()
d["image_source"] = self.image_source
d["image_asset_id"] = self.image_asset_id
return d
@classmethod
@@ -153,15 +153,15 @@ class StaticImagePictureSource(PictureSource):
return cls(
**common,
stream_type="static_image",
image_source=data.get("image_source") or "",
image_asset_id=data.get("image_asset_id") or data.get("image_source") or None,
)
@dataclass
class VideoCaptureSource(PictureSource):
"""A video stream from a file, HTTP URL, or YouTube link."""
"""A video stream from an uploaded video asset."""
url: str = ""
video_asset_id: Optional[str] = None
loop: bool = True
playback_speed: float = 1.0
start_time: Optional[float] = None
@@ -172,7 +172,7 @@ class VideoCaptureSource(PictureSource):
def to_dict(self) -> dict:
d = super().to_dict()
d["url"] = self.url
d["video_asset_id"] = self.video_asset_id
d["loop"] = self.loop
d["playback_speed"] = self.playback_speed
d["start_time"] = self.start_time
@@ -188,7 +188,7 @@ class VideoCaptureSource(PictureSource):
return cls(
**common,
stream_type="video",
url=data.get("url") or "",
video_asset_id=data.get("video_asset_id") or data.get("url") or None,
loop=data.get("loop", True),
playback_speed=data.get("playback_speed", 1.0),
start_time=data.get("start_time"),

View File

@@ -82,11 +82,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None,
image_source: Optional[str] = None,
image_asset_id: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# Video fields
url: Optional[str] = None,
video_asset_id: Optional[str] = None,
loop: bool = True,
playback_speed: float = 1.0,
start_time: Optional[float] = None,
@@ -121,11 +121,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
if self._detect_cycle(source_stream_id):
raise ValueError("Cycle detected in stream chain")
elif stream_type == "static_image":
if not image_source:
raise ValueError("Static image streams require image_source")
if not image_asset_id:
raise ValueError("Static image streams require image_asset_id")
elif stream_type == "video":
if not url:
raise ValueError("Video streams require url")
if not video_asset_id:
raise ValueError("Video streams require video_asset_id")
# Check for duplicate name
self._check_name_unique(name)
@@ -156,7 +156,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
elif stream_type == "video":
stream = VideoCaptureSource(
**common,
url=url, # type: ignore[arg-type]
video_asset_id=video_asset_id,
loop=loop,
playback_speed=playback_speed,
start_time=start_time,
@@ -168,7 +168,7 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
else:
stream = StaticImagePictureSource(
**common,
image_source=image_source, # type: ignore[arg-type]
image_asset_id=image_asset_id,
)
self._items[stream_id] = stream
@@ -186,11 +186,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
target_fps: Optional[int] = None,
source_stream_id: Optional[str] = None,
postprocessing_template_id: Optional[str] = None,
image_source: Optional[str] = None,
image_asset_id: Optional[str] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
# Video fields
url: Optional[str] = None,
video_asset_id: Optional[str] = None,
loop: Optional[bool] = None,
playback_speed: Optional[float] = None,
start_time: Optional[float] = None,
@@ -234,11 +234,11 @@ class PictureSourceStore(BaseSqliteStore[PictureSource]):
if postprocessing_template_id is not None:
stream.postprocessing_template_id = resolve_ref(postprocessing_template_id, stream.postprocessing_template_id)
elif isinstance(stream, StaticImagePictureSource):
if image_source is not None:
stream.image_source = image_source
if image_asset_id is not None:
stream.image_asset_id = resolve_ref(image_asset_id, stream.image_asset_id)
elif isinstance(stream, VideoCaptureSource):
if url is not None:
stream.url = url
if video_asset_id is not None:
stream.video_asset_id = resolve_ref(video_asset_id, stream.video_asset_id)
if loop is not None:
stream.loop = loop
if playback_speed is not None:

View File

@@ -212,6 +212,8 @@
{% include 'modals/test-value-source.html' %}
{% include 'modals/sync-clock-editor.html' %}
{% include 'modals/weather-source-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}

View File

@@ -0,0 +1,35 @@
<div id="asset-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2>
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="asset-editor-id">
<div id="asset-editor-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-editor-name" required maxlength="100">
<div id="asset-editor-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-editor-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-editor-description" maxlength="500">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetEditorModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveAssetMetadata()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,62 @@
<div id="asset-upload-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="asset-upload-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2>
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Optional display name. If blank, derived from filename.</small>
<input type="text" id="asset-upload-name" maxlength="100" placeholder="">
<div id="asset-upload-tags-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="asset-upload-description" data-i18n="asset.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.description.hint">Optional description for this asset.</small>
<input type="text" id="asset-upload-description" maxlength="500">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="asset.file">File:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
aria-label="Choose file or drag and drop">
<div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4"/>
<path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>
</svg>
</div>
<div class="file-dropzone-text">
<span class="file-dropzone-label" data-i18n="asset.drop_or_browse">Drop file here or click to browse</span>
</div>
<div id="asset-upload-file-info" class="file-dropzone-info" style="display:none">
<span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&times;</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAssetUploadModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="uploadAsset()" title="Upload" data-i18n-title="asset.upload" data-i18n-aria-label="asset.upload">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -458,23 +458,52 @@
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.filter_list.hint">One app name per line. Use Browse to pick from running processes.</small>
<div class="condition-field" id="css-editor-notification-filter-picker-container">
<div class="condition-apps-header">
<button type="button" class="btn-browse-apps" data-i18n="automations.condition.application.browse">Browse</button>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" data-i18n-title="automations.condition.application.browse" title="Browse"><svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></button>
</div>
<textarea id="css-editor-notification-filter-list" class="condition-apps" rows="3" data-i18n-placeholder="color_strip.notification.filter_list.placeholder" placeholder="Discord&#10;Slack&#10;Telegram"></textarea>
</div>
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_colors">App Colors</summary>
<summary data-i18n="color_strip.notification.sound">Sound</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_colors.label">Color Mappings:</label>
<label for="css-editor-notification-sound" data-i18n="color_strip.notification.sound.asset">Sound Asset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_colors.hint">Per-app color overrides. Each row maps an app name to a specific color.</small>
<div id="notification-app-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppColor()" data-i18n="color_strip.notification.app_colors.add">+ Add Mapping</button>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.asset.hint">Pick a sound asset to play when a notification fires. Leave empty for silent.</small>
<select id="css-editor-notification-sound">
<option value="" data-i18n="color_strip.notification.sound.none">None (silent)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-notification-volume">
<span data-i18n="color_strip.notification.sound.volume">Volume:</span>
<span id="css-editor-notification-volume-val">100%</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.sound.volume.hint">Global volume for notification sounds (0100%).</small>
<input type="range" id="css-editor-notification-volume" min="0" max="100" step="5" value="100"
oninput="document.getElementById('css-editor-notification-volume-val').textContent = this.value + '%'">
</div>
</div>
</details>
<details class="form-collapse">
<summary data-i18n="color_strip.notification.app_overrides">Per-App Overrides</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.notification.app_overrides.label">App Overrides:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.notification.app_overrides.hint">Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.</small>
<div id="notification-app-overrides-list"></div>
<button type="button" class="btn btn-secondary" onclick="notificationAddAppOverride()" data-i18n="color_strip.notification.app_overrides.add">+ Add Override</button>
</div>
</div>
</details>

View File

@@ -6,7 +6,7 @@
<button class="modal-close-btn" onclick="closeNotificationHistory()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<p class="form-hint" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<p class="form-hint" style="margin-bottom:0.75rem" data-i18n="color_strip.notification.history.hint">Recent OS notifications captured by the listener (newest first). Up to 50 entries.</p>
<div id="notification-history-status" style="display:none;color:var(--text-muted);font-size:0.85rem;margin-bottom:0.5rem"></div>
<div id="notification-history-list" style="max-height:340px;overflow-y:auto;border:1px solid var(--border-color);border-radius:4px;padding:0.25rem 0"></div>
</div>

View File

@@ -78,28 +78,15 @@
<!-- Static image fields -->
<div id="stream-static-image-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-image-source" data-i18n="streams.image_source">Image Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="streams.image_source.hint">Enter a URL (http/https) or local file path to an image</small>
<input type="text" id="stream-image-source" data-i18n-placeholder="streams.image_source.placeholder" placeholder="https://example.com/image.jpg or C:\path\to\image.png">
<label for="stream-image-asset" data-i18n="streams.image_asset">Image Asset:</label>
<select id="stream-image-asset"></select>
</div>
<div id="stream-image-preview-container" class="image-preview-container" style="display: none;">
<img id="stream-image-preview" class="stream-image-preview" src="" alt="Preview">
<div id="stream-image-info" class="stream-image-info"></div>
</div>
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></div>
</div>
<div id="stream-video-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-video-url" data-i18n="picture_source.video.url">Video URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.url.hint">Local file path, HTTP URL, or YouTube URL</small>
<input type="text" id="stream-video-url" data-i18n-placeholder="picture_source.video.url.placeholder" placeholder="https://example.com/video.mp4">
<label for="stream-video-asset" data-i18n="streams.video_asset">Video Asset:</label>
<select id="stream-video-asset"></select>
</div>
<div class="form-group settings-toggle-group">
<label data-i18n="picture_source.video.loop">Loop:</label>

View File

@@ -1,10 +1,13 @@
"""Atomic file write utilities."""
import json
import logging
import os
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
"""Write JSON data to file atomically via temp file + rename.
@@ -29,6 +32,7 @@ def atomic_write_json(file_path: Path, data: dict, indent: int = 2) -> None:
# Clean up temp file on any error
try:
os.unlink(tmp_path)
except OSError:
except OSError as e:
logger.debug("Failed to clean up temp file %s: %s", tmp_path, e)
pass
raise

View File

@@ -0,0 +1,54 @@
"""Validation utilities for image sources (URLs and local file paths).
Prevents SSRF via dangerous URL schemes and restricts file path access
to prevent arbitrary file reads through API query parameters.
"""
from pathlib import Path
from urllib.parse import urlparse
from fastapi import HTTPException
# Image file extensions considered safe to serve
_IMAGE_EXTENSIONS = frozenset({
".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp", ".tiff", ".tif", ".ico",
})
def validate_image_url(url: str) -> None:
"""Validate that *url* uses a safe scheme (http/https only).
Blocks ``file://``, ``ftp://``, ``gopher://``, and other dangerous schemes
that could be used for SSRF.
"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise HTTPException(
status_code=400,
detail=f"Unsupported URL scheme: {parsed.scheme!r}. Only http and https are allowed.",
)
if not parsed.hostname:
raise HTTPException(status_code=400, detail="URL has no hostname")
def validate_image_path(file_path: str | Path) -> Path:
"""Validate a local file path points to a real image file.
Checks:
- The extension is a known image format
- The resolved path does not escape via symlinks to unexpected locations
Returns the resolved Path on success, raises HTTPException on violation.
"""
resolved = Path(file_path).resolve()
suffix = resolved.suffix.lower()
if suffix not in _IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {suffix!r}. Only image files are allowed.",
)
return resolved

View File

@@ -0,0 +1,126 @@
"""Cross-platform asynchronous sound playback for notification alerts.
Windows: uses winsound.PlaySound (stdlib, no dependencies).
Linux: uses paplay (PulseAudio) or aplay (ALSA) via subprocess.
All playback is fire-and-forget on a background thread. A new notification
sound cancels any currently playing sound to prevent overlap.
"""
import subprocess
import sys
import threading
from pathlib import Path
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Lock + handle for cancelling previous sound
_play_lock = threading.Lock()
_current_process: subprocess.Popen | None = None
def _play_windows(file_path: Path, volume: float) -> None:
"""Play a WAV file on Windows using winsound."""
import winsound
# winsound doesn't support volume control natively,
# but SND_ASYNC plays non-blocking within this thread
try:
winsound.PlaySound(str(file_path), winsound.SND_FILENAME | winsound.SND_ASYNC)
except Exception as e:
logger.error(f"winsound playback failed: {e}")
def _play_linux(file_path: Path, volume: float) -> None:
"""Play a sound file on Linux using paplay or aplay."""
global _current_process
# Cancel previous sound
with _play_lock:
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate previous sound process: %s", e)
pass
_current_process = None
try:
# Try paplay first (PulseAudio/PipeWire) — supports volume
pa_volume = max(0, min(65536, int(volume * 65536)))
proc = subprocess.Popen(
["paplay", f"--volume={pa_volume}", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
try:
# Fallback to aplay (ALSA) — no volume control
proc = subprocess.Popen(
["aplay", "-q", str(file_path)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.warning("Neither paplay nor aplay found — cannot play notification sound")
return
with _play_lock:
_current_process = proc
# Wait for completion
proc.wait()
with _play_lock:
if _current_process is proc:
_current_process = None
def play_sound_async(file_path: Path, volume: float = 1.0) -> None:
"""Play a sound file asynchronously (fire-and-forget).
Args:
file_path: Path to the sound file (.wav).
volume: Volume level 0.0-1.0 (best-effort, not all backends support it).
"""
if not file_path.exists():
logger.warning(f"Sound file not found: {file_path}")
return
volume = max(0.0, min(1.0, volume))
if sys.platform == "win32":
player = _play_windows
else:
player = _play_linux
thread = threading.Thread(
target=player,
args=(file_path, volume),
name="sound-player",
daemon=True,
)
thread.start()
def stop_current_sound() -> None:
"""Stop any currently playing notification sound."""
if sys.platform == "win32":
try:
import winsound
winsound.PlaySound(None, winsound.SND_PURGE)
except Exception as e:
logger.debug("Failed to stop winsound playback: %s", e)
pass
else:
with _play_lock:
global _current_process
if _current_process is not None:
try:
_current_process.terminate()
except OSError as e:
logger.debug("Failed to terminate sound process: %s", e)
pass
_current_process = None

View File

@@ -1,18 +1,47 @@
"""Shared fixtures for end-to-end API tests.
Uses the real FastAPI app with a module-scoped TestClient to avoid
repeated lifespan startup/shutdown issues. Each test function gets
fresh, empty stores via the _clear_stores helper.
Uses the real FastAPI app with a session-scoped TestClient.
All e2e tests run against an ISOLATED temporary database and assets
directory — never the production data.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
from wled_controller.config import get_config
# ---------------------------------------------------------------------------
# Isolate e2e tests from production data.
#
# We must set the config singleton BEFORE wled_controller.main is imported,
# because main.py reads get_config() at module level to create the DB and
# all stores. By forcing the singleton here we guarantee the app opens a
# throwaway SQLite file in a temp directory.
# ---------------------------------------------------------------------------
_e2e_tmp = Path(tempfile.mkdtemp(prefix="wled_e2e_"))
_test_db_path = str(_e2e_tmp / "test_ledgrab.db")
_test_assets_dir = str(_e2e_tmp / "test_assets")
# Resolve the API key from the real config (same key used in production tests)
_config = get_config()
API_KEY = next(iter(_config.auth.api_keys.values()), "")
import wled_controller.config as _config_mod # noqa: E402
# Build a Config that mirrors production settings but with isolated paths.
_original_config = _config_mod.Config.load()
_test_config = _original_config.model_copy(
update={
"storage": _config_mod.StorageConfig(database_file=_test_db_path),
"assets": _config_mod.AssetsConfig(
assets_dir=_test_assets_dir,
max_file_size_mb=_original_config.assets.max_file_size_mb,
),
},
)
# Install as the global singleton so all subsequent get_config() calls
# (including main.py module-level code) use isolated paths.
_config_mod.config = _test_config
API_KEY = next(iter(_test_config.auth.api_keys.values()), "")
AUTH_HEADERS = {"Authorization": f"Bearer {API_KEY}"}
@@ -22,7 +51,7 @@ def _test_client():
The app's lifespan (MQTT, automation engine, health monitoring, etc.)
starts once for the entire e2e test session and shuts down after all
tests complete.
tests complete. The app uses the isolated test database set above.
"""
from fastapi.testclient import TestClient
from wled_controller.main import app
@@ -30,6 +59,9 @@ def _test_client():
with TestClient(app, raise_server_exceptions=False) as c:
yield c
# Clean up temp directory after all e2e tests finish
shutil.rmtree(_e2e_tmp, ignore_errors=True)
@pytest.fixture
def client(_test_client):
@@ -63,6 +95,7 @@ def _clear_stores():
(deps.get_sync_clock_store, "get_all", "delete"),
(deps.get_automation_store, "get_all", "delete"),
(deps.get_scene_preset_store, "get_all", "delete"),
(deps.get_asset_store, "get_all_assets", "delete_asset"),
]
for getter, list_method, delete_method in store_clearers:
try:

View File

@@ -1,9 +1,11 @@
"""E2E: Backup and restore flow.
Tests creating entities, backing up (SQLite .db file), deleting, then restoring.
Tests creating entities, backing up (ZIP containing SQLite .db + asset files),
deleting, then restoring.
"""
import io
import zipfile
class TestBackupRestoreFlow:
@@ -40,12 +42,17 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 1
# 2. Create a backup (GET returns a SQLite .db file)
# 2. Create a backup (GET returns a ZIP containing ledgrab.db + assets)
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
backup_bytes = resp.content
# SQLite files start with this magic header
assert backup_bytes[:16].startswith(b"SQLite format 3")
# Backup is a ZIP file (PK magic bytes)
assert backup_bytes[:4] == b"PK\x03\x04"
# ZIP should contain ledgrab.db
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as zf:
assert "ledgrab.db" in zf.namelist()
db_data = zf.read("ledgrab.db")
assert db_data[:16].startswith(b"SQLite format 3")
# 3. Delete all created entities
resp = client.delete(f"/api/v1/color-strip-sources/{css_id}")
@@ -59,23 +66,27 @@ class TestBackupRestoreFlow:
resp = client.get("/api/v1/color-strip-sources")
assert resp.json()["count"] == 0
# 4. Restore from backup (POST with the .db file upload)
# 4. Restore from backup (POST with the .zip file upload)
resp = client.post(
"/api/v1/system/restore",
files={"file": ("backup.db", io.BytesIO(backup_bytes), "application/octet-stream")},
files={"file": ("backup.zip", io.BytesIO(backup_bytes), "application/zip")},
)
assert resp.status_code == 200, f"Restore failed: {resp.text}"
restore_result = resp.json()
assert restore_result["status"] == "restored"
assert restore_result["restart_scheduled"] is True
def test_backup_is_valid_sqlite(self, client):
"""Backup response is a valid SQLite database file."""
def test_backup_is_valid_zip(self, client):
"""Backup response is a valid ZIP containing a SQLite database."""
resp = client.get("/api/v1/system/backup")
assert resp.status_code == 200
assert resp.content[:16].startswith(b"SQLite format 3")
assert resp.content[:4] == b"PK\x03\x04"
# Should have Content-Disposition header for download
assert "attachment" in resp.headers.get("content-disposition", "")
# ZIP should contain ledgrab.db with valid SQLite header
with zipfile.ZipFile(io.BytesIO(resp.content)) as zf:
assert "ledgrab.db" in zf.namelist()
assert zf.read("ledgrab.db")[:16].startswith(b"SQLite format 3")
def test_restore_rejects_invalid_format(self, client):
"""Uploading a non-SQLite file should fail validation."""