"""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.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, 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 ProcessorDependencies, 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.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 import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration 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 ColorStripProcessingTemplateStore from wled_controller.storage.gradient_store import GradientStore from wled_controller.storage.weather_source_store import WeatherSourceStore from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.weather.weather_manager import WeatherManager from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.devices.mqtt_client import set_mqtt_service from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.processing.os_notification_listener import OsNotificationListener from wled_controller.api.routes.system import STORE_MAP from wled_controller.utils import setup_logging, get_logger, install_broadcast_handler # Initialize logging setup_logging() install_broadcast_handler() logger = get_logger(__name__) # Get configuration config = get_config() # Seed demo data before stores are loaded (first-run only) if config.demo: from wled_controller.core.demo_seed import seed_demo_data seed_demo_data(config.storage) # 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) output_target_store = OutputTargetStore(config.storage.output_targets_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) color_strip_store = ColorStripStore(config.storage.color_strip_sources_file) audio_source_store = AudioSourceStore(config.storage.audio_sources_file) audio_template_store = AudioTemplateStore(config.storage.audio_templates_file) value_source_store = ValueSourceStore(config.storage.value_sources_file) automation_store = AutomationStore(config.storage.automations_file) scene_preset_store = ScenePresetStore(config.storage.scene_presets_file) sync_clock_store = SyncClockStore(config.storage.sync_clocks_file) cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file) gradient_store = GradientStore(config.storage.gradients_file) gradient_store.migrate_palette_references(color_strip_store) weather_source_store = WeatherSourceStore(config.storage.weather_sources_file) sync_clock_manager = SyncClockManager(sync_clock_store) weather_manager = WeatherManager(weather_source_store) processor_manager = ProcessorManager( ProcessorDependencies( 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, audio_source_store=audio_source_store, value_source_store=value_source_store, audio_template_store=audio_template_store, sync_clock_manager=sync_clock_manager, cspt_store=cspt_store, gradient_store=gradient_store, weather_manager=weather_manager, ) ) def _save_all_stores() -> None: """Persist every store to disk. Called during graceful shutdown to ensure in-memory data survives restarts even if no CRUD happened during the session. """ all_stores = [ device_store, template_store, pp_template_store, picture_source_store, output_target_store, pattern_template_store, color_strip_store, audio_source_store, audio_template_store, value_source_store, automation_store, scene_preset_store, sync_clock_store, cspt_store, gradient_store, weather_source_store, ] saved = 0 for store in all_stores: try: store._save(force=True) saved += 1 except Exception as e: logger.error(f"Failed to save {store._json_key} on shutdown: {e}") logger.info(f"Shutdown save: persisted {saved}/{len(all_stores)} stores to disk") @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}") print("\n =============================================") print(f" LED Grab v{__version__}") print(f" Open http://localhost:{config.server.port} in your browser") print(" =============================================\n") # Log authentication mode if not config.auth.api_keys: logger.info("Authentication disabled (no API keys configured)") else: logger.info(f"Authentication enabled ({len(config.auth.api_keys)} API key(s) configured)") client_labels = ", ".join(config.auth.api_keys.keys()) logger.info(f"Authorized clients: {client_labels}") # Create MQTT service (shared broker connection) mqtt_service = MQTTService(config.mqtt) set_mqtt_service(mqtt_service) # Create automation engine (needs processor_manager + mqtt_service + stores for scene activation) automation_engine = AutomationEngine( automation_store, processor_manager, mqtt_service=mqtt_service, scene_preset_store=scene_preset_store, target_store=output_target_store, device_store=device_store, ) # Create auto-backup engine — derive paths from storage config so that # demo mode auto-backups go to data/demo/ instead of data/. _data_dir = Path(config.storage.devices_file).parent auto_backup_engine = AutoBackupEngine( settings_path=_data_dir / "auto_backup_settings.json", backup_dir=_data_dir / "backups", store_map=STORE_MAP, storage_config=config.storage, ) # Verify STORE_MAP covers all StorageConfig file fields. # Catches missed additions early (at startup) rather than silently # excluding new stores from backups. storage_attrs = { attr for attr in config.storage.model_fields if attr.endswith("_file") } mapped_attrs = set(STORE_MAP.values()) unmapped = storage_attrs - mapped_attrs if unmapped: logger.warning( f"StorageConfig fields not in STORE_MAP (missing from backups): " f"{sorted(unmapped)}" ) # 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, output_target_store=output_target_store, color_strip_store=color_strip_store, audio_source_store=audio_source_store, audio_template_store=audio_template_store, value_source_store=value_source_store, automation_store=automation_store, scene_preset_store=scene_preset_store, automation_engine=automation_engine, auto_backup_engine=auto_backup_engine, sync_clock_store=sync_clock_store, sync_clock_manager=sync_clock_manager, cspt_store=cspt_store, gradient_store=gradient_store, weather_source_store=weather_source_store, weather_manager=weather_manager, ) # 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, ) 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 output targets in processor manager targets = output_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} output target(s)") # Start background health monitoring for all devices await processor_manager.start_health_monitoring() # Start MQTT service (broker connection for output, triggers, state) await mqtt_service.start() # Start automation engine (evaluates conditions and activates scenes) await automation_engine.start() # Start auto-backup engine (periodic configuration backups) await auto_backup_engine.start() # Start OS notification listener (Windows toast → notification CSS streams) os_notif_listener = OsNotificationListener( color_strip_store=color_strip_store, color_strip_stream_manager=processor_manager.color_strip_stream_manager, ) os_notif_listener.start() yield # Shutdown logger.info("Shutting down LED Grab") # Persist all stores to disk before stopping anything. # This ensures in-memory data survives force-kills and restarts # where no CRUD happened during the session. _save_all_stores() # Stop weather manager try: weather_manager.shutdown() except Exception as e: logger.error(f"Error stopping weather manager: {e}") # Stop auto-backup engine try: await auto_backup_engine.stop() except Exception as e: logger.error(f"Error stopping auto-backup engine: {e}") # Stop automation engine first (deactivates automation-managed scenes) try: await automation_engine.stop() logger.info("Stopped automation engine") except Exception as e: logger.error(f"Error stopping automation engine: {e}") # Stop OS notification listener try: os_notif_listener.stop() except Exception as e: logger.error(f"Error stopping OS notification listener: {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}") # Stop MQTT service try: await mqtt_service.stop() except Exception as e: logger.error(f"Error stopping MQTT service: {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=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=500) # Include API routes app.include_router(router) # PWA: serve manifest and service worker from root scope _static_root = Path(__file__).parent / "static" @app.get("/manifest.json", include_in_schema=False) async def pwa_manifest(): """Serve PWA manifest from root scope.""" return FileResponse(_static_root / "manifest.json", media_type="application/manifest+json") @app.get("/sw.js", include_in_schema=False) async def pwa_service_worker(): """Serve service worker from root scope (controls all pages).""" return FileResponse( _static_root / "sw.js", media_type="application/javascript", headers={"Cache-Control": "no-cache"}, ) # Middleware: no-cache for static JS/CSS (development convenience) @app.middleware("http") async def _no_cache_static(request: Request, call_next): path = request.url.path if path.startswith("/static/") and path.endswith((".js", ".css", ".json")): response = await call_next(request) response.headers["Cache-Control"] = "no-cache, must-revalidate" return response return await call_next(request) # 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.""" import uuid ref_id = uuid.uuid4().hex[:8] logger.error("Unhandled exception [ref=%s]: %s", ref_id, exc, exc_info=True) return JSONResponse( status_code=500, content={ "error": "InternalServerError", "message": "Internal server error", "ref": ref_id, }, ) @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 )