From 2e747b5ece1a0c23c06d80f6e7d01e739b44d9b0 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Feb 2026 16:57:42 +0300 Subject: [PATCH] Add profile conditions, scene presets, MQTT integration, and Scenes tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 1 — Profile Conditions: time-of-day, system idle (Win32 GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE) condition types for automatic profile activation. Feature 2 — Scene Presets: snapshot/restore system that captures target running states, device brightness, and profile enables. Server-side capture with 5-step activation order. Dedicated Scenes tab with CardSection-based card grid, command palette integration, and dashboard quick-activate section. Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt, MQTTLEDClient device provider for pixel output, MQTT profile condition type with topic/payload matching, and frontend support for MQTT device type and condition editor. Co-Authored-By: Claude Opus 4.6 --- server/config/default_config.yaml | 9 + server/pyproject.toml | 1 + server/src/wled_controller/api/__init__.py | 2 + .../src/wled_controller/api/dependencies.py | 13 +- .../wled_controller/api/routes/profiles.py | 32 +- .../api/routes/scene_presets.py | 387 ++++++++++++++++++ .../src/wled_controller/api/routes/system.py | 1 + .../wled_controller/api/schemas/profiles.py | 13 + .../api/schemas/scene_presets.py | 67 +++ server/src/wled_controller/config.py | 14 + .../core/devices/led_client.py | 3 + .../core/devices/mqtt_client.py | 98 +++++ .../core/devices/mqtt_provider.py | 51 +++ .../src/wled_controller/core/mqtt/__init__.py | 0 .../wled_controller/core/mqtt/mqtt_service.py | 176 ++++++++ .../core/profiles/platform_detector.py | 143 +++++++ .../core/profiles/profile_engine.py | 104 ++++- server/src/wled_controller/main.py | 22 +- server/src/wled_controller/static/js/app.js | 21 +- .../src/wled_controller/static/js/core/api.js | 4 + .../static/js/core/command-palette.js | 12 +- .../wled_controller/static/js/core/icons.js | 2 + .../static/js/core/navigation.js | 1 + .../static/js/features/dashboard.js | 17 +- .../static/js/features/device-discovery.js | 38 +- .../static/js/features/devices.js | 24 +- .../static/js/features/profiles.js | 121 +++++- .../static/js/features/scene-presets.js | 336 +++++++++++++++ .../static/js/features/tabs.js | 6 + .../wled_controller/static/locales/en.json | 56 +++ .../wled_controller/static/locales/ru.json | 56 +++ .../wled_controller/static/locales/zh.json | 56 +++ server/src/wled_controller/storage/profile.py | 100 +++++ .../wled_controller/storage/scene_preset.py | 125 ++++++ .../storage/scene_preset_store.py | 134 ++++++ .../src/wled_controller/templates/index.html | 8 + .../templates/modals/add-device.html | 1 + .../templates/modals/scene-preset-editor.html | 47 +++ 38 files changed, 2269 insertions(+), 32 deletions(-) create mode 100644 server/src/wled_controller/api/routes/scene_presets.py create mode 100644 server/src/wled_controller/api/schemas/scene_presets.py create mode 100644 server/src/wled_controller/core/devices/mqtt_client.py create mode 100644 server/src/wled_controller/core/devices/mqtt_provider.py create mode 100644 server/src/wled_controller/core/mqtt/__init__.py create mode 100644 server/src/wled_controller/core/mqtt/mqtt_service.py create mode 100644 server/src/wled_controller/static/js/features/scene-presets.js create mode 100644 server/src/wled_controller/storage/scene_preset.py create mode 100644 server/src/wled_controller/storage/scene_preset_store.py create mode 100644 server/src/wled_controller/templates/modals/scene-preset-editor.html 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 = `
${t('dashboard.no_targets')}
`; } else { const enriched = targets.map(target => ({ @@ -496,6 +498,17 @@ export async function loadDashboard(forceFullRender = false) { `; } + // Scene Presets section + if (scenePresets.length > 0) { + const sceneSec = renderScenePresetsSection(scenePresets); + if (sceneSec) { + dynamicHtml += `
+ ${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)} + ${_sectionContent('scenes', sceneSec.content)} +
`; + } + } + if (targets.length > 0) { let targetsInner = ''; diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index 4313957..8d4dce7 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -6,7 +6,7 @@ import { _discoveryScanRunning, set_discoveryScanRunning, _discoveryCache, set_discoveryCache, } from '../core/state.js'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, escapeHtml } from '../core/api.js'; +import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -43,7 +43,29 @@ export function onDeviceTypeChanged() { const ledTypeGroup = document.getElementById('device-led-type-group'); const sendLatencyGroup = document.getElementById('device-send-latency-group'); - if (isMockDevice(deviceType)) { + // URL label / hint / placeholder — adapt per device type + const urlLabel = document.getElementById('device-url-label'); + const urlHint = document.getElementById('device-url-hint'); + + const scanBtn = document.getElementById('scan-network-btn'); + + if (isMqttDevice(deviceType)) { + // MQTT: show URL (topic), LED count; hide serial/baud/led-type/latency/discovery + urlGroup.style.display = ''; + urlInput.setAttribute('required', ''); + serialGroup.style.display = 'none'; + serialSelect.removeAttribute('required'); + ledCountGroup.style.display = ''; + baudRateGroup.style.display = 'none'; + if (ledTypeGroup) ledTypeGroup.style.display = 'none'; + if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; + if (discoverySection) discoverySection.style.display = 'none'; + if (scanBtn) scanBtn.style.display = 'none'; + // Relabel URL field as "Topic" + if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); + if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); + urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; + } else if (isMockDevice(deviceType)) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); serialGroup.style.display = 'none'; @@ -53,6 +75,7 @@ export function onDeviceTypeChanged() { if (ledTypeGroup) ledTypeGroup.style.display = ''; if (sendLatencyGroup) sendLatencyGroup.style.display = ''; if (discoverySection) discoverySection.style.display = 'none'; + if (scanBtn) scanBtn.style.display = 'none'; } else if (isSerialDevice(deviceType)) { urlGroup.style.display = 'none'; urlInput.removeAttribute('required'); @@ -62,6 +85,7 @@ export function onDeviceTypeChanged() { baudRateGroup.style.display = ''; if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; + if (scanBtn) scanBtn.style.display = 'none'; // Hide discovery list — serial port dropdown replaces it if (discoverySection) discoverySection.style.display = 'none'; // Populate from cache or show placeholder (lazy-load on focus) @@ -85,6 +109,11 @@ export function onDeviceTypeChanged() { baudRateGroup.style.display = 'none'; if (ledTypeGroup) ledTypeGroup.style.display = 'none'; if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; + if (scanBtn) scanBtn.style.display = ''; + // Restore default URL label/hint/placeholder + if (urlLabel) urlLabel.textContent = t('device.url'); + if (urlHint) urlHint.textContent = t('device.url.hint'); + urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100'; // Show cached results or trigger scan for WLED if (deviceType in _discoveryCache) { _renderDiscoveryList(); @@ -316,6 +345,11 @@ export async function handleAddDevice(event) { url = document.getElementById('device-url').value.trim(); } + // MQTT: ensure mqtt:// prefix + if (isMqttDevice(deviceType) && url && !url.startsWith('mqtt://')) { + url = 'mqtt://' + url; + } + if (!name || (!isMockDevice(deviceType) && !url)) { error.textContent = t('device_discovery.error.fill_all_fields'); error.style.display = 'block'; diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index d831675..a223d1e 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -5,7 +5,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, } from '../core/state.js'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice } from '../core/api.js'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -174,22 +174,36 @@ export async function showSettings(deviceId) { document.getElementById('settings-health-interval').value = device.state_check_interval ?? 30; const isMock = isMockDevice(device.device_type); + const isMqtt = isMqttDevice(device.device_type); const urlGroup = document.getElementById('settings-url-group'); const serialGroup = document.getElementById('settings-serial-port-group'); + const urlLabel = urlGroup.querySelector('label[for="settings-device-url"]'); + const urlHint = urlGroup.querySelector('.input-hint'); + const urlInput = document.getElementById('settings-device-url'); if (isMock) { urlGroup.style.display = 'none'; - document.getElementById('settings-device-url').removeAttribute('required'); + urlInput.removeAttribute('required'); serialGroup.style.display = 'none'; } else if (isAdalight) { urlGroup.style.display = 'none'; - document.getElementById('settings-device-url').removeAttribute('required'); + urlInput.removeAttribute('required'); serialGroup.style.display = ''; _populateSettingsSerialPorts(device.url); } else { urlGroup.style.display = ''; - document.getElementById('settings-device-url').setAttribute('required', ''); - document.getElementById('settings-device-url').value = device.url; + urlInput.setAttribute('required', ''); + urlInput.value = device.url; serialGroup.style.display = 'none'; + // Relabel for MQTT + if (isMqtt) { + if (urlLabel) urlLabel.textContent = t('device.mqtt_topic'); + if (urlHint) urlHint.textContent = t('device.mqtt_topic.hint'); + urlInput.placeholder = t('device.mqtt_topic.placeholder') || 'mqtt://ledgrab/device/living-room'; + } else { + if (urlLabel) urlLabel.textContent = t('device.url'); + if (urlHint) urlHint.textContent = t('settings.url.hint'); + urlInput.placeholder = t('device.url.placeholder') || 'http://192.168.1.100'; + } } const ledCountGroup = document.getElementById('settings-led-count-group'); diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index fa642cd..a61b8fd 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -9,7 +9,7 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; -import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK } from '../core/icons.js'; +import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js'; class ProfileEditorModal extends Modal { constructor() { super('profile-editor-modal'); } @@ -116,6 +116,20 @@ function createProfileCard(profile, runningTargetIds = new Set()) { const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); return `${t('profiles.condition.application')}: ${apps} (${matchLabel})`; } + if (c.condition_type === 'time_of_day') { + return `${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; + } + if (c.condition_type === 'system_idle') { + const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active'); + return `${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})`; + } + if (c.condition_type === 'display_state') { + const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on')); + return `${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}`; + } + if (c.condition_type === 'mqtt') { + return `${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}`; + } return `${c.condition_type}`; }); const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or'); @@ -259,6 +273,10 @@ function addProfileConditionRow(condition) { @@ -273,6 +291,81 @@ function addProfileConditionRow(condition) { container.innerHTML = `${t('profiles.condition.always.hint')}`; return; } + if (type === 'time_of_day') { + const startTime = data.start_time || '00:00'; + const endTime = data.end_time || '23:59'; + container.innerHTML = ` +
+
+ + +
+
+ + +
+ ${t('profiles.condition.time_of_day.overnight_hint')} +
`; + return; + } + if (type === 'system_idle') { + const idleMinutes = data.idle_minutes ?? 5; + const whenIdle = data.when_idle ?? true; + container.innerHTML = ` +
+
+ + +
+
+ + +
+
`; + return; + } + if (type === 'display_state') { + const dState = data.state || 'on'; + container.innerHTML = ` +
+
+ + +
+
`; + return; + } + if (type === 'mqtt') { + const topic = data.topic || ''; + const payload = data.payload || ''; + const matchMode = data.match_mode || 'exact'; + container.innerHTML = ` +
+
+ + +
+
+ + +
+
+ + +
+
`; + return; + } const appsValue = (data.apps || []).join('\n'); const matchType = data.match_type || 'running'; container.innerHTML = ` @@ -308,7 +401,7 @@ function addProfileConditionRow(condition) { renderFields(condType, condition); typeSelect.addEventListener('change', () => { - renderFields(typeSelect.value, { apps: [], match_type: 'running' }); + renderFields(typeSelect.value, {}); }); list.appendChild(row); @@ -382,6 +475,30 @@ function getProfileEditorConditions() { const condType = typeSelect ? typeSelect.value : 'application'; if (condType === 'always') { conditions.push({ condition_type: 'always' }); + } else if (condType === 'time_of_day') { + conditions.push({ + condition_type: 'time_of_day', + start_time: row.querySelector('.condition-start-time').value || '00:00', + end_time: row.querySelector('.condition-end-time').value || '23:59', + }); + } else if (condType === 'system_idle') { + conditions.push({ + condition_type: 'system_idle', + idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5, + when_idle: row.querySelector('.condition-when-idle').value === 'true', + }); + } else if (condType === 'display_state') { + conditions.push({ + condition_type: 'display_state', + state: row.querySelector('.condition-display-state').value || 'on', + }); + } else if (condType === 'mqtt') { + conditions.push({ + condition_type: 'mqtt', + topic: row.querySelector('.condition-mqtt-topic').value.trim(), + payload: row.querySelector('.condition-mqtt-payload').value, + match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact', + }); } else { const matchType = row.querySelector('.condition-match-type').value; const appsText = row.querySelector('.condition-apps').value.trim(); diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js new file mode 100644 index 0000000..a19cf79 --- /dev/null +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -0,0 +1,336 @@ +/** + * Scene Presets — capture, activate, edit, delete system state snapshots. + * Renders as a dedicated tab and also provides dashboard section rendering. + */ + +import { apiKey } from '../core/state.js'; +import { fetchWithAuth, escapeHtml } from '../core/api.js'; +import { t } from '../core/i18n.js'; +import { showToast, showConfirm } from '../core/ui.js'; +import { Modal } from '../core/modal.js'; +import { CardSection } from '../core/card-sections.js'; +import { updateTabBadge } from './tabs.js'; +import { + ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SETTINGS, +} from '../core/icons.js'; + +let _presetsCache = []; +let _editingId = null; +let _scenesLoading = false; + +class ScenePresetEditorModal extends Modal { + constructor() { super('scene-preset-editor-modal'); } + snapshotValues() { + return { + name: document.getElementById('scene-preset-editor-name').value, + description: document.getElementById('scene-preset-editor-description').value, + color: document.getElementById('scene-preset-editor-color').value, + }; + } +} +const scenePresetModal = new ScenePresetEditorModal(); + +const csScenes = new CardSection('scenes', { + titleKey: 'scenes.title', + gridClass: 'devices-grid', + addCardOnclick: "openScenePresetCapture()", + keyAttr: 'data-scene-id', +}); + +// Re-render scenes when language changes (only if tab is active) +document.addEventListener('languageChanged', () => { + if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'scenes') loadScenes(); +}); + +// ===== Tab rendering ===== + +export async function loadScenes() { + if (_scenesLoading) return; + _scenesLoading = true; + + try { + const resp = await fetchWithAuth('/scene-presets'); + if (!resp.ok) { _scenesLoading = false; return; } + const data = await resp.json(); + _presetsCache = data.presets || []; + } catch { + _scenesLoading = false; + return; + } + + const container = document.getElementById('scenes-content'); + const items = csScenes.applySortOrder(_presetsCache.map(p => ({ key: p.id, html: _createSceneCard(p) }))); + + updateTabBadge('scenes', _presetsCache.length); + + if (csScenes.isMounted()) { + csScenes.reconcile(items); + } else { + const toolbar = `
`; + container.innerHTML = toolbar + csScenes.render(items); + csScenes.bind(); + } + + _scenesLoading = false; +} + +export function expandAllSceneSections() { + CardSection.expandAll([csScenes]); +} + +export function collapseAllSceneSections() { + CardSection.collapseAll([csScenes]); +} + +function _createSceneCard(preset) { + const targetCount = (preset.targets || []).length; + const deviceCount = (preset.devices || []).length; + const profileCount = (preset.profiles || []).length; + const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`; + + const meta = [ + targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, + deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null, + profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null, + ].filter(Boolean); + + const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; + + return `
+
+ +
+
+
${escapeHtml(preset.name)}
+
+ ${preset.description ? `
${escapeHtml(preset.description)}
` : ''} +
+ ${meta.map(m => `${m}`).join('')} + ${updated ? `${updated}` : ''} +
+
+ + + +
+
`; +} + +// ===== Dashboard section (compact cards) ===== + +export async function loadScenePresets() { + try { + const resp = await fetchWithAuth('/scene-presets'); + if (!resp.ok) return []; + const data = await resp.json(); + _presetsCache = data.presets || []; + return _presetsCache; + } catch { + return []; + } +} + +export function renderScenePresetsSection(presets) { + if (!presets || presets.length === 0) return ''; + + const captureBtn = ``; + const cards = presets.map(p => _renderDashboardPresetCard(p)).join(''); + + return { headerExtra: captureBtn, content: `
${cards}
` }; +} + +function _renderDashboardPresetCard(preset) { + const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`; + const targetCount = (preset.targets || []).length; + const deviceCount = (preset.devices || []).length; + const profileCount = (preset.profiles || []).length; + + const subtitle = [ + targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, + deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null, + profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null, + ].filter(Boolean).join(' \u00b7 '); + + return `
+
+ ${ICON_SCENE} +
+
${escapeHtml(preset.name)}
+ ${preset.description ? `
${escapeHtml(preset.description)}
` : ''} +
${subtitle}
+
+
+
+ +
+
`; +} + +// ===== Capture (create) ===== + +export function openScenePresetCapture() { + _editingId = null; + document.getElementById('scene-preset-editor-id').value = ''; + document.getElementById('scene-preset-editor-name').value = ''; + document.getElementById('scene-preset-editor-description').value = ''; + document.getElementById('scene-preset-editor-color').value = '#4fc3f7'; + document.getElementById('scene-preset-editor-error').style.display = 'none'; + + const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]'); + if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); } + + scenePresetModal.open(); + scenePresetModal.snapshot(); +} + +// ===== Edit metadata ===== + +export async function editScenePreset(presetId) { + const preset = _presetsCache.find(p => p.id === presetId); + if (!preset) return; + + _editingId = presetId; + document.getElementById('scene-preset-editor-id').value = presetId; + document.getElementById('scene-preset-editor-name').value = preset.name; + document.getElementById('scene-preset-editor-description').value = preset.description || ''; + document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7'; + document.getElementById('scene-preset-editor-error').style.display = 'none'; + + const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]'); + if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); } + + scenePresetModal.open(); + scenePresetModal.snapshot(); +} + +// ===== Save (create or update) ===== + +export async function saveScenePreset() { + const name = document.getElementById('scene-preset-editor-name').value.trim(); + const description = document.getElementById('scene-preset-editor-description').value.trim(); + const color = document.getElementById('scene-preset-editor-color').value; + const errorEl = document.getElementById('scene-preset-editor-error'); + + if (!name) { + errorEl.textContent = t('scenes.error.name_required'); + errorEl.style.display = 'block'; + return; + } + + try { + let resp; + if (_editingId) { + resp = await fetchWithAuth(`/scene-presets/${_editingId}`, { + method: 'PUT', + body: JSON.stringify({ name, description, color }), + }); + } else { + resp = await fetchWithAuth('/scene-presets', { + method: 'POST', + body: JSON.stringify({ name, description, color }), + }); + } + + if (!resp.ok) { + const err = await resp.json(); + errorEl.textContent = err.detail || t('scenes.error.save_failed'); + errorEl.style.display = 'block'; + return; + } + + scenePresetModal.forceClose(); + showToast(_editingId ? t('scenes.updated') : t('scenes.captured'), 'success'); + _reloadScenesTab(); + } catch (error) { + if (error.isAuth) return; + errorEl.textContent = t('scenes.error.save_failed'); + errorEl.style.display = 'block'; + } +} + +export async function closeScenePresetEditor() { + await scenePresetModal.close(); +} + +// ===== Activate ===== + +export async function activateScenePreset(presetId) { + try { + const resp = await fetchWithAuth(`/scene-presets/${presetId}/activate`, { + method: 'POST', + }); + if (!resp.ok) { + showToast(t('scenes.error.activate_failed'), 'error'); + return; + } + const result = await resp.json(); + if (result.status === 'activated') { + showToast(t('scenes.activated'), 'success'); + } else { + showToast(`${t('scenes.activated_partial')}: ${result.errors.length} ${t('scenes.errors')}`, 'warning'); + } + if (typeof window.loadDashboard === 'function') window.loadDashboard(true); + } catch (error) { + if (error.isAuth) return; + showToast(t('scenes.error.activate_failed'), 'error'); + } +} + +// ===== Recapture ===== + +export async function recaptureScenePreset(presetId) { + const preset = _presetsCache.find(p => p.id === presetId); + const name = preset ? preset.name : presetId; + const confirmed = await showConfirm(t('scenes.recapture_confirm', { name })); + if (!confirmed) return; + + try { + const resp = await fetchWithAuth(`/scene-presets/${presetId}/recapture`, { + method: 'POST', + }); + if (resp.ok) { + showToast(t('scenes.recaptured'), 'success'); + _reloadScenesTab(); + } else { + showToast(t('scenes.error.recapture_failed'), 'error'); + } + } catch (error) { + if (error.isAuth) return; + showToast(t('scenes.error.recapture_failed'), 'error'); + } +} + +// ===== Delete ===== + +export async function deleteScenePreset(presetId) { + const preset = _presetsCache.find(p => p.id === presetId); + const name = preset ? preset.name : presetId; + const confirmed = await showConfirm(t('scenes.delete_confirm', { name })); + if (!confirmed) return; + + try { + const resp = await fetchWithAuth(`/scene-presets/${presetId}`, { + method: 'DELETE', + }); + if (resp.ok) { + showToast(t('scenes.deleted'), 'success'); + _reloadScenesTab(); + } else { + showToast(t('scenes.error.delete_failed'), 'error'); + } + } catch (error) { + if (error.isAuth) return; + showToast(t('scenes.error.delete_failed'), 'error'); + } +} + +// ===== Helpers ===== + +function _reloadScenesTab() { + // Reload the scenes tab if it's active + if ((localStorage.getItem('activeTab') || 'dashboard') === 'scenes') { + loadScenes(); + } + // Also refresh dashboard (scene presets section) + if (typeof window.loadDashboard === 'function') window.loadDashboard(true); +} diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 6fa1af3..f030365 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -19,8 +19,12 @@ function _setHash(tab, subTab) { } let _suppressHashUpdate = false; +let _activeTab = null; export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { + if (_activeTab === name) return; + _activeTab = name; + document.querySelectorAll('.tab-btn').forEach(btn => { const isActive = btn.dataset.tab === name; btn.classList.toggle('active', isActive); @@ -56,6 +60,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } else if (name === 'profiles') { if (typeof window.loadProfiles === 'function') window.loadProfiles(); + } else if (name === 'scenes') { + if (typeof window.loadScenes === 'function') window.loadScenes(); } } } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 55cbfdd..c1e6e08 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -128,6 +128,9 @@ "device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)", "device.send_latency": "Send Latency (ms):", "device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds", + "device.mqtt_topic": "MQTT Topic:", + "device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room", "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", "device.name": "Device Name:", "device.name.placeholder": "Living Room TV", @@ -529,6 +532,7 @@ "dashboard.stop_all": "Stop All", "dashboard.failed": "Failed to load dashboard", "dashboard.section.profiles": "Profiles", + "dashboard.section.scenes": "Scene Presets", "dashboard.targets": "Targets", "dashboard.section.performance": "System Performance", "dashboard.perf.cpu": "CPU", @@ -568,6 +572,27 @@ "profiles.condition.application.match_type.topmost": "Topmost (foreground)", "profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen", "profiles.condition.application.match_type.fullscreen": "Fullscreen", + "profiles.condition.time_of_day": "Time of Day", + "profiles.condition.time_of_day.start_time": "Start Time:", + "profiles.condition.time_of_day.end_time": "End Time:", + "profiles.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.", + "profiles.condition.system_idle": "System Idle", + "profiles.condition.system_idle.idle_minutes": "Idle Timeout (minutes):", + "profiles.condition.system_idle.mode": "Trigger Mode:", + "profiles.condition.system_idle.when_idle": "When idle", + "profiles.condition.system_idle.when_active": "When active", + "profiles.condition.display_state": "Display State", + "profiles.condition.display_state.state": "Monitor State:", + "profiles.condition.display_state.on": "On", + "profiles.condition.display_state.off": "Off (sleeping)", + "profiles.condition.mqtt": "MQTT", + "profiles.condition.mqtt.topic": "Topic:", + "profiles.condition.mqtt.payload": "Payload:", + "profiles.condition.mqtt.match_mode": "Match Mode:", + "profiles.condition.mqtt.match_mode.exact": "Exact", + "profiles.condition.mqtt.match_mode.contains": "Contains", + "profiles.condition.mqtt.match_mode.regex": "Regex", + "profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "profiles.targets": "Targets:", "profiles.targets.hint": "Targets to start when this profile activates", "profiles.targets.empty": "No targets available", @@ -586,6 +611,36 @@ "profiles.error.name_required": "Name is required", "profiles.toggle_all.start": "Start all targets", "profiles.toggle_all.stop": "Stop all targets", + "scenes.title": "Scenes", + "scenes.add": "Capture Scene", + "scenes.edit": "Edit Scene", + "scenes.name": "Name:", + "scenes.name.hint": "A descriptive name for this scene preset", + "scenes.description": "Description:", + "scenes.description.hint": "Optional description of what this scene does", + "scenes.color": "Card Color:", + "scenes.color.hint": "Accent color for the scene card on the dashboard", + "scenes.capture": "Capture", + "scenes.activate": "Activate scene", + "scenes.recapture": "Recapture current state", + "scenes.delete": "Delete scene", + "scenes.targets_count": "targets", + "scenes.devices_count": "devices", + "scenes.profiles_count": "profiles", + "scenes.captured": "Scene captured", + "scenes.updated": "Scene updated", + "scenes.activated": "Scene activated", + "scenes.activated_partial": "Scene partially activated", + "scenes.errors": "errors", + "scenes.recaptured": "Scene recaptured", + "scenes.deleted": "Scene deleted", + "scenes.recapture_confirm": "Recapture current state into \"{name}\"?", + "scenes.delete_confirm": "Delete scene \"{name}\"?", + "scenes.error.name_required": "Name is required", + "scenes.error.save_failed": "Failed to save scene", + "scenes.error.activate_failed": "Failed to activate scene", + "scenes.error.recapture_failed": "Failed to recapture scene", + "scenes.error.delete_failed": "Failed to delete scene", "autostart.title": "Auto-start Targets", "autostart.toggle.enabled": "Auto-start enabled", "autostart.toggle.disabled": "Auto-start disabled", @@ -961,6 +1016,7 @@ "search.group.pattern_templates": "Pattern Templates", "search.group.audio": "Audio Sources", "search.group.value": "Value Sources", + "search.group.scenes": "Scene Presets", "settings.backup.label": "Backup Configuration", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.", "settings.backup.button": "Download Backup", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index d0b292e..66de89b 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -128,6 +128,9 @@ "device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)", "device.send_latency": "Задержка отправки (мс):", "device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах", + "device.mqtt_topic": "MQTT Топик:", + "device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", "device.name": "Имя Устройства:", "device.name.placeholder": "ТВ в Гостиной", @@ -529,6 +532,7 @@ "dashboard.stop_all": "Остановить все", "dashboard.failed": "Не удалось загрузить обзор", "dashboard.section.profiles": "Профили", + "dashboard.section.scenes": "Пресеты сцен", "dashboard.targets": "Цели", "dashboard.section.performance": "Производительность системы", "dashboard.perf.cpu": "ЦП", @@ -568,6 +572,27 @@ "profiles.condition.application.match_type.topmost": "На переднем плане", "profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран", "profiles.condition.application.match_type.fullscreen": "Полный экран", + "profiles.condition.time_of_day": "Время суток", + "profiles.condition.time_of_day.start_time": "Время начала:", + "profiles.condition.time_of_day.end_time": "Время окончания:", + "profiles.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.", + "profiles.condition.system_idle": "Бездействие системы", + "profiles.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", + "profiles.condition.system_idle.mode": "Режим срабатывания:", + "profiles.condition.system_idle.when_idle": "При бездействии", + "profiles.condition.system_idle.when_active": "При активности", + "profiles.condition.display_state": "Состояние дисплея", + "profiles.condition.display_state.state": "Состояние монитора:", + "profiles.condition.display_state.on": "Включён", + "profiles.condition.display_state.off": "Выключен (спящий режим)", + "profiles.condition.mqtt": "MQTT", + "profiles.condition.mqtt.topic": "Топик:", + "profiles.condition.mqtt.payload": "Значение:", + "profiles.condition.mqtt.match_mode": "Режим сравнения:", + "profiles.condition.mqtt.match_mode.exact": "Точное совпадение", + "profiles.condition.mqtt.match_mode.contains": "Содержит", + "profiles.condition.mqtt.match_mode.regex": "Регулярное выражение", + "profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", "profiles.targets": "Цели:", "profiles.targets.hint": "Цели для запуска при активации профиля", "profiles.targets.empty": "Нет доступных целей", @@ -586,6 +611,36 @@ "profiles.error.name_required": "Введите название", "profiles.toggle_all.start": "Запустить все цели", "profiles.toggle_all.stop": "Остановить все цели", + "scenes.title": "Сцены", + "scenes.add": "Захватить сцену", + "scenes.edit": "Редактировать сцену", + "scenes.name": "Название:", + "scenes.name.hint": "Описательное имя для этого пресета сцены", + "scenes.description": "Описание:", + "scenes.description.hint": "Необязательное описание назначения этой сцены", + "scenes.color": "Цвет карточки:", + "scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления", + "scenes.capture": "Захват", + "scenes.activate": "Активировать сцену", + "scenes.recapture": "Перезахватить текущее состояние", + "scenes.delete": "Удалить сцену", + "scenes.targets_count": "целей", + "scenes.devices_count": "устройств", + "scenes.profiles_count": "профилей", + "scenes.captured": "Сцена захвачена", + "scenes.updated": "Сцена обновлена", + "scenes.activated": "Сцена активирована", + "scenes.activated_partial": "Сцена активирована частично", + "scenes.errors": "ошибок", + "scenes.recaptured": "Сцена перезахвачена", + "scenes.deleted": "Сцена удалена", + "scenes.recapture_confirm": "Перезахватить текущее состояние в \"{name}\"?", + "scenes.delete_confirm": "Удалить сцену \"{name}\"?", + "scenes.error.name_required": "Необходимо указать название", + "scenes.error.save_failed": "Не удалось сохранить сцену", + "scenes.error.activate_failed": "Не удалось активировать сцену", + "scenes.error.recapture_failed": "Не удалось перезахватить сцену", + "scenes.error.delete_failed": "Не удалось удалить сцену", "autostart.title": "Автозапуск целей", "autostart.toggle.enabled": "Автозапуск включён", "autostart.toggle.disabled": "Автозапуск отключён", @@ -961,6 +1016,7 @@ "search.group.pattern_templates": "Шаблоны паттернов", "search.group.audio": "Аудиоисточники", "search.group.value": "Источники значений", + "search.group.scenes": "Пресеты сцен", "settings.backup.label": "Резервное копирование", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.", "settings.backup.button": "Скачать резервную копию", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index e00f75a..ac5b537 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -128,6 +128,9 @@ "device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)", "device.send_latency": "发送延迟(毫秒):", "device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)", + "device.mqtt_topic": "MQTT 主题:", + "device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)", "device.name": "设备名称:", "device.name.placeholder": "客厅电视", @@ -529,6 +532,7 @@ "dashboard.stop_all": "全部停止", "dashboard.failed": "加载仪表盘失败", "dashboard.section.profiles": "配置文件", + "dashboard.section.scenes": "场景预设", "dashboard.targets": "目标", "dashboard.section.performance": "系统性能", "dashboard.perf.cpu": "CPU", @@ -568,6 +572,27 @@ "profiles.condition.application.match_type.topmost": "最前(前台)", "profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏", "profiles.condition.application.match_type.fullscreen": "全屏", + "profiles.condition.time_of_day": "时段", + "profiles.condition.time_of_day.start_time": "开始时间:", + "profiles.condition.time_of_day.end_time": "结束时间:", + "profiles.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。", + "profiles.condition.system_idle": "系统空闲", + "profiles.condition.system_idle.idle_minutes": "空闲超时(分钟):", + "profiles.condition.system_idle.mode": "触发模式:", + "profiles.condition.system_idle.when_idle": "空闲时", + "profiles.condition.system_idle.when_active": "活跃时", + "profiles.condition.display_state": "显示器状态", + "profiles.condition.display_state.state": "显示器状态:", + "profiles.condition.display_state.on": "开启", + "profiles.condition.display_state.off": "关闭(休眠)", + "profiles.condition.mqtt": "MQTT", + "profiles.condition.mqtt.topic": "主题:", + "profiles.condition.mqtt.payload": "消息内容:", + "profiles.condition.mqtt.match_mode": "匹配模式:", + "profiles.condition.mqtt.match_mode.exact": "精确匹配", + "profiles.condition.mqtt.match_mode.contains": "包含", + "profiles.condition.mqtt.match_mode.regex": "正则表达式", + "profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", "profiles.targets": "目标:", "profiles.targets.hint": "配置文件激活时要启动的目标", "profiles.targets.empty": "没有可用的目标", @@ -586,6 +611,36 @@ "profiles.error.name_required": "名称为必填项", "profiles.toggle_all.start": "启动所有目标", "profiles.toggle_all.stop": "停止所有目标", + "scenes.title": "场景", + "scenes.add": "捕获场景", + "scenes.edit": "编辑场景", + "scenes.name": "名称:", + "scenes.name.hint": "此场景预设的描述性名称", + "scenes.description": "描述:", + "scenes.description.hint": "此场景功能的可选描述", + "scenes.color": "卡片颜色:", + "scenes.color.hint": "仪表盘上场景卡片的强调色", + "scenes.capture": "捕获", + "scenes.activate": "激活场景", + "scenes.recapture": "重新捕获当前状态", + "scenes.delete": "删除场景", + "scenes.targets_count": "目标", + "scenes.devices_count": "设备", + "scenes.profiles_count": "配置", + "scenes.captured": "场景已捕获", + "scenes.updated": "场景已更新", + "scenes.activated": "场景已激活", + "scenes.activated_partial": "场景部分激活", + "scenes.errors": "错误", + "scenes.recaptured": "场景已重新捕获", + "scenes.deleted": "场景已删除", + "scenes.recapture_confirm": "将当前状态重新捕获到\"{name}\"中?", + "scenes.delete_confirm": "删除场景\"{name}\"?", + "scenes.error.name_required": "名称为必填项", + "scenes.error.save_failed": "保存场景失败", + "scenes.error.activate_failed": "激活场景失败", + "scenes.error.recapture_failed": "重新捕获场景失败", + "scenes.error.delete_failed": "删除场景失败", "autostart.title": "自动启动目标", "autostart.toggle.enabled": "自动启动已启用", "autostart.toggle.disabled": "自动启动已禁用", @@ -961,6 +1016,7 @@ "search.group.pattern_templates": "图案模板", "search.group.audio": "音频源", "search.group.value": "值源", + "search.group.scenes": "场景预设", "settings.backup.label": "备份配置", "settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。", "settings.backup.button": "下载备份", diff --git a/server/src/wled_controller/storage/profile.py b/server/src/wled_controller/storage/profile.py index cd34718..539590c 100644 --- a/server/src/wled_controller/storage/profile.py +++ b/server/src/wled_controller/storage/profile.py @@ -22,6 +22,14 @@ class Condition: return AlwaysCondition.from_dict(data) if ct == "application": return ApplicationCondition.from_dict(data) + if ct == "time_of_day": + return TimeOfDayCondition.from_dict(data) + if ct == "system_idle": + return SystemIdleCondition.from_dict(data) + if ct == "display_state": + return DisplayStateCondition.from_dict(data) + if ct == "mqtt": + return MQTTCondition.from_dict(data) raise ValueError(f"Unknown condition type: {ct}") @@ -58,6 +66,98 @@ class ApplicationCondition(Condition): ) +@dataclass +class TimeOfDayCondition(Condition): + """Activate during a specific time range (server local time). + + Supports overnight ranges: if start_time > end_time, the range wraps + around midnight (e.g. 22:00 → 06:00). + """ + + condition_type: str = "time_of_day" + start_time: str = "00:00" # HH:MM + end_time: str = "23:59" # HH:MM + + def to_dict(self) -> dict: + d = super().to_dict() + d["start_time"] = self.start_time + d["end_time"] = self.end_time + return d + + @classmethod + def from_dict(cls, data: dict) -> "TimeOfDayCondition": + return cls( + start_time=data.get("start_time", "00:00"), + end_time=data.get("end_time", "23:59"), + ) + + +@dataclass +class SystemIdleCondition(Condition): + """Activate based on system idle time (keyboard/mouse inactivity).""" + + condition_type: str = "system_idle" + idle_minutes: int = 5 + when_idle: bool = True # True = active when idle; False = active when NOT idle + + def to_dict(self) -> dict: + d = super().to_dict() + d["idle_minutes"] = self.idle_minutes + d["when_idle"] = self.when_idle + return d + + @classmethod + def from_dict(cls, data: dict) -> "SystemIdleCondition": + return cls( + idle_minutes=data.get("idle_minutes", 5), + when_idle=data.get("when_idle", True), + ) + + +@dataclass +class DisplayStateCondition(Condition): + """Activate based on display/monitor power state.""" + + condition_type: str = "display_state" + state: str = "on" # "on" | "off" + + def to_dict(self) -> dict: + d = super().to_dict() + d["state"] = self.state + return d + + @classmethod + def from_dict(cls, data: dict) -> "DisplayStateCondition": + return cls( + state=data.get("state", "on"), + ) + + +@dataclass +class MQTTCondition(Condition): + """Activate based on an MQTT topic value.""" + + condition_type: str = "mqtt" + topic: str = "" + payload: str = "" + match_mode: str = "exact" # "exact" | "contains" | "regex" + + def to_dict(self) -> dict: + d = super().to_dict() + d["topic"] = self.topic + d["payload"] = self.payload + d["match_mode"] = self.match_mode + return d + + @classmethod + def from_dict(cls, data: dict) -> "MQTTCondition": + return cls( + topic=data.get("topic", ""), + payload=data.get("payload", ""), + match_mode=data.get("match_mode", "exact"), + ) + + @dataclass class Profile: """Automation profile that activates targets based on conditions.""" diff --git a/server/src/wled_controller/storage/scene_preset.py b/server/src/wled_controller/storage/scene_preset.py new file mode 100644 index 0000000..9c83970 --- /dev/null +++ b/server/src/wled_controller/storage/scene_preset.py @@ -0,0 +1,125 @@ +"""Scene preset data models — snapshot of current system state.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional + + +@dataclass +class TargetSnapshot: + """Snapshot of a single target's mutable state.""" + + target_id: str + running: bool = False + color_strip_source_id: str = "" + brightness_value_source_id: str = "" + fps: int = 30 + auto_start: bool = False + + def to_dict(self) -> dict: + return { + "target_id": self.target_id, + "running": self.running, + "color_strip_source_id": self.color_strip_source_id, + "brightness_value_source_id": self.brightness_value_source_id, + "fps": self.fps, + "auto_start": self.auto_start, + } + + @classmethod + def from_dict(cls, data: dict) -> "TargetSnapshot": + return cls( + target_id=data["target_id"], + running=data.get("running", False), + color_strip_source_id=data.get("color_strip_source_id", ""), + brightness_value_source_id=data.get("brightness_value_source_id", ""), + fps=data.get("fps", 30), + auto_start=data.get("auto_start", False), + ) + + +@dataclass +class DeviceBrightnessSnapshot: + """Snapshot of a device's software brightness.""" + + device_id: str + software_brightness: int = 255 + + def to_dict(self) -> dict: + return { + "device_id": self.device_id, + "software_brightness": self.software_brightness, + } + + @classmethod + def from_dict(cls, data: dict) -> "DeviceBrightnessSnapshot": + return cls( + device_id=data["device_id"], + software_brightness=data.get("software_brightness", 255), + ) + + +@dataclass +class ProfileSnapshot: + """Snapshot of a profile's enabled state.""" + + profile_id: str + enabled: bool = True + + def to_dict(self) -> dict: + return { + "profile_id": self.profile_id, + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: dict) -> "ProfileSnapshot": + return cls( + profile_id=data["profile_id"], + enabled=data.get("enabled", True), + ) + + +@dataclass +class ScenePreset: + """A named snapshot of system state that can be restored.""" + + id: str + name: str + description: str = "" + color: str = "#4fc3f7" # accent color for the card + targets: List[TargetSnapshot] = field(default_factory=list) + devices: List[DeviceBrightnessSnapshot] = field(default_factory=list) + profiles: List[ProfileSnapshot] = field(default_factory=list) + order: int = 0 + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "color": self.color, + "targets": [t.to_dict() for t in self.targets], + "devices": [d.to_dict() for d in self.devices], + "profiles": [p.to_dict() for p in self.profiles], + "order": self.order, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @classmethod + def from_dict(cls, data: dict) -> "ScenePreset": + return cls( + id=data["id"], + name=data["name"], + description=data.get("description", ""), + color=data.get("color", "#4fc3f7"), + targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], + devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])], + profiles=[ProfileSnapshot.from_dict(p) for p in data.get("profiles", [])], + order=data.get("order", 0), + created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + ) diff --git a/server/src/wled_controller/storage/scene_preset_store.py b/server/src/wled_controller/storage/scene_preset_store.py new file mode 100644 index 0000000..34827c9 --- /dev/null +++ b/server/src/wled_controller/storage/scene_preset_store.py @@ -0,0 +1,134 @@ +"""Scene preset storage using JSON files.""" + +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +from wled_controller.storage.scene_preset import ScenePreset +from wled_controller.utils import atomic_write_json, get_logger + +logger = get_logger(__name__) + + +class ScenePresetStore: + """Persistent storage for scene presets.""" + + def __init__(self, file_path: str): + self.file_path = Path(file_path) + self._presets: Dict[str, ScenePreset] = {} + self._load() + + def _load(self) -> None: + if not self.file_path.exists(): + return + + try: + with open(self.file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + presets_data = data.get("scene_presets", {}) + loaded = 0 + for preset_id, preset_dict in presets_data.items(): + try: + preset = ScenePreset.from_dict(preset_dict) + self._presets[preset_id] = preset + loaded += 1 + except Exception as e: + logger.error(f"Failed to load scene preset {preset_id}: {e}", exc_info=True) + + if loaded > 0: + logger.info(f"Loaded {loaded} scene presets from storage") + + except Exception as e: + logger.error(f"Failed to load scene presets from {self.file_path}: {e}") + raise + + logger.info(f"Scene preset store initialized with {len(self._presets)} presets") + + def _save(self) -> None: + try: + data = { + "version": "1.0.0", + "scene_presets": { + pid: p.to_dict() for pid, p in self._presets.items() + }, + } + atomic_write_json(self.file_path, data) + except Exception as e: + logger.error(f"Failed to save scene presets to {self.file_path}: {e}") + raise + + def get_all_presets(self) -> List[ScenePreset]: + return sorted(self._presets.values(), key=lambda p: p.order) + + def get_preset(self, preset_id: str) -> ScenePreset: + if preset_id not in self._presets: + raise ValueError(f"Scene preset not found: {preset_id}") + return self._presets[preset_id] + + def create_preset(self, preset: ScenePreset) -> ScenePreset: + for p in self._presets.values(): + if p.name == preset.name: + raise ValueError(f"Scene preset with name '{preset.name}' already exists") + + self._presets[preset.id] = preset + self._save() + logger.info(f"Created scene preset: {preset.name} ({preset.id})") + return preset + + def update_preset( + self, + preset_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + color: Optional[str] = None, + order: Optional[int] = None, + ) -> ScenePreset: + if preset_id not in self._presets: + raise ValueError(f"Scene preset not found: {preset_id}") + + preset = self._presets[preset_id] + + if name is not None: + for pid, p in self._presets.items(): + if pid != preset_id and p.name == name: + raise ValueError(f"Scene preset with name '{name}' already exists") + preset.name = name + if description is not None: + preset.description = description + if color is not None: + preset.color = color + if order is not None: + preset.order = order + + preset.updated_at = datetime.utcnow() + self._save() + logger.info(f"Updated scene preset: {preset_id}") + return preset + + def recapture_preset(self, preset_id: str, preset: ScenePreset) -> ScenePreset: + """Replace snapshot data of an existing preset (recapture current state).""" + if preset_id not in self._presets: + raise ValueError(f"Scene preset not found: {preset_id}") + + existing = self._presets[preset_id] + existing.targets = preset.targets + existing.devices = preset.devices + existing.profiles = preset.profiles + existing.updated_at = datetime.utcnow() + self._save() + logger.info(f"Recaptured scene preset: {preset_id}") + return existing + + def delete_preset(self, preset_id: str) -> None: + if preset_id not in self._presets: + raise ValueError(f"Scene preset not found: {preset_id}") + + del self._presets[preset_id] + self._save() + logger.info(f"Deleted scene preset: {preset_id}") + + def count(self) -> int: + return len(self._presets) diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 9d84b7c..3ee49d9 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -85,6 +85,7 @@ +
@@ -111,6 +112,12 @@
+
+
+
+
+
+