Replace profile targets with scene activation and searchable scene selector

Profiles now activate scene presets instead of individual targets, with
configurable deactivation behavior (none/revert/fallback scene). The
target checklist UI is replaced by a searchable combobox for scene
selection that scales well with many scenes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 17:29:02 +03:00
parent 2e747b5ece
commit da3e53e1f1
17 changed files with 739 additions and 353 deletions

View File

@@ -4,9 +4,9 @@ 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_picture_target_store,
get_profile_engine, get_profile_engine,
get_profile_store, get_profile_store,
get_scene_preset_store,
) )
from wled_controller.api.schemas.profiles import ( from wled_controller.api.schemas.profiles import (
ConditionSchema, ConditionSchema,
@@ -16,7 +16,6 @@ from wled_controller.api.schemas.profiles import (
ProfileUpdate, ProfileUpdate,
) )
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile import ( from wled_controller.storage.profile import (
AlwaysCondition, AlwaysCondition,
ApplicationCondition, ApplicationCondition,
@@ -27,6 +26,7 @@ from wled_controller.storage.profile import (
TimeOfDayCondition, TimeOfDayCondition,
) )
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -79,9 +79,10 @@ def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
enabled=profile.enabled, enabled=profile.enabled,
condition_logic=profile.condition_logic, condition_logic=profile.condition_logic,
conditions=[_condition_to_schema(c) for c in profile.conditions], conditions=[_condition_to_schema(c) for c in profile.conditions],
target_ids=profile.target_ids, scene_preset_id=profile.scene_preset_id,
deactivation_mode=profile.deactivation_mode,
deactivation_scene_preset_id=profile.deactivation_scene_preset_id,
is_active=state["is_active"], is_active=state["is_active"],
active_target_ids=state["active_target_ids"],
last_activated_at=state.get("last_activated_at"), last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_at"), last_deactivated_at=state.get("last_deactivated_at"),
created_at=profile.created_at, created_at=profile.created_at,
@@ -94,12 +95,21 @@ def _validate_condition_logic(logic: str) -> None:
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.") raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None: def _validate_scene_refs(
for tid in target_ids: scene_preset_id: str | None,
deactivation_scene_preset_id: str | None,
scene_store: ScenePresetStore,
) -> None:
"""Validate that referenced scene preset IDs exist."""
for sid, label in [
(scene_preset_id, "scene_preset_id"),
(deactivation_scene_preset_id, "deactivation_scene_preset_id"),
]:
if sid is not None:
try: try:
target_store.get_target(tid) scene_store.get_preset(sid)
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail=f"Target not found: {tid}") raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
# ===== CRUD Endpoints ===== # ===== CRUD Endpoints =====
@@ -115,11 +125,11 @@ async def create_profile(
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store), scene_store: ScenePresetStore = Depends(get_scene_preset_store),
): ):
"""Create a new profile.""" """Create a new profile."""
_validate_condition_logic(data.condition_logic) _validate_condition_logic(data.condition_logic)
_validate_target_ids(data.target_ids, target_store) _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
try: try:
conditions = [_condition_from_schema(c) for c in data.conditions] conditions = [_condition_from_schema(c) for c in data.conditions]
@@ -131,7 +141,9 @@ async def create_profile(
enabled=data.enabled, enabled=data.enabled,
condition_logic=data.condition_logic, condition_logic=data.condition_logic,
conditions=conditions, conditions=conditions,
target_ids=data.target_ids, scene_preset_id=data.scene_preset_id,
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
) )
if profile.enabled: if profile.enabled:
@@ -189,13 +201,14 @@ async def update_profile(
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: ProfileStore = Depends(get_profile_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: ProfileEngine = Depends(get_profile_engine),
target_store: PictureTargetStore = Depends(get_picture_target_store), scene_store: ScenePresetStore = Depends(get_scene_preset_store),
): ):
"""Update a profile.""" """Update a profile."""
if data.condition_logic is not None: if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic) _validate_condition_logic(data.condition_logic)
if data.target_ids is not None:
_validate_target_ids(data.target_ids, target_store) # Validate scene refs (only the ones being updated)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
conditions = None conditions = None
if data.conditions is not None: if data.conditions is not None:
@@ -209,18 +222,25 @@ async def update_profile(
if data.enabled is False: if data.enabled is False:
await engine.deactivate_if_active(profile_id) await engine.deactivate_if_active(profile_id)
profile = store.update_profile( # Build update kwargs — use sentinel for Optional[str] fields
update_kwargs = dict(
profile_id=profile_id, profile_id=profile_id,
name=data.name, name=data.name,
enabled=data.enabled, enabled=data.enabled,
condition_logic=data.condition_logic, condition_logic=data.condition_logic,
conditions=conditions, conditions=conditions,
target_ids=data.target_ids, deactivation_mode=data.deactivation_mode,
) )
if data.scene_preset_id is not None:
update_kwargs["scene_preset_id"] = data.scene_preset_id
if data.deactivation_scene_preset_id is not None:
update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id
profile = store.update_profile(**update_kwargs)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
# Re-evaluate immediately if profile is enabled (may have new conditions/targets) # Re-evaluate immediately if profile is enabled (may have new conditions/scene)
if profile.enabled: if profile.enabled:
await engine.trigger_evaluate() await engine.trigger_evaluate()

View File

@@ -22,15 +22,14 @@ from wled_controller.api.schemas.scene_presets import (
ScenePresetUpdate, ScenePresetUpdate,
) )
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.scenes.scene_activator import (
apply_scene_state,
capture_current_snapshot,
)
from wled_controller.storage import DeviceStore 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.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset import ( from wled_controller.storage.scene_preset import ScenePreset
DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset,
TargetSnapshot,
)
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -39,45 +38,6 @@ logger = get_logger(__name__)
router = APIRouter() 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: def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
return ScenePresetResponse( return ScenePresetResponse(
id=preset.id, id=preset.id,
@@ -124,7 +84,7 @@ async def create_scene_preset(
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, profiles = _capture_snapshot( targets, devices, profiles = capture_current_snapshot(
target_store, device_store, profile_store, manager, target_store, device_store, profile_store, manager,
) )
@@ -244,7 +204,7 @@ async def recapture_scene_preset(
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, profiles = _capture_snapshot( targets, devices, profiles = capture_current_snapshot(
target_store, device_store, profile_store, manager, target_store, device_store, profile_store, manager,
) )
@@ -287,101 +247,11 @@ 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))
errors = [] status, errors = await apply_scene_state(
preset, target_store, device_store, profile_store, engine, manager,
)
# 1. Toggle profile enable states if not errors:
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") logger.info(f"Scene preset '{preset.name}' activated successfully")
return ActivateResponse(status=status, errors=errors) return ActivateResponse(status=status, errors=errors)

View File

@@ -34,7 +34,9 @@ class ProfileCreate(BaseModel):
enabled: bool = Field(default=True, description="Whether the profile is enabled") enabled: bool = Field(default=True, description="Whether the profile is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'") condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions") conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
@@ -44,7 +46,9 @@ class ProfileUpdate(BaseModel):
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled") enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'") condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions") conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
class ProfileResponse(BaseModel): class ProfileResponse(BaseModel):
@@ -55,9 +59,10 @@ class ProfileResponse(BaseModel):
enabled: bool = Field(description="Whether the profile is enabled") enabled: bool = Field(description="Whether the profile is enabled")
condition_logic: str = Field(description="Condition combination logic") condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions") conditions: List[ConditionSchema] = Field(description="List of conditions")
target_ids: List[str] = Field(description="Target IDs to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
is_active: bool = Field(default=False, description="Whether the profile is currently active") is_active: bool = Field(default=False, description="Whether the profile is currently active")
active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile")
last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated") last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated")
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated") last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")

