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")