Simplify scenes to capture only target state, add target selector

- Remove DeviceBrightnessSnapshot and AutomationSnapshot from scene data model
- Simplify capture_current_snapshot and apply_scene_state to targets only
- Remove device/automation dependencies from scene preset API routes
- Add target selector (combobox + add/remove) to scene capture modal
- Fix stale profiles reference bug in scene_preset_store recapture
- Update automation engine call sites for simplified scene functions
- Sync scene presets cache between automations and scene-presets modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:55:11 +03:00
parent 0eb0f44ddb
commit ff4e7f8adb
14 changed files with 157 additions and 204 deletions

View File

@@ -7,9 +7,6 @@ from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
get_automation_engine,
get_automation_store,
get_device_store,
get_picture_target_store, get_picture_target_store,
get_processor_manager, get_processor_manager,
get_scene_preset_store, get_scene_preset_store,
@@ -26,12 +23,9 @@ from wled_controller.core.scenes.scene_activator import (
apply_scene_state, apply_scene_state,
capture_current_snapshot, capture_current_snapshot,
) )
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore 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 import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore 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 from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -52,14 +46,6 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"fps": t.fps, "fps": t.fps,
"auto_start": t.auto_start, "auto_start": t.auto_start,
} for t in preset.targets], } 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, order=preset.order,
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
@@ -79,14 +65,11 @@ async def create_scene_preset(
_auth: AuthRequired, _auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_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), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Capture current state as a new scene preset.""" """Capture current state as a new scene preset."""
targets, devices, automations = capture_current_snapshot( target_ids = set(data.target_ids) if data.target_ids is not None else None
target_store, device_store, automation_store, manager, targets = capture_current_snapshot(target_store, manager, target_ids)
)
now = datetime.utcnow() now = datetime.utcnow()
preset = ScenePreset( preset = ScenePreset(
@@ -95,8 +78,6 @@ async def create_scene_preset(
description=data.description, description=data.description,
color=data.color, color=data.color,
targets=targets, targets=targets,
devices=devices,
automations=automations,
order=store.count(), order=store.count(),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -199,21 +180,22 @@ async def recapture_scene_preset(
_auth: AuthRequired, _auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_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), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Re-capture current state into an existing preset (updates snapshot).""" """Re-capture current state into an existing preset (updates snapshot)."""
targets, devices, automations = capture_current_snapshot( try:
target_store, device_store, automation_store, manager, 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( new_snapshot = ScenePreset(
id=preset_id, id=preset_id,
name="", name="",
targets=targets, targets=targets,
devices=devices,
automations=automations,
) )
try: try:
@@ -236,9 +218,6 @@ async def activate_scene_preset(
_auth: AuthRequired, _auth: AuthRequired,
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_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), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Activate a scene preset — restore the captured state.""" """Activate a scene preset — restore the captured state."""
@@ -247,9 +226,7 @@ async def activate_scene_preset(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
status, errors = await apply_scene_state( status, errors = await apply_scene_state(preset, target_store, manager)
preset, target_store, device_store, automation_store, engine, manager,
)
if not errors: if not errors:
logger.info(f"Scene preset '{preset.name}' activated successfully") logger.info(f"Scene preset '{preset.name}' activated successfully")

View File

@@ -15,22 +15,13 @@ class TargetSnapshotSchema(BaseModel):
auto_start: bool = False 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): class ScenePresetCreate(BaseModel):
"""Create a scene preset by capturing current state.""" """Create a scene preset by capturing current state."""
name: str = Field(description="Preset name", min_length=1, max_length=100) name: str = Field(description="Preset name", min_length=1, max_length=100)
description: str = Field(default="", max_length=500) description: str = Field(default="", max_length=500)
color: str = Field(default="#4fc3f7", description="Card accent color") 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): class ScenePresetUpdate(BaseModel):
@@ -50,8 +41,6 @@ class ScenePresetResponse(BaseModel):
description: str description: str
color: str color: str
targets: List[TargetSnapshotSchema] targets: List[TargetSnapshotSchema]
devices: List[DeviceBrightnessSnapshotSchema]
automations: List[AutomationSnapshotSchema]
order: int order: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -310,22 +310,17 @@ class AutomationEngine:
# For "revert" mode, capture current state before activating # For "revert" mode, capture current state before activating
if automation.deactivation_mode == "revert": if automation.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot from wled_controller.core.scenes.scene_activator import capture_current_snapshot
targets, devices, automations = capture_current_snapshot( targets = capture_current_snapshot(self._target_store, self._manager)
self._target_store, self._device_store, self._store, self._manager,
)
self._pre_activation_snapshots[automation.id] = ScenePreset( self._pre_activation_snapshots[automation.id] = ScenePreset(
id=f"_revert_{automation.id}", id=f"_revert_{automation.id}",
name=f"Pre-activation snapshot for {automation.name}", name=f"Pre-activation snapshot for {automation.name}",
targets=targets, targets=targets,
devices=devices,
profiles=automations,
) )
# Apply the scene # Apply the scene
from wled_controller.core.scenes.scene_activator import apply_scene_state from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
preset, self._target_store, self._device_store, self._store, preset, self._target_store, self._manager,
self, self._manager, skip_automations=True,
) )
self._active_automations[automation.id] = True self._active_automations[automation.id] = True
@@ -352,11 +347,10 @@ class AutomationEngine:
if deactivation_mode == "revert": if deactivation_mode == "revert":
snapshot = self._pre_activation_snapshots.pop(automation_id, None) 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 from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
snapshot, self._target_store, self._device_store, self._store, snapshot, self._target_store, self._manager,
self, self._manager, skip_automations=True,
) )
if errors: if errors:
logger.warning(f"Automation {automation_id} revert errors: {errors}") logger.warning(f"Automation {automation_id} revert errors: {errors}")
@@ -367,13 +361,12 @@ class AutomationEngine:
elif deactivation_mode == "fallback_scene": elif deactivation_mode == "fallback_scene":
fallback_id = automation.deactivation_scene_preset_id if automation else None 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: try:
fallback = self._scene_preset_store.get_preset(fallback_id) fallback = self._scene_preset_store.get_preset(fallback_id)
from wled_controller.core.scenes.scene_activator import apply_scene_state from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
fallback, self._target_store, self._device_store, self._store, fallback, self._target_store, self._manager,
self, self._manager, skip_automations=True,
) )
if errors: if errors:
logger.warning(f"Automation {automation_id} fallback errors: {errors}") logger.warning(f"Automation {automation_id} fallback errors: {errors}")

View File

@@ -3,15 +3,11 @@
These functions are used by both the scene-presets API route and the automation engine. 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.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.picture_target_store import PictureTargetStore
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ( from wled_controller.storage.scene_preset import (
AutomationSnapshot,
DeviceBrightnessSnapshot,
ScenePreset, ScenePreset,
TargetSnapshot, TargetSnapshot,
) )
@@ -22,16 +18,18 @@ logger = get_logger(__name__)
def capture_current_snapshot( def capture_current_snapshot(
target_store: PictureTargetStore, target_store: PictureTargetStore,
device_store: DeviceStore,
automation_store: AutomationStore,
processor_manager: ProcessorManager, processor_manager: ProcessorManager,
) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[AutomationSnapshot]]: target_ids: Optional[Set[str]] = None,
"""Capture current system state as snapshot lists. ) -> 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 = [] targets = []
for t in target_store.get_all_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) proc = processor_manager._processors.get(t.id)
running = proc.is_running if proc else False running = proc.is_running if proc else False
targets.append(TargetSnapshot( targets.append(TargetSnapshot(
@@ -43,44 +41,20 @@ def capture_current_snapshot(
auto_start=getattr(t, "auto_start", False), auto_start=getattr(t, "auto_start", False),
)) ))
devices = [] return targets
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
async def apply_scene_state( async def apply_scene_state(
preset: ScenePreset, preset: ScenePreset,
target_store: PictureTargetStore, target_store: PictureTargetStore,
device_store: DeviceStore,
automation_store: AutomationStore,
automation_engine,
processor_manager: ProcessorManager, processor_manager: ProcessorManager,
*,
skip_automations: bool = False,
) -> Tuple[str, List[str]]: ) -> Tuple[str, List[str]]:
"""Apply a scene preset's state to the system. """Apply a scene preset's state to the system.
Args: Args:
preset: The scene preset to activate. preset: The scene preset to activate.
target_store: Target store for reading/updating targets. 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. 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: Returns:
(status, errors) where status is "activated" or "partial" and (status, errors) where status is "activated" or "partial" and
@@ -88,21 +62,7 @@ async def apply_scene_state(
""" """
errors: List[str] = [] errors: List[str] = []
# 1. Toggle automation enable states # 1. Stop targets that should be stopped
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
for ts in preset.targets: for ts in preset.targets:
if not ts.running: if not ts.running:
try: try:
@@ -112,7 +72,7 @@ async def apply_scene_state(
except Exception as e: except Exception as e:
errors.append(f"Stop target {ts.target_id}: {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: for ts in preset.targets:
try: try:
target = target_store.get_target(ts.target_id) target = target_store.get_target(ts.target_id)
@@ -147,7 +107,7 @@ async def apply_scene_state(
except Exception as e: except Exception as e:
errors.append(f"Target {ts.target_id} config: {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: for ts in preset.targets:
if ts.running: if ts.running:
try: try:
@@ -157,28 +117,6 @@ async def apply_scene_state(
except Exception as e: except Exception as e:
errors.append(f"Start target {ts.target_id}: {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" status = "activated" if not errors else "partial"
if errors: if errors:
logger.warning(f"Scene activation errors: {errors}") logger.warning(f"Scene activation errors: {errors}")

View File

@@ -413,3 +413,27 @@ textarea:focus-visible {
border-color: var(--primary-color); border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); 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;
}

View File

@@ -85,6 +85,7 @@ import {
import { import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, recaptureScenePreset, deleteScenePreset, activateScenePreset, recaptureScenePreset, deleteScenePreset,
addSceneTarget, removeSceneTarget,
} from './features/scene-presets.js'; } from './features/scene-presets.js';
// Layer 5: device-discovery, targets // Layer 5: device-discovery, targets
@@ -318,6 +319,8 @@ Object.assign(window, {
activateScenePreset, activateScenePreset,
recaptureScenePreset, recaptureScenePreset,
deleteScenePreset, deleteScenePreset,
addSceneTarget,
removeSceneTarget,
// device-discovery // device-discovery
onDeviceTypeChanged, onDeviceTypeChanged,

View File

@@ -10,7 +10,7 @@ import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.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 { 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) ===== // ===== Scene presets cache (shared by both selectors) =====
let _scenesCache = []; let _scenesCache = [];
@@ -62,6 +62,7 @@ export async function loadAutomations() {
const data = await automationsResp.json(); const data = await automationsResp.json();
const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] }; const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] };
_scenesCache = scenesData.presets || []; _scenesCache = scenesData.presets || [];
updatePresetsCache(_scenesCache);
// Build scene name map for card rendering // Build scene name map for card rendering
const sceneMap = new Map(_scenesCache.map(s => [s.id, s])); const sceneMap = new Map(_scenesCache.map(s => [s.id, s]));
@@ -208,6 +209,7 @@ export async function openAutomationEditor(automationId) {
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
_scenesCache = data.presets || []; _scenesCache = data.presets || [];
updatePresetsCache(_scenesCache);
} }
} catch { /* use cached */ } } catch { /* use cached */ }

View File

@@ -9,19 +9,26 @@ import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js'; import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js'; import { CardSection } from '../core/card-sections.js';
import { 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'; } from '../core/icons.js';
let _presetsCache = []; let _presetsCache = [];
let _editingId = null; 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 { class ScenePresetEditorModal extends Modal {
constructor() { super('scene-preset-editor-modal'); } constructor() { super('scene-preset-editor-modal'); }
snapshotValues() { snapshotValues() {
const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId).sort().join(',');
return { return {
name: document.getElementById('scene-preset-editor-name').value, name: document.getElementById('scene-preset-editor-name').value,
description: document.getElementById('scene-preset-editor-description').value, description: document.getElementById('scene-preset-editor-description').value,
color: document.getElementById('scene-preset-editor-color').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) { export function createSceneCard(preset) {
const targetCount = (preset.targets || []).length; 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 colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
const meta = [ const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, 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); ].filter(Boolean);
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
@@ -94,13 +97,9 @@ export function renderScenePresetsSection(presets) {
function _renderDashboardPresetCard(preset) { function _renderDashboardPresetCard(preset) {
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`; const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const deviceCount = (preset.devices || []).length;
const automationCount = (preset.automations || []).length;
const subtitle = [ const subtitle = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, 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 '); ].filter(Boolean).join(' \u00b7 ');
return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" style="${borderStyle}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}"> return `<div class="dashboard-target dashboard-scene-preset dashboard-card-link" data-scene-id="${preset.id}" style="${borderStyle}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'scenes','data-scene-id','${preset.id}')}">
@@ -120,7 +119,7 @@ function _renderDashboardPresetCard(preset) {
// ===== Capture (create) ===== // ===== Capture (create) =====
export function openScenePresetCapture() { export async function openScenePresetCapture() {
_editingId = null; _editingId = null;
document.getElementById('scene-preset-editor-id').value = ''; document.getElementById('scene-preset-editor-id').value = '';
document.getElementById('scene-preset-editor-name').value = ''; document.getElementById('scene-preset-editor-name').value = '';
@@ -131,6 +130,22 @@ export function openScenePresetCapture() {
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]'); const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); } if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.add'); titleEl.textContent = t('scenes.add'); }
// Fetch targets and populate selector
const selectorGroup = document.getElementById('scene-target-selector-group');
const targetList = document.getElementById('scene-target-list');
if (selectorGroup && targetList) {
selectorGroup.style.display = '';
targetList.innerHTML = '';
try {
const resp = await fetchWithAuth('/picture-targets');
if (resp.ok) {
const data = await resp.json();
_allTargets = data.targets || [];
_refreshTargetSelect();
}
} catch { /* ignore */ }
}
scenePresetModal.open(); scenePresetModal.open();
scenePresetModal.snapshot(); scenePresetModal.snapshot();
} }
@@ -148,6 +163,10 @@ export async function editScenePreset(presetId) {
document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7'; document.getElementById('scene-preset-editor-color').value = preset.color || '#4fc3f7';
document.getElementById('scene-preset-editor-error').style.display = 'none'; document.getElementById('scene-preset-editor-error').style.display = 'none';
// Hide target selector in edit mode (metadata only)
const selectorGroup = document.getElementById('scene-target-selector-group');
if (selectorGroup) selectorGroup.style.display = 'none';
const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]'); const titleEl = document.querySelector('#scene-preset-editor-title span[data-i18n]');
if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); } if (titleEl) { titleEl.setAttribute('data-i18n', 'scenes.edit'); titleEl.textContent = t('scenes.edit'); }
@@ -177,9 +196,11 @@ export async function saveScenePreset() {
body: JSON.stringify({ name, description, color }), body: JSON.stringify({ name, description, color }),
}); });
} else { } else {
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId);
resp = await fetchWithAuth('/scene-presets', { resp = await fetchWithAuth('/scene-presets', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, description, color }), body: JSON.stringify({ name, description, color, target_ids }),
}); });
} }
@@ -204,6 +225,49 @@ export async function closeScenePresetEditor() {
await scenePresetModal.close(); await scenePresetModal.close();
} }
// ===== Target selector helpers =====
function _refreshTargetSelect() {
const select = document.getElementById('scene-target-select');
if (!select) return;
const added = new Set(
[...document.querySelectorAll('#scene-target-list .scene-target-item')]
.map(el => el.dataset.targetId)
);
select.innerHTML = '';
for (const tgt of _allTargets) {
if (added.has(tgt.id)) continue;
const opt = document.createElement('option');
opt.value = tgt.id;
opt.textContent = tgt.name;
select.appendChild(opt);
}
// Disable add button when no targets available
const addBtn = select.parentElement?.querySelector('button');
if (addBtn) addBtn.disabled = select.options.length === 0;
}
export function addSceneTarget() {
const select = document.getElementById('scene-target-select');
const list = document.getElementById('scene-target-list');
if (!select || !list || !select.value) return;
const targetId = select.value;
const targetName = select.options[select.selectedIndex].text;
const item = document.createElement('div');
item.className = 'scene-target-item';
item.dataset.targetId = targetId;
item.innerHTML = `<span>${escapeHtml(targetName)}</span><button type="button" class="btn-remove-condition" onclick="removeSceneTarget(this)" title="Remove">&#x2715;</button>`;
list.appendChild(item);
_refreshTargetSelect();
}
export function removeSceneTarget(btn) {
btn.closest('.scene-target-item').remove();
_refreshTargetSelect();
}
// ===== Activate ===== // ===== Activate =====
export async function activateScenePreset(presetId) { export async function activateScenePreset(presetId) {

View File

@@ -630,13 +630,13 @@
"scenes.description.hint": "Optional description of what this scene does", "scenes.description.hint": "Optional description of what this scene does",
"scenes.color": "Card Color:", "scenes.color": "Card Color:",
"scenes.color.hint": "Accent color for the scene card on the dashboard", "scenes.color.hint": "Accent color for the scene card on the dashboard",
"scenes.targets": "Targets:",
"scenes.targets.hint": "Select which targets to include in this scene snapshot",
"scenes.capture": "Capture", "scenes.capture": "Capture",
"scenes.activate": "Activate scene", "scenes.activate": "Activate scene",
"scenes.recapture": "Recapture current state", "scenes.recapture": "Recapture current state",
"scenes.delete": "Delete scene", "scenes.delete": "Delete scene",
"scenes.targets_count": "targets", "scenes.targets_count": "targets",
"scenes.devices_count": "devices",
"scenes.automations_count": "automations",
"scenes.captured": "Scene captured", "scenes.captured": "Scene captured",
"scenes.updated": "Scene updated", "scenes.updated": "Scene updated",
"scenes.activated": "Scene activated", "scenes.activated": "Scene activated",

View File

@@ -630,13 +630,13 @@
"scenes.description.hint": "Необязательное описание назначения этой сцены", "scenes.description.hint": "Необязательное описание назначения этой сцены",
"scenes.color": "Цвет карточки:", "scenes.color": "Цвет карточки:",
"scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления", "scenes.color.hint": "Акцентный цвет для карточки сцены на панели управления",
"scenes.targets": "Цели:",
"scenes.targets.hint": "Выберите какие цели включить в снимок сцены",
"scenes.capture": "Захват", "scenes.capture": "Захват",
"scenes.activate": "Активировать сцену", "scenes.activate": "Активировать сцену",
"scenes.recapture": "Перезахватить текущее состояние", "scenes.recapture": "Перезахватить текущее состояние",
"scenes.delete": "Удалить сцену", "scenes.delete": "Удалить сцену",
"scenes.targets_count": "целей", "scenes.targets_count": "целей",
"scenes.devices_count": "устройств",
"scenes.automations_count": "автоматизаций",
"scenes.captured": "Сцена захвачена", "scenes.captured": "Сцена захвачена",
"scenes.updated": "Сцена обновлена", "scenes.updated": "Сцена обновлена",
"scenes.activated": "Сцена активирована", "scenes.activated": "Сцена активирована",

View File

@@ -630,13 +630,13 @@
"scenes.description.hint": "此场景功能的可选描述", "scenes.description.hint": "此场景功能的可选描述",
"scenes.color": "卡片颜色:", "scenes.color": "卡片颜色:",
"scenes.color.hint": "仪表盘上场景卡片的强调色", "scenes.color.hint": "仪表盘上场景卡片的强调色",
"scenes.targets": "目标:",
"scenes.targets.hint": "选择要包含在此场景快照中的目标",
"scenes.capture": "捕获", "scenes.capture": "捕获",
"scenes.activate": "激活场景", "scenes.activate": "激活场景",
"scenes.recapture": "重新捕获当前状态", "scenes.recapture": "重新捕获当前状态",
"scenes.delete": "删除场景", "scenes.delete": "删除场景",
"scenes.targets_count": "目标", "scenes.targets_count": "目标",
"scenes.devices_count": "设备",
"scenes.automations_count": "自动化",
"scenes.captured": "场景已捕获", "scenes.captured": "场景已捕获",
"scenes.updated": "场景已更新", "scenes.updated": "场景已更新",
"scenes.activated": "场景已激活", "scenes.activated": "场景已激活",

View File

@@ -1,8 +1,8 @@
"""Scene preset data models — snapshot of current system state.""" """Scene preset data models — snapshot of target state."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import List
@dataclass @dataclass
@@ -38,59 +38,15 @@ class TargetSnapshot:
) )
@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 AutomationSnapshot:
"""Snapshot of an automation's enabled state."""
automation_id: str
enabled: bool = True
def to_dict(self) -> dict:
return {
"automation_id": self.automation_id,
"enabled": self.enabled,
}
@classmethod
def from_dict(cls, data: dict) -> "AutomationSnapshot":
return cls(
automation_id=data.get("automation_id", ""),
enabled=data.get("enabled", True),
)
@dataclass @dataclass
class ScenePreset: class ScenePreset:
"""A named snapshot of system state that can be restored.""" """A named snapshot of target state that can be restored."""
id: str id: str
name: str name: str
description: str = "" description: str = ""
color: str = "#4fc3f7" # accent color for the card color: str = "#4fc3f7" # accent color for the card
targets: List[TargetSnapshot] = field(default_factory=list) targets: List[TargetSnapshot] = field(default_factory=list)
devices: List[DeviceBrightnessSnapshot] = field(default_factory=list)
automations: List[AutomationSnapshot] = field(default_factory=list)
order: int = 0 order: int = 0
created_at: datetime = field(default_factory=datetime.utcnow) created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow)
@@ -102,8 +58,6 @@ class ScenePreset:
"description": self.description, "description": self.description,
"color": self.color, "color": self.color,
"targets": [t.to_dict() for t in self.targets], "targets": [t.to_dict() for t in self.targets],
"devices": [d.to_dict() for d in self.devices],
"automations": [a.to_dict() for a in self.automations],
"order": self.order, "order": self.order,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
@@ -117,8 +71,6 @@ class ScenePreset:
description=data.get("description", ""), description=data.get("description", ""),
color=data.get("color", "#4fc3f7"), color=data.get("color", "#4fc3f7"),
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
automations=[AutomationSnapshot.from_dict(a) for a in data.get("automations", [])],
order=data.get("order", 0), order=data.get("order", 0),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),

