diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py index 42849cb..58e4bd7 100644 --- a/server/src/wled_controller/api/routes/scene_presets.py +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -7,9 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( - get_automation_engine, - get_automation_store, - get_device_store, get_picture_target_store, get_processor_manager, get_scene_preset_store, @@ -26,12 +23,9 @@ from wled_controller.core.scenes.scene_activator import ( apply_scene_state, capture_current_snapshot, ) -from wled_controller.storage import DeviceStore from wled_controller.storage.picture_target_store import PictureTargetStore -from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset import ScenePreset from wled_controller.storage.scene_preset_store import ScenePresetStore -from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -52,14 +46,6 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse: "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], - automations=[{ - "automation_id": a.automation_id, - "enabled": a.enabled, - } for a in preset.automations], order=preset.order, created_at=preset.created_at, updated_at=preset.updated_at, @@ -79,14 +65,11 @@ async def create_scene_preset( _auth: AuthRequired, store: ScenePresetStore = Depends(get_scene_preset_store), target_store: PictureTargetStore = Depends(get_picture_target_store), - device_store: DeviceStore = Depends(get_device_store), - automation_store: AutomationStore = Depends(get_automation_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Capture current state as a new scene preset.""" - targets, devices, automations = capture_current_snapshot( - target_store, device_store, automation_store, manager, - ) + target_ids = set(data.target_ids) if data.target_ids is not None else None + targets = capture_current_snapshot(target_store, manager, target_ids) now = datetime.utcnow() preset = ScenePreset( @@ -95,8 +78,6 @@ async def create_scene_preset( description=data.description, color=data.color, targets=targets, - devices=devices, - automations=automations, order=store.count(), created_at=now, updated_at=now, @@ -199,21 +180,22 @@ async def recapture_scene_preset( _auth: AuthRequired, store: ScenePresetStore = Depends(get_scene_preset_store), target_store: PictureTargetStore = Depends(get_picture_target_store), - device_store: DeviceStore = Depends(get_device_store), - automation_store: AutomationStore = Depends(get_automation_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Re-capture current state into an existing preset (updates snapshot).""" - targets, devices, automations = capture_current_snapshot( - target_store, device_store, automation_store, manager, - ) + try: + existing = store.get_preset(preset_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # Only recapture targets that are already in the preset + existing_ids = {t.target_id for t in existing.targets} + targets = capture_current_snapshot(target_store, manager, existing_ids) new_snapshot = ScenePreset( id=preset_id, name="", targets=targets, - devices=devices, - automations=automations, ) try: @@ -236,9 +218,6 @@ async def activate_scene_preset( _auth: AuthRequired, store: ScenePresetStore = Depends(get_scene_preset_store), target_store: PictureTargetStore = Depends(get_picture_target_store), - device_store: DeviceStore = Depends(get_device_store), - automation_store: AutomationStore = Depends(get_automation_store), - engine: AutomationEngine = Depends(get_automation_engine), manager: ProcessorManager = Depends(get_processor_manager), ): """Activate a scene preset — restore the captured state.""" @@ -247,9 +226,7 @@ async def activate_scene_preset( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - status, errors = await apply_scene_state( - preset, target_store, device_store, automation_store, engine, manager, - ) + status, errors = await apply_scene_state(preset, target_store, manager) if not errors: logger.info(f"Scene preset '{preset.name}' activated successfully") diff --git a/server/src/wled_controller/api/schemas/scene_presets.py b/server/src/wled_controller/api/schemas/scene_presets.py index 06fdcc1..7ed57ca 100644 --- a/server/src/wled_controller/api/schemas/scene_presets.py +++ b/server/src/wled_controller/api/schemas/scene_presets.py @@ -15,22 +15,13 @@ class TargetSnapshotSchema(BaseModel): auto_start: bool = False -class DeviceBrightnessSnapshotSchema(BaseModel): - device_id: str - software_brightness: int = 255 - - -class AutomationSnapshotSchema(BaseModel): - automation_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") + target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)") class ScenePresetUpdate(BaseModel): @@ -50,8 +41,6 @@ class ScenePresetResponse(BaseModel): description: str color: str targets: List[TargetSnapshotSchema] - devices: List[DeviceBrightnessSnapshotSchema] - automations: List[AutomationSnapshotSchema] order: int created_at: datetime updated_at: datetime diff --git a/server/src/wled_controller/core/automations/automation_engine.py b/server/src/wled_controller/core/automations/automation_engine.py index 1c2ceb9..636aa8f 100644 --- a/server/src/wled_controller/core/automations/automation_engine.py +++ b/server/src/wled_controller/core/automations/automation_engine.py @@ -310,22 +310,17 @@ class AutomationEngine: # For "revert" mode, capture current state before activating if automation.deactivation_mode == "revert": from wled_controller.core.scenes.scene_activator import capture_current_snapshot - targets, devices, automations = capture_current_snapshot( - self._target_store, self._device_store, self._store, self._manager, - ) + targets = capture_current_snapshot(self._target_store, self._manager) self._pre_activation_snapshots[automation.id] = ScenePreset( id=f"_revert_{automation.id}", name=f"Pre-activation snapshot for {automation.name}", targets=targets, - devices=devices, - profiles=automations, ) # Apply the scene from wled_controller.core.scenes.scene_activator import apply_scene_state status, errors = await apply_scene_state( - preset, self._target_store, self._device_store, self._store, - self, self._manager, skip_automations=True, + preset, self._target_store, self._manager, ) self._active_automations[automation.id] = True @@ -352,11 +347,10 @@ class AutomationEngine: if deactivation_mode == "revert": snapshot = self._pre_activation_snapshots.pop(automation_id, None) - if snapshot and self._target_store and self._device_store: + if snapshot and self._target_store: from wled_controller.core.scenes.scene_activator import apply_scene_state status, errors = await apply_scene_state( - snapshot, self._target_store, self._device_store, self._store, - self, self._manager, skip_automations=True, + snapshot, self._target_store, self._manager, ) if errors: logger.warning(f"Automation {automation_id} revert errors: {errors}") @@ -367,13 +361,12 @@ class AutomationEngine: elif deactivation_mode == "fallback_scene": fallback_id = automation.deactivation_scene_preset_id if automation else None - if fallback_id and self._scene_preset_store and self._target_store and self._device_store: + if fallback_id and self._scene_preset_store and self._target_store: try: fallback = self._scene_preset_store.get_preset(fallback_id) from wled_controller.core.scenes.scene_activator import apply_scene_state status, errors = await apply_scene_state( - fallback, self._target_store, self._device_store, self._store, - self, self._manager, skip_automations=True, + fallback, self._target_store, self._manager, ) if errors: logger.warning(f"Automation {automation_id} fallback errors: {errors}") diff --git a/server/src/wled_controller/core/scenes/scene_activator.py b/server/src/wled_controller/core/scenes/scene_activator.py index 39fa2bd..3f68e35 100644 --- a/server/src/wled_controller/core/scenes/scene_activator.py +++ b/server/src/wled_controller/core/scenes/scene_activator.py @@ -3,15 +3,11 @@ These functions are used by both the scene-presets API route and the automation engine. """ -from typing import List, Optional, Tuple +from typing import List, Optional, Set, Tuple 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.automation_store import AutomationStore from wled_controller.storage.scene_preset import ( - AutomationSnapshot, - DeviceBrightnessSnapshot, ScenePreset, TargetSnapshot, ) @@ -22,16 +18,18 @@ logger = get_logger(__name__) def capture_current_snapshot( target_store: PictureTargetStore, - device_store: DeviceStore, - automation_store: AutomationStore, processor_manager: ProcessorManager, -) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[AutomationSnapshot]]: - """Capture current system state as snapshot lists. + target_ids: Optional[Set[str]] = None, +) -> List[TargetSnapshot]: + """Capture current target state as a snapshot list. - Returns (targets, devices, automations) snapshot tuples. + Args: + target_ids: If provided, only capture these targets. Captures all if None. """ targets = [] for t in target_store.get_all_targets(): + if target_ids is not None and t.id not in target_ids: + continue proc = processor_manager._processors.get(t.id) running = proc.is_running if proc else False targets.append(TargetSnapshot( @@ -43,44 +41,20 @@ def capture_current_snapshot( 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), - )) - - automations = [] - for a in automation_store.get_all_automations(): - automations.append(AutomationSnapshot( - automation_id=a.id, - enabled=a.enabled, - )) - - return targets, devices, automations + return targets async def apply_scene_state( preset: ScenePreset, target_store: PictureTargetStore, - device_store: DeviceStore, - automation_store: AutomationStore, - automation_engine, processor_manager: ProcessorManager, - *, - skip_automations: bool = False, ) -> Tuple[str, List[str]]: """Apply a scene preset's state to the system. Args: preset: The scene preset to activate. target_store: Target store for reading/updating targets. - device_store: Device store for reading/updating devices. - automation_store: Automation store for reading/updating automations. - automation_engine: Automation engine for deactivation and re-evaluation. processor_manager: Processor manager for starting/stopping targets. - skip_automations: If True, skip toggling automation enable states (used when - called from the automation engine itself to avoid recursion). Returns: (status, errors) where status is "activated" or "partial" and @@ -88,21 +62,7 @@ async def apply_scene_state( """ errors: List[str] = [] - # 1. Toggle automation enable states - if not skip_automations: - for auto_snap in preset.automations: - try: - a = automation_store.get_automation(auto_snap.automation_id) - if a.enabled != auto_snap.enabled: - if not auto_snap.enabled: - await automation_engine.deactivate_if_active(auto_snap.automation_id) - automation_store.update_automation(auto_snap.automation_id, enabled=auto_snap.enabled) - except ValueError: - errors.append(f"Automation {auto_snap.automation_id} not found (skipped)") - except Exception as e: - errors.append(f"Automation {auto_snap.automation_id}: {e}") - - # 2. Stop targets that should be stopped + # 1. Stop targets that should be stopped for ts in preset.targets: if not ts.running: try: @@ -112,7 +72,7 @@ async def apply_scene_state( except Exception as e: errors.append(f"Stop target {ts.target_id}: {e}") - # 3. Update target configs (CSS, brightness source, FPS) + # 2. Update target configs (CSS, brightness source, FPS) for ts in preset.targets: try: target = target_store.get_target(ts.target_id) @@ -147,7 +107,7 @@ async def apply_scene_state( except Exception as e: errors.append(f"Target {ts.target_id} config: {e}") - # 4. Start targets that should be running + # 3. Start targets that should be running for ts in preset.targets: if ts.running: try: @@ -157,28 +117,6 @@ async def apply_scene_state( 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 = processor_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 automation re-evaluation after all changes - if not skip_automations: - try: - await automation_engine.trigger_evaluate() - except Exception as e: - errors.append(f"Automation re-evaluation: {e}") - status = "activated" if not errors else "partial" if errors: logger.warning(f"Scene activation errors: {errors}") diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 52df1c3..2d0f35b 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -413,3 +413,27 @@ textarea:focus-visible { border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); } + +/* Scene target selector */ +.scene-target-add-row { + display: flex; + gap: 6px; + margin-bottom: 8px; +} +.scene-target-add-row select { flex: 1; } +.scene-target-add-row .btn { padding: 4px 10px; min-width: 0; flex: 0 0 auto; font-size: 0.85rem; } +.scene-target-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.scene-target-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-primary); + font-size: 0.9rem; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index fe1e686..ed42332 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -85,6 +85,7 @@ import { import { openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, activateScenePreset, recaptureScenePreset, deleteScenePreset, + addSceneTarget, removeSceneTarget, } from './features/scene-presets.js'; // Layer 5: device-discovery, targets @@ -318,6 +319,8 @@ Object.assign(window, { activateScenePreset, recaptureScenePreset, deleteScenePreset, + addSceneTarget, + removeSceneTarget, // device-discovery onDeviceTypeChanged, diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index e72fb8a..32d3197 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -10,7 +10,7 @@ import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { updateTabBadge } from './tabs.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js'; -import { csScenes, createSceneCard } from './scene-presets.js'; +import { csScenes, createSceneCard, updatePresetsCache } from './scene-presets.js'; // ===== Scene presets cache (shared by both selectors) ===== let _scenesCache = []; @@ -62,6 +62,7 @@ export async function loadAutomations() { const data = await automationsResp.json(); const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] }; _scenesCache = scenesData.presets || []; + updatePresetsCache(_scenesCache); // Build scene name map for card rendering const sceneMap = new Map(_scenesCache.map(s => [s.id, s])); @@ -208,6 +209,7 @@ export async function openAutomationEditor(automationId) { if (resp.ok) { const data = await resp.json(); _scenesCache = data.presets || []; + updatePresetsCache(_scenesCache); } } catch { /* use cached */ } diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index 6b00a62..ddb1dd8 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -9,19 +9,26 @@ import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { CardSection } from '../core/card-sections.js'; import { - ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_SETTINGS, + ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, } from '../core/icons.js'; let _presetsCache = []; let _editingId = null; +let _allTargets = []; // fetched on capture open + +/** Update the internal presets cache (called from automations tab after fetching). */ +export function updatePresetsCache(presets) { _presetsCache = presets; } class ScenePresetEditorModal extends Modal { constructor() { super('scene-preset-editor-modal'); } snapshotValues() { + const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')] + .map(el => el.dataset.targetId).sort().join(','); 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, + targets: items, }; } } @@ -36,14 +43,10 @@ export const csScenes = new CardSection('scenes', { export function createSceneCard(preset) { const targetCount = (preset.targets || []).length; - const deviceCount = (preset.devices || []).length; - const automationCount = (preset.automations || []).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, - automationCount > 0 ? `${automationCount} ${t('scenes.automations_count')}` : null, ].filter(Boolean); const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; @@ -94,13 +97,9 @@ export function renderScenePresetsSection(presets) { function _renderDashboardPresetCard(preset) { const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`; const targetCount = (preset.targets || []).length; - const deviceCount = (preset.devices || []).length; - const automationCount = (preset.automations || []).length; const subtitle = [ targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, - deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null, - automationCount > 0 ? `${automationCount} ${t('scenes.automations_count')}` : null, ].filter(Boolean).join(' \u00b7 '); return `