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.dependencies import (
get_picture_target_store,
get_profile_engine,
get_profile_store,
get_scene_preset_store,
)
from wled_controller.api.schemas.profiles import (
ConditionSchema,
@@ -16,7 +16,6 @@ from wled_controller.api.schemas.profiles import (
ProfileUpdate,
)
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile import (
AlwaysCondition,
ApplicationCondition,
@@ -27,6 +26,7 @@ from wled_controller.storage.profile import (
TimeOfDayCondition,
)
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -79,9 +79,10 @@ def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
enabled=profile.enabled,
condition_logic=profile.condition_logic,
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"],
active_target_ids=state["active_target_ids"],
last_activated_at=state.get("last_activated_at"),
last_deactivated_at=state.get("last_deactivated_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'.")
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None:
for tid in target_ids:
try:
target_store.get_target(tid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Target not found: {tid}")
def _validate_scene_refs(
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:
scene_store.get_preset(sid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
# ===== CRUD Endpoints =====
@@ -115,11 +125,11 @@ async def create_profile(
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
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."""
_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:
conditions = [_condition_from_schema(c) for c in data.conditions]
@@ -131,7 +141,9 @@ async def create_profile(
enabled=data.enabled,
condition_logic=data.condition_logic,
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:
@@ -189,13 +201,14 @@ async def update_profile(
_auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store),
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."""
if data.condition_logic is not None:
_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
if data.conditions is not None:
@@ -209,18 +222,25 @@ async def update_profile(
if data.enabled is False:
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,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
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:
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:
await engine.trigger_evaluate()

View File

@@ -22,15 +22,14 @@ from wled_controller.api.schemas.scene_presets import (
ScenePresetUpdate,
)
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.picture_target_store import PictureTargetStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset import (
DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset,
TargetSnapshot,
)
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import get_logger
@@ -39,45 +38,6 @@ logger = get_logger(__name__)
router = APIRouter()
# ===== Helpers =====
def _capture_snapshot(
target_store: PictureTargetStore,
device_store: DeviceStore,
profile_store: ProfileStore,
processor_manager: ProcessorManager,
) -> tuple:
"""Capture current system state as snapshot lists."""
targets = []
for t in target_store.get_all_targets():
proc = processor_manager._processors.get(t.id)
running = proc.is_running if proc else False
targets.append(TargetSnapshot(
target_id=t.id,
running=running,
color_strip_source_id=getattr(t, "color_strip_source_id", ""),
brightness_value_source_id=getattr(t, "brightness_value_source_id", ""),
fps=getattr(t, "fps", 30),
auto_start=getattr(t, "auto_start", False),
))
devices = []
for d in device_store.get_all_devices():
devices.append(DeviceBrightnessSnapshot(
device_id=d.id,
software_brightness=getattr(d, "software_brightness", 255),
))
profiles = []
for p in profile_store.get_all_profiles():
profiles.append(ProfileSnapshot(
profile_id=p.id,
enabled=p.enabled,
))
return targets, devices, profiles
def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
return ScenePresetResponse(
id=preset.id,
@@ -124,7 +84,7 @@ async def create_scene_preset(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""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,
)
@@ -244,7 +204,7 @@ async def recapture_scene_preset(
manager: ProcessorManager = Depends(get_processor_manager),
):
"""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,
)
@@ -287,101 +247,11 @@ async def activate_scene_preset(
except ValueError as 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
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:
if not errors:
logger.info(f"Scene preset '{preset.name}' activated successfully")
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")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
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):
@@ -44,7 +46,9 @@ class ProfileUpdate(BaseModel):
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
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):
@@ -55,9 +59,10 @@ class ProfileResponse(BaseModel):
enabled: bool = Field(description="Whether the profile is enabled")
condition_logic: str = Field(description="Condition combination logic")
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")
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_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
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 re
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.storage.profile import (
@@ -17,27 +17,41 @@ from wled_controller.storage.profile import (
TimeOfDayCondition,
)
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import get_logger
logger = get_logger(__name__)
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,
mqtt_service=None):
def __init__(
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._manager = processor_manager
self._poll_interval = poll_interval
self._detector = PlatformDetector()
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._eval_lock = asyncio.Lock()
# Runtime state (not persisted)
# profile_id → set of target_ids that THIS profile started
self._active_profiles: Dict[str, Set[str]] = {}
# profile_id → True when profile is currently active
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
self._last_activated: 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)
async def _activate_profile(self, profile: Profile) -> None:
started: Set[str] = set()
failed = False
for target_id in profile.target_ids:
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
if not profile.scene_preset_id:
# No scene configured — just mark active (conditions matched but nothing to do)
self._active_profiles[profile.id] = True
self._last_activated[profile.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated", list(started))
logger.info(f"Profile '{profile.name}' activated ({len(started)} targets started)")
self._fire_event(profile.id, "activated")
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:
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:
owned = self._active_profiles.pop(profile_id, set())
stopped = []
was_active = self._active_profiles.pop(profile_id, False)
if not was_active:
return
for target_id in owned:
try:
proc = self._manager._processors.get(target_id)
if proc and proc.is_running:
await self._manager.stop_processing(target_id)
stopped.append(target_id)
logger.info(f"Profile {profile_id} stopped target {target_id}")
except Exception as e:
logger.warning(f"Profile {profile_id} failed to stop target {target_id}: {e}")
# Look up the profile for deactivation settings
try:
profile = self._store.get_profile(profile_id)
except ValueError:
profile = None
if stopped:
self._last_deactivated[profile_id] = datetime.now(timezone.utc)
self._fire_event(profile_id, "deactivated", stopped)
logger.info(f"Profile {profile_id} deactivated ({len(stopped)} targets stopped)")
deactivation_mode = profile.deactivation_mode if profile else "none"
def _fire_event(self, profile_id: str, action: str, target_ids: list) -> None:
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")
self._last_deactivated[profile_id] = datetime.now(timezone.utc)
self._fire_event(profile_id, "deactivated")
# Clean up any leftover snapshot
self._pre_activation_snapshots.pop(profile_id, None)
def _fire_event(self, profile_id: str, action: str) -> None:
try:
self._manager._fire_event({
"type": "profile_state_changed",
"profile_id": profile_id,
"action": action,
"target_ids": target_ids,
})
except Exception:
pass
@@ -334,10 +407,8 @@ class ProfileEngine:
def get_profile_state(self, profile_id: str) -> dict:
"""Get runtime state of a single profile."""
is_active = profile_id in self._active_profiles
owned = list(self._active_profiles.get(profile_id, set()))
return {
"is_active": is_active,
"active_target_ids": owned,
"last_activated_at": self._last_activated.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)
set_mqtt_service(mqtt_service)
# Create profile engine (needs processor_manager + mqtt_service)
profile_engine = ProfileEngine(profile_store, processor_manager, mqtt_service=mqtt_service)
# Create profile engine (needs processor_manager + mqtt_service + stores for scene activation)
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
auto_backup_engine = AutoBackupEngine(

View File

@@ -174,28 +174,99 @@
text-align: center;
}
/* Profile target checklist */
.profile-targets-checklist {
/* Scene selector (searchable combobox) */
.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;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px;
border-top: none;
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;
align-items: center;
gap: 8px;
padding: 4px 6px;
cursor: pointer;
border-radius: 3px;
gap: 6px;
transition: background 0.1s;
}
.profile-target-item:hover {
background: var(--bg-secondary, var(--bg-color));
.scene-selector-item:hover {
background: rgba(33, 150, 243, 0.15);
}
.profile-target-item input[type="checkbox"] {
margin: 0;
.scene-selector-item.selected {
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 {
loadProfiles, openProfileEditor, closeProfileEditorModal,
saveProfileEditor, addProfileCondition,
toggleProfileEnabled, toggleProfileTargets, deleteProfile,
toggleProfileEnabled, deleteProfile,
expandAllProfileSections, collapseAllProfileSections,
} from './features/profiles.js';
import {
@@ -307,7 +307,6 @@ Object.assign(window, {
saveProfileEditor,
addProfileCondition,
toggleProfileEnabled,
toggleProfileTargets,
deleteProfile,
expandAllProfileSections,
collapseAllProfileSections,

View File

@@ -11,7 +11,7 @@ import { startAutoRefresh, updateTabBadge } from './tabs.js';
import {
getTargetTypeIcon,
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';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
@@ -269,12 +269,6 @@ function _updateProfilesInPlace(profiles) {
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');
if (btn) {
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 inactiveProfiles = profiles.filter(p => !p.is_active);
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">
${_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 isDisabled = !profile.enabled;
@@ -693,9 +688,9 @@ function renderDashboardProfile(profile) {
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length;
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
// 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');
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">
@@ -703,15 +698,10 @@ function renderDashboardProfile(profile) {
<div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
</div>
${statusBadge}
</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">
<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}

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';
@@ -9,7 +9,10 @@ import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { CardSection } from '../core/card-sections.js';
import { updateTabBadge } from './tabs.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_TARGET, ICON_PROFILE, ICON_HELP, ICON_OK, 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 {
constructor() { super('profile-editor-modal'); }
@@ -20,7 +23,9 @@ class ProfileEditorModal extends Modal {
enabled: document.getElementById('profile-editor-enabled').checked.toString(),
logic: document.getElementById('profile-editor-logic').value,
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);
try {
const [profilesResp, targetsResp] = await Promise.all([
const [profilesResp, scenesResp] = await Promise.all([
fetchWithAuth('/profiles'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/scene-presets'),
]);
if (!profilesResp.ok) throw new Error('Failed to load profiles');
const data = await profilesResp.json();
const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] };
const allTargets = targetsData.targets || [];
// Batch fetch all target states in a single request
const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states');
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const runningTargetIds = new Set(
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
);
const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] };
_scenesCache = scenesData.presets || [];
// Build scene name map for card rendering
const sceneMap = new Map(_scenesCache.map(s => [s.id, s]));
set_profilesCache(data.profiles);
const activeCount = data.profiles.filter(p => p.is_active).length;
updateTabBadge('profiles', activeCount);
renderProfiles(data.profiles, runningTargetIds);
renderProfiles(data.profiles, sceneMap);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load profiles:', error);
@@ -84,22 +87,21 @@ export function collapseAllProfileSections() {
CardSection.collapseAll([csProfiles]);
}
function renderProfiles(profiles, runningTargetIds = new Set()) {
function renderProfiles(profiles, sceneMap) {
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>`;
container.innerHTML = toolbar + csProfiles.render(items);
csProfiles.bind();
// Localize data-i18n elements within the profiles container only
// (calling global updateAllText() would trigger loadProfiles() again → infinite loop)
container.querySelectorAll('[data-i18n]').forEach(el => {
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 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>`);
}
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 = '';
if (profile.last_activated_at) {
@@ -157,20 +171,13 @@ function createProfileCard(profile, runningTargetIds = new Set()) {
</div>
<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">${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}
</div>
<div class="stream-card-props">${condPills}</div>
<div class="card-actions">
<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')}">
${profile.enabled ? ICON_PAUSE : ICON_START}
</button>
@@ -191,7 +198,18 @@ export async function openProfileEditor(profileId) {
errorEl.style.display = 'none';
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) {
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`;
@@ -209,7 +227,13 @@ export async function openProfileEditor(profileId) {
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) {
showToast(e.message, 'error');
return;
@@ -220,44 +244,132 @@ export async function openProfileEditor(profileId) {
nameInput.value = '';
enabledInput.checked = true;
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();
modal.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
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() {
await profileModal.close();
}
async function loadProfileTargetChecklist(selectedIds) {
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 || [];
// ===== Scene selector logic =====
if (targets.length === 0) {
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
return;
function _initSceneSelector(prefix, selectedId) {
const hiddenInput = document.getElementById(`${prefix}-id`);
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);
}
// Render dropdown items
function renderDropdown(filter) {
const query = (filter || '').toLowerCase();
const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache;
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('');
}
container.innerHTML = targets.map(tgt => {
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
return `<label class="profile-target-item">
<input type="checkbox" value="${tgt.id}" ${checked}>
<span>${escapeHtml(tgt.name)}</span>
</label>`;
}).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() {
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
@@ -444,7 +556,7 @@ function renderProcessPicker(picker, processes, existing) {
}
listEl.innerHTML = processes.map(p => {
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('');
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
@@ -455,7 +567,7 @@ function renderProcessPicker(picker, processes, existing) {
const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc;
item.classList.add('added');
item.textContent = proc + ' ';
item.textContent = proc + ' \u2713';
picker._existing.add(proc.toLowerCase());
});
});
@@ -509,11 +621,6 @@ function getProfileEditorConditions() {
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() {
const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name');
@@ -531,7 +638,9 @@ export async function saveProfileEditor() {
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
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;
@@ -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) {
try {
const action = enable ? 'enable' : 'disable';

View File

@@ -593,9 +593,18 @@
"profiles.condition.mqtt.match_mode.contains": "Contains",
"profiles.condition.mqtt.match_mode.regex": "Regex",
"profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"profiles.targets": "Targets:",
"profiles.targets.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available",
"profiles.scene": "Scene:",
"profiles.scene.hint": "Scene preset to activate when conditions are met",
"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.inactive": "Inactive",
"profiles.status.disabled": "Disabled",
@@ -609,8 +618,6 @@
"profiles.created": "Profile created",
"profiles.deleted": "Profile deleted",
"profiles.error.name_required": "Name is required",
"profiles.toggle_all.start": "Start all targets",
"profiles.toggle_all.stop": "Stop all targets",
"scenes.title": "Scenes",
"scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene",

View File

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

View File

@@ -593,9 +593,18 @@
"profiles.condition.mqtt.match_mode.contains": "包含",
"profiles.condition.mqtt.match_mode.regex": "正则表达式",
"profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"profiles.targets": "目标",
"profiles.targets.hint": "配置文件激活时要启动的目标",
"profiles.targets.empty": "没有可用的目标",
"profiles.scene": "场景",
"profiles.scene.hint": "条件满足时激活的场景预设",
"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.inactive": "非活动",
"profiles.status.disabled": "已禁用",
@@ -609,8 +618,6 @@
"profiles.created": "配置文件已创建",
"profiles.deleted": "配置文件已删除",
"profiles.error.name_required": "名称为必填项",
"profiles.toggle_all.start": "启动所有目标",
"profiles.toggle_all.stop": "停止所有目标",
"scenes.title": "场景",
"scenes.add": "捕获场景",
"scenes.edit": "编辑场景",

View File

@@ -160,14 +160,16 @@ class MQTTCondition(Condition):
@dataclass
class Profile:
"""Automation profile that activates targets based on conditions."""
"""Automation profile that activates a scene preset based on conditions."""
id: str
name: str
enabled: bool
condition_logic: str # "or" | "and"
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
updated_at: datetime
@@ -178,7 +180,9 @@ class Profile:
"enabled": self.enabled,
"condition_logic": self.condition_logic,
"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(),
"updated_at": self.updated_at.isoformat(),
}
@@ -198,7 +202,9 @@ class Profile:
enabled=data.get("enabled", True),
condition_logic=data.get("condition_logic", "or"),
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())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

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

View File

@@ -56,11 +56,47 @@
<div class="form-group">
<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>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.targets.hint">Targets to start when this profile activates</small>
<div id="profile-targets-list" class="profile-targets-checklist"></div>
<small class="input-hint" style="display:none" data-i18n="profiles.scene.hint">Scene preset to activate when conditions are met</small>
<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 id="profile-editor-error" class="error-message" style="display: none;"></div>