refactor: rename project to LedGrab, split HA integration into separate repo
Lint & Test / test (push) Successful in 1m56s

- Rename Python package: wled_controller -> ledgrab
- Rename env var prefix: WLED_ -> LEDGRAB_ (with auto-migration for old vars)
- Rename localStorage key: wled_api_key -> ledgrab_api_key (with migration)
- Rename HA integration domain: wled_screen_controller -> ledgrab
- Update all imports, build scripts, Docker, installer, config, docs
- Remove HA integration (moved to ledgrab-haos-integration repo)
- Remove hacs.json (belongs in HA repo now)
- Add startup warning for users with old WLED_ env vars
- All tests pass (715/715), ruff clean, tsc clean, frontend builds
This commit is contained in:
2026-04-12 22:45:28 +03:00
parent 38f73badbf
commit 02cd9d519c
548 changed files with 3502 additions and 5180 deletions
@@ -3,7 +3,7 @@
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("wled-screen-controller")
__version__ = version("ledgrab")
except PackageNotFoundError:
# Running from source without pip install (e.g. dev, embedded Python)
__version__ = "0.0.0-dev"
@@ -13,6 +13,6 @@ __email__ = "dolgolyov.alexei@gmail.com"
# ─── Project links ───────────────────────────────────────────
GITEA_BASE_URL = "https://git.dolgolyov-family.by"
GITEA_REPO = "alexei.dolgolyov/wled-screen-controller-mixed"
GITEA_REPO = "alexei.dolgolyov/ledgrab"
REPO_URL = f"{GITEA_BASE_URL}/{GITEA_REPO}"
DONATE_URL = "" # TODO: set once donation platform is chosen
@@ -1,4 +1,4 @@
"""Entry point for ``python -m wled_controller``.
"""Entry point for ``python -m ledgrab``.
Starts the uvicorn server and, on Windows when *pystray* is installed,
shows a system-tray icon with **Show UI** / **Exit** actions.
@@ -36,10 +36,10 @@ _fix_embedded_tcl_paths()
import uvicorn # noqa: E402
from wled_controller.config import get_config # noqa: E402
from wled_controller.server_ref import set_server, set_tray # noqa: E402
from wled_controller.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from wled_controller.utils import setup_logging, get_logger # noqa: E402
from ledgrab.config import get_config # noqa: E402
from ledgrab.server_ref import set_server, set_tray # noqa: E402
from ledgrab.tray import PYSTRAY_AVAILABLE, TrayManager # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
setup_logging()
logger = get_logger(__name__)
@@ -62,7 +62,7 @@ def _open_browser(port: int, delay: float = 2.0) -> None:
def _is_restart() -> bool:
"""Detect if this is a restart (vs first launch)."""
return os.environ.get("WLED_RESTART", "") == "1"
return os.environ.get("LEDGRAB_RESTART", "") == "1"
def _check_port(host: str, port: int) -> None:
@@ -81,7 +81,7 @@ def main() -> None:
_check_port(config.server.host, config.server.port)
uv_config = uvicorn.Config(
"wled_controller.main:app",
"ledgrab.main:app",
host=config.server.host,
port=config.server.port,
log_level=config.server.log_level.lower(),
@@ -133,10 +133,10 @@ def _request_shutdown(server: uvicorn.Server) -> None:
def _force_tray() -> bool:
"""Allow forcing tray on non-Windows via WLED_TRAY=1."""
"""Allow forcing tray on non-Windows via LEDGRAB_TRAY=1."""
import os
return os.environ.get("WLED_TRAY", "").strip() in ("1", "true", "yes")
return os.environ.get("LEDGRAB_TRAY", "").strip() in ("1", "true", "yes")
if __name__ == "__main__":
@@ -6,8 +6,8 @@ from typing import Annotated
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from wled_controller.config import get_config
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -6,39 +6,39 @@ All getter function signatures remain unchanged for FastAPI Depends() compatibil
from typing import Any, Dict, TypeVar
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.database import Database
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_processing_template_store import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.database import Database
from ledgrab.storage import DeviceStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.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
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.storage.asset_store import AssetStore
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.core.update.update_service import UpdateService
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.pattern_template_store import PatternTemplateStore
T = TypeVar("T")
@@ -8,9 +8,9 @@ from typing import Callable, Optional
import numpy as np
from starlette.websockets import WebSocket
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.utils import get_logger
from wled_controller.utils.image_codec import (
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.utils import get_logger
from ledgrab.utils.image_codec import (
encode_jpeg,
encode_jpeg_data_uri,
resize_down,
@@ -31,7 +31,8 @@ def authenticate_ws_token(token: str) -> bool:
Delegates to the canonical implementation in auth module.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
return verify_ws_token(token)
@@ -160,14 +161,16 @@ async def stream_capture_test(
thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY)
fps = fc / elapsed if elapsed > 0 else 0
avg_ms = (tc / fc * 1000) if fc > 0 else 0
await websocket.send_json({
"type": "frame",
"thumbnail": thumb_uri,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
await websocket.send_json(
{
"type": "frame",
"thumbnail": thumb_uri,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
}
)
# Wait for capture thread to fully finish
await capture_future
@@ -199,17 +202,19 @@ async def stream_capture_test(
thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH)
thumb_uri = _encode_jpeg(thumb, 85)
await websocket.send_json({
"type": "result",
"full_image": full_uri,
"thumbnail": thumb_uri,
"width": w,
"height": h,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
})
await websocket.send_json(
{
"type": "result",
"full_image": full_uri,
"thumbnail": thumb_uri,
"width": w,
"height": h,
"frame_count": fc,
"elapsed_s": round(elapsed, 2),
"fps": round(fps, 1),
"avg_capture_ms": round(avg_ms, 1),
}
)
except Exception as e:
# WebSocket disconnect or send error — signal capture thread to stop
@@ -5,17 +5,17 @@ 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 (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import fire_entity_event, get_asset_store
from ledgrab.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
from ledgrab.config import get_config
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -103,7 +103,9 @@ async def upload_asset(
if not data:
raise HTTPException(status_code=400, detail="Empty file")
display_name = name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
display_name = (
name or Path(file.filename or "unnamed").stem.replace("_", " ").replace("-", " ").title()
)
try:
asset = store.create_asset(
@@ -4,8 +4,8 @@ import asyncio
from fastapi import APIRouter
from wled_controller.api.auth import AuthRequired
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from ledgrab.api.auth import AuthRequired
from ledgrab.core.audio.audio_capture import AudioCaptureManager
router = APIRouter()
@@ -2,15 +2,15 @@
from fastapi import APIRouter, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_audio_processing_template_store
from wled_controller.api.schemas.filters import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_audio_processing_template_store
from ledgrab.api.schemas.filters import (
FilterOptionDefSchema,
FilterTypeListResponse,
FilterTypeResponse,
)
from wled_controller.core.audio.filters import AudioFilterRegistry
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.core.audio.filters import AudioFilterRegistry
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
router = APIRouter()
@@ -2,24 +2,24 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_processing import (
from ledgrab.api.schemas.audio_processing import (
AudioProcessingTemplateCreate,
AudioProcessingTemplateListResponse,
AudioProcessingTemplateResponse,
AudioProcessingTemplateUpdate,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import AudioProcessingTemplateStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.core.filters.filter_instance import FilterInstance
from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -6,8 +6,8 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_processing_template_store,
get_audio_source_store,
@@ -15,7 +15,7 @@ from wled_controller.api.dependencies import (
get_color_strip_store,
get_processor_manager,
)
from wled_controller.api.schemas.audio_sources import (
from ledgrab.api.schemas.audio_sources import (
AudioSourceCreate,
AudioSourceListResponse,
AudioSourceResponse,
@@ -23,15 +23,15 @@ from wled_controller.api.schemas.audio_sources import (
CaptureAudioSourceResponse,
ProcessedAudioSourceResponse,
)
from wled_controller.storage.audio_source import (
from ledgrab.storage.audio_source import (
AudioSource,
CaptureAudioSource,
ProcessedAudioSource,
)
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -178,7 +178,7 @@ async def delete_audio_source(
"""Delete an audio source."""
try:
# Check if any CSS entities reference this audio source
from wled_controller.storage.color_strip_source import AudioColorStripSource
from ledgrab.storage.color_strip_source import AudioColorStripSource
for css in css_store.get_all_sources():
if (
@@ -215,8 +215,8 @@ async def test_audio_source_ws(
analysis before sending, so the WebSocket output matches what running
streams see.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.audio.filters.pipeline import build_pipeline_from_template_ids
from ledgrab.api.auth import verify_ws_token
from ledgrab.core.audio.filters.pipeline import build_pipeline_from_template_ids
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -4,9 +4,14 @@ import asyncio
from fastapi import APIRouter, HTTPException, Depends, Query
from starlette.websockets import WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import fire_entity_event, get_audio_template_store, get_audio_source_store, get_processor_manager
from wled_controller.api.schemas.audio_templates import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_audio_template_store,
get_audio_source_store,
get_processor_manager,
)
from ledgrab.api.schemas.audio_templates import (
AudioEngineInfo,
AudioEngineListResponse,
AudioTemplateCreate,
@@ -14,11 +19,11 @@ from wled_controller.api.schemas.audio_templates import (
AudioTemplateResponse,
AudioTemplateUpdate,
)
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.core.audio.factory import AudioEngineRegistry
from ledgrab.storage.audio_template_store import AudioTemplateStore
from ledgrab.storage.audio_source_store import AudioSourceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -27,7 +32,10 @@ router = APIRouter()
# ===== AUDIO TEMPLATE ENDPOINTS =====
@router.get("/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-templates", response_model=AudioTemplateListResponse, tags=["Audio Templates"]
)
async def list_audio_templates(
_auth: AuthRequired,
store: AudioTemplateStore = Depends(get_audio_template_store),
@@ -37,10 +45,14 @@ async def list_audio_templates(
templates = store.get_all_templates()
responses = [
AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
for t in templates
]
@@ -50,7 +62,12 @@ async def list_audio_templates(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/audio-templates", response_model=AudioTemplateResponse, tags=["Audio Templates"], status_code=201)
@router.post(
"/api/v1/audio-templates",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
status_code=201,
)
async def create_audio_template(
data: AudioTemplateCreate,
_auth: AuthRequired,
@@ -59,16 +76,22 @@ async def create_audio_template(
"""Create a new audio capture template."""
try:
template = store.create_template(
name=data.name, engine_type=data.engine_type,
engine_config=data.engine_config, description=data.description,
name=data.name,
engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
)
fire_entity_event("audio_template", "created", template.id)
return AudioTemplateResponse(
id=template.id, name=template.name, engine_type=template.engine_type,
engine_config=template.engine_config, tags=template.tags,
id=template.id,
name=template.name,
engine_type=template.engine_type,
engine_config=template.engine_config,
tags=template.tags,
created_at=template.created_at,
updated_at=template.updated_at, description=template.description,
updated_at=template.updated_at,
description=template.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -80,7 +103,11 @@ async def create_audio_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-templates/{template_id}",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
)
async def get_audio_template(
template_id: str,
_auth: AuthRequired,
@@ -92,14 +119,22 @@ async def get_audio_template(
except ValueError:
raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found")
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
@router.put("/api/v1/audio-templates/{template_id}", response_model=AudioTemplateResponse, tags=["Audio Templates"])
@router.put(
"/api/v1/audio-templates/{template_id}",
response_model=AudioTemplateResponse,
tags=["Audio Templates"],
)
async def update_audio_template(
template_id: str,
data: AudioTemplateUpdate,
@@ -109,16 +144,23 @@ async def update_audio_template(
"""Update an audio template."""
try:
t = store.update_template(
template_id=template_id, name=data.name,
engine_type=data.engine_type, engine_config=data.engine_config,
description=data.description, tags=data.tags,
template_id=template_id,
name=data.name,
engine_type=data.engine_type,
engine_config=data.engine_config,
description=data.description,
tags=data.tags,
)
fire_entity_event("audio_template", "updated", template_id)
return AudioTemplateResponse(
id=t.id, name=t.name, engine_type=t.engine_type,
engine_config=t.engine_config, tags=t.tags,
id=t.id,
name=t.name,
engine_type=t.engine_type,
engine_config=t.engine_config,
tags=t.tags,
created_at=t.created_at,
updated_at=t.updated_at, description=t.description,
updated_at=t.updated_at,
description=t.description,
)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -155,7 +197,10 @@ async def delete_audio_template(
# ===== AUDIO ENGINE ENDPOINTS =====
@router.get("/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"])
@router.get(
"/api/v1/audio-engines", response_model=AudioEngineListResponse, tags=["Audio Templates"]
)
async def list_audio_engines(_auth: AuthRequired):
"""List all registered audio capture engines."""
try:
@@ -195,7 +240,8 @@ async def test_audio_template_ws(
Auth via ?token=<api_key>. Device specified via ?device_index=N&is_loopback=0|1.
Streams AudioAnalysis snapshots as JSON at ~20 Hz.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
return
@@ -214,13 +260,17 @@ async def test_audio_template_ws(
loopback = is_loopback != 0
try:
stream = audio_mgr.acquire(device_index, loopback, template.engine_type, template.engine_config)
stream = audio_mgr.acquire(
device_index, loopback, template.engine_type, template.engine_config
)
except RuntimeError as e:
await websocket.close(code=4003, reason=str(e))
return
await websocket.accept()
logger.info(f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}")
logger.info(
f"Audio template test WS connected: template={template_id} device={device_index} loopback={loopback}"
)
last_ts = 0.0
try:
@@ -228,13 +278,15 @@ async def test_audio_template_ws(
analysis = stream.get_latest_analysis()
if analysis is not None and analysis.timestamp != last_ts:
last_ts = analysis.timestamp
await websocket.send_json({
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
})
await websocket.send_json(
{
"spectrum": analysis.spectrum.tolist(),
"rms": round(analysis.rms, 4),
"peak": round(analysis.peak, 4),
"beat": analysis.beat,
"beat_intensity": round(analysis.beat_intensity, 4),
}
)
await asyncio.sleep(0.05)
except WebSocketDisconnect:
logger.debug("Audio template test WebSocket disconnected")
@@ -4,22 +4,22 @@ import secrets
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_automation_engine,
get_automation_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.automations import (
from ledgrab.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
RuleSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import (
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
@@ -30,10 +30,10 @@ from wled_controller.storage.automation import (
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -97,7 +97,7 @@ def _automation_to_response(
for r in automation.rules:
if isinstance(r, WebhookRule) and r.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
from ledgrab.api.routes.system import load_external_url
ext = load_external_url()
if ext:
@@ -15,20 +15,20 @@ 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_asset_store, get_auto_backup_engine, get_database
from wled_controller.api.schemas.system import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_asset_store, get_auto_backup_engine, get_database
from ledgrab.api.schemas.system import (
AutoBackupSettings,
AutoBackupStatusResponse,
BackupFileInfo,
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
from ledgrab.config import get_config
from ledgrab.core.backup.auto_backup import AutoBackupEngine
from ledgrab.storage.asset_store import AssetStore
from ledgrab.storage.database import Database, freeze_writes
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -42,11 +42,17 @@ def _schedule_restart() -> None:
def _restart():
import time
time.sleep(1)
if sys.platform == "win32":
subprocess.Popen(
["powershell", "-ExecutionPolicy", "Bypass", "-File",
str(_SERVER_DIR / "restart.ps1")],
[
"powershell",
"-ExecutionPolicy",
"Bypass",
"-File",
str(_SERVER_DIR / "restart.ps1"),
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
@@ -71,6 +77,7 @@ def backup_config(
):
"""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)
@@ -95,6 +102,7 @@ def backup_config(
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}.zip"
@@ -129,7 +137,9 @@ async def restore_config(
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)")
raise HTTPException(
status_code=400, detail="Not a valid backup file (expected .zip or .db)"
)
if is_zip:
# Extract DB and assets from ZIP
@@ -160,6 +170,7 @@ async def restore_config(
tmp_path = Path(tmp.name)
try:
def _restore():
db.restore_from(tmp_path)
@@ -181,7 +192,8 @@ async def restore_config(
@router.post("/api/v1/system/restart", tags=["System"])
def restart_server(_: AuthRequired):
"""Schedule a server restart and return immediately."""
from wled_controller.server_ref import _broadcast_restarting
from ledgrab.server_ref import _broadcast_restarting
_broadcast_restarting()
_schedule_restart()
return {"status": "restarting"}
@@ -190,7 +202,8 @@ def restart_server(_: AuthRequired):
@router.post("/api/v1/system/shutdown", tags=["System"])
def shutdown_server(_: AuthRequired):
"""Gracefully shut down the server."""
from wled_controller.server_ref import request_shutdown
from ledgrab.server_ref import request_shutdown
request_shutdown()
return {"status": "shutting_down"}
@@ -6,27 +6,27 @@ import uuid as _uuid
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_cspt_store,
get_device_store,
get_processor_manager,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.color_strip_processing import (
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.api.schemas.color_strip_processing import (
ColorStripProcessingTemplateCreate,
ColorStripProcessingTemplateListResponse,
ColorStripProcessingTemplateResponse,
ColorStripProcessingTemplateUpdate,
)
from wled_controller.core.filters import FilterInstance
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage import DeviceStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.core.filters import FilterInstance
from ledgrab.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage import DeviceStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -46,7 +46,11 @@ def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse:
)
@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"])
@router.get(
"/api/v1/color-strip-processing-templates",
response_model=ColorStripProcessingTemplateListResponse,
tags=["Color Strip Processing"],
)
async def list_cspt(
_auth: AuthRequired,
store: ColorStripProcessingTemplateStore = Depends(get_cspt_store),
@@ -61,7 +65,12 @@ async def list_cspt(
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)
@router.post(
"/api/v1/color-strip-processing-templates",
response_model=ColorStripProcessingTemplateResponse,
tags=["Color Strip Processing"],
status_code=201,
)
async def create_cspt(
data: ColorStripProcessingTemplateCreate,
_auth: AuthRequired,
@@ -88,7 +97,11 @@ async def create_cspt(
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"])
@router.get(
"/api/v1/color-strip-processing-templates/{template_id}",
response_model=ColorStripProcessingTemplateResponse,
tags=["Color Strip Processing"],
)
async def get_cspt(
template_id: str,
_auth: AuthRequired,
@@ -99,10 +112,16 @@ async def get_cspt(
template = store.get_template(template_id)
return _cspt_to_response(template)
except ValueError:
raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found")
raise HTTPException(
status_code=404, detail=f"Color strip processing template {template_id} not found"
)
@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"])
@router.put(
"/api/v1/color-strip-processing-templates/{template_id}",
response_model=ColorStripProcessingTemplateResponse,
tags=["Color Strip Processing"],
)
async def update_cspt(
template_id: str,
data: ColorStripProcessingTemplateUpdate,
@@ -111,7 +130,11 @@ async def update_cspt(
):
"""Update a color strip processing template."""
try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
filters = (
[FilterInstance(f.filter_id, f.options) for f in data.filters]
if data.filters is not None
else None
)
template = store.update_template(
template_id=template_id,
name=data.name,
@@ -131,7 +154,11 @@ async def update_cspt(
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"])
@router.delete(
"/api/v1/color-strip-processing-templates/{template_id}",
status_code=204,
tags=["Color Strip Processing"],
)
async def delete_cspt(
template_id: str,
_auth: AuthRequired,
@@ -147,7 +174,7 @@ async def delete_cspt(
raise HTTPException(
status_code=409,
detail=f"Cannot delete: template is referenced by: {names}. "
"Please reassign before deleting.",
"Please reassign before deleting.",
)
store.delete_template(template_id)
fire_entity_event("cspt", "deleted", template_id)
@@ -165,6 +192,7 @@ async def delete_cspt(
# ── Test / Preview WebSocket ──────────────────────────────────────────
@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws")
async def test_cspt_ws(
websocket: WebSocket,
@@ -179,9 +207,9 @@ async def test_cspt_ws(
Takes an input CSS source, applies the CSPT filter chain, and streams
the processed RGB frames. Auth via ``?token=<api_key>``.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.core.filters import FilterRegistry
from wled_controller.core.processing.processor_manager import ProcessorManager
from ledgrab.api.auth import verify_ws_token
from ledgrab.core.filters import FilterRegistry
from ledgrab.core.processing.processor_manager import ProcessorManager
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -9,8 +9,8 @@ from typing import Annotated
import numpy as np
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_device_store,
@@ -20,7 +20,7 @@ from wled_controller.api.dependencies import (
get_processor_manager,
get_template_store,
)
from wled_controller.api.schemas.color_strip_sources import (
from ledgrab.api.schemas.color_strip_sources import (
ApiInputCSSResponse,
AudioCSSResponse,
CandlelightCSSResponse,
@@ -47,17 +47,17 @@ from wled_controller.api.schemas.color_strip_sources import (
StaticCSSResponse,
WeatherCSSResponse,
)
from wled_controller.api.schemas.devices import (
from ledgrab.api.schemas.devices import (
Calibration as CalibrationSchema,
CalibrationTestModeResponse,
)
from wled_controller.core.capture.calibration import (
from ledgrab.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import (
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
ApiInputColorStripSource,
AudioColorStripSource,
@@ -76,17 +76,17 @@ from wled_controller.storage.color_strip_source import (
StaticColorStripSource,
WeatherColorStripSource,
)
from wled_controller.storage import DeviceStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source import (
from ledgrab.storage import DeviceStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -371,7 +371,7 @@ async def create_color_strip_source(
if data.source_type == "composite" and kwargs.get("layers"):
child_ids = [ly.get("source_id", "") for ly in kwargs["layers"] if ly.get("source_id")]
# No parent_id yet (new source), just check depth
from wled_controller.storage.color_strip_store import MAX_COMPOSITE_DEPTH
from ledgrab.storage.color_strip_store import MAX_COMPOSITE_DEPTH
for cid in child_ids:
depth = store.get_nesting_depth(cid)
@@ -524,19 +524,19 @@ async def test_key_colors_source(
pp_template_store=Depends(get_pp_template_store),
):
"""Test a key_colors source: capture a frame, extract colors from each rectangle."""
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.picture_source import (
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.picture_source import (
ScreenCapturePictureSource,
StaticImagePictureSource,
)
from wled_controller.utils.image_codec import encode_jpeg_data_uri
from ledgrab.utils.image_codec import encode_jpeg_data_uri
stream = None
try:
@@ -553,10 +553,10 @@ async def test_key_colors_source(
chain = source_store.resolve_stream_chain(source.picture_source_id)
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import load_image_file
from ledgrab.utils.image_codec import load_image_file
if isinstance(raw_stream, StaticImagePictureSource):
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
image_path = (
@@ -681,15 +681,15 @@ async def test_key_colors_ws(
"""WebSocket for real-time key_colors test preview with frame + rectangle overlay."""
import json as ws_json
import time as ws_time
from wled_controller.api.auth import verify_ws_token
from wled_controller.storage.color_strip_source import KeyColorsColorStripSource
from wled_controller.core.capture.screen_capture import (
from ledgrab.api.auth import verify_ws_token
from ledgrab.storage.color_strip_source import KeyColorsColorStripSource
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
)
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.storage.picture_source import ScreenCapturePictureSource
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1095,7 +1095,7 @@ async def notify_source(
@router.get("/api/v1/color-strip-sources/os-notifications/history", tags=["Color Strip Sources"])
async def os_notification_history(_auth: AuthRequired):
"""Return recent OS notification capture history (newest first)."""
from wled_controller.core.processing.os_notification_listener import (
from ledgrab.core.processing.os_notification_listener import (
get_os_notification_listener,
)
@@ -1139,7 +1139,7 @@ async def preview_color_strip_ws(
Subsequent text messages are treated as config updates: if the source_type
changed the old stream is replaced; otherwise ``update_source()`` is used.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1168,7 +1168,7 @@ async def preview_color_strip_ws(
def _build_source(config: dict):
"""Build a ColorStripSource from a raw config dict, injecting synthetic id/name."""
from wled_controller.storage.color_strip_source import ColorStripSource
from ledgrab.storage.color_strip_source import ColorStripSource
config.setdefault("id", "__preview__")
config.setdefault("name", "__preview__")
@@ -1176,7 +1176,7 @@ async def preview_color_strip_ws(
def _create_stream(source):
"""Instantiate and start the appropriate stream class for *source*."""
from wled_controller.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
@@ -1185,7 +1185,7 @@ async def preview_color_strip_ws(
# Inject gradient store for palette resolution
if hasattr(s, "set_gradient_store"):
try:
from wled_controller.api.dependencies import get_gradient_store
from ledgrab.api.dependencies import get_gradient_store
s.set_gradient_store(get_gradient_store())
except Exception:
@@ -1283,7 +1283,7 @@ async def preview_color_strip_ws(
# Handle "fire" command for notification streams
if new_config.get("action") == "fire":
from wled_controller.core.processing.notification_stream import (
from ledgrab.core.processing.notification_stream import (
NotificationColorStripStream,
)
@@ -1349,7 +1349,7 @@ async def css_api_input_ws(
Auth via ?token=<api_key>. Accepts JSON frames ({"colors": [[R,G,B], ...]})
or binary frames (raw RGBRGB... bytes, 3 bytes per LED).
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1391,7 +1391,7 @@ async def css_api_input_ws(
if "segments" in data:
# Segment-based path — validate and push
try:
from wled_controller.api.schemas.color_strip_sources import SegmentPayload
from ledgrab.api.schemas.color_strip_sources import SegmentPayload
seg_dicts = [SegmentPayload(**s).model_dump() for s in data["segments"]]
except Exception as e:
@@ -1460,7 +1460,7 @@ async def test_color_strip_ws(
First message is JSON metadata (source_type, led_count, calibration segments).
Subsequent messages are binary RGB frames (``led_count * 3`` bytes).
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -1506,9 +1506,9 @@ async def test_color_strip_ws(
logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})")
try:
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from ledgrab.core.processing.api_input_stream import ApiInputColorStripStream
is_api_input = isinstance(stream, ApiInputColorStripStream)
_last_push_gen = 0 # track api_input push generation to skip unchanged frames
@@ -1643,7 +1643,7 @@ async def test_color_strip_ws(
try:
frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None:
from wled_controller.utils.image_codec import encode_jpeg
from ledgrab.utils.image_codec import encode_jpeg
import cv2 as _cv2
img = frame.image
@@ -3,19 +3,19 @@
import httpx
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.core.devices.led_client import (
from ledgrab.api.auth import AuthRequired
from ledgrab.core.devices.led_client import (
get_all_providers,
get_device_capabilities,
get_provider,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
from ledgrab.api.schemas.devices import (
BrightnessRequest,
DeviceCreate,
DeviceListResponse,
@@ -28,10 +28,10 @@ from wled_controller.api.schemas.devices import (
OpenRGBZonesResponse,
PowerRequest,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -300,14 +300,14 @@ async def get_openrgb_zones(
"""List available zones on an OpenRGB device."""
import asyncio
from wled_controller.core.devices.openrgb_client import parse_openrgb_url
from ledgrab.core.devices.openrgb_client import parse_openrgb_url
host, port, device_index, _zones = parse_openrgb_url(url)
def _fetch_zones():
from openrgb import OpenRGBClient
client = OpenRGBClient(host, port, name="WLED Controller (zones)")
client = OpenRGBClient(host, port, name="LedGrab (zones)")
try:
devices = client.devices
if device_index >= len(devices):
@@ -742,7 +742,7 @@ async def device_ws_stream(
Wire format: [brightness_byte][R G B R G B ...]
Auth via ?token=<api_key>.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -760,7 +760,7 @@ async def device_ws_stream(
await websocket.accept()
from wled_controller.core.devices.ws_client import get_ws_broadcaster
from ledgrab.core.devices.ws_client import get_ws_broadcaster
broadcaster = get_ws_broadcaster()
broadcaster.add_client(device_id, websocket)
@@ -10,14 +10,14 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_database,
get_game_integration_store,
get_game_event_bus,
)
from wled_controller.api.schemas.game_integration import (
from ledgrab.api.schemas.game_integration import (
AdapterInfoResponse,
AdapterListResponse,
ApplyPresetRequest,
@@ -34,13 +34,13 @@ from wled_controller.api.schemas.game_integration import (
PresetListResponse,
RecentEventsResponse,
)
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.utils import get_logger
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.game_integration import EventMapping
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -135,7 +135,7 @@ def _cleanup_state(integration_id: str) -> None:
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
from wled_controller.api.schemas.game_integration import EventMappingSchema
from ledgrab.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
id=config.id,
@@ -171,7 +171,7 @@ def _config_to_response(config: Any) -> GameIntegrationResponse:
)
async def list_presets(_auth: AuthRequired):
"""List all available built-in effect presets."""
from wled_controller.core.game_integration.presets import get_all_presets
from ledgrab.core.game_integration.presets import get_all_presets
presets = get_all_presets()
responses = [
@@ -554,7 +554,7 @@ async def apply_preset(
If replace=true, replaces all existing mappings.
If replace=false (default), appends preset mappings to existing ones.
"""
from wled_controller.core.game_integration.presets import get_preset
from ledgrab.core.game_integration.presets import get_preset
try:
config = store.get_integration(integration_id)
@@ -619,7 +619,7 @@ async def auto_setup_integration(
)
# Determine server URL
from wled_controller.api.routes.system_settings import load_external_url
from ledgrab.api.routes.system_settings import load_external_url
db = get_database()
server_url = load_external_url(db)
@@ -2,23 +2,23 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_gradient_store,
)
from wled_controller.api.schemas.gradients import (
from ledgrab.api.schemas.gradients import (
GradientCreate,
GradientListResponse,
GradientResponse,
GradientUpdate,
)
from wled_controller.storage.gradient import Gradient
from wled_controller.storage.gradient_store import GradientStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.utils import get_logger
from ledgrab.storage.gradient import Gradient
from ledgrab.storage.gradient_store import GradientStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -51,7 +51,9 @@ async def list_gradients(
)
@router.post("/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"])
@router.post(
"/api/v1/gradients", response_model=GradientResponse, status_code=201, tags=["Gradients"]
)
async def create_gradient(
data: GradientCreate,
_auth: AuthRequired,
@@ -109,7 +111,12 @@ async def update_gradient(
raise HTTPException(status_code=status, detail=str(e))
@router.post("/api/v1/gradients/{gradient_id}/clone", response_model=GradientResponse, status_code=201, tags=["Gradients"])
@router.post(
"/api/v1/gradients/{gradient_id}/clone",
response_model=GradientResponse,
status_code=201,
tags=["Gradients"],
)
async def clone_gradient(
gradient_id: str,
_auth: AuthRequired,
@@ -143,9 +150,7 @@ async def delete_gradient(
# Check references
for source in css_store.get_all_sources():
if getattr(source, "gradient_id", None) == gradient_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
store.delete_gradient(gradient_id)
fire_entity_event("gradient", "deleted", gradient_id)
except (ValueError, EntityNotFoundError) as e:
@@ -5,13 +5,13 @@ import json
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_ha_manager,
get_ha_store,
)
from wled_controller.api.schemas.home_assistant import (
from ledgrab.api.schemas.home_assistant import (
HomeAssistantConnectionStatus,
HomeAssistantEntityListResponse,
HomeAssistantEntityResponse,
@@ -22,12 +22,12 @@ from wled_controller.api.schemas.home_assistant import (
HomeAssistantStatusResponse,
HomeAssistantTestResponse,
)
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.home_assistant.ha_runtime import HARuntime
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.home_assistant_source import HomeAssistantSource
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.utils import get_logger
from ledgrab.core.home_assistant.ha_manager import HomeAssistantManager
from ledgrab.core.home_assistant.ha_runtime import HARuntime
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.home_assistant_source import HomeAssistantSource
from ledgrab.storage.home_assistant_store import HomeAssistantStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -5,13 +5,13 @@ import asyncio
import aiomqtt
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_mqtt_manager,
get_mqtt_store,
)
from wled_controller.api.schemas.mqtt import (
from ledgrab.api.schemas.mqtt import (
MQTTConnectionStatus,
MQTTSourceCreate,
MQTTSourceListResponse,
@@ -20,11 +20,11 @@ from wled_controller.api.schemas.mqtt import (
MQTTStatusResponse,
MQTTTestResponse,
)
from wled_controller.core.mqtt.mqtt_manager import MQTTManager
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.mqtt_source import MQTTSource
from wled_controller.storage.mqtt_source_store import MQTTSourceStore
from wled_controller.utils import get_logger
from ledgrab.core.mqtt.mqtt_manager import MQTTManager
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.mqtt_source import MQTTSource
from ledgrab.storage.mqtt_source_store import MQTTSourceStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -5,14 +5,14 @@ from typing import Annotated
from fastapi import APIRouter, Body, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_device_store,
get_output_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
from ledgrab.api.schemas.output_targets import (
HALightMappingSchema,
HALightOutputTargetResponse,
LedOutputTargetResponse,
@@ -21,17 +21,17 @@ from wled_controller.api.schemas.output_targets import (
OutputTargetResponse,
OutputTargetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.bindable import BindableFloat
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.ha_light_output_target import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.storage import DeviceStore
from ledgrab.storage.bindable import BindableFloat
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.ha_light_output_target import (
HALightMapping,
HALightOutputTarget,
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -5,30 +5,30 @@ Extracted from output_targets.py to keep files under 800 lines.
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
get_color_strip_store,
get_output_target_store,
get_picture_source_store,
get_processor_manager,
)
from wled_controller.api.schemas.output_targets import (
from ledgrab.api.schemas.output_targets import (
BulkTargetRequest,
BulkTargetResponse,
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.color_strip_source import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.storage.color_strip_source import (
AdvancedPictureColorStripSource,
PictureColorStripSource,
)
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.wled_output_target import WledOutputTarget
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -208,7 +208,7 @@ async def events_ws(
token: str = Query(""),
):
"""WebSocket for real-time state change events. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -272,7 +272,7 @@ async def start_target_overlay(
):
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import (
from ledgrab.api.routes.color_strip_sources import (
_resolve_display_index,
)
@@ -348,7 +348,7 @@ async def ha_light_colors_ws(
Streams: {"type": "colors_update", "colors": {entity_id: {r,g,b,hex}, ...}}
at the target's update_rate.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -390,7 +390,7 @@ async def led_preview_ws(
token: str = Query(""),
):
"""WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=<api_key>."""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -2,24 +2,24 @@
from fastapi import APIRouter, HTTPException, Depends
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_pattern_template_store,
get_output_target_store,
)
from wled_controller.api.schemas.pattern_templates import (
from ledgrab.api.schemas.pattern_templates import (
PatternTemplateCreate,
PatternTemplateListResponse,
PatternTemplateResponse,
PatternTemplateUpdate,
)
from wled_controller.api.schemas.output_targets import KeyColorRectangleSchema
from wled_controller.storage.pattern_template import KeyColorRectangle
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.api.schemas.output_targets import KeyColorRectangleSchema
from ledgrab.storage.pattern_template import KeyColorRectangle
from ledgrab.storage.pattern_template_store import PatternTemplateStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -9,20 +9,20 @@ import numpy as np
from fastapi import APIRouter, Body, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.picture_sources import (
from ledgrab.api.schemas.picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
PictureSourceCreate,
@@ -35,20 +35,20 @@ from wled_controller.api.schemas.picture_sources import (
StaticImagePictureSourceResponse,
VideoPictureSourceResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import (
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, ImagePool
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import (
ProcessedPictureSource,
ScreenCapturePictureSource,
StaticImagePictureSource,
VideoCaptureSource,
)
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -142,7 +142,7 @@ async def validate_image(
"""Validate an image source (URL or file path) and return a preview thumbnail."""
try:
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
from ledgrab.utils.safe_source import validate_image_path, validate_image_url
source = data.image_source.strip()
if not source:
@@ -161,7 +161,7 @@ async def validate_image(
img_bytes = path
def _process_image(src):
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
load_image_bytes,
load_image_file,
@@ -198,7 +198,7 @@ async def get_full_image(
):
"""Serve the full-resolution image for lightbox preview."""
from wled_controller.utils.safe_source import validate_image_path, validate_image_url
from ledgrab.utils.safe_source import validate_image_path, validate_image_url
try:
if source.startswith(("http://", "https://")):
@@ -214,7 +214,7 @@ async def get_full_image(
img_bytes = path
def _encode_full(src):
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg,
load_image_bytes,
load_image_file,
@@ -375,9 +375,9 @@ async def get_video_thumbnail(
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Get a thumbnail for a video picture source (first frame)."""
from wled_controller.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.core.processing.video_stream import extract_thumbnail
from ledgrab.storage.picture_source import VideoCaptureSource
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
try:
source = store.get_stream(stream_id)
@@ -385,7 +385,7 @@ async def get_video_thumbnail(
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
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
asset_store = _get_asset_store()
video_path = (
@@ -449,8 +449,8 @@ async def test_picture_source(
if isinstance(raw_stream, StaticImagePictureSource):
# 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
from ledgrab.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.utils.image_codec import load_image_file
asset_store = _get_asset_store()
image_path = (
@@ -531,7 +531,7 @@ async def test_picture_source(
image = last_frame.image
# Create thumbnail + encode (CPU-bound — run in thread)
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
@@ -628,11 +628,11 @@ async def test_picture_source_ws(
preview_width: int = Query(0),
):
"""WebSocket for picture source test with intermediate frame previews."""
from wled_controller.api.routes._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
@@ -662,8 +662,8 @@ 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
from ledgrab.core.processing.video_stream import VideoCaptureLiveStream
from ledgrab.api.dependencies import get_asset_store as _get_asset_store2
asset_store = _get_asset_store2()
video_path = (
@@ -690,7 +690,7 @@ async def test_picture_source_ws(
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from wled_controller.utils.image_codec import encode_jpeg_data_uri, resize_down
from ledgrab.utils.image_codec import encode_jpeg_data_uri, resize_down
if pw:
image = resize_down(image, pw)
@@ -5,34 +5,34 @@ import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.filters import FilterInstanceSchema
from wled_controller.api.schemas.postprocessing import (
from ledgrab.api.schemas.filters import FilterInstanceSchema
from ledgrab.api.schemas.postprocessing import (
PostprocessingTemplateCreate,
PostprocessingTemplateListResponse,
PostprocessingTemplateResponse,
PostprocessingTemplateUpdate,
PPTemplateTestRequest,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, FilterInstance, ImagePool
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry, FilterInstance, ImagePool
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.postprocessing_template_store import PostprocessingTemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -52,7 +52,11 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse:
)
@router.get("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateListResponse, tags=["Postprocessing Templates"])
@router.get(
"/api/v1/postprocessing-templates",
response_model=PostprocessingTemplateListResponse,
tags=["Postprocessing Templates"],
)
async def list_pp_templates(
_auth: AuthRequired,
store: PostprocessingTemplateStore = Depends(get_pp_template_store),
@@ -63,7 +67,12 @@ async def list_pp_templates(
return PostprocessingTemplateListResponse(templates=responses, count=len(responses))
@router.post("/api/v1/postprocessing-templates", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"], status_code=201)
@router.post(
"/api/v1/postprocessing-templates",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
status_code=201,
)
async def create_pp_template(
data: PostprocessingTemplateCreate,
_auth: AuthRequired,
@@ -90,7 +99,11 @@ async def create_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@router.get(
"/api/v1/postprocessing-templates/{template_id}",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
)
async def get_pp_template(
template_id: str,
_auth: AuthRequired,
@@ -101,10 +114,16 @@ async def get_pp_template(
template = store.get_template(template_id)
return _pp_template_to_response(template)
except ValueError:
raise HTTPException(status_code=404, detail=f"Postprocessing template {template_id} not found")
raise HTTPException(
status_code=404, detail=f"Postprocessing template {template_id} not found"
)
@router.put("/api/v1/postprocessing-templates/{template_id}", response_model=PostprocessingTemplateResponse, tags=["Postprocessing Templates"])
@router.put(
"/api/v1/postprocessing-templates/{template_id}",
response_model=PostprocessingTemplateResponse,
tags=["Postprocessing Templates"],
)
async def update_pp_template(
template_id: str,
data: PostprocessingTemplateUpdate,
@@ -113,7 +132,11 @@ async def update_pp_template(
):
"""Update a postprocessing template."""
try:
filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None
filters = (
[FilterInstance(f.filter_id, f.options) for f in data.filters]
if data.filters is not None
else None
)
template = store.update_template(
template_id=template_id,
name=data.name,
@@ -133,7 +156,11 @@ async def update_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete("/api/v1/postprocessing-templates/{template_id}", status_code=204, tags=["Postprocessing Templates"])
@router.delete(
"/api/v1/postprocessing-templates/{template_id}",
status_code=204,
tags=["Postprocessing Templates"],
)
async def delete_pp_template(
template_id: str,
_auth: AuthRequired,
@@ -149,7 +176,7 @@ async def delete_pp_template(
raise HTTPException(
status_code=409,
detail=f"Cannot delete postprocessing template: it is referenced by picture source(s): {names}. "
"Please reassign those streams before deleting.",
"Please reassign those streams before deleting.",
)
store.delete_template(template_id)
fire_entity_event("pp_template", "deleted", template_id)
@@ -165,7 +192,11 @@ async def delete_pp_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"])
@router.post(
"/api/v1/postprocessing-templates/{template_id}/test",
response_model=TemplateTestResponse,
tags=["Postprocessing Templates"],
)
async def test_pp_template(
template_id: str,
test_request: PPTemplateTestRequest,
@@ -194,7 +225,7 @@ async def test_pp_template(
raw_stream = chain["raw_stream"]
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
load_image_file,
thumbnail as make_thumbnail,
@@ -202,10 +233,14 @@ async def test_pp_template(
if isinstance(raw_stream, StaticImagePictureSource):
# Static image: load from asset
from wled_controller.api.dependencies import get_asset_store as _get_asset_store
from ledgrab.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
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")
@@ -238,7 +273,9 @@ async def test_pp_template(
)
stream.initialize()
logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}")
logger.info(
f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}"
)
frame_count = 0
total_capture_time = 0.0
@@ -346,11 +383,11 @@ async def test_pp_template_ws(
preview_width: int = Query(0),
):
"""WebSocket for PP template test with intermediate frame previews."""
from wled_controller.api.routes._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
from wled_controller.api.dependencies import (
from ledgrab.api.dependencies import (
get_picture_source_store as _get_ps_store,
get_template_store as _get_t_store,
get_pp_template_store as _get_pp_store,
@@ -400,7 +437,9 @@ async def test_pp_template_ws(
return
if capture_template.engine_type not in EngineRegistry.get_available_engines():
await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available")
await websocket.close(
code=4003, reason=f"Engine '{capture_template.engine_type}' not available"
)
return
# Resolve PP filters
@@ -422,7 +461,9 @@ async def test_pp_template_ws(
try:
await stream_capture_test(
websocket, engine_factory, duration,
websocket,
engine_factory,
duration,
pp_filters=pp_filters,
preview_width=preview_width or None,
)
@@ -5,30 +5,30 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_scene_preset_store,
)
from wled_controller.api.schemas.scene_presets import (
from ledgrab.api.schemas.scene_presets import (
ActivateResponse,
ScenePresetCreate,
ScenePresetListResponse,
ScenePresetResponse,
ScenePresetUpdate,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.scenes.scene_activator import (
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.scenes.scene_activator import (
apply_scene_state,
capture_current_snapshot,
)
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.storage.scene_preset_store import ScenePresetStore
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
@@ -39,13 +39,16 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
id=preset.id,
name=preset.name,
description=preset.description,
targets=[{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
} for t in preset.targets],
targets=[
{
"target_id": t.target_id,
"running": t.running,
"color_strip_source_id": t.color_strip_source_id,
"brightness_value_source_id": t.brightness_value_source_id,
"fps": t.fps,
}
for t in preset.targets
],
order=preset.order,
tags=preset.tags,
created_at=preset.created_at,
@@ -55,6 +58,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
# ===== CRUD =====
@router.post(
"/api/v1/scene-presets",
response_model=ScenePresetResponse,
@@ -180,7 +184,9 @@ async def update_scene_preset(
tags=data.tags,
)
except ValueError as e:
raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e))
raise HTTPException(
status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)
)
fire_entity_event("scene_preset", "updated", preset_id)
return _preset_to_response(preset)
@@ -206,6 +212,7 @@ async def delete_scene_preset(
# ===== Recapture =====
@router.post(
"/api/v1/scene-presets/{preset_id}/recapture",
response_model=ScenePresetResponse,
@@ -244,6 +251,7 @@ async def recapture_scene_preset(
# ===== Activate =====
@router.post(
"/api/v1/scene-presets/{preset_id}/activate",
response_model=ActivateResponse,
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_color_strip_store,
get_sync_clock_manager,
get_sync_clock_store,
)
from wled_controller.api.schemas.sync_clocks import (
from ledgrab.api.schemas.sync_clocks import (
SyncClockCreate,
SyncClockListResponse,
SyncClockResponse,
SyncClockUpdate,
)
from wled_controller.storage.sync_clock import SyncClock
from wled_controller.storage.sync_clock_store import SyncClockStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.sync_clock import SyncClock
from ledgrab.storage.sync_clock_store import SyncClockStore
from ledgrab.storage.color_strip_store import ColorStripStore
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -57,7 +57,9 @@ async def list_sync_clocks(
)
@router.post("/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"]
)
async def create_sync_clock(
data: SyncClockCreate,
_auth: AuthRequired,
@@ -81,7 +83,9 @@ async def create_sync_clock(
raise HTTPException(status_code=400, detail=str(e))
@router.get("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.get(
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def get_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -96,7 +100,9 @@ async def get_sync_clock(
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.put(
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def update_sync_clock(
clock_id: str,
data: SyncClockUpdate,
@@ -138,9 +144,7 @@ async def delete_sync_clock(
# Check references
for source in css_store.get_all_sources():
if getattr(source, "clock_id", None) == clock_id:
raise ValueError(
f"Cannot delete: referenced by color strip source '{source.name}'"
)
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
manager.release_all_for(clock_id)
store.delete_clock(clock_id)
fire_entity_event("sync_clock", "deleted", clock_id)
@@ -153,7 +157,10 @@ async def delete_sync_clock(
# ── Runtime control ──────────────────────────────────────────────────
@router.post("/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def pause_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -170,7 +177,9 @@ async def pause_sync_clock(
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def resume_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -187,7 +196,9 @@ async def resume_sync_clock(
return _to_response(clock, manager)
@router.post("/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"])
@router.post(
"/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"]
)
async def reset_sync_clock(
clock_id: str,
_auth: AuthRequired,
@@ -15,9 +15,9 @@ import os
import psutil
from fastapi import APIRouter, Depends, HTTPException, Query
from wled_controller import __version__, REPO_URL, DONATE_URL
from wled_controller.api.auth import AuthRequired, is_auth_enabled
from wled_controller.api.dependencies import (
from ledgrab import __version__, REPO_URL, DONATE_URL
from ledgrab.api.auth import AuthRequired, is_auth_enabled
from ledgrab.api.dependencies import (
get_audio_source_store,
get_audio_template_store,
get_automation_store,
@@ -34,7 +34,7 @@ from wled_controller.api.dependencies import (
get_template_store,
get_value_source_store,
)
from wled_controller.api.schemas.system import (
from ledgrab.api.schemas.system import (
DisplayInfo,
DisplayListResponse,
GpuInfo,
@@ -43,13 +43,13 @@ from wled_controller.api.schemas.system import (
ProcessListResponse,
VersionResponse,
)
from wled_controller.config import get_config, is_demo_mode
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.config import get_config, is_demo_mode
from ledgrab.core.capture.screen_capture import get_available_displays
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
# Re-export load_external_url so existing callers still work
from wled_controller.api.routes.system_settings import load_external_url # noqa: F401
from ledgrab.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
@@ -59,7 +59,7 @@ _process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from wled_controller.utils.gpu import ( # noqa: E402
from ledgrab.utils.gpu import ( # noqa: E402
nvml_available as _nvml_available,
nvml as _nvml,
nvml_handle as _nvml_handle,
@@ -139,7 +139,7 @@ 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
from ledgrab.api.dependencies import get_asset_store
store_getters = [
get_device_store,
@@ -185,7 +185,7 @@ async def get_displays(
logger.info(f"Listing available displays (engine_type={engine_type})")
try:
from wled_controller.core.capture_engines import EngineRegistry
from ledgrab.core.capture_engines import EngineRegistry
if engine_type:
engine_cls = EngineRegistry.get_engine(engine_type)
@@ -240,7 +240,7 @@ async def get_running_processes(_: AuthRequired):
Returns a sorted list of unique process names for use in automation conditions.
"""
from wled_controller.core.automations.platform_detector import PlatformDetector
from ledgrab.core.automations.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
@@ -348,7 +348,7 @@ async def get_integrations_status(
Used by the dashboard to show connectivity indicators.
"""
from wled_controller.core.devices.mqtt_client import get_mqtt_service
from ledgrab.core.devices.mqtt_client import get_mqtt_service
# MQTT status
mqtt_service = get_mqtt_service()
@@ -10,9 +10,9 @@ import re
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import get_database
from wled_controller.api.schemas.system import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_database
from ledgrab.api.schemas.system import (
ExternalUrlRequest,
ExternalUrlResponse,
LogLevelRequest,
@@ -20,9 +20,9 @@ from wled_controller.api.schemas.system import (
MQTTSettingsRequest,
MQTTSettingsResponse,
)
from wled_controller.config import get_config
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
from ledgrab.config import get_config
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -76,7 +76,9 @@ async def get_mqtt_settings(_: AuthRequired, db: Database = Depends(get_database
response_model=MQTTSettingsResponse,
tags=["System"],
)
async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)):
async def update_mqtt_settings(
_: AuthRequired, body: MQTTSettingsRequest, db: Database = Depends(get_database)
):
"""Update MQTT broker settings. If password is empty string, the existing password is preserved."""
current = _load_mqtt_settings(db)
@@ -110,10 +112,12 @@ async def update_mqtt_settings(_: AuthRequired, body: MQTTSettingsRequest, db: D
# External URL setting
# ---------------------------------------------------------------------------
def load_external_url(db: Database | None = None) -> str:
"""Load the external URL setting. Returns empty string if not set."""
if db is None:
from wled_controller.api.dependencies import get_database
from ledgrab.api.dependencies import get_database
db = get_database()
data = db.get_setting("external_url")
if data:
@@ -136,7 +140,9 @@ async def get_external_url(_: AuthRequired, db: Database = Depends(get_database)
response_model=ExternalUrlResponse,
tags=["System"],
)
async def update_external_url(_: AuthRequired, body: ExternalUrlRequest, db: Database = Depends(get_database)):
async def update_external_url(
_: AuthRequired, body: ExternalUrlRequest, db: Database = Depends(get_database)
):
"""Set the external base URL used in webhook URLs and other user-visible URLs."""
url = body.external_url.strip().rstrip("/")
db.set_setting("external_url", {"external_url": url})
@@ -159,8 +165,8 @@ async def logs_ws(
Auth via ``?token=<api_key>``. On connect, sends the last ~500 buffered
lines as individual text messages, then pushes new lines as they appear.
"""
from wled_controller.api.auth import verify_ws_token
from wled_controller.utils import log_broadcaster
from ledgrab.api.auth import verify_ws_token
from ledgrab.utils import log_broadcaster
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -205,9 +211,7 @@ async def logs_ws(
# ---------------------------------------------------------------------------
# Regex: IPv4 address with optional port, e.g. "192.168.1.5" or "192.168.1.5:5555"
_ADB_ADDRESS_RE = re.compile(
r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$"
)
_ADB_ADDRESS_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?$")
class AdbConnectRequest(BaseModel):
@@ -244,7 +248,8 @@ def _validate_adb_address(address: str) -> None:
def _get_adb_path() -> str:
"""Get the adb binary path from the scrcpy engine's resolver."""
from wled_controller.core.capture_engines.scrcpy_engine import _get_adb
from ledgrab.core.capture_engines.scrcpy_engine import _get_adb
return _get_adb()
@@ -265,7 +270,9 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest):
logger.info(f"Connecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "connect", address,
adb,
"connect",
address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -295,7 +302,9 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest):
logger.info(f"Disconnecting ADB device: {address}")
try:
proc = await asyncio.create_subprocess_exec(
adb, "disconnect", address,
adb,
"disconnect",
address,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
@@ -5,20 +5,20 @@ import time
import numpy as np
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_cspt_store,
get_picture_source_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
from ledgrab.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.templates import (
from ledgrab.api.schemas.templates import (
EngineInfo,
EngineListResponse,
TemplateCreate,
@@ -27,18 +27,18 @@ from wled_controller.api.schemas.templates import (
TemplateTestRequest,
TemplateUpdate,
)
from wled_controller.api.schemas.filters import (
from ledgrab.api.schemas.filters import (
FilterOptionDefSchema,
FilterTypeListResponse,
FilterTypeResponse,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.core.capture_engines import EngineRegistry
from ledgrab.core.filters import FilterRegistry
from ledgrab.storage.template_store import TemplateStore
from ledgrab.storage.picture_source_store import PictureSourceStore
from ledgrab.storage.picture_source import ScreenCapturePictureSource
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -47,6 +47,7 @@ router = APIRouter()
# ===== CAPTURE TEMPLATE ENDPOINTS =====
@router.get("/api/v1/capture-templates", response_model=TemplateListResponse, tags=["Templates"])
async def list_templates(
_auth: AuthRequired,
@@ -80,7 +81,12 @@ async def list_templates(
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates", response_model=TemplateResponse, tags=["Templates"], status_code=201)
@router.post(
"/api/v1/capture-templates",
response_model=TemplateResponse,
tags=["Templates"],
status_code=201,
)
async def create_template(
template_data: TemplateCreate,
_auth: AuthRequired,
@@ -111,7 +117,6 @@ async def create_template(
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -119,7 +124,9 @@ async def create_template(
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@router.get(
"/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"]
)
async def get_template(
template_id: str,
_auth: AuthRequired,
@@ -143,7 +150,9 @@ async def get_template(
)
@router.put("/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"])
@router.put(
"/api/v1/capture-templates/{template_id}", response_model=TemplateResponse, tags=["Templates"]
)
async def update_template(
template_id: str,
update_data: TemplateUpdate,
@@ -176,7 +185,6 @@ async def update_template(
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -199,7 +207,10 @@ async def delete_template(
# Check if any streams are using this template
streams_using_template = []
for stream in stream_store.get_all_streams():
if isinstance(stream, ScreenCapturePictureSource) and stream.capture_template_id == template_id:
if (
isinstance(stream, ScreenCapturePictureSource)
and stream.capture_template_id == template_id
):
streams_using_template.append(stream.name)
if streams_using_template:
@@ -207,7 +218,7 @@ async def delete_template(
raise HTTPException(
status_code=409,
detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. "
f"Please reassign these streams to a different template before deleting."
f"Please reassign these streams to a different template before deleting.",
)
# Proceed with deletion
@@ -245,7 +256,7 @@ async def list_engines(_auth: AuthRequired):
name=engine_type.upper(),
default_config=engine_class.get_default_config(),
available=(engine_type in available_set),
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
has_own_displays=getattr(engine_class, "HAS_OWN_DISPLAYS", False),
)
)
@@ -256,7 +267,9 @@ async def list_engines(_auth: AuthRequired):
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"])
@router.post(
"/api/v1/capture-templates/test", response_model=TemplateTestResponse, tags=["Templates"]
)
def test_template(
test_request: TemplateTestRequest,
_auth: AuthRequired,
@@ -276,7 +289,7 @@ def test_template(
if test_request.engine_type not in EngineRegistry.get_available_engines():
raise HTTPException(
status_code=400,
detail=f"Engine '{test_request.engine_type}' is not available on this system"
detail=f"Engine '{test_request.engine_type}' is not available on this system",
)
# Create and initialize capture stream
@@ -286,7 +299,9 @@ def test_template(
stream.initialize()
# Run sustained capture test
logger.info(f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}")
logger.info(
f"Starting {test_request.capture_duration}s capture test with {test_request.engine_type}"
)
frame_count = 0
total_capture_time = 0.0
@@ -321,7 +336,7 @@ def test_template(
raise ValueError("Unexpected image format from engine")
image = last_frame.image
from wled_controller.utils.image_codec import (
from ledgrab.utils.image_codec import (
encode_jpeg_data_uri,
thumbnail as make_thumbnail,
)
@@ -361,7 +376,6 @@ def test_template(
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
@@ -391,7 +405,7 @@ async def test_template_ws(
Config is sent as the first client message (JSON with engine_type,
engine_config, display_index, capture_duration).
"""
from wled_controller.api.routes._preview_helpers import (
from ledgrab.api.routes._preview_helpers import (
authenticate_ws_token,
stream_capture_test,
)
@@ -417,7 +431,9 @@ async def test_template_ws(
pw = int(config.get("preview_width", 0)) or None
if engine_type not in EngineRegistry.get_available_engines():
await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"})
await websocket.send_json(
{"type": "error", "detail": f"Engine '{engine_type}' not available"}
)
await websocket.close(code=4003)
return
@@ -428,7 +444,9 @@ async def test_template_ws(
s.initialize()
return s
logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)")
logger.info(
f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)"
)
try:
await stream_capture_test(websocket, engine_factory, duration, preview_width=pw)
@@ -443,6 +461,7 @@ async def test_template_ws(
# ===== FILTER TYPE ENDPOINTS =====
@router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"])
async def list_filter_types(
_auth: AuthRequired,
@@ -467,23 +486,31 @@ async def list_filter_types(
for opt in schema:
choices = opt.choices
# Enrich filter_template choices with current template list
if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None:
if (
filter_id == "filter_template"
and opt.key == "template_id"
and template_choices is not None
):
choices = template_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
))
opt_schemas.append(
FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
)
)
responses.append(
FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
)
)
return FilterTypeListResponse(filters=responses, count=len(responses))
@@ -512,21 +539,29 @@ async def list_strip_filter_types(
opt_schemas = []
for opt in schema:
choices = opt.choices
if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None:
if (
filter_id == "css_filter_template"
and opt.key == "template_id"
and cspt_choices is not None
):
choices = cspt_choices
opt_schemas.append(FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
))
responses.append(FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
))
opt_schemas.append(
FilterOptionDefSchema(
key=opt.key,
label=opt.label,
type=opt.option_type,
default=opt.default,
min_value=opt.min_value,
max_value=opt.max_value,
step=opt.step,
choices=choices,
)
)
responses.append(
FilterTypeResponse(
filter_id=filter_cls.filter_id,
filter_name=filter_cls.filter_name,
options_schema=opt_schemas,
)
)
return FilterTypeListResponse(filters=responses, count=len(responses))
@@ -3,16 +3,16 @@
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 (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import get_update_service
from ledgrab.api.schemas.update import (
DismissRequest,
UpdateSettingsRequest,
UpdateSettingsResponse,
UpdateStatusResponse,
)
from wled_controller.core.update.update_service import UpdateService
from wled_controller.utils import get_logger
from ledgrab.core.update.update_service import UpdateService
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -57,7 +57,9 @@ async def apply_update(
if not status["can_auto_update"]:
return JSONResponse(
status_code=400,
content={"detail": f"Auto-update not supported for install type: {status['install_type']}"},
content={
"detail": f"Auto-update not supported for install type: {status['install_type']}"
},
)
try:
await service.apply_update()
@@ -5,14 +5,14 @@ from typing import Annotated, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_output_target_store,
get_processor_manager,
get_value_source_store,
)
from wled_controller.api.schemas.value_sources import (
from ledgrab.api.schemas.value_sources import (
AdaptiveSceneValueSourceResponse,
AdaptiveTimeColorValueSourceResponse,
AdaptiveTimeValueSourceResponse,
@@ -31,7 +31,7 @@ from wled_controller.api.schemas.value_sources import (
ValueSourceResponse,
ValueSourceUpdate,
)
from wled_controller.storage.value_source import (
from ledgrab.storage.value_source import (
AdaptiveTimeColorValueSource,
AdaptiveValueSource,
AnimatedColorValueSource,
@@ -46,12 +46,12 @@ from wled_controller.storage.value_source import (
SystemMetricsValueSource,
ValueSource,
)
from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
from ledgrab.storage.value_source_store import ValueSourceStore
from ledgrab.storage.output_target_store import OutputTargetStore
from ledgrab.core.processing.processor_manager import ProcessorManager
from ledgrab.core.processing.value_stream import ValueStream
from ledgrab.utils import get_logger
from ledgrab.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
@@ -340,7 +340,7 @@ async def delete_value_source(
"""Delete a value source."""
try:
# Check if any targets reference this value source
from wled_controller.storage.wled_output_target import WledOutputTarget
from ledgrab.storage.wled_output_target import WledOutputTarget
for target in target_store.get_all_targets():
if isinstance(target, WledOutputTarget):
@@ -370,7 +370,7 @@ async def test_value_source_ws(
Acquires a ValueStream for the given source, polls get_value() at ~20 Hz,
and streams {value: float} JSON to the client.
"""
from wled_controller.api.auth import verify_ws_token
from ledgrab.api.auth import verify_ws_token
if not verify_ws_token(token):
await websocket.close(code=4001, reason="Unauthorized")
@@ -2,25 +2,25 @@
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
from ledgrab.api.auth import AuthRequired
from ledgrab.api.dependencies import (
fire_entity_event,
get_weather_manager,
get_weather_source_store,
)
from wled_controller.api.schemas.weather_sources import (
from ledgrab.api.schemas.weather_sources import (
WeatherSourceCreate,
WeatherSourceListResponse,
WeatherSourceResponse,
WeatherSourceUpdate,
WeatherTestResponse,
)
from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.core.weather.weather_provider import WMO_CONDITION_NAMES
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.weather_source import WeatherSource
from wled_controller.storage.weather_source_store import WeatherSourceStore
from wled_controller.utils import get_logger
from ledgrab.core.weather.weather_manager import WeatherManager
from ledgrab.core.weather.weather_provider import WMO_CONDITION_NAMES
from ledgrab.storage.base_store import EntityNotFoundError
from ledgrab.storage.weather_source import WeatherSource
from ledgrab.storage.weather_source_store import WeatherSourceStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -44,7 +44,9 @@ def _to_response(source: WeatherSource) -> WeatherSourceResponse:
)
@router.get("/api/v1/weather-sources", response_model=WeatherSourceListResponse, tags=["Weather Sources"])
@router.get(
"/api/v1/weather-sources", response_model=WeatherSourceListResponse, tags=["Weather Sources"]
)
async def list_weather_sources(
_auth: AuthRequired,
store: WeatherSourceStore = Depends(get_weather_source_store),
@@ -56,7 +58,12 @@ async def list_weather_sources(
)
@router.post("/api/v1/weather-sources", response_model=WeatherSourceResponse, status_code=201, tags=["Weather Sources"])
@router.post(
"/api/v1/weather-sources",
response_model=WeatherSourceResponse,
status_code=201,
tags=["Weather Sources"],
)
async def create_weather_source(
data: WeatherSourceCreate,
_auth: AuthRequired,
@@ -79,7 +86,11 @@ async def create_weather_source(
return _to_response(source)
@router.get("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
@router.get(
"/api/v1/weather-sources/{source_id}",
response_model=WeatherSourceResponse,
tags=["Weather Sources"],
)
async def get_weather_source(
source_id: str,
_auth: AuthRequired,
@@ -91,7 +102,11 @@ async def get_weather_source(
raise HTTPException(status_code=404, detail=f"Weather source {source_id} not found")
@router.put("/api/v1/weather-sources/{source_id}", response_model=WeatherSourceResponse, tags=["Weather Sources"])
@router.put(
"/api/v1/weather-sources/{source_id}",
response_model=WeatherSourceResponse,
tags=["Weather Sources"],
)
async def update_weather_source(
source_id: str,
data: WeatherSourceUpdate,
@@ -133,7 +148,11 @@ async def delete_weather_source(
fire_entity_event("weather_source", "deleted", source_id)
@router.post("/api/v1/weather-sources/{source_id}/test", response_model=WeatherTestResponse, tags=["Weather Sources"])
@router.post(
"/api/v1/weather-sources/{source_id}/test",
response_model=WeatherTestResponse,
tags=["Weather Sources"],
)
async def test_weather_source(
source_id: str,
_auth: AuthRequired,
@@ -13,11 +13,11 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from wled_controller.api.dependencies import get_automation_engine, get_automation_store
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import WebhookCondition
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.utils import get_logger
from ledgrab.api.dependencies import get_automation_engine, get_automation_store
from ledgrab.core.automations.automation_engine import AutomationEngine
from ledgrab.storage.automation import WebhookCondition
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
@@ -75,12 +75,17 @@ 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 secrets.compare_digest(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(
"Webhook %s: automation '%s' (%s) → %s",
token[:8], automation.name, automation.id, body.action,
token[:8],
automation.name,
automation.id,
body.action,
)
return {
"ok": True,
@@ -10,7 +10,9 @@ class AudioTemplateCreate(BaseModel):
"""Request to create an audio capture template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1)
engine_type: str = Field(
description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1
)
engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration")
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -12,7 +12,9 @@ class ColorStripProcessingTemplateCreate(BaseModel):
"""Request to create a color strip processing template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -21,7 +23,9 @@ class ColorStripProcessingTemplateUpdate(BaseModel):
"""Request to update a color strip processing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
filters: Optional[List[FilterInstanceSchema]] = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
@@ -5,7 +5,7 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
from wled_controller.api.schemas.devices import Calibration
from ledgrab.api.schemas.devices import Calibration
# =====================================================================
@@ -48,5 +48,7 @@ class TemplateTestResponse(BaseModel):
"""Response from template test."""
full_capture: CaptureImage = Field(description="Full screen capture with thumbnail")
border_extraction: Optional[BorderExtraction] = Field(None, description="Extracted border images (deprecated)")
border_extraction: Optional[BorderExtraction] = Field(
None, description="Extracted border images (deprecated)"
)
performance: PerformanceMetrics = Field(description="Performance metrics")
@@ -22,8 +22,12 @@ class FilterOptionDefSchema(BaseModel):
min_value: Any = Field(description="Minimum value")
max_value: Any = Field(description="Maximum value")
step: Any = Field(description="Step increment")
choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type")
max_length: Optional[int] = Field(default=None, description="Maximum string length for string type")
choices: Optional[List[Dict[str, str]]] = Field(
default=None, description="Available choices for select type"
)
max_length: Optional[int] = Field(
default=None, description="Maximum string length for string type"
)
class FilterTypeResponse(BaseModel):
@@ -12,7 +12,9 @@ class PatternTemplateCreate(BaseModel):
"""Request to create a pattern template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
rectangles: List[KeyColorRectangleSchema] = Field(
default_factory=list, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -21,7 +23,9 @@ class PatternTemplateUpdate(BaseModel):
"""Request to update a pattern template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(
None, description="List of named rectangles"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
@@ -12,7 +12,9 @@ class PostprocessingTemplateCreate(BaseModel):
"""Request to create a postprocessing template."""
name: str = Field(description="Template name", min_length=1, max_length=100)
filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances")
filters: List[FilterInstanceSchema] = Field(
default_factory=list, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -21,7 +23,9 @@ class PostprocessingTemplateUpdate(BaseModel):
"""Request to update a postprocessing template."""
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances")
filters: Optional[List[FilterInstanceSchema]] = Field(
None, description="Ordered list of filter instances"
)
description: Optional[str] = Field(None, description="Template description", max_length=500)
tags: Optional[List[str]] = None
@@ -41,7 +45,9 @@ class PostprocessingTemplateResponse(BaseModel):
class PostprocessingTemplateListResponse(BaseModel):
"""List of postprocessing templates response."""
templates: List[PostprocessingTemplateResponse] = Field(description="List of postprocessing templates")
templates: List[PostprocessingTemplateResponse] = Field(
description="List of postprocessing templates"
)
count: int = Field(description="Number of templates")
@@ -49,4 +55,6 @@ class PPTemplateTestRequest(BaseModel):
"""Request to test a postprocessing template against a source stream."""
source_stream_id: str = Field(description="ID of the source picture source to capture from")
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
capture_duration: float = Field(
default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds"
)
@@ -19,7 +19,9 @@ class ScenePresetCreate(BaseModel):
name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)")
target_ids: Optional[List[str]] = Field(
None, description="Target IDs to capture (all if omitted)"
)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -29,7 +31,10 @@ class ScenePresetUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
order: Optional[int] = None
target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed")
target_ids: Optional[List[str]] = Field(
None,
description="Update target list: keep state for existing, capture fresh for new, drop removed",
)
tags: Optional[List[str]] = None
@@ -53,7 +53,9 @@ class EngineInfo(BaseModel):
name: str = Field(description="Human-readable engine name")
default_config: Dict = Field(description="Default configuration for this engine")
available: bool = Field(description="Whether engine is available on this system")
has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)")
has_own_displays: bool = Field(
default=False, description="Engine has its own device list (not desktop monitors)"
)
class EngineListResponse(BaseModel):
@@ -76,4 +78,6 @@ class TemplateTestRequest(BaseModel):
engine_config: Dict = Field(default={}, description="Engine configuration")
display_index: int = Field(description="Display index to capture")
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels")
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
capture_duration: float = Field(
default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds"
)
@@ -10,11 +10,19 @@ class WeatherSourceCreate(BaseModel):
"""Request to create a weather source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
provider: Literal["open_meteo"] = Field(default="open_meteo", description="Weather data provider")
provider: Literal["open_meteo"] = Field(
default="open_meteo", description="Weather data provider"
)
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: float = Field(default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: float = Field(default=0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
update_interval: int = Field(default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
latitude: float = Field(
default=50.0, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
longitude: float = Field(
default=0.0, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
update_interval: int = Field(
default=600, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
@@ -25,9 +33,15 @@ class WeatherSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
provider: Optional[Literal["open_meteo"]] = Field(None, description="Weather data provider")
provider_config: Optional[Dict] = Field(None, description="Provider-specific configuration")
latitude: Optional[float] = Field(None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0)
update_interval: Optional[int] = Field(None, description="API poll interval in seconds (60-3600)", ge=60, le=3600)
latitude: Optional[float] = Field(
None, description="Geographic latitude (-90 to 90)", ge=-90.0, le=90.0
)
longitude: Optional[float] = Field(
None, description="Geographic longitude (-180 to 180)", ge=-180.0, le=180.0
)
update_interval: Optional[int] = Field(
None, description="API poll interval in seconds (60-3600)", ge=60, le=3600
)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
tags: Optional[List[str]] = None
@@ -38,7 +52,9 @@ class WeatherSourceResponse(BaseModel):
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
provider: str = Field(description="Weather data provider")
provider_config: Dict = Field(default_factory=dict, description="Provider-specific configuration")
provider_config: Dict = Field(
default_factory=dict, description="Provider-specific configuration"
)
latitude: float = Field(description="Geographic latitude")
longitude: float = Field(description="Geographic longitude")
update_interval: int = Field(description="API poll interval in seconds")
@@ -1,6 +1,7 @@
"""Configuration management for WLED Screen Controller."""
"""Configuration management for LedGrab."""
import os
import sys
from pathlib import Path
from typing import List, Literal
@@ -8,6 +9,43 @@ import yaml
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# ── Legacy env var migration ─────────────────────────────────
# Warn users who still have WLED_ env vars from pre-rename installs.
_OLD_PREFIX = "WLED_"
_NEW_PREFIX = "LEDGRAB_"
_ENV_MIGRATION_MAP = {
"WLED_CONFIG_PATH": "LEDGRAB_CONFIG_PATH",
"WLED_DEMO": "LEDGRAB_DEMO",
"WLED_RESTART": "LEDGRAB_RESTART",
"WLED_TRAY": "LEDGRAB_TRAY",
}
def _migrate_legacy_env_vars() -> None:
"""Detect old WLED_ env vars and auto-forward them to LEDGRAB_ equivalents."""
migrated = []
for old_key, new_key in _ENV_MIGRATION_MAP.items():
old_val = os.environ.get(old_key)
if old_val is not None and os.environ.get(new_key) is None:
os.environ[new_key] = old_val
migrated.append(f" {old_key} -> {new_key}")
# Also forward any WLED_<nested> vars (e.g. WLED_SERVER__PORT)
for key in list(os.environ):
if key.startswith(_OLD_PREFIX) and key not in _ENV_MIGRATION_MAP:
new_key = _NEW_PREFIX + key[len(_OLD_PREFIX) :]
if os.environ.get(new_key) is None:
os.environ[new_key] = os.environ[key]
migrated.append(f" {key} -> {new_key}")
if migrated:
print(
"WARNING: Detected legacy WLED_ environment variables. "
"The app was renamed to LedGrab — please update your env vars:\n" + "\n".join(migrated),
file=sys.stderr,
)
_migrate_legacy_env_vars()
class ServerConfig(BaseSettings):
"""Server configuration."""
@@ -27,8 +65,8 @@ class AuthConfig(BaseSettings):
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
max_file_size_mb: int = 50 # Max upload size in MB
assets_dir: str = "data/assets" # Directory for uploaded asset files
class StorageConfig(BaseSettings):
@@ -53,7 +91,7 @@ class LoggingConfig(BaseSettings):
"""Logging configuration."""
format: Literal["json", "text"] = "json"
file: str = "logs/wled_controller.log"
file: str = "logs/ledgrab.log"
max_size_mb: int = 100
backup_count: int = 5
@@ -62,7 +100,7 @@ class Config(BaseSettings):
"""Main application configuration."""
model_config = SettingsConfigDict(
env_prefix="WLED_",
env_prefix="LEDGRAB_",
env_nested_delimiter="__",
case_sensitive=False,
)
@@ -112,21 +150,21 @@ class Config(BaseSettings):
"""Load configuration from default locations.
Tries to load from:
1. Environment variable WLED_CONFIG_PATH
2. WLED_DEMO=true ./config/demo_config.yaml (if it exists)
1. Environment variable LEDGRAB_CONFIG_PATH
2. LEDGRAB_DEMO=true ./config/demo_config.yaml (if it exists)
3. ./config/default_config.yaml
4. Default values
Returns:
Config instance
"""
config_path = os.getenv("WLED_CONFIG_PATH")
config_path = os.getenv("LEDGRAB_CONFIG_PATH")
if config_path:
return cls.from_yaml(config_path)
# Demo mode: try dedicated demo config first
if os.getenv("WLED_DEMO", "").lower() in ("true", "1", "yes"):
if os.getenv("LEDGRAB_DEMO", "").lower() in ("true", "1", "yes"):
demo_path = Path("config/demo_config.yaml")
if demo_path.exists():
return cls.from_yaml(demo_path)
@@ -1,6 +1,6 @@
"""Core functionality for screen capture and WLED control."""
from wled_controller.core.capture.screen_capture import (
from ledgrab.core.capture.screen_capture import (
get_available_displays,
capture_display,
extract_border_pixels,
@@ -1,21 +1,21 @@
"""Audio capture engine abstraction layer."""
from wled_controller.core.audio.base import (
from ledgrab.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.core.audio.analysis import (
from ledgrab.core.audio.factory import AudioEngineRegistry
from ledgrab.core.audio.analysis import (
AudioAnalysis,
AudioAnalyzer,
NUM_BANDS,
DEFAULT_SAMPLE_RATE,
DEFAULT_CHUNK_SIZE,
)
from wled_controller.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
from wled_controller.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
from wled_controller.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
from ledgrab.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
# Auto-register available engines
AudioEngineRegistry.register(WasapiEngine)
@@ -40,7 +40,9 @@ class AudioAnalysis:
left_rms: float = 0.0
left_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
right_rms: float = 0.0
right_spectrum: np.ndarray = field(default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32))
right_spectrum: np.ndarray = field(
default_factory=lambda: np.zeros(NUM_BANDS, dtype=np.float32)
)
def _build_log_bands(num_bands: int, fft_size: int, sample_rate: int) -> List[Tuple[int, int]]:
@@ -74,7 +76,9 @@ class AudioAnalyzer:
thread calls analyze() (the capture thread).
"""
def __init__(self, sample_rate: int = DEFAULT_SAMPLE_RATE, chunk_size: int = DEFAULT_CHUNK_SIZE):
def __init__(
self, sample_rate: int = DEFAULT_SAMPLE_RATE, chunk_size: int = DEFAULT_CHUNK_SIZE
):
self._sample_rate = sample_rate
self._chunk_size = chunk_size
@@ -102,12 +106,18 @@ class AudioAnalyzer:
# Double-buffered output spectra — avoids allocating new arrays each
# analyze() call. Consumers hold a reference to the "old" buffer while
# the analyzer writes into the alternate one.
self._out_spectrum = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_left = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum_right = [np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32)]
self._out_spectrum = [
np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32),
]
self._out_spectrum_left = [
np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32),
]
self._out_spectrum_right = [
np.zeros(NUM_BANDS, dtype=np.float32),
np.zeros(NUM_BANDS, dtype=np.float32),
]
self._out_idx = 0 # toggles 0/1 each analyze() call
# Pre-compute band start/end arrays and widths for vectorized binning
@@ -147,14 +157,14 @@ class AudioAnalyzer:
# Split channels and mix to mono
if channels > 1:
data = raw_data.reshape(-1, channels)
np.copyto(self._left_buf[:len(data)], data[:, 0])
np.copyto(self._left_buf[: len(data)], data[:, 0])
right_col = data[:, 1] if channels >= 2 else data[:, 0]
np.copyto(self._right_buf[:len(data)], right_col)
np.add(data[:, 0], right_col, out=self._mono_buf[:len(data)])
self._mono_buf[:len(data)] *= 0.5
samples = self._mono_buf[:len(data)]
left_samples = self._left_buf[:len(data)]
right_samples = self._right_buf[:len(data)]
np.copyto(self._right_buf[: len(data)], right_col)
np.add(data[:, 0], right_col, out=self._mono_buf[: len(data)])
self._mono_buf[: len(data)] *= 0.5
samples = self._mono_buf[: len(data)]
left_samples = self._left_buf[: len(data)]
right_samples = self._right_buf[: len(data)]
else:
samples = raw_data
left_samples = samples
@@ -176,13 +186,22 @@ class AudioAnalyzer:
right_rms = rms
# FFT for mono, left, right
self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum,
alpha, one_minus_alpha)
self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum, alpha, one_minus_alpha)
if channels > 1:
self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left,
alpha, one_minus_alpha)
self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right,
alpha, one_minus_alpha)
self._fft_bands(
left_samples,
self._spectrum_buf_left,
self._smooth_spectrum_left,
alpha,
one_minus_alpha,
)
self._fft_bands(
right_samples,
self._spectrum_buf_right,
self._smooth_spectrum_right,
alpha,
one_minus_alpha,
)
else:
np.copyto(self._smooth_spectrum_left, self._smooth_spectrum)
np.copyto(self._smooth_spectrum_right, self._smooth_spectrum)
@@ -233,7 +252,7 @@ class AudioAnalyzer:
chunk = np.pad(chunk, (0, chunk_size - len(chunk)))
np.multiply(chunk, self._window, out=self._fft_windowed)
fft_mag = np.abs(np.fft.rfft(self._fft_windowed))
fft_mag *= (1.0 / chunk_size)
fft_mag *= 1.0 / chunk_size
fft_len = len(fft_mag)
# Vectorized band binning using cumulative sum
valid = (self._band_starts < fft_len) & (self._band_ends <= fft_len) & (self._band_ends > 0)
@@ -246,6 +265,6 @@ class AudioAnalyzer:
buf[valid] = band_sums / self._band_widths[valid]
spec_max = float(np.max(buf))
if spec_max > 1e-6:
buf *= (1.0 / spec_max)
buf *= 1.0 / spec_max
smooth_buf *= one_minus_alpha
smooth_buf += alpha * buf
@@ -13,13 +13,13 @@ import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from wled_controller.core.audio.analysis import (
from ledgrab.core.audio.analysis import (
AudioAnalysis,
AudioAnalyzer,
)
from wled_controller.core.audio.base import AudioCaptureStreamBase
from wled_controller.core.audio.factory import AudioEngineRegistry
from wled_controller.utils import get_logger
from ledgrab.core.audio.base import AudioCaptureStreamBase
from ledgrab.core.audio.factory import AudioEngineRegistry
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -36,6 +36,7 @@ __all__ = [
# ManagedAudioStream — wraps engine stream + analyzer in background thread
# ---------------------------------------------------------------------------
class ManagedAudioStream:
"""Wraps an AudioCaptureStreamBase + AudioAnalyzer in a background thread.
@@ -66,9 +67,10 @@ class ManagedAudioStream:
return
self._running = True
self._thread = threading.Thread(
target=self._capture_loop, daemon=True,
target=self._capture_loop,
daemon=True,
name=f"AudioCapture-{self._engine_type}-{self._device_index}-"
f"{'lb' if self._is_loopback else 'in'}",
f"{'lb' if self._is_loopback else 'in'}",
)
self._thread.start()
logger.info(
@@ -99,8 +101,10 @@ class ManagedAudioStream:
stream: Optional[AudioCaptureStreamBase] = None
try:
stream = AudioEngineRegistry.create_stream(
self._engine_type, self._device_index,
self._is_loopback, self._engine_config,
self._engine_type,
self._device_index,
self._is_loopback,
self._engine_config,
)
stream.initialize()
@@ -155,6 +159,7 @@ class ManagedAudioStream:
# AudioCaptureManager — ref-counted shared capture streams
# ---------------------------------------------------------------------------
class AudioCaptureManager:
"""Manages shared ManagedAudioStream instances with reference counting.
@@ -201,7 +206,10 @@ class AudioCaptureManager:
return stream
stream = ManagedAudioStream(
engine_type, device_index, is_loopback, engine_config,
engine_type,
device_index,
is_loopback,
engine_config,
)
stream.start()
self._streams[key] = (stream, 1)
@@ -279,15 +287,17 @@ class AudioCaptureManager:
if key in seen:
continue
seen.add(key)
result.append({
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
})
result.append(
{
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
}
)
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
return result
@@ -306,15 +316,17 @@ class AudioCaptureManager:
continue
devices = []
for dev in engine_class.enumerate_devices():
devices.append({
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
})
devices.append(
{
"index": dev.index,
"name": dev.name,
"is_input": dev.is_input,
"is_loopback": dev.is_loopback,
"channels": dev.channels,
"default_samplerate": dev.default_samplerate,
"engine_type": engine_type,
}
)
result[engine_type] = devices
except Exception as e:
logger.error(f"Error enumerating devices for engine '{engine_type}': {e}")
@@ -9,7 +9,7 @@ from typing import Tuple
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS
from ledgrab.core.audio.analysis import NUM_BANDS
def compute_band_mask(freq_low: float, freq_high: float) -> np.ndarray:
@@ -5,13 +5,13 @@ from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.config import is_demo_mode
from wled_controller.core.audio.base import (
from ledgrab.config import is_demo_mode
from ledgrab.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -126,14 +126,16 @@ class DemoAudioEngine(AudioCaptureEngine):
def enumerate_devices(cls) -> List[AudioDeviceInfo]:
devices = []
for idx, (name, is_loopback, channels, samplerate) in enumerate(_VIRTUAL_DEVICES):
devices.append(AudioDeviceInfo(
index=idx,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=channels,
default_samplerate=samplerate,
))
devices.append(
AudioDeviceInfo(
index=idx,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=channels,
default_samplerate=samplerate,
)
)
logger.debug(f"Demo audio engine: {len(devices)} virtual device(s)")
return devices
@@ -2,9 +2,9 @@
from typing import Any, Dict, List, Optional, Type
from wled_controller.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from wled_controller.config import is_demo_mode
from wled_controller.utils import get_logger
from ledgrab.core.audio.base import AudioCaptureEngine, AudioCaptureStreamBase
from ledgrab.config import is_demo_mode
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -78,9 +78,7 @@ class AudioEngineRegistry:
if engine_class.is_available():
available.append(engine_type)
except Exception as e:
logger.error(
f"Error checking availability for audio engine '{engine_type}': {e}"
)
logger.error(f"Error checking availability for audio engine '{engine_type}': {e}")
return available
@classmethod
@@ -101,9 +99,7 @@ class AudioEngineRegistry:
best_priority = engine_class.ENGINE_PRIORITY
best_type = engine_type
except Exception as e:
logger.error(
f"Error checking availability for audio engine '{engine_type}': {e}"
)
logger.error(f"Error checking availability for audio engine '{engine_type}': {e}")
return best_type
@classmethod
@@ -144,9 +140,7 @@ class AudioEngineRegistry:
engine_class = cls.get_engine(engine_type)
if not engine_class.is_available():
raise ValueError(
f"Audio engine '{engine_type}' is not available on this system"
)
raise ValueError(f"Audio engine '{engine_type}' is not available on this system")
try:
stream = engine_class.create_stream(device_index, is_loopback, config)
@@ -157,9 +151,7 @@ class AudioEngineRegistry:
return stream
except Exception as e:
logger.error(f"Failed to create stream for audio engine '{engine_type}': {e}")
raise RuntimeError(
f"Failed to create stream for audio engine '{engine_type}': {e}"
)
raise RuntimeError(f"Failed to create stream for audio engine '{engine_type}': {e}")
@classmethod
def clear_registry(cls):
@@ -0,0 +1,31 @@
"""Audio filter system.
Provides a pluggable filter architecture for audio analysis postprocessing.
Import this package to ensure all built-in filters are registered.
"""
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.pipeline import AudioFilterPipeline
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
# Import individual filters to trigger auto-registration
import ledgrab.core.audio.filters.audio_filter_template # noqa: F401
import ledgrab.core.audio.filters.channel_extract # noqa: F401
import ledgrab.core.audio.filters.band_extract # noqa: F401
import ledgrab.core.audio.filters.peak_hold # noqa: F401
import ledgrab.core.audio.filters.gain # noqa: F401
import ledgrab.core.audio.filters.noise_gate # noqa: F401
import ledgrab.core.audio.filters.envelope_follower # noqa: F401
import ledgrab.core.audio.filters.spectral_smoothing # noqa: F401
import ledgrab.core.audio.filters.compressor # noqa: F401
import ledgrab.core.audio.filters.inverter # noqa: F401
import ledgrab.core.audio.filters.beat_gate # noqa: F401
import ledgrab.core.audio.filters.delay # noqa: F401
import ledgrab.core.audio.filters.auto_gain # noqa: F401
__all__ = [
"AudioFilter",
"AudioFilterOptionDef",
"AudioFilterPipeline",
"AudioFilterRegistry",
]
@@ -7,9 +7,9 @@ referenced template's filters when building the filter chain.
from typing import List
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -10,9 +10,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -3,10 +3,10 @@
from dataclasses import replace
from typing import Any, Dict, List
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from wled_controller.core.audio.band_filter import apply_band_filter, compute_band_mask
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.band_filter import apply_band_filter, compute_band_mask
# Preset frequency ranges
@@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from wled_controller.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.analysis import AudioAnalysis
@dataclass
@@ -6,9 +6,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -6,9 +6,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
# Assumed update rate for sizing the ring buffer
_UPDATE_RATE_HZ = 30
@@ -6,9 +6,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
def _time_constant_coeff(time_ms: float, dt: float) -> float:
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
_ZERO_SPECTRUM = np.zeros(NUM_BANDS, dtype=np.float32)
@@ -6,9 +6,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -13,14 +13,14 @@ from __future__ import annotations
import threading
from typing import TYPE_CHECKING, List
from wled_controller.core.audio.analysis import AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from wled_controller.utils import get_logger
from ledgrab.core.audio.analysis import AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.filters.filter_instance import FilterInstance
from wled_controller.storage.audio_processing_template_store import (
from ledgrab.core.filters.filter_instance import FilterInstance
from ledgrab.storage.audio_processing_template_store import (
AudioProcessingTemplateStore,
)
@@ -2,8 +2,8 @@
from typing import Dict, Type
from wled_controller.core.audio.filters.base import AudioFilter
from wled_controller.utils import get_logger
from ledgrab.core.audio.filters.base import AudioFilter
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -5,9 +5,9 @@ from typing import Any, Dict, List
import numpy as np
from wled_controller.core.audio.analysis import NUM_BANDS, AudioAnalysis
from wled_controller.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from wled_controller.core.audio.filters.registry import AudioFilterRegistry
from ledgrab.core.audio.analysis import NUM_BANDS, AudioAnalysis
from ledgrab.core.audio.filters.base import AudioFilter, AudioFilterOptionDef
from ledgrab.core.audio.filters.registry import AudioFilterRegistry
@AudioFilterRegistry.register
@@ -4,12 +4,12 @@ from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.audio.base import (
from ledgrab.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -51,9 +51,7 @@ class SounddeviceCaptureStream(AudioCaptureStreamBase):
dev_info = sd.query_devices(device_id)
self._channels = min(self._channels, int(dev_info["max_input_channels"]))
if self._channels < 1:
raise RuntimeError(
f"Device {device_id} ({dev_info['name']}) has no input channels"
)
raise RuntimeError(f"Device {device_id} ({dev_info['name']}) has no input channels")
self._sample_rate = int(dev_info["default_samplerate"])
self._sd_stream = sd.InputStream(
@@ -104,6 +102,7 @@ class SounddeviceEngine(AudioCaptureEngine):
def is_available(cls) -> bool:
try:
import sounddevice # noqa: F401
return True
except ImportError as e:
logger.debug("Sounddevice engine unavailable: %s", e)
@@ -136,14 +135,16 @@ class SounddeviceEngine(AudioCaptureEngine):
# On PulseAudio/PipeWire, monitor sources are loopback-capable
is_loopback = "monitor" in name.lower()
result.append(AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=max_in,
default_samplerate=dev["default_samplerate"],
))
result.append(
AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=max_in,
default_samplerate=dev["default_samplerate"],
)
)
return result
@@ -4,12 +4,12 @@ from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.audio.base import (
from ledgrab.core.audio.base import (
AudioCaptureEngine,
AudioCaptureStreamBase,
AudioDeviceInfo,
)
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -140,6 +140,7 @@ class WasapiEngine(AudioCaptureEngine):
def is_available(cls) -> bool:
try:
import pyaudiowpatch # noqa: F401
return True
except ImportError as e:
logger.debug("WASAPI engine unavailable (pyaudiowpatch not installed): %s", e)
@@ -187,14 +188,16 @@ class WasapiEngine(AudioCaptureEngine):
if is_loopback:
loopback_names.add(name)
result.append(AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=dev["maxInputChannels"],
default_samplerate=dev["defaultSampleRate"],
))
result.append(
AudioDeviceInfo(
index=i,
name=name,
is_input=True,
is_loopback=is_loopback,
channels=dev["maxInputChannels"],
default_samplerate=dev["defaultSampleRate"],
)
)
# Second pass: add loopback entries for output devices that
# don't already have a dedicated loopback input endpoint.
@@ -209,14 +212,16 @@ class WasapiEngine(AudioCaptureEngine):
if loopback_name in loopback_names:
continue # already covered by a dedicated loopback endpoint
result.append(AudioDeviceInfo(
index=i,
name=loopback_name,
is_input=False,
is_loopback=True,
channels=dev["maxOutputChannels"],
default_samplerate=dev["defaultSampleRate"],
))
result.append(
AudioDeviceInfo(
index=i,
name=loopback_name,
is_input=False,
is_loopback=True,
channels=dev["maxOutputChannels"],
default_samplerate=dev["defaultSampleRate"],
)
)
return result
@@ -5,8 +5,8 @@ import re
from datetime import datetime, timezone
from typing import Dict, Optional, Set
from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.automation import (
from ledgrab.core.automations.platform_detector import PlatformDetector
from ledgrab.storage.automation import (
ApplicationRule,
Automation,
DisplayStateRule,
@@ -18,9 +18,9 @@ from wled_controller.storage.automation import (
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import get_logger
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -500,7 +500,7 @@ class AutomationEngine:
# For "revert" mode, capture current state before activating
if automation.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
from ledgrab.core.scenes.scene_activator import capture_current_snapshot
targets = capture_current_snapshot(self._target_store, self._manager)
self._pre_activation_snapshots[automation.id] = ScenePreset(
@@ -510,7 +510,7 @@ class AutomationEngine:
)
# Apply the scene
from wled_controller.core.scenes.scene_activator import apply_scene_state
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
preset,
@@ -556,7 +556,7 @@ class AutomationEngine:
"""Revert to pre-activation snapshot."""
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
if snapshot and self._target_store:
from wled_controller.core.scenes.scene_activator import apply_scene_state
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
snapshot,
@@ -576,7 +576,7 @@ class AutomationEngine:
if fallback_id and self._scene_preset_store and self._target_store:
try:
fallback = self._scene_preset_store.get_preset(fallback_id)
from wled_controller.core.scenes.scene_activator import apply_scene_state
from ledgrab.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state(
fallback,
@@ -12,7 +12,7 @@ import sys
import threading
from typing import Optional, Set
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -56,13 +56,29 @@ class PlatformDetector:
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
0x56,
0x95,
0xE6,
0x6F,
0x4A,
0x70,
0xA0,
0x47,
0x8F,
0x24,
0xC2,
0x8D,
0x93,
0x6F,
0xDA,
0x47,
)
user32.DefWindowProcW.argtypes = [
ctypes.wintypes.HWND, ctypes.c_uint,
ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM,
ctypes.wintypes.HWND,
ctypes.c_uint,
ctypes.wintypes.WPARAM,
ctypes.wintypes.LPARAM,
]
user32.DefWindowProcW.restype = ctypes.c_ssize_t
@@ -113,8 +129,18 @@ class PlatformDetector:
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
hwnd = user32.CreateWindowExW(
0, wc.lpszClassName, "LedGrab Display Monitor",
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
0,
wc.lpszClassName,
"LedGrab Display Monitor",
0,
0,
0,
0,
0,
HWND_MESSAGE,
None,
wc.hInstance,
None,
)
if not hwnd:
logger.warning("Failed to create display monitor hidden window")
@@ -153,6 +179,7 @@ class PlatformDetector:
return None
try:
class LASTINPUTINFO(ctypes.Structure):
_fields_ = [
("cbSize", ctypes.c_uint),
@@ -192,7 +219,8 @@ class PlatformDetector:
pid_array = (ctypes.wintypes.DWORD * 2048)()
cb_needed = ctypes.wintypes.DWORD()
psapi.EnumProcesses(
ctypes.byref(pid_array), ctypes.sizeof(pid_array),
ctypes.byref(pid_array),
ctypes.sizeof(pid_array),
ctypes.byref(cb_needed),
)
n_pids = cb_needed.value // ctypes.sizeof(ctypes.wintypes.DWORD)
@@ -205,14 +233,19 @@ class PlatformDetector:
if pid == 0:
continue
handle = kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid,
PROCESS_QUERY_LIMITED_INFORMATION,
False,
pid,
)
if not handle:
continue
try:
buf_size = ctypes.wintypes.DWORD(512)
if kernel32.QueryFullProcessImageNameW(
handle, 0, name_buf, ctypes.byref(buf_size),
handle,
0,
name_buf,
ctypes.byref(buf_size),
):
procs.add(os.path.basename(name_buf.value).lower())
finally:
@@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import List, Optional
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
from ledgrab.storage.database import Database
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -48,11 +48,14 @@ class AutoBackupEngine:
return dict(DEFAULT_SETTINGS)
def _save_settings(self) -> None:
self._db.set_setting("auto_backup", {
"enabled": self._settings["enabled"],
"interval_hours": self._settings["interval_hours"],
"max_backups": self._settings["max_backups"],
})
self._db.set_setting(
"auto_backup",
{
"enabled": self._settings["enabled"],
"interval_hours": self._settings["interval_hours"],
"max_backups": self._settings["max_backups"],
},
)
# ─── Lifecycle ─────────────────────────────────────────────
@@ -152,7 +155,9 @@ class AutoBackupEngine:
"enabled": self._settings["enabled"],
"interval_hours": self._settings["interval_hours"],
"max_backups": self._settings["max_backups"],
"last_backup_time": self._last_backup_time.isoformat() if self._last_backup_time else None,
"last_backup_time": (
self._last_backup_time.isoformat() if self._last_backup_time else None
),
"next_backup_time": next_backup,
}
@@ -164,9 +169,7 @@ class AutoBackupEngine:
if enabled:
self._start_loop()
logger.info(
f"Auto-backup enabled (every {interval_hours}h, max {max_backups})"
)
logger.info(f"Auto-backup enabled (every {interval_hours}h, max {max_backups})")
else:
self._cancel_loop()
logger.info("Auto-backup disabled")
@@ -176,13 +179,19 @@ class AutoBackupEngine:
def list_backups(self) -> List[dict]:
backups = []
for f in sorted(self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True):
for f in sorted(
self._backup_dir.glob(f"*{_BACKUP_EXT}"), key=lambda p: p.stat().st_mtime, reverse=True
):
stat = f.stat()
backups.append({
"filename": f.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
backups.append(
{
"filename": f.name,
"size_bytes": stat.st_size,
"created_at": datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).isoformat(),
}
)
return backups
def _safe_backup_path(self, filename: str) -> Path:
@@ -1,13 +1,13 @@
"""Screen capture and calibration."""
from wled_controller.core.capture.screen_capture import (
from ledgrab.core.capture.screen_capture import (
BorderPixels,
ScreenCapture,
capture_display,
extract_border_pixels,
get_available_displays,
)
from wled_controller.core.capture.calibration import (
from ledgrab.core.capture.calibration import (
CalibrationConfig,
PixelMapper,
create_default_calibration,
@@ -5,13 +5,13 @@ from typing import Dict, List, Literal, Set, Tuple
import numpy as np
from wled_controller.core.capture.screen_capture import (
from ledgrab.core.capture.screen_capture import (
BorderPixels,
calculate_average_color,
calculate_median_color,
calculate_dominant_color,
)
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -31,9 +31,19 @@ EDGE_ORDER: Dict[Tuple[str, str], List[str]] = {
# Whether LEDs are reversed on each edge for each (start_position, layout) combination.
EDGE_REVERSE: Dict[Tuple[str, str], Dict[str, bool]] = {
("bottom_left", "clockwise"): {"left": True, "top": False, "right": False, "bottom": True},
("bottom_left", "counterclockwise"): {"bottom": False, "right": True, "top": True, "left": False},
("bottom_left", "counterclockwise"): {
"bottom": False,
"right": True,
"top": True,
"left": False,
},
("bottom_right", "clockwise"): {"bottom": True, "left": True, "top": False, "right": False},
("bottom_right", "counterclockwise"): {"right": True, "top": True, "left": False, "bottom": False},
("bottom_right", "counterclockwise"): {
"right": True,
"top": True,
"left": False,
"bottom": False,
},
("top_left", "clockwise"): {"top": False, "right": False, "bottom": True, "left": True},
("top_left", "counterclockwise"): {"left": False, "bottom": False, "right": True, "top": True},
("top_right", "clockwise"): {"right": False, "bottom": True, "left": True, "top": False},
@@ -118,12 +128,14 @@ class CalibrationConfig:
for edge in edge_order:
count = led_counts[edge]
if count > 0:
segments.append(CalibrationSegment(
edge=edge,
led_start=led_start,
led_count=count,
reverse=reverse_map.get(edge, False),
))
segments.append(
CalibrationSegment(
edge=edge,
led_start=led_start,
led_count=count,
reverse=reverse_map.get(edge, False),
)
)
led_start += count
return segments
@@ -172,8 +184,12 @@ class CalibrationConfig:
if total <= 0:
raise ValueError("Calibration must have at least one LED")
for edge, count in [("top", self.leds_top), ("right", self.leds_right),
("bottom", self.leds_bottom), ("left", self.leds_left)]:
for edge, count in [
("top", self.leds_top),
("right", self.leds_right),
("bottom", self.leds_bottom),
("left", self.leds_left),
]:
if count < 0:
raise ValueError(f"LED count for {edge} must be non-negative, got {count}")
@@ -182,7 +198,9 @@ class CalibrationConfig:
if not (0.0 <= start <= 1.0) or not (0.0 <= end <= 1.0):
raise ValueError(f"Span for {edge} must be in [0.0, 1.0], got ({start}, {end})")
if end <= start:
raise ValueError(f"Span end must be greater than start for {edge}, got ({start}, {end})")
raise ValueError(
f"Span end must be greater than start for {edge}, got ({start}, {end})"
)
return True
@@ -376,10 +394,7 @@ class PixelMapper:
segment_sums = cumsum_buf[ends] - cumsum_buf[starts]
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
def map_border_to_leds(
self,
border_pixels: BorderPixels
) -> np.ndarray:
def map_border_to_leds(self, border_pixels: BorderPixels) -> np.ndarray:
"""Map screen border pixels to LED colors.
Uses pre-allocated buffers and pre-computed index arrays to avoid
@@ -402,32 +417,30 @@ class PixelMapper:
edge_pixels = self._get_edge_pixels(border_pixels, segment.edge)
if self._use_fast_avg:
colors = self._map_edge_average(
edge_pixels, segment.edge, segment.led_count
)
colors = self._map_edge_average(edge_pixels, segment.edge, segment.led_count)
else:
colors = self._map_edge_fallback(
edge_pixels, segment.edge, segment.led_count
)
colors = self._map_edge_fallback(edge_pixels, segment.edge, segment.led_count)
led_array[self._segment_indices[i]] = colors
# Phase 3: Physical skip — resample full perimeter to active LEDs
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting='unsafe')
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start:end_idx] = self._skip_resampled
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
return led_array
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
def test_calibration(
self, edge: str, color: Tuple[int, int, int]
) -> List[Tuple[int, int, int]]:
"""Generate test pattern to light up specific edge.
Useful for verifying calibration configuration.
@@ -525,8 +538,11 @@ class AdvancedPixelMapper:
@staticmethod
def _extract_edge_strip(
frame: np.ndarray, edge: str, border_width: int,
span_start: float, span_end: float,
frame: np.ndarray,
edge: str,
border_width: int,
span_start: float,
span_end: float,
) -> np.ndarray:
"""Extract a border strip from a frame for the given edge and span."""
h, w = frame.shape[:2]
@@ -555,7 +571,10 @@ class AdvancedPixelMapper:
return strip
def _map_edge_average(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
self,
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
cache_key: int,
) -> np.ndarray:
"""Vectorized average-color mapping (same algo as PixelMapper)."""
@@ -588,7 +607,10 @@ class AdvancedPixelMapper:
return np.clip(segment_sums / lengths, 0, 255).astype(np.uint8)
def _map_edge_fallback(
self, edge_pixels: np.ndarray, edge_name: str, led_count: int,
self,
edge_pixels: np.ndarray,
edge_name: str,
led_count: int,
) -> np.ndarray:
"""Per-LED color mapping for median/dominant modes."""
if edge_name in ("top", "bottom"):
@@ -627,31 +649,39 @@ class AdvancedPixelMapper:
continue
edge_pixels = self._extract_edge_strip(
frame, line.edge, line.border_width,
line.span_start, line.span_end,
frame,
line.edge,
line.border_width,
line.span_start,
line.span_end,
)
if self._use_fast_avg:
colors = self._map_edge_average(
edge_pixels, line.edge, line.led_count, cache_key=i,
edge_pixels,
line.edge,
line.led_count,
cache_key=i,
)
else:
colors = self._map_edge_fallback(
edge_pixels, line.edge, line.led_count,
edge_pixels,
line.edge,
line.led_count,
)
led_array[self._line_indices[i]] = colors
# Phase 3: Physical skip (same as PixelMapper)
if self._skip_src is not None:
np.copyto(self._skip_float, led_array, casting='unsafe')
np.copyto(self._skip_float, led_array, casting="unsafe")
for ch in range(3):
self._skip_resampled[:, ch] = np.round(
np.interp(self._skip_src, self._skip_x, self._skip_float[:, ch])
).astype(np.uint8)
led_array[:] = 0
end_idx = self._total_leds - self._skip_end
led_array[self._skip_start:end_idx] = self._skip_resampled
led_array[self._skip_start : end_idx] = self._skip_resampled
elif self._active_count <= 0:
led_array[:] = 0
@@ -691,7 +721,7 @@ def create_default_calibration(
# Distribute LEDs proportionally to aspect ratio (same density per edge)
perimeter = 2 * (aspect_width + aspect_height)
h_frac = aspect_width / perimeter # fraction for each horizontal edge
h_frac = aspect_width / perimeter # fraction for each horizontal edge
v_frac = aspect_height / perimeter # fraction for each vertical edge
# Float counts, then round so total == led_count
@@ -3,7 +3,7 @@
from typing import List, Tuple, Union
import numpy as np
from wled_controller.utils import get_logger
from ledgrab.utils import get_logger
logger = get_logger(__name__)
@@ -6,7 +6,7 @@ from typing import List
import mss
import numpy as np
from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
logger = get_logger(__name__)
@@ -123,7 +123,8 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
# Direct bytes→numpy (skips PIL intermediate object)
img_array = np.frombuffer(
screenshot.rgb, dtype=np.uint8,
screenshot.rgb,
dtype=np.uint8,
).reshape(screenshot.height, screenshot.width, 3)
logger.debug(
@@ -144,10 +145,7 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
raise RuntimeError(f"Screen capture failed: {e}")
def extract_border_pixels(
screen_capture: ScreenCapture,
border_width: int = 10
) -> BorderPixels:
def extract_border_pixels(screen_capture: ScreenCapture, border_width: int = 10) -> BorderPixels:
"""Extract border pixels from screen capture.
Args:
@@ -199,9 +197,7 @@ def extract_border_pixels(
def get_edge_segments(
edge_pixels: np.ndarray,
segment_count: int,
edge_name: str
edge_pixels: np.ndarray, segment_count: int, edge_name: str
) -> List[np.ndarray]:
"""Divide edge pixels into segments.

Some files were not shown because too many files have changed in this diff Show More