Extracts color processing and calibration out of WledPictureTarget into a new PictureColorStripSource entity, enabling multiple LED targets to share one capture/processing pipeline. New entities & processing: - storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models - storage/color_strip_store.py: JSON-backed CRUD store (prefix css_) - core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread) - core/processing/color_strip_stream_manager.py: ref-counted shared stream manager Modified storage/processing: - WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval - Device model: calibration field removed - WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline - ProcessorManager: wires ColorStripStreamManager into TargetContext API layer: - New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test - Removed calibration endpoints from /devices - Updated /picture-targets CRUD for new target structure Frontend: - New color-strips.js module with CSS editor modal and card rendering - Calibration modal extended with CSS mode (css-id hidden field + device picker) - targets.js: Color Strip Sources section added to LED tab; target editor/card updated - app.js: imports and window globals for CSS + showCSSCalibration - en.json / ru.json: color_strip.* and targets.section.color_strips keys added Data migration runs at startup: existing WledPictureTargets are converted to reference a new PictureColorStripSource created from their old settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 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.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
|
|
)
|