"""FastAPI application entry point.""" import sys from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.requests import Request from wled_controller import __version__ from wled_controller.api import router from wled_controller.api.dependencies import init_dependencies from wled_controller.config import get_config from wled_controller.core.processing.processor_manager import ProcessorManager 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.pattern_template_store import PatternTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.profile_store import ProfileStore from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.utils import setup_logging, get_logger # Initialize logging setup_logging() logger = get_logger(__name__) # Get configuration config = get_config() # Initialize storage and processing device_store = DeviceStore(config.storage.devices_file) template_store = TemplateStore(config.storage.templates_file) pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file) picture_source_store = PictureSourceStore(config.storage.picture_sources_file) picture_target_store = PictureTargetStore(config.storage.picture_targets_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) profile_store = ProfileStore(config.storage.profiles_file) processor_manager = ProcessorManager( picture_source_store=picture_source_store, capture_template_store=template_store, pp_template_store=pp_template_store, pattern_template_store=pattern_template_store, device_store=device_store, color_strip_store=color_strip_store, ) def _migrate_devices_to_targets(): """One-time migration: create picture targets from legacy device settings. If the target store is empty and any device has legacy picture_source_id or settings in raw JSON, migrate them to WledPictureTargets. """ if picture_target_store.count() > 0: return # Already have targets, skip migration raw = device_store.load_raw() devices_raw = raw.get("devices", {}) if not devices_raw: return migrated = 0 for device_id, device_data in devices_raw.items(): legacy_source_id = device_data.get("picture_source_id", "") if not legacy_source_id: continue device_name = device_data.get("name", device_id) target_name = f"{device_name} Target" try: target = picture_target_store.create_target( name=target_name, target_type="wled", device_id=device_id, description=f"Auto-migrated from device {device_name}", ) migrated += 1 logger.info(f"Migrated device {device_id} -> target {target.id}") except Exception as e: logger.error(f"Failed to migrate device {device_id} to target: {e}") if migrated > 0: logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings") def _migrate_targets_to_color_strips(): """One-time migration: create ColorStripSources from legacy WledPictureTarget data. For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON) but no color_strip_source_id, create a ColorStripSource and link it. """ from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.core.capture.calibration import create_default_calibration migrated = 0 for target in picture_target_store.get_all_targets(): if not isinstance(target, WledPictureTarget): continue if target.color_strip_source_id: continue # already migrated if not target._legacy_picture_source_id: continue # no legacy source to migrate legacy_settings = target._legacy_settings or {} # Try to get calibration from device (old location) device = device_store.get_device(target.device_id) if target.device_id else None calibration = getattr(device, "_legacy_calibration", None) if device else None if calibration is None: calibration = create_default_calibration(0) css_name = f"{target.name} Strip" # Ensure unique name existing_names = {s.name for s in color_strip_store.get_all_sources()} if css_name in existing_names: css_name = f"{target.name} Strip (migrated)" try: css = color_strip_store.create_source( name=css_name, source_type="picture", picture_source_id=target._legacy_picture_source_id, fps=legacy_settings.get("fps", 30), brightness=legacy_settings.get("brightness", 1.0), smoothing=legacy_settings.get("smoothing", 0.3), interpolation_mode=legacy_settings.get("interpolation_mode", "average"), calibration=calibration, ) # Update target to reference the new CSS target.color_strip_source_id = css.id target.standby_interval = legacy_settings.get("standby_interval", 1.0) target.state_check_interval = legacy_settings.get("state_check_interval", 30) picture_target_store._save() migrated += 1 logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})") except Exception as e: logger.error(f"Failed to migrate target {target.id} to CSS: {e}") if migrated > 0: logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets") @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager. Handles startup and shutdown events. """ # Startup logger.info(f"Starting LED Grab v{__version__}") logger.info(f"Python version: {sys.version}") logger.info(f"Server listening on {config.server.host}:{config.server.port}") # Validate authentication configuration if not config.auth.api_keys: logger.error("=" * 70) logger.error("CRITICAL: No API keys configured!") logger.error("Authentication is REQUIRED for all API requests.") logger.error("Please add API keys to your configuration:") logger.error(" 1. Generate keys: openssl rand -hex 32") logger.error(" 2. Add to config/default_config.yaml under auth.api_keys") logger.error(" 3. Format: label: \"your-generated-key\"") logger.error("=" * 70) raise RuntimeError("No API keys configured - server cannot start without authentication") # Log authentication status logger.info(f"API Authentication: ENFORCED ({len(config.auth.api_keys)} clients configured)") client_labels = ", ".join(config.auth.api_keys.keys()) logger.info(f"Authorized clients: {client_labels}") logger.info("All API requests require valid Bearer token authentication") # Run migrations _migrate_devices_to_targets() _migrate_targets_to_color_strips() # Create profile engine (needs processor_manager) profile_engine = ProfileEngine(profile_store, processor_manager) # Initialize API dependencies init_dependencies( device_store, template_store, processor_manager, pp_template_store=pp_template_store, pattern_template_store=pattern_template_store, picture_source_store=picture_source_store, picture_target_store=picture_target_store, color_strip_store=color_strip_store, profile_store=profile_store, profile_engine=profile_engine, ) # Register devices in processor manager for health monitoring devices = device_store.get_all_devices() for device in devices: try: processor_manager.add_device( device_id=device.id, device_url=device.url, led_count=device.led_count, device_type=device.device_type, baud_rate=device.baud_rate, software_brightness=device.software_brightness, auto_shutdown=device.auto_shutdown, static_color=device.static_color, ) logger.info(f"Registered device: {device.name} ({device.id})") except Exception as e: logger.error(f"Failed to register device {device.id}: {e}") logger.info(f"Registered {len(devices)} devices for health monitoring") # Register picture targets in processor manager targets = picture_target_store.get_all_targets() registered_targets = 0 for target in targets: try: target.register_with_manager(processor_manager) registered_targets += 1 logger.info(f"Registered target: {target.name} ({target.id})") except Exception as e: logger.error(f"Failed to register target {target.id}: {e}") logger.info(f"Registered {registered_targets} picture target(s)") # Start background health monitoring for all devices await processor_manager.start_health_monitoring() # Start profile engine (evaluates conditions and auto-starts/stops targets) await profile_engine.start() yield # Shutdown logger.info("Shutting down LED Grab") # Stop profile engine first (deactivates profile-managed targets) try: await profile_engine.stop() logger.info("Stopped profile engine") except Exception as e: logger.error(f"Error stopping profile engine: {e}") # Stop all processing try: await processor_manager.stop_all() logger.info("Stopped all processors") except Exception as e: logger.error(f"Error stopping processors: {e}") # Create FastAPI application app = FastAPI( title="LED Grab", description="Control WLED devices based on screen content for ambient lighting", version=__version__, lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", openapi_url="/openapi.json", ) # Configure CORS app.add_middleware( CORSMiddleware, allow_origins=config.server.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # Include API routes app.include_router(router) # Mount static files static_path = Path(__file__).parent / "static" if static_path.exists(): app.mount("/static", StaticFiles(directory=str(static_path)), name="static") logger.info(f"Mounted static files from {static_path}") else: logger.warning(f"Static files directory not found: {static_path}") # Jinja2 templates templates_path = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(templates_path)) @app.exception_handler(Exception) async def global_exception_handler(request, exc): """Global exception handler for unhandled errors.""" logger.error(f"Unhandled exception: {exc}", exc_info=True) return JSONResponse( status_code=500, content={ "error": "InternalServerError", "message": "An unexpected error occurred", "detail": str(exc) if config.server.log_level == "DEBUG" else None, }, ) @app.get("/") async def root(request: Request): """Serve the web UI dashboard.""" return templates.TemplateResponse(request, "index.html") if __name__ == "__main__": import uvicorn uvicorn.run( "wled_controller.main:app", host=config.server.host, port=config.server.port, log_level=config.server.log_level.lower(), reload=False, # Disabled due to watchfiles infinite reload loop )