View File

@@ -1,9 +1,9 @@
"""Profile engine — background loop that evaluates conditions and manages targets.""" """Profile engine — background loop that evaluates conditions and activates scenes."""
import asyncio import asyncio
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional, Set from typing import Dict, List, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.core.profiles.platform_detector import PlatformDetector
from wled_controller.storage.profile import ( from wled_controller.storage.profile import (
@@ -17,27 +17,41 @@ from wled_controller.storage.profile import (
TimeOfDayCondition, TimeOfDayCondition,
) )
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class ProfileEngine: class ProfileEngine:
"""Evaluates profile conditions and starts/stops targets accordingly.""" """Evaluates profile conditions and activates/deactivates scene presets."""
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0, def __init__(
mqtt_service=None): self,
profile_store: ProfileStore,
processor_manager,
poll_interval: float = 1.0,
mqtt_service=None,
scene_preset_store=None,
target_store=None,
device_store=None,
):
self._store = profile_store self._store = profile_store
self._manager = processor_manager self._manager = processor_manager
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._detector = PlatformDetector() self._detector = PlatformDetector()
self._mqtt_service = mqtt_service self._mqtt_service = mqtt_service
self._scene_preset_store = scene_preset_store
self._target_store = target_store
self._device_store = device_store
self._task: Optional[asyncio.Task] = None self._task: Optional[asyncio.Task] = None
self._eval_lock = asyncio.Lock() self._eval_lock = asyncio.Lock()
# Runtime state (not persisted) # Runtime state (not persisted)
# profile_id → set of target_ids that THIS profile started # profile_id → True when profile is currently active
self._active_profiles: Dict[str, Set[str]] = {} self._active_profiles: Dict[str, bool] = {}
# profile_id → snapshot captured before activation (for "revert" mode)
self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
# profile_id → datetime of last activation / deactivation # profile_id → datetime of last activation / deactivation
self._last_activated: Dict[str, datetime] = {} self._last_activated: Dict[str, datetime] = {}
self._last_deactivated: Dict[str, datetime] = {} self._last_deactivated: Dict[str, datetime] = {}
@@ -274,57 +288,116 @@ class ProfileEngine:
return any(app in running_procs for app in apps_lower) return any(app in running_procs for app in apps_lower)
async def _activate_profile(self, profile: Profile) -> None: async def _activate_profile(self, profile: Profile) -> None:
started: Set[str] = set() if not profile.scene_preset_id:
failed = False # No scene configured — just mark active (conditions matched but nothing to do)
for target_id in profile.target_ids: self._active_profiles[profile.id] = True
try:
# Skip targets that are already running (manual or other profile)
proc = self._manager._processors.get(target_id)
if proc and proc.is_running:
continue
await self._manager.start_processing(target_id)
started.add(target_id)
logger.info(f"Profile '{profile.name}' started target {target_id}")
except Exception as e:
failed = True
logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}")
if started or not failed:
# Active: either we started targets, or all were already running
self._active_profiles[profile.id] = started
self._last_activated[profile.id] = datetime.now(timezone.utc) self._last_activated[profile.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated", list(started)) self._fire_event(profile.id, "activated")
logger.info(f"Profile '{profile.name}' activated ({len(started)} targets started)") logger.info(f"Profile '{profile.name}' activated (no scene configured)")
return
if not self._scene_preset_store or not self._target_store or not self._device_store:
logger.warning(f"Profile '{profile.name}' matched but scene stores not available")
return
# Load the scene preset
try:
preset = self._scene_preset_store.get_preset(profile.scene_preset_id)
except ValueError:
logger.warning(f"Profile '{profile.name}': scene preset {profile.scene_preset_id} not found")
return
# For "revert" mode, capture current state before activating
if profile.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot
targets, devices, profiles = capture_current_snapshot(
self._target_store, self._device_store, self._store, self._manager,
)
self._pre_activation_snapshots[profile.id] = ScenePreset(
id=f"_revert_{profile.id}",
name=f"Pre-activation snapshot for {profile.name}",
targets=targets,
devices=devices,
profiles=profiles,
)
# 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_profiles=True,
)
self._active_profiles[profile.id] = True
self._last_activated[profile.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated")
if errors:
logger.warning(f"Profile '{profile.name}' activated with errors: {errors}")
else: else:
logger.debug(f"Profile '{profile.name}' matched but targets failed to start — will retry") logger.info(f"Profile '{profile.name}' activated (scene '{preset.name}' applied)")
async def _deactivate_profile(self, profile_id: str) -> None: async def _deactivate_profile(self, profile_id: str) -> None:
owned = self._active_profiles.pop(profile_id, set()) was_active = self._active_profiles.pop(profile_id, False)
stopped = [] if not was_active:
return
for target_id in owned: # Look up the profile for deactivation settings
try: try:
proc = self._manager._processors.get(target_id) profile = self._store.get_profile(profile_id)
if proc and proc.is_running: except ValueError:
await self._manager.stop_processing(target_id) profile = None
stopped.append(target_id)
logger.info(f"Profile {profile_id} stopped target {target_id}") deactivation_mode = profile.deactivation_mode if profile else "none"
except Exception as e:
logger.warning(f"Profile {profile_id} failed to stop target {target_id}: {e}") if deactivation_mode == "revert":
snapshot = self._pre_activation_snapshots.pop(profile_id, None)
if snapshot and self._target_store and self._device_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_profiles=True,
)
if errors:
logger.warning(f"Profile {profile_id} revert errors: {errors}")
else:
logger.info(f"Profile {profile_id} deactivated (reverted to previous state)")
else:
logger.warning(f"Profile {profile_id}: no snapshot available for revert")
elif deactivation_mode == "fallback_scene":
fallback_id = profile.deactivation_scene_preset_id if profile else None
if fallback_id and self._scene_preset_store and self._target_store and self._device_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_profiles=True,
)
if errors:
logger.warning(f"Profile {profile_id} fallback errors: {errors}")
else:
logger.info(f"Profile {profile_id} deactivated (fallback scene '{fallback.name}' applied)")
except ValueError:
logger.warning(f"Profile {profile_id}: fallback scene {fallback_id} not found")
else:
logger.info(f"Profile {profile_id} deactivated (no fallback scene configured)")
else:
# "none" mode — just clear active state
logger.info(f"Profile {profile_id} deactivated")
if stopped:
self._last_deactivated[profile_id] = datetime.now(timezone.utc) self._last_deactivated[profile_id] = datetime.now(timezone.utc)
self._fire_event(profile_id, "deactivated", stopped) self._fire_event(profile_id, "deactivated")
logger.info(f"Profile {profile_id} deactivated ({len(stopped)} targets stopped)") # Clean up any leftover snapshot
self._pre_activation_snapshots.pop(profile_id, None)
def _fire_event(self, profile_id: str, action: str, target_ids: list) -> None: def _fire_event(self, profile_id: str, action: str) -> None:
try: try:
self._manager._fire_event({ self._manager._fire_event({
"type": "profile_state_changed", "type": "profile_state_changed",
"profile_id": profile_id, "profile_id": profile_id,
"action": action, "action": action,
"target_ids": target_ids,
}) })
except Exception: except Exception:
pass pass
@@ -334,10 +407,8 @@ class ProfileEngine:
def get_profile_state(self, profile_id: str) -> dict: def get_profile_state(self, profile_id: str) -> dict:
"""Get runtime state of a single profile.""" """Get runtime state of a single profile."""
is_active = profile_id in self._active_profiles is_active = profile_id in self._active_profiles
owned = list(self._active_profiles.get(profile_id, set()))
return { return {
"is_active": is_active, "is_active": is_active,
"active_target_ids": owned,
"last_activated_at": self._last_activated.get(profile_id), "last_activated_at": self._last_activated.get(profile_id),
"last_deactivated_at": self._last_deactivated.get(profile_id), "last_deactivated_at": self._last_deactivated.get(profile_id),
} }

