356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""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.core.processing.sync_clock_manager import SyncClockManager
|
|
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)
|
|
sync_clock_manager = SyncClockManager(sync_clock_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,
|
|
)
|
|
)
|
|
|
|
|
|
@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")
|
|
|
|
# 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")
|
|
|
|
# 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,
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
|
|
# 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")
|
|
|
|
# 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
|
|
)
|