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:
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
8
server/src/wled_controller/core/scenes/__init__.py
Normal file
8
server/src/wled_controller/core/scenes/__init__.py
Normal 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"]
|
||||||
186
server/src/wled_controller/core/scenes/scene_activator.py
Normal file
186
server/src/wled_controller/core/scenes/scene_activator.py
Normal 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
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}">●</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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Редактировать сцену",
|
||||||
|
|||||||
@@ -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": "编辑场景",
|
||||||
|
|||||||
@@ -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())),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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">×</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">×</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user