View File

@@ -0,0 +1,8 @@
"""Scene activation and snapshot utilities."""
from wled_controller.core.scenes.scene_activator import (
apply_scene_state,
capture_current_snapshot,
)
__all__ = ["apply_scene_state", "capture_current_snapshot"]

View File

@@ -0,0 +1,186 @@
"""Reusable scene activation and snapshot capture logic.
These functions are used by both the scene-presets API route and the profile engine.
"""
from typing import List, Optional, 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.profile_store import ProfileStore
from wled_controller.storage.scene_preset import (
DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset,
TargetSnapshot,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
def capture_current_snapshot(
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
processor_manager: ProcessorManager,
) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[ProfileSnapshot]]:
"""Capture current system state as snapshot lists.
Returns (targets, devices, profiles) snapshot tuples.
"""
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
async def apply_scene_state(
preset: ScenePreset,
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
profile_engine,
processor_manager: ProcessorManager,
*,
skip_profiles: 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.
profile_store: Profile store for reading/updating profiles.
profile_engine: Profile engine for deactivation and re-evaluation.
processor_manager: Processor manager for starting/stopping targets.
skip_profiles: If True, skip toggling profile enable states (used when
called from the profile engine itself to avoid recursion).
Returns:
(status, errors) where status is "activated" or "partial" and
errors is a list of error messages.
"""
errors: List[str] = []
# 1. Toggle profile enable states
if not skip_profiles:
for ps in preset.profiles:
try:
p = profile_store.get_profile(ps.profile_id)
if p.enabled != ps.enabled:
if not ps.enabled:
await profile_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 = processor_manager._processors.get(ts.target_id)
if proc and proc.is_running:
await processor_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 = processor_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(processor_manager, settings_changed=False, css_changed=True)
if bvs_changed:
target.sync_with_manager(processor_manager, settings_changed=False, brightness_vs_changed=True)
if settings_changed:
target.sync_with_manager(processor_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 = processor_manager._processors.get(ts.target_id)
if not proc or not proc.is_running:
await processor_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 = 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 profile re-evaluation after all changes
if not skip_profiles:
try:
await profile_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 activation errors: {errors}")
return status, errors

View File

@@ -108,8 +108,14 @@ async def lifespan(app: FastAPI):
mqtt_service = MQTTService(config.mqtt) mqtt_service = MQTTService(config.mqtt)
set_mqtt_service(mqtt_service) set_mqtt_service(mqtt_service)
# Create profile engine (needs processor_manager + mqtt_service) # Create profile engine (needs processor_manager + mqtt_service + stores for scene activation)
profile_engine = ProfileEngine(profile_store, processor_manager, mqtt_service=mqtt_service) profile_engine = ProfileEngine(
profile_store, processor_manager,
mqtt_service=mqtt_service,
scene_preset_store=scene_preset_store,
target_store=picture_target_store,
device_store=device_store,
)
# Create auto-backup engine # Create auto-backup engine
auto_backup_engine = AutoBackupEngine( auto_backup_engine = AutoBackupEngine(

View File

@@ -174,28 +174,99 @@
text-align: center; text-align: center;
} }
/* Profile target checklist */ /* Scene selector (searchable combobox) */
.profile-targets-checklist { .scene-selector {
position: relative;
}
.scene-selector-input-wrap {
display: flex;
align-items: center;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
overflow: hidden;
}
.scene-selector-input {
flex: 1;
padding: 6px 8px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
outline: none;
}
.scene-selector-clear {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
display: none;
}
.scene-selector-clear.visible {
display: block;
}
.scene-selector-clear:hover {
color: var(--danger-color, #dc3545);
}
.scene-selector-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-top: none;
padding: 6px; border-radius: 0 0 4px 4px;
background: var(--bg-color);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
.profile-target-item { .scene-selector-dropdown.open {
display: block;
}
.scene-selector-item {
padding: 6px 10px;
font-size: 0.85rem;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding: 4px 6px; transition: background 0.1s;
cursor: pointer;
border-radius: 3px;
} }
.profile-target-item:hover { .scene-selector-item:hover {
background: var(--bg-secondary, var(--bg-color)); background: rgba(33, 150, 243, 0.15);
} }
.profile-target-item input[type="checkbox"] { .scene-selector-item.selected {
margin: 0; background: rgba(33, 150, 243, 0.2);
font-weight: 600;
}
.scene-selector-item .scene-color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.scene-selector-empty {
padding: 8px 10px;
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
} }

View File

@@ -79,7 +79,7 @@ import {
import { import {
loadProfiles, openProfileEditor, closeProfileEditorModal, loadProfiles, openProfileEditor, closeProfileEditorModal,
saveProfileEditor, addProfileCondition, saveProfileEditor, addProfileCondition,
toggleProfileEnabled, toggleProfileTargets, deleteProfile, toggleProfileEnabled, deleteProfile,
expandAllProfileSections, collapseAllProfileSections, expandAllProfileSections, collapseAllProfileSections,
} from './features/profiles.js'; } from './features/profiles.js';
import { import {
@@ -307,7 +307,6 @@ Object.assign(window, {
saveProfileEditor, saveProfileEditor,
addProfileCondition, addProfileCondition,
toggleProfileEnabled, toggleProfileEnabled,
toggleProfileTargets,
deleteProfile, deleteProfile,
expandAllProfileSections, expandAllProfileSections,
collapseAllProfileSections, collapseAllProfileSections,

View File

@@ -11,7 +11,7 @@ import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
getTargetTypeIcon, getTargetTypeIcon,
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE,
} from '../core/icons.js'; } from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
@@ -269,12 +269,6 @@ function _updateProfilesInPlace(profiles) {
badge.textContent = t('profiles.status.inactive'); badge.textContent = t('profiles.status.inactive');
} }
} }
const metricVal = card.querySelector('.dashboard-metric-value');
if (metricVal) {
const cnt = p.target_ids.length;
const active = (p.active_target_ids || []).length;
metricVal.textContent = p.is_active ? `${active}/${cnt}` : `${cnt}`;
}
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
if (btn) { if (btn) {
btn.className = `dashboard-action-btn ${p.enabled ? 'stop' : 'start'}`; btn.className = `dashboard-action-btn ${p.enabled ? 'stop' : 'start'}`;
@@ -490,7 +484,8 @@ export async function loadDashboard(forceFullRender = false) {
const activeProfiles = profiles.filter(p => p.is_active); const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active);
updateTabBadge('profiles', activeProfiles.length); updateTabBadge('profiles', activeProfiles.length);
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p, sceneMap)).join('');
dynamicHtml += `<div class="dashboard-section"> dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} ${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
@@ -669,7 +664,7 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
} }
} }
function renderDashboardProfile(profile) { function renderDashboardProfile(profile, sceneMap = new Map()) {
const isActive = profile.is_active; const isActive = profile.is_active;
const isDisabled = !profile.enabled; const isDisabled = !profile.enabled;
@@ -693,9 +688,9 @@ function renderDashboardProfile(profile) {
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>` ? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`; : `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length; // Scene info
const activeCount = (profile.active_target_ids || []).length; const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected');
return `<div class="dashboard-target dashboard-profile dashboard-card-link" data-profile-id="${profile.id}" onclick="if(!event.target.closest('button')){navigateToCard('profiles',null,'profiles','data-profile-id','${profile.id}')}"> return `<div class="dashboard-target dashboard-profile dashboard-card-link" data-profile-id="${profile.id}" onclick="if(!event.target.closest('button')){navigateToCard('profiles',null,'profiles','data-profile-id','${profile.id}')}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
@@ -703,15 +698,10 @@ function renderDashboardProfile(profile) {
<div> <div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div> <div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''} ${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
</div> </div>
${statusBadge} ${statusBadge}
</div> </div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${targetsInfo}</div>
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
</div>
</div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="dashboard-action-btn ${profile.enabled ? 'stop' : 'start'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}"> <button class="dashboard-action-btn ${profile.enabled ? 'stop' : 'start'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
${profile.enabled ? ICON_STOP_PLAIN : ICON_START} ${profile.enabled ? ICON_STOP_PLAIN : ICON_START}

View File

@@ -1,5 +1,5 @@
/** /**
* Profiles — profile cards, editor, condition builder, process picker. * Profiles — profile cards, editor, condition builder, process picker, scene selector.
*/ */
import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js'; import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js';
@@ -9,7 +9,10 @@ import { showToast, showConfirm, setTabRefreshing } 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 { updateTabBadge } from './tabs.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, ICON_TIMER, ICON_MONITOR, ICON_RADIO } from '../core/icons.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js';
// ===== Scene presets cache (shared by both selectors) =====
let _scenesCache = [];
class ProfileEditorModal extends Modal { class ProfileEditorModal extends Modal {
constructor() { super('profile-editor-modal'); } constructor() { super('profile-editor-modal'); }
@@ -20,7 +23,9 @@ class ProfileEditorModal extends Modal {
enabled: document.getElementById('profile-editor-enabled').checked.toString(), enabled: document.getElementById('profile-editor-enabled').checked.toString(),
logic: document.getElementById('profile-editor-logic').value, logic: document.getElementById('profile-editor-logic').value,
conditions: JSON.stringify(getProfileEditorConditions()), conditions: JSON.stringify(getProfileEditorConditions()),
targets: JSON.stringify(getProfileEditorTargetIds()), scenePresetId: document.getElementById('profile-scene-id').value,
deactivationMode: document.getElementById('profile-deactivation-mode').value,
deactivationScenePresetId: document.getElementById('profile-fallback-scene-id').value,
}; };
} }
} }
@@ -48,24 +53,22 @@ export async function loadProfiles() {
setTabRefreshing('profiles-content', true); setTabRefreshing('profiles-content', true);
try { try {
const [profilesResp, targetsResp] = await Promise.all([ const [profilesResp, scenesResp] = await Promise.all([
fetchWithAuth('/profiles'), fetchWithAuth('/profiles'),
fetchWithAuth('/picture-targets'), fetchWithAuth('/scene-presets'),
]); ]);
if (!profilesResp.ok) throw new Error('Failed to load profiles'); if (!profilesResp.ok) throw new Error('Failed to load profiles');
const data = await profilesResp.json(); const data = await profilesResp.json();
const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] }; const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] };
const allTargets = targetsData.targets || []; _scenesCache = scenesData.presets || [];
// Batch fetch all target states in a single request
const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states'); // Build scene name map for card rendering
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const sceneMap = new Map(_scenesCache.map(s => [s.id, s]));
const runningTargetIds = new Set(
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
);
set_profilesCache(data.profiles); set_profilesCache(data.profiles);
const activeCount = data.profiles.filter(p => p.is_active).length; const activeCount = data.profiles.filter(p => p.is_active).length;
updateTabBadge('profiles', activeCount); updateTabBadge('profiles', activeCount);
renderProfiles(data.profiles, runningTargetIds); renderProfiles(data.profiles, sceneMap);
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load profiles:', error); console.error('Failed to load profiles:', error);
@@ -84,22 +87,21 @@ export function collapseAllProfileSections() {
CardSection.collapseAll([csProfiles]); CardSection.collapseAll([csProfiles]);
} }
function renderProfiles(profiles, runningTargetIds = new Set()) { function renderProfiles(profiles, sceneMap) {
const container = document.getElementById('profiles-content'); const container = document.getElementById('profiles-content');
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, runningTargetIds) }))); const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, sceneMap) })));
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`; const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllProfileSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllProfileSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startProfilesTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csProfiles.render(items); container.innerHTML = toolbar + csProfiles.render(items);
csProfiles.bind(); csProfiles.bind();
// Localize data-i18n elements within the profiles container only // Localize data-i18n elements within the profiles container only
// (calling global updateAllText() would trigger loadProfiles() again → infinite loop)
container.querySelectorAll('[data-i18n]').forEach(el => { container.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n')); el.textContent = t(el.getAttribute('data-i18n'));
}); });
} }
function createProfileCard(profile, runningTargetIds = new Set()) { function createProfileCard(profile, sceneMap = new Map()) {
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive'; const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive'); const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
@@ -136,7 +138,19 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`); condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
} }
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`; // Scene info
const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
// Deactivation mode label
let deactivationLabel = '';
if (profile.deactivation_mode === 'revert') {
deactivationLabel = t('profiles.deactivation_mode.revert');
} else if (profile.deactivation_mode === 'fallback_scene') {
const fallback = profile.deactivation_scene_preset_id ? sceneMap.get(profile.deactivation_scene_preset_id) : null;
deactivationLabel = fallback ? `${t('profiles.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('profiles.deactivation_mode.fallback_scene');
}
let lastActivityMeta = ''; let lastActivityMeta = '';
if (profile.last_activated_at) { if (profile.last_activated_at) {
@@ -157,20 +171,13 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
</div> </div>
<div class="card-subtitle"> <div class="card-subtitle">
<span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span> <span class="card-meta">${profile.condition_logic === 'and' ? t('profiles.logic.all') : t('profiles.logic.any')}</span>
<span class="card-meta">${ICON_TARGET} ${targetCountText}</span> <span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
${lastActivityMeta} ${lastActivityMeta}
</div> </div>
<div class="stream-card-props">${condPills}</div> <div class="stream-card-props">${condPills}</div>
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">${ICON_SETTINGS}</button> <button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">${ICON_SETTINGS}</button>
${profile.target_ids.length > 0 ? (() => {
const anyRunning = profile.target_ids.some(id => runningTargetIds.has(id));
return `<button class="btn btn-icon ${anyRunning ? 'btn-warning' : 'btn-success'}"
onclick="toggleProfileTargets('${profile.id}')"
title="${anyRunning ? t('profiles.toggle_all.stop') : t('profiles.toggle_all.start')}">
${anyRunning ? ICON_STOP_PLAIN : ICON_START}
</button>`;
})() : ''}
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}"> <button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
${profile.enabled ? ICON_PAUSE : ICON_START} ${profile.enabled ? ICON_PAUSE : ICON_START}
</button> </button>
@@ -191,7 +198,18 @@ export async function openProfileEditor(profileId) {
errorEl.style.display = 'none'; errorEl.style.display = 'none';
condList.innerHTML = ''; condList.innerHTML = '';
await loadProfileTargetChecklist([]); // Fetch scenes for selector
try {
const resp = await fetchWithAuth('/scene-presets');
if (resp.ok) {
const data = await resp.json();
_scenesCache = data.presets || [];
}
} catch { /* use cached */ }
// Reset deactivation mode
document.getElementById('profile-deactivation-mode').value = 'none';
document.getElementById('profile-fallback-scene-group').style.display = 'none';
if (profileId) { if (profileId) {
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`; titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`;
@@ -209,7 +227,13 @@ export async function openProfileEditor(profileId) {
addProfileConditionRow(c); addProfileConditionRow(c);
} }
await loadProfileTargetChecklist(profile.target_ids); // Scene selector
_initSceneSelector('profile-scene', profile.scene_preset_id);
// Deactivation mode
document.getElementById('profile-deactivation-mode').value = profile.deactivation_mode || 'none';
_onDeactivationModeChange();
_initSceneSelector('profile-fallback-scene', profile.deactivation_scene_preset_id);
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
return; return;
@@ -220,44 +244,132 @@ export async function openProfileEditor(profileId) {
nameInput.value = ''; nameInput.value = '';
enabledInput.checked = true; enabledInput.checked = true;
logicSelect.value = 'or'; logicSelect.value = 'or';
_initSceneSelector('profile-scene', null);
_initSceneSelector('profile-fallback-scene', null);
} }
// Wire up deactivation mode change
document.getElementById('profile-deactivation-mode').onchange = _onDeactivationModeChange;
profileModal.open(); profileModal.open();
modal.querySelectorAll('[data-i18n]').forEach(el => { modal.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n')); el.textContent = t(el.getAttribute('data-i18n'));
}); });
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
profileModal.snapshot(); profileModal.snapshot();
} }
function _onDeactivationModeChange() {
const mode = document.getElementById('profile-deactivation-mode').value;
document.getElementById('profile-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none';
}
export async function closeProfileEditorModal() { export async function closeProfileEditorModal() {
await profileModal.close(); await profileModal.close();
} }
async function loadProfileTargetChecklist(selectedIds) { // ===== Scene selector logic =====
const container = document.getElementById('profile-targets-list');
try {
const resp = await fetchWithAuth('/picture-targets');
if (!resp.ok) throw new Error('Failed to load targets');
const data = await resp.json();
const targets = data.targets || [];
if (targets.length === 0) { function _initSceneSelector(prefix, selectedId) {
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`; const hiddenInput = document.getElementById(`${prefix}-id`);
return; const searchInput = document.getElementById(`${prefix}-search`);
const clearBtn = document.getElementById(`${prefix}-clear`);
const dropdown = document.getElementById(`${prefix}-dropdown`);
hiddenInput.value = selectedId || '';
// Set initial display text
if (selectedId) {
const scene = _scenesCache.find(s => s.id === selectedId);
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
} else {
searchInput.value = '';
clearBtn.classList.toggle('visible', false);
} }
container.innerHTML = targets.map(tgt => { // Render dropdown items
const checked = selectedIds.includes(tgt.id) ? 'checked' : ''; function renderDropdown(filter) {
return `<label class="profile-target-item"> const query = (filter || '').toLowerCase();
<input type="checkbox" value="${tgt.id}" ${checked}> const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache;
<span>${escapeHtml(tgt.name)}</span>
</label>`; if (filtered.length === 0) {
dropdown.innerHTML = `<div class="scene-selector-empty">${t('profiles.scene.none_available')}</div>`;
} else {
dropdown.innerHTML = filtered.map(s => {
const selected = s.id === hiddenInput.value ? ' selected' : '';
return `<div class="scene-selector-item${selected}" data-scene-id="${s.id}"><span class="scene-color-dot" style="background:${escapeHtml(s.color || '#4fc3f7')}"></span>${escapeHtml(s.name)}</div>`;
}).join(''); }).join('');
} catch (e) {
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
} }
// Attach click handlers
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
item.addEventListener('click', () => {
const id = item.dataset.sceneId;
const scene = _scenesCache.find(s => s.id === id);
hiddenInput.value = id;
searchInput.value = scene ? scene.name : '';
clearBtn.classList.toggle('visible', true);
dropdown.classList.remove('open');
});
});
}
// Show dropdown on focus/click
searchInput.onfocus = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
};
searchInput.oninput = () => {
renderDropdown(searchInput.value);
dropdown.classList.add('open');
// If text doesn't match any scene, clear the hidden input
const exactMatch = _scenesCache.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
if (!exactMatch) {
hiddenInput.value = '';
clearBtn.classList.toggle('visible', !!searchInput.value);
}
};
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Select first visible item
const first = dropdown.querySelector('.scene-selector-item');
if (first) first.click();
} else if (e.key === 'Escape') {
dropdown.classList.remove('open');
searchInput.blur();
}
};
// Clear button
clearBtn.onclick = () => {
hiddenInput.value = '';
searchInput.value = '';
clearBtn.classList.remove('visible');
dropdown.classList.remove('open');
};
// Close dropdown when clicking outside
const selectorEl = searchInput.closest('.scene-selector');
// Remove old listener if any (re-init)
if (selectorEl._outsideClickHandler) {
document.removeEventListener('click', selectorEl._outsideClickHandler);
}
selectorEl._outsideClickHandler = (e) => {
if (!selectorEl.contains(e.target)) {
dropdown.classList.remove('open');
}
};
document.addEventListener('click', selectorEl._outsideClickHandler);
} }
// ===== Condition editor =====
export function addProfileCondition() { export function addProfileCondition() {
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
} }
@@ -444,7 +556,7 @@ function renderProcessPicker(picker, processes, existing) {
} }
listEl.innerHTML = processes.map(p => { listEl.innerHTML = processes.map(p => {
const added = existing.has(p.toLowerCase()); const added = existing.has(p.toLowerCase());
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ' : ''}</div>`; return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' \u2713' : ''}</div>`;
}).join(''); }).join('');
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
@@ -455,7 +567,7 @@ function renderProcessPicker(picker, processes, existing) {
const current = textarea.value.trim(); const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc; textarea.value = current ? current + '\n' + proc : proc;
item.classList.add('added'); item.classList.add('added');
item.textContent = proc + ' '; item.textContent = proc + ' \u2713';
picker._existing.add(proc.toLowerCase()); picker._existing.add(proc.toLowerCase());
}); });
}); });
@@ -509,11 +621,6 @@ function getProfileEditorConditions() {
return conditions; return conditions;
} }
function getProfileEditorTargetIds() {
const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
export async function saveProfileEditor() { export async function saveProfileEditor() {
const idInput = document.getElementById('profile-editor-id'); const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name'); const nameInput = document.getElementById('profile-editor-name');
@@ -531,7 +638,9 @@ export async function saveProfileEditor() {
enabled: enabledInput.checked, enabled: enabledInput.checked,
condition_logic: logicSelect.value, condition_logic: logicSelect.value,
conditions: getProfileEditorConditions(), conditions: getProfileEditorConditions(),
target_ids: getProfileEditorTargetIds(), scene_preset_id: document.getElementById('profile-scene-id').value || null,
deactivation_mode: document.getElementById('profile-deactivation-mode').value,
deactivation_scene_preset_id: document.getElementById('profile-fallback-scene-id').value || null,
}; };
const profileId = idInput.value; const profileId = idInput.value;
@@ -557,28 +666,6 @@ export async function saveProfileEditor() {
} }
} }
export async function toggleProfileTargets(profileId) {
try {
const profileResp = await fetchWithAuth(`/profiles/${profileId}`);
if (!profileResp.ok) throw new Error('Failed to load profile');
const profile = await profileResp.json();
// Batch fetch all target states to determine which are running
const batchResp = await fetchWithAuth('/picture-targets/batch/states');
const allStates = batchResp.ok ? (await batchResp.json()).states : {};
const runningSet = new Set(
profile.target_ids.filter(id => allStates[id]?.processing)
);
const shouldStop = profile.target_ids.some(id => runningSet.has(id));
await Promise.all(profile.target_ids.map(id =>
fetchWithAuth(`/picture-targets/${id}/${shouldStop ? 'stop' : 'start'}`, { method: 'POST' }).catch(() => {})
));
loadProfiles();
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function toggleProfileEnabled(profileId, enable) { export async function toggleProfileEnabled(profileId, enable) {
try { try {
const action = enable ? 'enable' : 'disable'; const action = enable ? 'enable' : 'disable';

View File

@@ -593,9 +593,18 @@
"profiles.condition.mqtt.match_mode.contains": "Contains", "profiles.condition.mqtt.match_mode.contains": "Contains",
"profiles.condition.mqtt.match_mode.regex": "Regex", "profiles.condition.mqtt.match_mode.regex": "Regex",
"profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"profiles.targets": "Targets:", "profiles.scene": "Scene:",
"profiles.targets.hint": "Targets to start when this profile activates", "profiles.scene.hint": "Scene preset to activate when conditions are met",
"profiles.targets.empty": "No targets available", "profiles.scene.search_placeholder": "Search scenes...",
"profiles.scene.none_selected": "No scene",
"profiles.scene.none_available": "No scenes available",
"profiles.deactivation_mode": "Deactivation:",
"profiles.deactivation_mode.hint": "What happens when conditions stop matching",
"profiles.deactivation_mode.none": "None — keep current state",
"profiles.deactivation_mode.revert": "Revert to previous state",
"profiles.deactivation_mode.fallback_scene": "Activate fallback scene",
"profiles.deactivation_scene": "Fallback Scene:",
"profiles.deactivation_scene.hint": "Scene to activate when this profile deactivates",
"profiles.status.active": "Active", "profiles.status.active": "Active",
"profiles.status.inactive": "Inactive", "profiles.status.inactive": "Inactive",
"profiles.status.disabled": "Disabled", "profiles.status.disabled": "Disabled",
@@ -609,8 +618,6 @@
"profiles.created": "Profile created", "profiles.created": "Profile created",
"profiles.deleted": "Profile deleted", "profiles.deleted": "Profile deleted",
"profiles.error.name_required": "Name is required", "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.title": "Scenes",
"scenes.add": "Capture Scene", "scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene", "scenes.edit": "Edit Scene",

View File

@@ -593,9 +593,18 @@
"profiles.condition.mqtt.match_mode.contains": "Содержит", "profiles.condition.mqtt.match_mode.contains": "Содержит",
"profiles.condition.mqtt.match_mode.regex": "Регулярное выражение", "profiles.condition.mqtt.match_mode.regex": "Регулярное выражение",
"profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", "profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
"profiles.targets": "Цели:", "profiles.scene": "Сцена:",
"profiles.targets.hint": "Цели для запуска при активации профиля", "profiles.scene.hint": "Пресет сцены для активации при выполнении условий",
"profiles.targets.empty": "Нет доступных целей", "profiles.scene.search_placeholder": "Поиск сцен...",
"profiles.scene.none_selected": "Нет сцены",
"profiles.scene.none_available": "Нет доступных сцен",
"profiles.deactivation_mode": "Деактивация:",
"profiles.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться",
"profiles.deactivation_mode.none": "Ничего — оставить текущее состояние",
"profiles.deactivation_mode.revert": "Вернуть предыдущее состояние",
"profiles.deactivation_mode.fallback_scene": "Активировать резервную сцену",
"profiles.deactivation_scene": "Резервная сцена:",
"profiles.deactivation_scene.hint": "Сцена для активации при деактивации профиля",
"profiles.status.active": "Активен", "profiles.status.active": "Активен",
"profiles.status.inactive": "Неактивен", "profiles.status.inactive": "Неактивен",
"profiles.status.disabled": "Отключён", "profiles.status.disabled": "Отключён",
@@ -609,8 +618,6 @@
"profiles.created": "Профиль создан", "profiles.created": "Профиль создан",
"profiles.deleted": "Профиль удалён", "profiles.deleted": "Профиль удалён",
"profiles.error.name_required": "Введите название", "profiles.error.name_required": "Введите название",
"profiles.toggle_all.start": "Запустить все цели",
"profiles.toggle_all.stop": "Остановить все цели",
"scenes.title": "Сцены", "scenes.title": "Сцены",
"scenes.add": "Захватить сцену", "scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену", "scenes.edit": "Редактировать сцену",

View File

@@ -593,9 +593,18 @@
"profiles.condition.mqtt.match_mode.contains": "包含", "profiles.condition.mqtt.match_mode.contains": "包含",
"profiles.condition.mqtt.match_mode.regex": "正则表达式", "profiles.condition.mqtt.match_mode.regex": "正则表达式",
"profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", "profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"profiles.targets": "目标", "profiles.scene": "场景",
"profiles.targets.hint": "配置文件激活时要启动的目标", "profiles.scene.hint": "条件满足时激活的场景预设",
"profiles.targets.empty": "没有可用的目标", "profiles.scene.search_placeholder": "搜索场景...",
"profiles.scene.none_selected": "无场景",
"profiles.scene.none_available": "没有可用的场景",
"profiles.deactivation_mode": "停用方式:",
"profiles.deactivation_mode.hint": "条件不再满足时的行为",
"profiles.deactivation_mode.none": "无 — 保持当前状态",
"profiles.deactivation_mode.revert": "恢复到之前的状态",
"profiles.deactivation_mode.fallback_scene": "激活备用场景",
"profiles.deactivation_scene": "备用场景:",
"profiles.deactivation_scene.hint": "配置文件停用时激活的场景",
"profiles.status.active": "活动", "profiles.status.active": "活动",
"profiles.status.inactive": "非活动", "profiles.status.inactive": "非活动",
"profiles.status.disabled": "已禁用", "profiles.status.disabled": "已禁用",
@@ -609,8 +618,6 @@
"profiles.created": "配置文件已创建", "profiles.created": "配置文件已创建",
"profiles.deleted": "配置文件已删除", "profiles.deleted": "配置文件已删除",
"profiles.error.name_required": "名称为必填项", "profiles.error.name_required": "名称为必填项",
"profiles.toggle_all.start": "启动所有目标",
"profiles.toggle_all.stop": "停止所有目标",
"scenes.title": "场景", "scenes.title": "场景",
"scenes.add": "捕获场景", "scenes.add": "捕获场景",
"scenes.edit": "编辑场景", "scenes.edit": "编辑场景",

View File

@@ -160,14 +160,16 @@ class MQTTCondition(Condition):
@dataclass @dataclass
class Profile: class Profile:
"""Automation profile that activates targets based on conditions.""" """Automation profile that activates a scene preset based on conditions."""
id: str id: str
name: str name: str
enabled: bool enabled: bool
condition_logic: str # "or" | "and" condition_logic: str # "or" | "and"
conditions: List[Condition] conditions: List[Condition]
target_ids: List[str] scene_preset_id: Optional[str] # scene to activate when conditions are met
deactivation_mode: str # "none" | "revert" | "fallback_scene"
deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -178,7 +180,9 @@ class Profile:
"enabled": self.enabled, "enabled": self.enabled,
"condition_logic": self.condition_logic, "condition_logic": self.condition_logic,
"conditions": [c.to_dict() for c in self.conditions], "conditions": [c.to_dict() for c in self.conditions],
"target_ids": list(self.target_ids), "scene_preset_id": self.scene_preset_id,
"deactivation_mode": self.deactivation_mode,
"deactivation_scene_preset_id": self.deactivation_scene_preset_id,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
} }
@@ -198,7 +202,9 @@ class Profile:
enabled=data.get("enabled", True), enabled=data.get("enabled", True),
condition_logic=data.get("condition_logic", "or"), condition_logic=data.get("condition_logic", "or"),
conditions=conditions, conditions=conditions,
target_ids=data.get("target_ids", []), scene_preset_id=data.get("scene_preset_id"),
deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
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

@@ -74,7 +74,9 @@ class ProfileStore:
enabled: bool = True, enabled: bool = True,
condition_logic: str = "or", condition_logic: str = "or",
conditions: Optional[List[Condition]] = None, conditions: Optional[List[Condition]] = None,
target_ids: Optional[List[str]] = None, scene_preset_id: Optional[str] = None,
deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None,
) -> Profile: ) -> Profile:
for p in self._profiles.values(): for p in self._profiles.values():
if p.name == name: if p.name == name:
@@ -89,7 +91,9 @@ class ProfileStore:
enabled=enabled, enabled=enabled,
condition_logic=condition_logic, condition_logic=condition_logic,
conditions=conditions or [], conditions=conditions or [],
target_ids=target_ids or [], scene_preset_id=scene_preset_id,
deactivation_mode=deactivation_mode,
deactivation_scene_preset_id=deactivation_scene_preset_id,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
@@ -107,7 +111,9 @@ class ProfileStore:
enabled: Optional[bool] = None, enabled: Optional[bool] = None,
condition_logic: Optional[str] = None, condition_logic: Optional[str] = None,
conditions: Optional[List[Condition]] = None, conditions: Optional[List[Condition]] = None,
target_ids: Optional[List[str]] = None, scene_preset_id: str = "__unset__",
deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__",
) -> Profile: ) -> Profile:
if profile_id not in self._profiles: if profile_id not in self._profiles:
raise ValueError(f"Profile not found: {profile_id}") raise ValueError(f"Profile not found: {profile_id}")
@@ -125,8 +131,12 @@ class ProfileStore:
profile.condition_logic = condition_logic profile.condition_logic = condition_logic
if conditions is not None: if conditions is not None:
profile.conditions = conditions profile.conditions = conditions
if target_ids is not None: if scene_preset_id != "__unset__":
profile.target_ids = target_ids profile.scene_preset_id = scene_preset_id
if deactivation_mode is not None:
profile.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
profile.deactivation_scene_preset_id = deactivation_scene_preset_id
profile.updated_at = datetime.utcnow() profile.updated_at = datetime.utcnow()
self._save() self._save()

View File

@@ -56,11 +56,47 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label data-i18n="profiles.targets">Targets:</label> <label data-i18n="profiles.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="profiles.targets.hint">Targets to start when this profile activates</small> <small class="input-hint" style="display:none" data-i18n="profiles.scene.hint">Scene preset to activate when conditions are met</small>
<div id="profile-targets-list" class="profile-targets-checklist"></div> <div id="profile-scene-selector" class="scene-selector">
<input type="hidden" id="profile-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="profile-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="profiles.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="profile-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="profile-scene-dropdown"></div>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="profile-deactivation-mode" data-i18n="profiles.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_mode.hint">What happens when conditions stop matching</small>
<select id="profile-deactivation-mode">
<option value="none" data-i18n="profiles.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="profiles.deactivation_mode.revert">Revert to previous state</option>
<option value="fallback_scene" data-i18n="profiles.deactivation_mode.fallback_scene">Activate fallback scene</option>
</select>
</div>
<div class="form-group" id="profile-fallback-scene-group" style="display:none">
<div class="label-row">
<label data-i18n="profiles.deactivation_scene">Fallback Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_scene.hint">Scene to activate when this profile deactivates</small>
<div id="profile-fallback-scene-selector" class="scene-selector">
<input type="hidden" id="profile-fallback-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="profile-fallback-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="profiles.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="profile-fallback-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="profile-fallback-scene-dropdown"></div>
</div>
</div> </div>
<div id="profile-editor-error" class="error-message" style="display: none;"></div> <div id="profile-editor-error" class="error-message" style="display: none;"></div>