Files
ledgrab/server/src/wled_controller/main.py
T
alexei.dolgolyov 304fa24389 Comprehensive WebUI review: 41 UX/feature/CSS improvements
Safety & Correctness:
- Add confirmation dialogs to Stop All, turnOffDevice
- i18n confirm dialog (title, yes, no buttons)
- Fix duplicate tutorial-overlay ID
- Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg)
- Fix toast z-index conflict with confirm dialog (2500 → 3000)

UX Consistency:
- Add backdrop-close to test modals
- Add device clone feature (only entity without it)
- Add sync clocks to command palette
- Replace 20+ hardcoded accent colors with CSS vars/color-mix()
- Remove dead .badge duplicate from components.css
- Make calibration elements keyboard-accessible (div → button)
- Add aria-labels to color picker swatches
- Fix pattern canvas mobile horizontal scroll
- Fix graph editor mobile bottom clipping

Polish:
- Add empty-state messages to all CardSection instances
- Convert 21 px font-sizes to rem
- Add scroll-behavior: smooth with reduced-motion override
- Add @media print styles
- Add :focus-visible to 4 missing interactive elements
- Fix settings modal close label (Cancel → Close)
- Fix api-key submit button i18n

New Features:
- Command palette actions: start/stop targets, activate scenes, enable/disable
- Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop)
- OS notification history viewer modal
- Scene "used by" automation reference count on cards
- Clock elapsed time display on Streams tab cards
- Device "last seen" relative timestamp on cards
- Audio device refresh button in edit modal
- Composite layer drag-to-reorder
- MQTT settings panel (broker config with JSON persistence)
- WebSocket log viewer with level filtering and ring buffer
- Runtime log-level adjustment (GET/PUT endpoints + settings UI)
- Animated value source waveform canvas preview
- Gradient custom preset save/delete (localStorage)
- API key read-only display in settings
- Backup metadata (file size, auto/manual badges)
- Server restart button with confirm + overlay
- Partial config export/import per entity type
- Progressive disclosure in target editor (Advanced section)

CSS Architecture:
- Define radius scale tokens (--radius-sm/md/lg/pill)
- Scope .cs-filter selectors to remove 7 !important overrides
- Consolidate duplicate toggle switch (filter-list → settings-toggle)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:46:38 +03:00

340 lines
13 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 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()
# 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(
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}")
# 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
auto_backup_engine = AutoBackupEngine(
settings_path=Path("data/auto_backup_settings.json"),
backup_dir=Path("data/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."""
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
)