diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index 8580fef..1b01b40 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -20,6 +20,15 @@ storage: picture_targets_file: "data/picture_targets.json" pattern_templates_file: "data/pattern_templates.json" +mqtt: + enabled: false + broker_host: "localhost" + broker_port: 1883 + username: "" + password: "" + client_id: "ledgrab" + base_topic: "ledgrab" + logging: format: "json" # json or text file: "logs/wled_controller.log" diff --git a/server/pyproject.toml b/server/pyproject.toml index 62a114f..9a497c7 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "nvidia-ml-py>=12.0.0", "PyAudioWPatch>=0.2.12; sys_platform == 'win32'", "sounddevice>=0.5", + "aiomqtt>=2.0.0", ] [project.optional-dependencies] diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index e365064..ff0654e 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -15,6 +15,7 @@ from .routes.audio_sources import router as audio_sources_router from .routes.audio_templates import router as audio_templates_router from .routes.value_sources import router as value_sources_router from .routes.profiles import router as profiles_router +from .routes.scene_presets import router as scene_presets_router router = APIRouter() router.include_router(system_router) @@ -30,5 +31,6 @@ router.include_router(audio_templates_router) router.include_router(value_sources_router) router.include_router(picture_targets_router) router.include_router(profiles_router) +router.include_router(scene_presets_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index 0bdad2d..ccc423e 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -12,6 +12,7 @@ from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.profile_store import ProfileStore +from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine @@ -29,6 +30,7 @@ _audio_template_store: AudioTemplateStore | None = None _value_source_store: ValueSourceStore | None = None _processor_manager: ProcessorManager | None = None _profile_store: ProfileStore | None = None +_scene_preset_store: ScenePresetStore | None = None _profile_engine: ProfileEngine | None = None @@ -116,6 +118,13 @@ def get_profile_store() -> ProfileStore: return _profile_store +def get_scene_preset_store() -> ScenePresetStore: + """Get scene preset store dependency.""" + if _scene_preset_store is None: + raise RuntimeError("Scene preset store not initialized") + return _scene_preset_store + + def get_profile_engine() -> ProfileEngine: """Get profile engine dependency.""" if _profile_engine is None: @@ -143,6 +152,7 @@ def init_dependencies( audio_template_store: AudioTemplateStore | None = None, value_source_store: ValueSourceStore | None = None, profile_store: ProfileStore | None = None, + scene_preset_store: ScenePresetStore | None = None, profile_engine: ProfileEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None, ): @@ -150,7 +160,7 @@ def init_dependencies( global _device_store, _template_store, _processor_manager global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _color_strip_store, _audio_source_store, _audio_template_store - global _value_source_store, _profile_store, _profile_engine, _auto_backup_engine + global _value_source_store, _profile_store, _scene_preset_store, _profile_engine, _auto_backup_engine _device_store = device_store _template_store = template_store _processor_manager = processor_manager @@ -163,5 +173,6 @@ def init_dependencies( _audio_template_store = audio_template_store _value_source_store = value_source_store _profile_store = profile_store + _scene_preset_store = scene_preset_store _profile_engine = profile_engine _auto_backup_engine = auto_backup_engine diff --git a/server/src/wled_controller/api/routes/profiles.py b/server/src/wled_controller/api/routes/profiles.py index a281dbe..61721d8 100644 --- a/server/src/wled_controller/api/routes/profiles.py +++ b/server/src/wled_controller/api/routes/profiles.py @@ -17,7 +17,15 @@ from wled_controller.api.schemas.profiles import ( ) from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.storage.picture_target_store import PictureTargetStore -from wled_controller.storage.profile import ApplicationCondition, Condition +from wled_controller.storage.profile import ( + AlwaysCondition, + ApplicationCondition, + Condition, + DisplayStateCondition, + MQTTCondition, + SystemIdleCondition, + TimeOfDayCondition, +) from wled_controller.storage.profile_store import ProfileStore from wled_controller.utils import get_logger @@ -28,11 +36,33 @@ router = APIRouter() # ===== Helpers ===== def _condition_from_schema(s: ConditionSchema) -> Condition: + if s.condition_type == "always": + return AlwaysCondition() if s.condition_type == "application": return ApplicationCondition( apps=s.apps or [], match_type=s.match_type or "running", ) + if s.condition_type == "time_of_day": + return TimeOfDayCondition( + start_time=s.start_time or "00:00", + end_time=s.end_time or "23:59", + ) + if s.condition_type == "system_idle": + return SystemIdleCondition( + idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5, + when_idle=s.when_idle if s.when_idle is not None else True, + ) + if s.condition_type == "display_state": + return DisplayStateCondition( + state=s.state or "on", + ) + if s.condition_type == "mqtt": + return MQTTCondition( + topic=s.topic or "", + payload=s.payload or "", + match_mode=s.match_mode or "exact", + ) raise ValueError(f"Unknown condition type: {s.condition_type}") diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py new file mode 100644 index 0000000..ec4c07a --- /dev/null +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -0,0 +1,387 @@ +"""Scene preset API routes — CRUD, capture, activate, recapture.""" + +import uuid +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + get_device_store, + get_picture_target_store, + get_processor_manager, + get_profile_engine, + get_profile_store, + get_scene_preset_store, +) +from wled_controller.api.schemas.scene_presets import ( + ActivateResponse, + ScenePresetCreate, + ScenePresetListResponse, + ScenePresetResponse, + ScenePresetUpdate, +) +from wled_controller.core.processing.processor_manager import ProcessorManager +from wled_controller.storage import DeviceStore +from wled_controller.storage.picture_target_store import PictureTargetStore +from wled_controller.storage.profile_store import ProfileStore +from wled_controller.storage.scene_preset import ( + DeviceBrightnessSnapshot, + ProfileSnapshot, + ScenePreset, + TargetSnapshot, +) +from wled_controller.storage.scene_preset_store import ScenePresetStore +from wled_controller.core.profiles.profile_engine import ProfileEngine +from wled_controller.utils import get_logger + +logger = get_logger(__name__) +router = APIRouter() + + +# ===== Helpers ===== + +def _capture_snapshot( + target_store: PictureTargetStore, + device_store: DeviceStore, + profile_store: ProfileStore, + processor_manager: ProcessorManager, +) -> tuple: + """Capture current system state as snapshot lists.""" + targets = [] + for t in target_store.get_all_targets(): + proc = processor_manager._processors.get(t.id) + running = proc.is_running if proc else False + targets.append(TargetSnapshot( + target_id=t.id, + running=running, + color_strip_source_id=getattr(t, "color_strip_source_id", ""), + brightness_value_source_id=getattr(t, "brightness_value_source_id", ""), + fps=getattr(t, "fps", 30), + auto_start=getattr(t, "auto_start", False), + )) + + devices = [] + for d in device_store.get_all_devices(): + devices.append(DeviceBrightnessSnapshot( + device_id=d.id, + software_brightness=getattr(d, "software_brightness", 255), + )) + + profiles = [] + for p in profile_store.get_all_profiles(): + profiles.append(ProfileSnapshot( + profile_id=p.id, + enabled=p.enabled, + )) + + return targets, devices, profiles + + +def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse: + return ScenePresetResponse( + id=preset.id, + name=preset.name, + description=preset.description, + color=preset.color, + targets=[{ + "target_id": t.target_id, + "running": t.running, + "color_strip_source_id": t.color_strip_source_id, + "brightness_value_source_id": t.brightness_value_source_id, + "fps": t.fps, + "auto_start": t.auto_start, + } for t in preset.targets], + devices=[{ + "device_id": d.device_id, + "software_brightness": d.software_brightness, + } for d in preset.devices], + profiles=[{ + "profile_id": p.profile_id, + "enabled": p.enabled, + } for p in preset.profiles], + order=preset.order, + created_at=preset.created_at, + updated_at=preset.updated_at, + ) + + +# ===== CRUD ===== + +@router.post( + "/api/v1/scene-presets", + response_model=ScenePresetResponse, + tags=["Scene Presets"], + status_code=201, +) +async def create_scene_preset( + data: ScenePresetCreate, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), + device_store: DeviceStore = Depends(get_device_store), + profile_store: ProfileStore = Depends(get_profile_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Capture current state as a new scene preset.""" + targets, devices, profiles = _capture_snapshot( + target_store, device_store, profile_store, manager, + ) + + now = datetime.utcnow() + preset = ScenePreset( + id=f"scene_{uuid.uuid4().hex[:8]}", + name=data.name, + description=data.description, + color=data.color, + targets=targets, + devices=devices, + profiles=profiles, + order=store.count(), + created_at=now, + updated_at=now, + ) + + try: + preset = store.create_preset(preset) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return _preset_to_response(preset) + + +@router.get( + "/api/v1/scene-presets", + response_model=ScenePresetListResponse, + tags=["Scene Presets"], +) +async def list_scene_presets( + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), +): + """List all scene presets.""" + presets = store.get_all_presets() + return ScenePresetListResponse( + presets=[_preset_to_response(p) for p in presets], + count=len(presets), + ) + + +@router.get( + "/api/v1/scene-presets/{preset_id}", + response_model=ScenePresetResponse, + tags=["Scene Presets"], +) +async def get_scene_preset( + preset_id: str, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), +): + """Get a single scene preset.""" + try: + preset = store.get_preset(preset_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return _preset_to_response(preset) + + +@router.put( + "/api/v1/scene-presets/{preset_id}", + response_model=ScenePresetResponse, + tags=["Scene Presets"], +) +async def update_scene_preset( + preset_id: str, + data: ScenePresetUpdate, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), +): + """Update scene preset metadata.""" + try: + preset = store.update_preset( + preset_id, + name=data.name, + description=data.description, + color=data.color, + order=data.order, + ) + except ValueError as e: + raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)) + return _preset_to_response(preset) + + +@router.delete( + "/api/v1/scene-presets/{preset_id}", + status_code=204, + tags=["Scene Presets"], +) +async def delete_scene_preset( + preset_id: str, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), +): + """Delete a scene preset.""" + try: + store.delete_preset(preset_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +# ===== Recapture ===== + +@router.post( + "/api/v1/scene-presets/{preset_id}/recapture", + response_model=ScenePresetResponse, + tags=["Scene Presets"], +) +async def recapture_scene_preset( + preset_id: str, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), + device_store: DeviceStore = Depends(get_device_store), + profile_store: ProfileStore = Depends(get_profile_store), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Re-capture current state into an existing preset (updates snapshot).""" + targets, devices, profiles = _capture_snapshot( + target_store, device_store, profile_store, manager, + ) + + new_snapshot = ScenePreset( + id=preset_id, + name="", + targets=targets, + devices=devices, + profiles=profiles, + ) + + try: + preset = store.recapture_preset(preset_id, new_snapshot) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return _preset_to_response(preset) + + +# ===== Activate ===== + +@router.post( + "/api/v1/scene-presets/{preset_id}/activate", + response_model=ActivateResponse, + tags=["Scene Presets"], +) +async def activate_scene_preset( + preset_id: str, + _auth: AuthRequired, + store: ScenePresetStore = Depends(get_scene_preset_store), + target_store: PictureTargetStore = Depends(get_picture_target_store), + device_store: DeviceStore = Depends(get_device_store), + profile_store: ProfileStore = Depends(get_profile_store), + engine: ProfileEngine = Depends(get_profile_engine), + manager: ProcessorManager = Depends(get_processor_manager), +): + """Activate a scene preset — restore the captured state.""" + try: + preset = store.get_preset(preset_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + errors = [] + + # 1. Toggle profile enable states + for ps in preset.profiles: + try: + p = profile_store.get_profile(ps.profile_id) + if p.enabled != ps.enabled: + if not ps.enabled: + await engine.deactivate_if_active(ps.profile_id) + profile_store.update_profile(ps.profile_id, enabled=ps.enabled) + except ValueError: + errors.append(f"Profile {ps.profile_id} not found (skipped)") + except Exception as e: + errors.append(f"Profile {ps.profile_id}: {e}") + + # 2. Stop targets that should be stopped + for ts in preset.targets: + if not ts.running: + try: + proc = manager._processors.get(ts.target_id) + if proc and proc.is_running: + await manager.stop_processing(ts.target_id) + except Exception as e: + errors.append(f"Stop target {ts.target_id}: {e}") + + # 3. Update target configs (CSS, brightness source, FPS) + for ts in preset.targets: + try: + target = target_store.get_target(ts.target_id) + changed = {} + if getattr(target, "color_strip_source_id", None) != ts.color_strip_source_id: + changed["color_strip_source_id"] = ts.color_strip_source_id + if getattr(target, "brightness_value_source_id", None) != ts.brightness_value_source_id: + changed["brightness_value_source_id"] = ts.brightness_value_source_id + if getattr(target, "fps", None) != ts.fps: + changed["fps"] = ts.fps + if getattr(target, "auto_start", None) != ts.auto_start: + changed["auto_start"] = ts.auto_start + + if changed: + target.update_fields(**changed) + target_store.update_target(ts.target_id, **changed) + + # Sync live processor if running + proc = manager._processors.get(ts.target_id) + if proc and proc.is_running: + css_changed = "color_strip_source_id" in changed + bvs_changed = "brightness_value_source_id" in changed + settings_changed = "fps" in changed + if css_changed: + target.sync_with_manager(manager, settings_changed=False, css_changed=True) + if bvs_changed: + target.sync_with_manager(manager, settings_changed=False, brightness_vs_changed=True) + if settings_changed: + target.sync_with_manager(manager, settings_changed=True) + except ValueError: + errors.append(f"Target {ts.target_id} not found (skipped)") + except Exception as e: + errors.append(f"Target {ts.target_id} config: {e}") + + # 4. Start targets that should be running + for ts in preset.targets: + if ts.running: + try: + proc = manager._processors.get(ts.target_id) + if not proc or not proc.is_running: + await manager.start_processing(ts.target_id) + except Exception as e: + errors.append(f"Start target {ts.target_id}: {e}") + + # 5. Set device brightness + for ds in preset.devices: + try: + device = device_store.get_device(ds.device_id) + if device.software_brightness != ds.software_brightness: + device_store.update_device(ds.device_id, software_brightness=ds.software_brightness) + # Update live processor brightness + dev_state = manager._devices.get(ds.device_id) + if dev_state: + dev_state.software_brightness = ds.software_brightness + except ValueError: + errors.append(f"Device {ds.device_id} not found (skipped)") + except Exception as e: + errors.append(f"Device {ds.device_id} brightness: {e}") + + # Trigger profile re-evaluation after all changes + try: + await engine.trigger_evaluate() + except Exception as e: + errors.append(f"Profile re-evaluation: {e}") + + status = "activated" if not errors else "partial" + if errors: + logger.warning(f"Scene preset {preset_id} activation errors: {errors}") + else: + logger.info(f"Scene preset '{preset.name}' activated successfully") + + return ActivateResponse(status=status, errors=errors) diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index b088f0b..ad8b99a 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -272,6 +272,7 @@ STORE_MAP = { "audio_templates": "audio_templates_file", "value_sources": "value_sources_file", "profiles": "profiles_file", + "scene_presets": "scene_presets_file", } _SERVER_DIR = Path(__file__).resolve().parents[4] diff --git a/server/src/wled_controller/api/schemas/profiles.py b/server/src/wled_controller/api/schemas/profiles.py index 2aaccc1..2af910b 100644 --- a/server/src/wled_controller/api/schemas/profiles.py +++ b/server/src/wled_controller/api/schemas/profiles.py @@ -10,8 +10,21 @@ class ConditionSchema(BaseModel): """A single condition within a profile.""" condition_type: str = Field(description="Condition type discriminator (e.g. 'application')") + # Application condition fields apps: Optional[List[str]] = Field(None, description="Process names (for application condition)") match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)") + # Time-of-day condition fields + start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day condition)") + end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)") + # System idle condition fields + idle_minutes: Optional[int] = Field(None, description="Idle timeout in minutes (for system_idle condition)") + when_idle: Optional[bool] = Field(None, description="True=active when idle (for system_idle condition)") + # Display state condition fields + state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)") + # MQTT condition fields + topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)") + payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)") + match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)") class ProfileCreate(BaseModel): diff --git a/server/src/wled_controller/api/schemas/scene_presets.py b/server/src/wled_controller/api/schemas/scene_presets.py new file mode 100644 index 0000000..d198b01 --- /dev/null +++ b/server/src/wled_controller/api/schemas/scene_presets.py @@ -0,0 +1,67 @@ +"""Scene preset API schemas.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class TargetSnapshotSchema(BaseModel): + target_id: str + running: bool = False + color_strip_source_id: str = "" + brightness_value_source_id: str = "" + fps: int = 30 + auto_start: bool = False + + +class DeviceBrightnessSnapshotSchema(BaseModel): + device_id: str + software_brightness: int = 255 + + +class ProfileSnapshotSchema(BaseModel): + profile_id: str + enabled: bool = True + + +class ScenePresetCreate(BaseModel): + """Create a scene preset by capturing current state.""" + + name: str = Field(description="Preset name", min_length=1, max_length=100) + description: str = Field(default="", max_length=500) + color: str = Field(default="#4fc3f7", description="Card accent color") + + +class ScenePresetUpdate(BaseModel): + """Update scene preset metadata (not snapshot data — use recapture for that).""" + + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + color: Optional[str] = None + order: Optional[int] = None + + +class ScenePresetResponse(BaseModel): + """Scene preset with full snapshot data.""" + + id: str + name: str + description: str + color: str + targets: List[TargetSnapshotSchema] + devices: List[DeviceBrightnessSnapshotSchema] + profiles: List[ProfileSnapshotSchema] + order: int + created_at: datetime + updated_at: datetime + + +class ScenePresetListResponse(BaseModel): + presets: List[ScenePresetResponse] + count: int + + +class ActivateResponse(BaseModel): + status: str = Field(description="'activated' or 'partial'") + errors: List[str] = Field(default_factory=list) diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index ff3b7c2..308c66a 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -38,6 +38,19 @@ class StorageConfig(BaseSettings): audio_templates_file: str = "data/audio_templates.json" value_sources_file: str = "data/value_sources.json" profiles_file: str = "data/profiles.json" + scene_presets_file: str = "data/scene_presets.json" + + +class MQTTConfig(BaseSettings): + """MQTT broker configuration.""" + + enabled: bool = False + broker_host: str = "localhost" + broker_port: int = 1883 + username: str = "" + password: str = "" + client_id: str = "ledgrab" + base_topic: str = "ledgrab" class LoggingConfig(BaseSettings): @@ -61,6 +74,7 @@ class Config(BaseSettings): server: ServerConfig = Field(default_factory=ServerConfig) auth: AuthConfig = Field(default_factory=AuthConfig) storage: StorageConfig = Field(default_factory=StorageConfig) + mqtt: MQTTConfig = Field(default_factory=MQTTConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) @classmethod diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index 07e6016..c540330 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -282,5 +282,8 @@ def _register_builtin_providers(): from wled_controller.core.devices.mock_provider import MockDeviceProvider register_provider(MockDeviceProvider()) + from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider + register_provider(MQTTDeviceProvider()) + _register_builtin_providers() diff --git a/server/src/wled_controller/core/devices/mqtt_client.py b/server/src/wled_controller/core/devices/mqtt_client.py new file mode 100644 index 0000000..f5b42f9 --- /dev/null +++ b/server/src/wled_controller/core/devices/mqtt_client.py @@ -0,0 +1,98 @@ +"""MQTT LED client — publishes pixel data to an MQTT topic.""" + +import json +from typing import List, Optional, Tuple, Union + +import numpy as np + +from wled_controller.core.devices.led_client import DeviceHealth, LEDClient +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +# Singleton reference — injected from main.py at startup +_mqtt_service = None + + +def set_mqtt_service(service) -> None: + global _mqtt_service + _mqtt_service = service + + +def get_mqtt_service(): + return _mqtt_service + + +def parse_mqtt_url(url: str) -> str: + """Extract topic from an mqtt:// URL. + + Format: mqtt://topic/path (broker connection is global via config) + """ + if url.startswith("mqtt://"): + return url[7:] + return url + + +class MQTTLEDClient(LEDClient): + """Publishes JSON pixel data to an MQTT topic via the shared service.""" + + def __init__(self, url: str, led_count: int = 0, **kwargs): + self._topic = parse_mqtt_url(url) + self._led_count = led_count + self._connected = False + + async def connect(self) -> bool: + svc = _mqtt_service + if svc is None or not svc.is_enabled: + raise ConnectionError("MQTT service not available") + if not svc.is_connected: + raise ConnectionError("MQTT service not connected to broker") + self._connected = True + return True + + async def close(self) -> None: + self._connected = False + + @property + def is_connected(self) -> bool: + return self._connected and _mqtt_service is not None and _mqtt_service.is_connected + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + svc = _mqtt_service + if svc is None or not svc.is_connected: + return False + + if isinstance(pixels, np.ndarray): + pixel_list = pixels.tolist() + else: + pixel_list = list(pixels) + + payload = json.dumps({ + "pixels": pixel_list, + "brightness": brightness, + "led_count": len(pixel_list), + }) + + await svc.publish(self._topic, payload, retain=False, qos=0) + return True + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health=None, + ) -> DeviceHealth: + from datetime import datetime + svc = _mqtt_service + if svc is None or not svc.is_enabled: + return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow()) + return DeviceHealth( + online=svc.is_connected, + last_checked=datetime.utcnow(), + error=None if svc.is_connected else "MQTT broker disconnected", + ) diff --git a/server/src/wled_controller/core/devices/mqtt_provider.py b/server/src/wled_controller/core/devices/mqtt_provider.py new file mode 100644 index 0000000..46c4013 --- /dev/null +++ b/server/src/wled_controller/core/devices/mqtt_provider.py @@ -0,0 +1,51 @@ +"""MQTT device provider — factory, validation, health checks.""" + +from datetime import datetime +from typing import List, Optional, Tuple + +from wled_controller.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, +) +from wled_controller.core.devices.mqtt_client import ( + MQTTLEDClient, + get_mqtt_service, + parse_mqtt_url, +) +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MQTTDeviceProvider(LEDDeviceProvider): + """Provider for MQTT-based LED devices.""" + + @property + def device_type(self) -> str: + return "mqtt" + + @property + def capabilities(self) -> set: + return {"manual_led_count"} + + def create_client(self, url: str, **kwargs) -> LEDClient: + return MQTTLEDClient(url, **kwargs) + + async def check_health( + self, url: str, http_client, prev_health=None, + ) -> DeviceHealth: + return await MQTTLEDClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Validate MQTT device URL (topic path).""" + topic = parse_mqtt_url(url) + if not topic or topic == "/": + raise ValueError("MQTT topic cannot be empty") + # Can't auto-detect LED count — require manual entry + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + # MQTT devices are not auto-discoverable + return [] diff --git a/server/src/wled_controller/core/mqtt/__init__.py b/server/src/wled_controller/core/mqtt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/src/wled_controller/core/mqtt/mqtt_service.py b/server/src/wled_controller/core/mqtt/mqtt_service.py new file mode 100644 index 0000000..2d0455c --- /dev/null +++ b/server/src/wled_controller/core/mqtt/mqtt_service.py @@ -0,0 +1,176 @@ +"""Singleton async MQTT service — shared broker connection for all features.""" + +import asyncio +import json +from typing import Callable, Dict, Optional, Set + +import aiomqtt + +from wled_controller.config import MQTTConfig +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MQTTService: + """Manages a persistent MQTT broker connection. + + Features: + - Publish messages (retained or transient) + - Subscribe to topics with callback dispatch + - Topic value cache for synchronous reads (profile condition evaluation) + - Auto-reconnect loop + - Birth / will messages for online status + """ + + def __init__(self, config: MQTTConfig): + self._config = config + self._client: Optional[aiomqtt.Client] = None + self._task: Optional[asyncio.Task] = None + self._connected = False + + # Subscription management + self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks + self._topic_cache: Dict[str, str] = {} # topic -> last payload string + + # Pending publishes queued while disconnected + self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000) + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def is_enabled(self) -> bool: + return self._config.enabled + + async def start(self) -> None: + if not self._config.enabled: + logger.info("MQTT service disabled in configuration") + return + if self._task is not None: + return + self._task = asyncio.create_task(self._connection_loop()) + logger.info(f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}") + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._connected = False + logger.info("MQTT service stopped") + + async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None: + """Publish a message. If disconnected, queue for later delivery.""" + if not self._config.enabled: + return + if self._connected and self._client: + try: + await self._client.publish(topic, payload, retain=retain, qos=qos) + return + except Exception as e: + logger.warning(f"MQTT publish failed ({topic}): {e}") + # Queue for retry + try: + self._publish_queue.put_nowait((topic, payload, retain, qos)) + except asyncio.QueueFull: + pass + + async def subscribe(self, topic: str, callback: Callable) -> None: + """Subscribe to a topic. Callback receives (topic: str, payload: str).""" + if topic not in self._subscriptions: + self._subscriptions[topic] = set() + self._subscriptions[topic].add(callback) + + # Subscribe on the live client if connected + if self._connected and self._client: + try: + await self._client.subscribe(topic) + except Exception as e: + logger.warning(f"MQTT subscribe failed ({topic}): {e}") + + def get_last_value(self, topic: str) -> Optional[str]: + """Get cached last value for a topic (synchronous — for profile evaluation).""" + return self._topic_cache.get(topic) + + async def _connection_loop(self) -> None: + """Persistent connection loop with auto-reconnect.""" + base_topic = self._config.base_topic + will_topic = f"{base_topic}/status" + will_payload = "offline" + + while True: + try: + async with aiomqtt.Client( + hostname=self._config.broker_host, + port=self._config.broker_port, + username=self._config.username or None, + password=self._config.password or None, + identifier=self._config.client_id, + will=aiomqtt.Will( + topic=will_topic, + payload=will_payload, + retain=True, + ), + ) as client: + self._client = client + self._connected = True + logger.info("MQTT connected to broker") + + # Publish birth message + await client.publish(will_topic, "online", retain=True) + + # Re-subscribe to all registered topics + for topic in self._subscriptions: + await client.subscribe(topic) + + # Drain pending publishes + while not self._publish_queue.empty(): + try: + t, p, r, q = self._publish_queue.get_nowait() + await client.publish(t, p, retain=r, qos=q) + except Exception: + break + + # Message receive loop + async for msg in client.messages: + topic_str = str(msg.topic) + payload_str = msg.payload.decode("utf-8", errors="replace") if msg.payload else "" + self._topic_cache[topic_str] = payload_str + + # Dispatch to callbacks + for sub_topic, callbacks in self._subscriptions.items(): + if aiomqtt.Topic(sub_topic).matches(msg.topic): + for cb in callbacks: + try: + if asyncio.iscoroutinefunction(cb): + asyncio.create_task(cb(topic_str, payload_str)) + else: + cb(topic_str, payload_str) + except Exception as e: + logger.error(f"MQTT callback error ({topic_str}): {e}") + + except asyncio.CancelledError: + break + except Exception as e: + self._connected = False + self._client = None + logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...") + await asyncio.sleep(5) + + # ===== State exposure helpers ===== + + async def publish_target_state(self, target_id: str, state: dict) -> None: + """Publish target state to MQTT (called from event handler).""" + topic = f"{self._config.base_topic}/target/{target_id}/state" + await self.publish(topic, json.dumps(state), retain=True) + + async def publish_profile_state(self, profile_id: str, action: str) -> None: + """Publish profile state change to MQTT.""" + topic = f"{self._config.base_topic}/profile/{profile_id}/state" + await self.publish(topic, json.dumps({"action": action}), retain=True) diff --git a/server/src/wled_controller/core/profiles/platform_detector.py b/server/src/wled_controller/core/profiles/platform_detector.py index d5891a8..c692d77 100644 --- a/server/src/wled_controller/core/profiles/platform_detector.py +++ b/server/src/wled_controller/core/profiles/platform_detector.py @@ -9,6 +9,7 @@ import ctypes import ctypes.wintypes import os import sys +import threading from typing import Optional, Set from wled_controller.utils import get_logger @@ -21,6 +22,148 @@ _IS_WINDOWS = sys.platform == "win32" class PlatformDetector: """Detect running processes and the foreground window's process.""" + def __init__(self) -> None: + self._display_on: bool = True + self._display_listener_started = False + if _IS_WINDOWS: + t = threading.Thread(target=self._display_power_listener, daemon=True) + t.start() + + # ---- Display power state (event-driven) ---- + + def _display_power_listener(self) -> None: + """Background thread: hidden window that receives display power events.""" + try: + user32 = ctypes.windll.user32 + + WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_long, + ctypes.wintypes.HWND, + ctypes.c_uint, + ctypes.wintypes.WPARAM, + ctypes.wintypes.LPARAM, + ) + + WM_POWERBROADCAST = 0x0218 + PBT_POWERSETTINGCHANGE = 0x8013 + + class POWERBROADCAST_SETTING(ctypes.Structure): + _fields_ = [ + ("PowerSetting", ctypes.c_ubyte * 16), # GUID + ("DataLength", ctypes.wintypes.DWORD), + ("Data", ctypes.c_ubyte * 1), + ] + + # GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47} + GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)( + 0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47, + 0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47, + ) + + def wnd_proc(hwnd, msg, wparam, lparam): + if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE: + try: + setting = ctypes.cast( + lparam, ctypes.POINTER(POWERBROADCAST_SETTING) + ).contents + # Data: 0=off, 1=on, 2=dimmed (treat dimmed as on) + value = setting.Data[0] + self._display_on = value != 0 + except Exception: + pass + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + wnd_proc_cb = WNDPROC(wnd_proc) + + # Register window class + class WNDCLASSEXW(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("style", ctypes.c_uint), + ("lpfnWndProc", WNDPROC), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", ctypes.wintypes.HINSTANCE), + ("hIcon", ctypes.wintypes.HICON), + ("hCursor", ctypes.wintypes.HANDLE), + ("hbrBackground", ctypes.wintypes.HBRUSH), + ("lpszMenuName", ctypes.wintypes.LPCWSTR), + ("lpszClassName", ctypes.wintypes.LPCWSTR), + ("hIconSm", ctypes.wintypes.HICON), + ] + + wc = WNDCLASSEXW() + wc.cbSize = ctypes.sizeof(WNDCLASSEXW) + wc.lpfnWndProc = wnd_proc_cb + wc.lpszClassName = "LedGrabDisplayMonitor" + wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None) + + atom = user32.RegisterClassExW(ctypes.byref(wc)) + if not atom: + logger.warning("Failed to register display monitor window class") + return + + HWND_MESSAGE = ctypes.wintypes.HWND(-3) + hwnd = user32.CreateWindowExW( + 0, wc.lpszClassName, "LedGrab Display Monitor", + 0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None, + ) + if not hwnd: + logger.warning("Failed to create display monitor hidden window") + return + + # Register for display power notifications + user32.RegisterPowerSettingNotification( + hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0 + ) + + self._display_listener_started = True + logger.debug("Display power listener started") + + # Message pump + msg = ctypes.wintypes.MSG() + while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + except Exception as e: + logger.error(f"Display power listener failed: {e}") + + def _get_display_power_state_sync(self) -> Optional[str]: + """Get display power state: 'on' or 'off'. Returns None if unavailable.""" + if not _IS_WINDOWS: + return None + return "on" if self._display_on else "off" + + # ---- System idle detection ---- + + def _get_idle_seconds_sync(self) -> Optional[float]: + """Get system idle time in seconds (keyboard/mouse inactivity). + + Returns None if detection is unavailable. + """ + if not _IS_WINDOWS: + return None + + try: + class LASTINPUTINFO(ctypes.Structure): + _fields_ = [ + ("cbSize", ctypes.c_uint), + ("dwTime", ctypes.c_uint), + ] + + lii = LASTINPUTINFO() + lii.cbSize = ctypes.sizeof(LASTINPUTINFO) + if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)): + return None + millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime + return millis / 1000.0 + except Exception as e: + logger.error(f"Failed to get idle time: {e}") + return None + + # ---- Process detection ---- + def _get_running_processes_sync(self) -> Set[str]: """Get set of lowercase process names via Win32 EnumProcesses. diff --git a/server/src/wled_controller/core/profiles/profile_engine.py b/server/src/wled_controller/core/profiles/profile_engine.py index ef0015e..70b2318 100644 --- a/server/src/wled_controller/core/profiles/profile_engine.py +++ b/server/src/wled_controller/core/profiles/profile_engine.py @@ -1,11 +1,21 @@ """Profile engine — background loop that evaluates conditions and manages targets.""" import asyncio +import re from datetime import datetime, timezone from typing import Dict, Optional, Set from wled_controller.core.profiles.platform_detector import PlatformDetector -from wled_controller.storage.profile import AlwaysCondition, ApplicationCondition, Condition, Profile +from wled_controller.storage.profile import ( + AlwaysCondition, + ApplicationCondition, + Condition, + DisplayStateCondition, + MQTTCondition, + Profile, + SystemIdleCondition, + TimeOfDayCondition, +) from wled_controller.storage.profile_store import ProfileStore from wled_controller.utils import get_logger @@ -15,11 +25,13 @@ logger = get_logger(__name__) class ProfileEngine: """Evaluates profile conditions and starts/stops targets accordingly.""" - def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0): + def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0, + mqtt_service=None): self._store = profile_store self._manager = processor_manager self._poll_interval = poll_interval self._detector = PlatformDetector() + self._mqtt_service = mqtt_service self._task: Optional[asyncio.Task] = None self._eval_lock = asyncio.Lock() @@ -70,11 +82,12 @@ class ProfileEngine: def _detect_all_sync( self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool, + needs_idle: bool, needs_display_state: bool, ) -> tuple: """Run all platform detection in a single thread call. - Batching the three detection calls into one executor submission reduces - event-loop wake-ups from 3 to 1, minimising asyncio.sleep() jitter in + Batching detection calls into one executor submission reduces + event-loop wake-ups, minimising asyncio.sleep() jitter in latency-sensitive processing loops. """ running_procs = self._detector._get_running_processes_sync() if needs_running else set() @@ -83,7 +96,9 @@ class ProfileEngine: else: topmost_proc, topmost_fullscreen = None, False fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set() - return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs + idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None + display_state = self._detector._get_display_power_state_sync() if needs_display_state else None + return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state async def _evaluate_all_locked(self) -> None: profiles = self._store.get_all_profiles() @@ -95,23 +110,30 @@ class ProfileEngine: # Determine which detection methods are actually needed match_types_used: set = set() + needs_idle = False + needs_display_state = False for p in profiles: if p.enabled: for c in p.conditions: - mt = getattr(c, "match_type", "running") - match_types_used.add(mt) + if isinstance(c, ApplicationCondition): + match_types_used.add(c.match_type) + elif isinstance(c, SystemIdleCondition): + needs_idle = True + elif isinstance(c, DisplayStateCondition): + needs_display_state = True needs_running = "running" in match_types_used needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"}) needs_fullscreen = "fullscreen" in match_types_used - # Single executor call for all platform detection (avoids 3 separate - # event-loop roundtrips that can jitter processing-loop timing) + # Single executor call for all platform detection loop = asyncio.get_event_loop() - running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs = ( + (running_procs, topmost_proc, topmost_fullscreen, + fullscreen_procs, idle_seconds, display_state) = ( await loop.run_in_executor( None, self._detect_all_sync, needs_running, needs_topmost, needs_fullscreen, + needs_idle, needs_display_state, ) ) @@ -121,7 +143,9 @@ class ProfileEngine: should_be_active = ( profile.enabled and (len(profile.conditions) == 0 - or self._evaluate_conditions(profile, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)) + or self._evaluate_conditions( + profile, running_procs, topmost_proc, topmost_fullscreen, + fullscreen_procs, idle_seconds, display_state)) ) is_active = profile.id in self._active_profiles @@ -143,9 +167,13 @@ class ProfileEngine: self, profile: Profile, running_procs: Set[str], topmost_proc: Optional[str], topmost_fullscreen: bool, fullscreen_procs: Set[str], + idle_seconds: Optional[float], display_state: Optional[str], ) -> bool: results = [ - self._evaluate_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) + self._evaluate_condition( + c, running_procs, topmost_proc, topmost_fullscreen, + fullscreen_procs, idle_seconds, display_state, + ) for c in profile.conditions ] @@ -157,11 +185,63 @@ class ProfileEngine: self, condition: Condition, running_procs: Set[str], topmost_proc: Optional[str], topmost_fullscreen: bool, fullscreen_procs: Set[str], + idle_seconds: Optional[float], display_state: Optional[str], ) -> bool: if isinstance(condition, AlwaysCondition): return True if isinstance(condition, ApplicationCondition): return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs) + if isinstance(condition, TimeOfDayCondition): + return self._evaluate_time_of_day(condition) + if isinstance(condition, SystemIdleCondition): + return self._evaluate_idle(condition, idle_seconds) + if isinstance(condition, DisplayStateCondition): + return self._evaluate_display_state(condition, display_state) + if isinstance(condition, MQTTCondition): + return self._evaluate_mqtt(condition) + return False + + @staticmethod + def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool: + now = datetime.now() + current = now.hour * 60 + now.minute + parts_s = condition.start_time.split(":") + parts_e = condition.end_time.split(":") + start = int(parts_s[0]) * 60 + int(parts_s[1]) + end = int(parts_e[0]) * 60 + int(parts_e[1]) + if start <= end: + return start <= current <= end + # Overnight range (e.g. 22:00 → 06:00) + return current >= start or current <= end + + @staticmethod + def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool: + if idle_seconds is None: + return False + is_idle = idle_seconds >= (condition.idle_minutes * 60) + return is_idle if condition.when_idle else not is_idle + + @staticmethod + def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool: + if display_state is None: + return False + return display_state == condition.state + + def _evaluate_mqtt(self, condition: MQTTCondition) -> bool: + if self._mqtt_service is None or not self._mqtt_service.is_connected: + return False + value = self._mqtt_service.get_last_value(condition.topic) + if value is None: + return False + if condition.match_mode == "exact": + return value == condition.payload + if condition.match_mode == "contains": + return condition.payload in value + if condition.match_mode == "regex": + try: + return bool(re.search(condition.payload, value)) + except re.error: + return False return False def _evaluate_app_condition( diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index a68ff31..807379f 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -28,7 +28,10 @@ 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.profile_store import ProfileStore +from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.core.profiles.profile_engine import ProfileEngine +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.api.routes.system import STORE_MAP from wled_controller.utils import setup_logging, get_logger @@ -52,6 +55,7 @@ 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) profile_store = ProfileStore(config.storage.profiles_file) +scene_preset_store = ScenePresetStore(config.storage.scene_presets_file) # Migrate embedded audio config from CSS entities to audio sources audio_source_store.migrate_from_css(color_strip_store) @@ -100,8 +104,12 @@ async def lifespan(app: FastAPI): logger.info(f"Authorized clients: {client_labels}") logger.info("All API requests require valid Bearer token authentication") - # Create profile engine (needs processor_manager) - profile_engine = ProfileEngine(profile_store, processor_manager) + # Create MQTT service (shared broker connection) + mqtt_service = MQTTService(config.mqtt) + set_mqtt_service(mqtt_service) + + # Create profile engine (needs processor_manager + mqtt_service) + profile_engine = ProfileEngine(profile_store, processor_manager, mqtt_service=mqtt_service) # Create auto-backup engine auto_backup_engine = AutoBackupEngine( @@ -123,6 +131,7 @@ async def lifespan(app: FastAPI): audio_template_store=audio_template_store, value_source_store=value_source_store, profile_store=profile_store, + scene_preset_store=scene_preset_store, profile_engine=profile_engine, auto_backup_engine=auto_backup_engine, ) @@ -162,6 +171,9 @@ async def lifespan(app: FastAPI): # 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 profile engine (evaluates conditions and auto-starts/stops targets) await profile_engine.start() @@ -206,6 +218,12 @@ async def lifespan(app: FastAPI): 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", diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index ec39a2e..387222d 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -82,6 +82,11 @@ import { toggleProfileEnabled, toggleProfileTargets, deleteProfile, expandAllProfileSections, collapseAllProfileSections, } from './features/profiles.js'; +import { + loadScenes, expandAllSceneSections, collapseAllSceneSections, + openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, + activateScenePreset, recaptureScenePreset, deleteScenePreset, +} from './features/scene-presets.js'; // Layer 5: device-discovery, targets import { @@ -307,6 +312,18 @@ Object.assign(window, { expandAllProfileSections, collapseAllProfileSections, + // scene presets + loadScenes, + expandAllSceneSections, + collapseAllSceneSections, + openScenePresetCapture, + editScenePreset, + saveScenePreset, + closeScenePresetEditor, + activateScenePreset, + recaptureScenePreset, + deleteScenePreset, + // device-discovery onDeviceTypeChanged, updateBaudFpsHint, @@ -422,9 +439,9 @@ document.addEventListener('keydown', (e) => { return; } - // Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) + // Tab shortcuts: Ctrl+1..5 (skip when typing in inputs) if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { - const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams' }; + const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams', '5': 'scenes' }; const tab = tabMap[e.key]; if (tab) { e.preventDefault(); diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 83cae9f..6b8d752 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -78,6 +78,10 @@ export function isMockDevice(type) { return type === 'mock'; } +export function isMqttDevice(type) { + return type === 'mqtt'; +} + export function handle401Error() { if (!apiKey) return; // Already handled or no session localStorage.removeItem('wled_api_key'); diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js index 54d892c..bbd5d7b 100644 --- a/server/src/wled_controller/static/js/core/command-palette.js +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -7,7 +7,7 @@ import { t } from './i18n.js'; import { navigateToCard } from './navigation.js'; import { getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, - ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, + ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, ICON_SCENE, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, } from './icons.js'; @@ -31,7 +31,7 @@ function _mapEntities(data, mapFn) { } function _buildItems(results) { - const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams] = results; + const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results; const items = []; _mapEntities(devices, d => items.push({ @@ -99,6 +99,11 @@ function _buildItems(results) { }); }); + _mapEntities(scenePresets, sp => items.push({ + name: sp.name, detail: sp.description || '', group: 'scenes', icon: ICON_SCENE, + nav: ['scenes', null, 'scenes', 'data-scene-id', sp.id], + })); + return items; } @@ -114,6 +119,7 @@ const _responseKeys = [ ['/audio-sources', 'sources'], ['/value-sources', 'sources'], ['/picture-sources', 'streams'], + ['/scene-presets', 'presets'], ]; async function _fetchAllEntities() { @@ -132,7 +138,7 @@ async function _fetchAllEntities() { const _groupOrder = [ 'devices', 'targets', 'kc_targets', 'css', 'profiles', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates', - 'audio', 'value', + 'audio', 'value', 'scenes', ]; const _groupRank = new Map(_groupOrder.map((g, i) => [g, i])); diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 1531029..8efa79b 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -144,3 +144,5 @@ export const ICON_ROTATE_CW = _svg(P.rotateCw); export const ICON_ROTATE_CCW = _svg(P.rotateCcw); export const ICON_DOWNLOAD = _svg(P.download); export const ICON_UNDO = _svg(P.undo2); +export const ICON_SCENE = _svg(P.sparkles); +export const ICON_CAPTURE = _svg(P.camera); diff --git a/server/src/wled_controller/static/js/core/navigation.js b/server/src/wled_controller/static/js/core/navigation.js index 7792ca0..cb02412 100644 --- a/server/src/wled_controller/static/js/core/navigation.js +++ b/server/src/wled_controller/static/js/core/navigation.js @@ -90,6 +90,7 @@ function _triggerTabLoad(tab) { else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles(); else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources(); else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes(); } function _showDimOverlay(duration) { diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index b887294..dc6a22d 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -13,6 +13,7 @@ import { ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, } from '../core/icons.js'; +import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; @@ -373,13 +374,14 @@ export async function loadDashboard(forceFullRender = false) { try { // Fire all requests in a single batch to avoid sequential RTTs - const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp] = await Promise.all([ + const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([ fetchWithAuth('/picture-targets'), fetchWithAuth('/profiles').catch(() => null), fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/picture-targets/batch/states').catch(() => null), fetchWithAuth('/picture-targets/batch/metrics').catch(() => null), + loadScenePresets(), ]); const targetsData = await targetsResp.json(); @@ -401,7 +403,7 @@ export async function loadDashboard(forceFullRender = false) { let runningIds = []; let newAutoStartIds = ''; - if (targets.length === 0 && profiles.length === 0) { + if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) { dynamicHtml = `