View File

@@ -115,8 +115,6 @@ class ScenePresetStore:
existing = self._presets[preset_id] existing = self._presets[preset_id]
existing.targets = preset.targets existing.targets = preset.targets
existing.devices = preset.devices
existing.profiles = preset.profiles
existing.updated_at = datetime.utcnow() existing.updated_at = datetime.utcnow()
self._save() self._save()
logger.info(f"Recaptured scene preset: {preset_id}") logger.info(f"Recaptured scene preset: {preset_id}")

View File

@@ -36,6 +36,19 @@
<input type="color" id="scene-preset-editor-color" value="#4fc3f7"> <input type="color" id="scene-preset-editor-color" value="#4fc3f7">
</div> </div>
<div class="form-group" id="scene-target-selector-group" style="display:none">
<div class="label-row">
<label data-i18n="scenes.targets">Targets:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="scenes.targets.hint">Select which targets to include in this scene snapshot</small>
<div class="scene-target-add-row">
<select id="scene-target-select"></select>
<button type="button" class="btn btn-sm btn-secondary" onclick="addSceneTarget()">+</button>
</div>
<div id="scene-target-list" class="scene-target-list"></div>
</div>
<div id="scene-preset-editor-error" class="error-message" style="display: none;"></div> <div id="scene-preset-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>