Rename profiles to automations across backend and frontend

Rename the "profiles" entity to "automations" throughout the entire
codebase for clarity. Updates Python models, storage, API routes/schemas,
engine, frontend JS modules, HTML templates, CSS classes, i18n keys
(en/ru/zh), dashboard, tutorials, and command palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 18:01:39 +03:00
parent da3e53e1f1
commit 21248e2dc9
39 changed files with 1180 additions and 1179 deletions

View File

@@ -14,7 +14,7 @@ from .routes.audio import router as audio_router
from .routes.audio_sources import router as audio_sources_router from .routes.audio_sources import router as audio_sources_router
from .routes.audio_templates import router as audio_templates_router from .routes.audio_templates import router as audio_templates_router
from .routes.value_sources import router as value_sources_router from .routes.value_sources import router as value_sources_router
from .routes.profiles import router as profiles_router from .routes.automations import router as automations_router
from .routes.scene_presets import router as scene_presets_router from .routes.scene_presets import router as scene_presets_router
router = APIRouter() router = APIRouter()
@@ -30,7 +30,7 @@ router.include_router(audio_sources_router)
router.include_router(audio_templates_router) router.include_router(audio_templates_router)
router.include_router(value_sources_router) router.include_router(value_sources_router)
router.include_router(picture_targets_router) router.include_router(picture_targets_router)
router.include_router(profiles_router) router.include_router(automations_router)
router.include_router(scene_presets_router) router.include_router(scene_presets_router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -11,9 +11,9 @@ from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_template_store import AudioTemplateStore
from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.automation_store import AutomationStore
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.automations.automation_engine import AutomationEngine
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
# Global instances (initialized in main.py) # Global instances (initialized in main.py)
@@ -29,9 +29,9 @@ _audio_source_store: AudioSourceStore | None = None
_audio_template_store: AudioTemplateStore | None = None _audio_template_store: AudioTemplateStore | None = None
_value_source_store: ValueSourceStore | None = None _value_source_store: ValueSourceStore | None = None
_processor_manager: ProcessorManager | None = None _processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None _automation_store: AutomationStore | None = None
_scene_preset_store: ScenePresetStore | None = None _scene_preset_store: ScenePresetStore | None = None
_profile_engine: ProfileEngine | None = None _automation_engine: AutomationEngine | None = None
def get_device_store() -> DeviceStore: def get_device_store() -> DeviceStore:
@@ -111,11 +111,11 @@ def get_processor_manager() -> ProcessorManager:
return _processor_manager return _processor_manager
def get_profile_store() -> ProfileStore: def get_automation_store() -> AutomationStore:
"""Get profile store dependency.""" """Get automation store dependency."""
if _profile_store is None: if _automation_store is None:
raise RuntimeError("Profile store not initialized") raise RuntimeError("Automation store not initialized")
return _profile_store return _automation_store
def get_scene_preset_store() -> ScenePresetStore: def get_scene_preset_store() -> ScenePresetStore:
@@ -125,11 +125,11 @@ def get_scene_preset_store() -> ScenePresetStore:
return _scene_preset_store return _scene_preset_store
def get_profile_engine() -> ProfileEngine: def get_automation_engine() -> AutomationEngine:
"""Get profile engine dependency.""" """Get automation engine dependency."""
if _profile_engine is None: if _automation_engine is None:
raise RuntimeError("Profile engine not initialized") raise RuntimeError("Automation engine not initialized")
return _profile_engine return _automation_engine
def get_auto_backup_engine() -> AutoBackupEngine: def get_auto_backup_engine() -> AutoBackupEngine:
@@ -151,16 +151,16 @@ def init_dependencies(
audio_source_store: AudioSourceStore | None = None, audio_source_store: AudioSourceStore | None = None,
audio_template_store: AudioTemplateStore | None = None, audio_template_store: AudioTemplateStore | None = None,
value_source_store: ValueSourceStore | None = None, value_source_store: ValueSourceStore | None = None,
profile_store: ProfileStore | None = None, automation_store: AutomationStore | None = None,
scene_preset_store: ScenePresetStore | None = None, scene_preset_store: ScenePresetStore | None = None,
profile_engine: ProfileEngine | None = None, automation_engine: AutomationEngine | None = None,
auto_backup_engine: AutoBackupEngine | None = None, auto_backup_engine: AutoBackupEngine | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _color_strip_store, _audio_source_store, _audio_template_store global _color_strip_store, _audio_source_store, _audio_template_store
global _value_source_store, _profile_store, _scene_preset_store, _profile_engine, _auto_backup_engine global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
_device_store = device_store _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -172,7 +172,7 @@ def init_dependencies(
_audio_source_store = audio_source_store _audio_source_store = audio_source_store
_audio_template_store = audio_template_store _audio_template_store = audio_template_store
_value_source_store = value_source_store _value_source_store = value_source_store
_profile_store = profile_store _automation_store = automation_store
_scene_preset_store = scene_preset_store _scene_preset_store = scene_preset_store
_profile_engine = profile_engine _automation_engine = automation_engine
_auto_backup_engine = auto_backup_engine _auto_backup_engine = auto_backup_engine

View File

@@ -1,22 +1,22 @@
"""Profile management API routes.""" """Automation management API routes."""
from fastapi import APIRouter, Depends, HTTPException 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_profile_engine, get_automation_engine,
get_profile_store, get_automation_store,
get_scene_preset_store, get_scene_preset_store,
) )
from wled_controller.api.schemas.profiles import ( from wled_controller.api.schemas.automations import (
AutomationCreate,
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
ConditionSchema, ConditionSchema,
ProfileCreate,
ProfileListResponse,
ProfileResponse,
ProfileUpdate,
) )
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.profile import ( from wled_controller.storage.automation import (
AlwaysCondition, AlwaysCondition,
ApplicationCondition, ApplicationCondition,
Condition, Condition,
@@ -25,7 +25,7 @@ from wled_controller.storage.profile import (
SystemIdleCondition, SystemIdleCondition,
TimeOfDayCondition, TimeOfDayCondition,
) )
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -71,22 +71,22 @@ def _condition_to_schema(c: Condition) -> ConditionSchema:
return ConditionSchema(**d) return ConditionSchema(**d)
def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse: def _automation_to_response(automation, engine: AutomationEngine) -> AutomationResponse:
state = engine.get_profile_state(profile.id) state = engine.get_automation_state(automation.id)
return ProfileResponse( return AutomationResponse(
id=profile.id, id=automation.id,
name=profile.name, name=automation.name,
enabled=profile.enabled, enabled=automation.enabled,
condition_logic=profile.condition_logic, condition_logic=automation.condition_logic,
conditions=[_condition_to_schema(c) for c in profile.conditions], conditions=[_condition_to_schema(c) for c in automation.conditions],
scene_preset_id=profile.scene_preset_id, scene_preset_id=automation.scene_preset_id,
deactivation_mode=profile.deactivation_mode, deactivation_mode=automation.deactivation_mode,
deactivation_scene_preset_id=profile.deactivation_scene_preset_id, deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
is_active=state["is_active"], is_active=state["is_active"],
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=automation.created_at,
updated_at=profile.updated_at, updated_at=automation.updated_at,
) )
@@ -115,19 +115,19 @@ def _validate_scene_refs(
# ===== CRUD Endpoints ===== # ===== CRUD Endpoints =====
@router.post( @router.post(
"/api/v1/profiles", "/api/v1/automations",
response_model=ProfileResponse, response_model=AutomationResponse,
tags=["Profiles"], tags=["Automations"],
status_code=201, status_code=201,
) )
async def create_profile( async def create_automation(
data: ProfileCreate, data: AutomationCreate,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store), scene_store: ScenePresetStore = Depends(get_scene_preset_store),
): ):
"""Create a new profile.""" """Create a new automation."""
_validate_condition_logic(data.condition_logic) _validate_condition_logic(data.condition_logic)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store) _validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
@@ -136,7 +136,7 @@ async def create_profile(
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
profile = store.create_profile( automation = store.create_automation(
name=data.name, name=data.name,
enabled=data.enabled, enabled=data.enabled,
condition_logic=data.condition_logic, condition_logic=data.condition_logic,
@@ -146,64 +146,64 @@ async def create_profile(
deactivation_scene_preset_id=data.deactivation_scene_preset_id, deactivation_scene_preset_id=data.deactivation_scene_preset_id,
) )
if profile.enabled: if automation.enabled:
await engine.trigger_evaluate() await engine.trigger_evaluate()
return _profile_to_response(profile, engine) return _automation_to_response(automation, engine)
@router.get( @router.get(
"/api/v1/profiles", "/api/v1/automations",
response_model=ProfileListResponse, response_model=AutomationListResponse,
tags=["Profiles"], tags=["Automations"],
) )
async def list_profiles( async def list_automations(
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""List all profiles.""" """List all automations."""
profiles = store.get_all_profiles() automations = store.get_all_automations()
return ProfileListResponse( return AutomationListResponse(
profiles=[_profile_to_response(p, engine) for p in profiles], automations=[_automation_to_response(a, engine) for a in automations],
count=len(profiles), count=len(automations),
) )
@router.get( @router.get(
"/api/v1/profiles/{profile_id}", "/api/v1/automations/{automation_id}",
response_model=ProfileResponse, response_model=AutomationResponse,
tags=["Profiles"], tags=["Automations"],
) )
async def get_profile( async def get_automation(
profile_id: str, automation_id: str,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""Get a single profile.""" """Get a single automation."""
try: try:
profile = store.get_profile(profile_id) automation = store.get_automation(automation_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine) return _automation_to_response(automation, engine)
@router.put( @router.put(
"/api/v1/profiles/{profile_id}", "/api/v1/automations/{automation_id}",
response_model=ProfileResponse, response_model=AutomationResponse,
tags=["Profiles"], tags=["Automations"],
) )
async def update_profile( async def update_automation(
profile_id: str, automation_id: str,
data: ProfileUpdate, data: AutomationUpdate,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
scene_store: ScenePresetStore = Depends(get_scene_preset_store), scene_store: ScenePresetStore = Depends(get_scene_preset_store),
): ):
"""Update a profile.""" """Update an automation."""
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)
@@ -220,11 +220,11 @@ async def update_profile(
try: try:
# If disabling, deactivate first # If disabling, deactivate first
if data.enabled is False: if data.enabled is False:
await engine.deactivate_if_active(profile_id) await engine.deactivate_if_active(automation_id)
# Build update kwargs — use sentinel for Optional[str] fields # Build update kwargs — use sentinel for Optional[str] fields
update_kwargs = dict( update_kwargs = dict(
profile_id=profile_id, automation_id=automation_id,
name=data.name, name=data.name,
enabled=data.enabled, enabled=data.enabled,
condition_logic=data.condition_logic, condition_logic=data.condition_logic,
@@ -236,34 +236,34 @@ async def update_profile(
if data.deactivation_scene_preset_id is not None: if data.deactivation_scene_preset_id is not None:
update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id update_kwargs["deactivation_scene_preset_id"] = data.deactivation_scene_preset_id
profile = store.update_profile(**update_kwargs) automation = store.update_automation(**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/scene) # Re-evaluate immediately if automation is enabled (may have new conditions/scene)
if profile.enabled: if automation.enabled:
await engine.trigger_evaluate() await engine.trigger_evaluate()
return _profile_to_response(profile, engine) return _automation_to_response(automation, engine)
@router.delete( @router.delete(
"/api/v1/profiles/{profile_id}", "/api/v1/automations/{automation_id}",
status_code=204, status_code=204,
tags=["Profiles"], tags=["Automations"],
) )
async def delete_profile( async def delete_automation(
profile_id: str, automation_id: str,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""Delete a profile.""" """Delete an automation."""
# Deactivate first (stop owned targets) # Deactivate first
await engine.deactivate_if_active(profile_id) await engine.deactivate_if_active(automation_id)
try: try:
store.delete_profile(profile_id) store.delete_automation(automation_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
@@ -271,45 +271,45 @@ async def delete_profile(
# ===== Enable/Disable ===== # ===== Enable/Disable =====
@router.post( @router.post(
"/api/v1/profiles/{profile_id}/enable", "/api/v1/automations/{automation_id}/enable",
response_model=ProfileResponse, response_model=AutomationResponse,
tags=["Profiles"], tags=["Automations"],
) )
async def enable_profile( async def enable_automation(
profile_id: str, automation_id: str,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""Enable a profile.""" """Enable an automation."""
try: try:
profile = store.update_profile(profile_id=profile_id, enabled=True) automation = store.update_automation(automation_id=automation_id, enabled=True)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
# Evaluate immediately so targets start without waiting for the next poll cycle # Evaluate immediately so scene activates without waiting for the next poll cycle
await engine.trigger_evaluate() await engine.trigger_evaluate()
return _profile_to_response(profile, engine) return _automation_to_response(automation, engine)
@router.post( @router.post(
"/api/v1/profiles/{profile_id}/disable", "/api/v1/automations/{automation_id}/disable",
response_model=ProfileResponse, response_model=AutomationResponse,
tags=["Profiles"], tags=["Automations"],
) )
async def disable_profile( async def disable_automation(
profile_id: str, automation_id: str,
_auth: AuthRequired, _auth: AuthRequired,
store: ProfileStore = Depends(get_profile_store), store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
): ):
"""Disable a profile and stop any targets it owns.""" """Disable an automation and deactivate it."""
await engine.deactivate_if_active(profile_id) await engine.deactivate_if_active(automation_id)
try: try:
profile = store.update_profile(profile_id=profile_id, enabled=False) automation = store.update_automation(automation_id=automation_id, enabled=False)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
return _profile_to_response(profile, engine) return _automation_to_response(automation, engine)

View File

@@ -7,11 +7,11 @@ from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import ( from wled_controller.api.dependencies import (
get_automation_engine,
get_automation_store,
get_device_store, get_device_store,
get_picture_target_store, get_picture_target_store,
get_processor_manager, get_processor_manager,
get_profile_engine,
get_profile_store,
get_scene_preset_store, get_scene_preset_store,
) )
from wled_controller.api.schemas.scene_presets import ( from wled_controller.api.schemas.scene_presets import (
@@ -28,10 +28,10 @@ from wled_controller.core.scenes.scene_activator import (
) )
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.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.scene_preset_store import ScenePresetStore
from wled_controller.core.profiles.profile_engine import ProfileEngine from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -56,10 +56,10 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse:
"device_id": d.device_id, "device_id": d.device_id,
"software_brightness": d.software_brightness, "software_brightness": d.software_brightness,
} for d in preset.devices], } for d in preset.devices],
profiles=[{ automations=[{
"profile_id": p.profile_id, "automation_id": a.automation_id,
"enabled": p.enabled, "enabled": a.enabled,
} for p in preset.profiles], } for a in preset.automations],
order=preset.order, order=preset.order,
created_at=preset.created_at, created_at=preset.created_at,
updated_at=preset.updated_at, updated_at=preset.updated_at,
@@ -80,12 +80,12 @@ async def create_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store), target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store), automation_store: AutomationStore = Depends(get_automation_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Capture current state as a new scene preset.""" """Capture current state as a new scene preset."""
targets, devices, profiles = capture_current_snapshot( targets, devices, automations = capture_current_snapshot(
target_store, device_store, profile_store, manager, target_store, device_store, automation_store, manager,
) )
now = datetime.utcnow() now = datetime.utcnow()
@@ -96,7 +96,7 @@ async def create_scene_preset(
color=data.color, color=data.color,
targets=targets, targets=targets,
devices=devices, devices=devices,
profiles=profiles, automations=automations,
order=store.count(), order=store.count(),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -200,12 +200,12 @@ async def recapture_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store), target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store), automation_store: AutomationStore = Depends(get_automation_store),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Re-capture current state into an existing preset (updates snapshot).""" """Re-capture current state into an existing preset (updates snapshot)."""
targets, devices, profiles = capture_current_snapshot( targets, devices, automations = capture_current_snapshot(
target_store, device_store, profile_store, manager, target_store, device_store, automation_store, manager,
) )
new_snapshot = ScenePreset( new_snapshot = ScenePreset(
@@ -213,7 +213,7 @@ async def recapture_scene_preset(
name="", name="",
targets=targets, targets=targets,
devices=devices, devices=devices,
profiles=profiles, automations=automations,
) )
try: try:
@@ -237,8 +237,8 @@ async def activate_scene_preset(
store: ScenePresetStore = Depends(get_scene_preset_store), store: ScenePresetStore = Depends(get_scene_preset_store),
target_store: PictureTargetStore = Depends(get_picture_target_store), target_store: PictureTargetStore = Depends(get_picture_target_store),
device_store: DeviceStore = Depends(get_device_store), device_store: DeviceStore = Depends(get_device_store),
profile_store: ProfileStore = Depends(get_profile_store), automation_store: AutomationStore = Depends(get_automation_store),
engine: ProfileEngine = Depends(get_profile_engine), engine: AutomationEngine = Depends(get_automation_engine),
manager: ProcessorManager = Depends(get_processor_manager), manager: ProcessorManager = Depends(get_processor_manager),
): ):
"""Activate a scene preset — restore the captured state.""" """Activate a scene preset — restore the captured state."""
@@ -248,7 +248,7 @@ async def activate_scene_preset(
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
preset, target_store, device_store, profile_store, engine, manager, preset, target_store, device_store, automation_store, engine, manager,
) )
if not errors: if not errors:

View File

@@ -182,9 +182,9 @@ async def get_displays(
async def get_running_processes(_: AuthRequired): async def get_running_processes(_: AuthRequired):
"""Get list of currently running process names. """Get list of currently running process names.
Returns a sorted list of unique process names for use in profile conditions. Returns a sorted list of unique process names for use in automation conditions.
""" """
from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.core.automations.platform_detector import PlatformDetector
try: try:
detector = PlatformDetector() detector = PlatformDetector()
@@ -271,7 +271,7 @@ STORE_MAP = {
"audio_sources": "audio_sources_file", "audio_sources": "audio_sources_file",
"audio_templates": "audio_templates_file", "audio_templates": "audio_templates_file",
"value_sources": "value_sources_file", "value_sources": "value_sources_file",
"profiles": "profiles_file", "automations": "automations_file",
"scene_presets": "scene_presets_file", "scene_presets": "scene_presets_file",
} }

View File

@@ -1,4 +1,4 @@
"""Profile-related schemas.""" """Automation-related schemas."""
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
class ConditionSchema(BaseModel): class ConditionSchema(BaseModel):
"""A single condition within a profile.""" """A single condition within an automation."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')") condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields # Application condition fields
@@ -27,11 +27,11 @@ class ConditionSchema(BaseModel):
match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)") match_mode: Optional[str] = Field(None, description="'exact', 'contains', or 'regex' (for mqtt condition)")
class ProfileCreate(BaseModel): class AutomationCreate(BaseModel):
"""Request to create a profile.""" """Request to create an automation."""
name: str = Field(description="Profile name", min_length=1, max_length=100) name: str = Field(description="Automation name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the profile is enabled") enabled: bool = Field(default=True, description="Whether the automation 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")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
@@ -39,11 +39,11 @@ class ProfileCreate(BaseModel):
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
class ProfileUpdate(BaseModel): class AutomationUpdate(BaseModel):
"""Request to update a profile.""" """Request to update an automation."""
name: Optional[str] = Field(None, description="Profile name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled") enabled: Optional[bool] = Field(None, description="Whether the automation 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")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
@@ -51,26 +51,26 @@ class ProfileUpdate(BaseModel):
deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation")
class ProfileResponse(BaseModel): class AutomationResponse(BaseModel):
"""Profile information response.""" """Automation information response."""
id: str = Field(description="Profile ID") id: str = Field(description="Automation ID")
name: str = Field(description="Profile name") name: str = Field(description="Automation name")
enabled: bool = Field(description="Whether the profile is enabled") enabled: bool = Field(description="Whether the automation 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")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") 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 automation is currently active")
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 automation 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 automation was deactivated")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp") updated_at: datetime = Field(description="Last update timestamp")
class ProfileListResponse(BaseModel): class AutomationListResponse(BaseModel):
"""List of profiles response.""" """List of automations response."""
profiles: List[ProfileResponse] = Field(description="List of profiles") automations: List[AutomationResponse] = Field(description="List of automations")
count: int = Field(description="Number of profiles") count: int = Field(description="Number of automations")

View File

@@ -20,8 +20,8 @@ class DeviceBrightnessSnapshotSchema(BaseModel):
software_brightness: int = 255 software_brightness: int = 255
class ProfileSnapshotSchema(BaseModel): class AutomationSnapshotSchema(BaseModel):
profile_id: str automation_id: str
enabled: bool = True enabled: bool = True
@@ -51,7 +51,7 @@ class ScenePresetResponse(BaseModel):
color: str color: str
targets: List[TargetSnapshotSchema] targets: List[TargetSnapshotSchema]
devices: List[DeviceBrightnessSnapshotSchema] devices: List[DeviceBrightnessSnapshotSchema]
profiles: List[ProfileSnapshotSchema] automations: List[AutomationSnapshotSchema]
order: int order: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -37,7 +37,7 @@ class StorageConfig(BaseSettings):
audio_sources_file: str = "data/audio_sources.json" audio_sources_file: str = "data/audio_sources.json"
audio_templates_file: str = "data/audio_templates.json" audio_templates_file: str = "data/audio_templates.json"
value_sources_file: str = "data/value_sources.json" value_sources_file: str = "data/value_sources.json"
profiles_file: str = "data/profiles.json" automations_file: str = "data/automations.json"
scene_presets_file: str = "data/scene_presets.json" scene_presets_file: str = "data/scene_presets.json"

View File

@@ -0,0 +1 @@
"""Automation engine — condition evaluation and scene activation."""

View File

@@ -1,34 +1,34 @@
"""Profile engine — background loop that evaluates conditions and activates scenes.""" """Automation 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, List, Optional, Set from typing import Dict, List, Optional, Set
from wled_controller.core.profiles.platform_detector import PlatformDetector from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.profile import ( from wled_controller.storage.automation import (
AlwaysCondition, AlwaysCondition,
ApplicationCondition, ApplicationCondition,
Automation,
Condition, Condition,
DisplayStateCondition, DisplayStateCondition,
MQTTCondition, MQTTCondition,
Profile,
SystemIdleCondition, SystemIdleCondition,
TimeOfDayCondition, TimeOfDayCondition,
) )
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset from wled_controller.storage.scene_preset import ScenePreset
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class ProfileEngine: class AutomationEngine:
"""Evaluates profile conditions and activates/deactivates scene presets.""" """Evaluates automation conditions and activates/deactivates scene presets."""
def __init__( def __init__(
self, self,
profile_store: ProfileStore, automation_store: AutomationStore,
processor_manager, processor_manager,
poll_interval: float = 1.0, poll_interval: float = 1.0,
mqtt_service=None, mqtt_service=None,
@@ -36,7 +36,7 @@ class ProfileEngine:
target_store=None, target_store=None,
device_store=None, device_store=None,
): ):
self._store = profile_store self._store = automation_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()
@@ -48,11 +48,11 @@ class ProfileEngine:
self._eval_lock = asyncio.Lock() self._eval_lock = asyncio.Lock()
# Runtime state (not persisted) # Runtime state (not persisted)
# profile_id → True when profile is currently active # automation_id → True when automation is currently active
self._active_profiles: Dict[str, bool] = {} self._active_automations: Dict[str, bool] = {}
# profile_id → snapshot captured before activation (for "revert" mode) # automation_id → snapshot captured before activation (for "revert" mode)
self._pre_activation_snapshots: Dict[str, ScenePreset] = {} self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
# profile_id → datetime of last activation / deactivation # automation_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] = {}
@@ -60,7 +60,7 @@ class ProfileEngine:
if self._task is not None: if self._task is not None:
return return
self._task = asyncio.create_task(self._poll_loop()) self._task = asyncio.create_task(self._poll_loop())
logger.info("Profile engine started") logger.info("Automation engine started")
async def stop(self) -> None: async def stop(self) -> None:
if self._task is None: if self._task is None:
@@ -73,11 +73,11 @@ class ProfileEngine:
pass pass
self._task = None self._task = None
# Deactivate all profiles (stop owned targets) # Deactivate all automations
for profile_id in list(self._active_profiles.keys()): for automation_id in list(self._active_automations.keys()):
await self._deactivate_profile(profile_id) await self._deactivate_automation(automation_id)
logger.info("Profile engine stopped") logger.info("Automation engine stopped")
async def _poll_loop(self) -> None: async def _poll_loop(self) -> None:
try: try:
@@ -85,7 +85,7 @@ class ProfileEngine:
try: try:
await self._evaluate_all() await self._evaluate_all()
except Exception as e: except Exception as e:
logger.error(f"Profile evaluation error: {e}", exc_info=True) logger.error(f"Automation evaluation error: {e}", exc_info=True)
await asyncio.sleep(self._poll_interval) await asyncio.sleep(self._poll_interval)
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
@@ -115,20 +115,20 @@ class ProfileEngine:
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
async def _evaluate_all_locked(self) -> None: async def _evaluate_all_locked(self) -> None:
profiles = self._store.get_all_profiles() automations = self._store.get_all_automations()
if not profiles: if not automations:
# No profiles — deactivate any stale state # No automations — deactivate any stale state
for pid in list(self._active_profiles.keys()): for aid in list(self._active_automations.keys()):
await self._deactivate_profile(pid) await self._deactivate_automation(aid)
return return
# Determine which detection methods are actually needed # Determine which detection methods are actually needed
match_types_used: set = set() match_types_used: set = set()
needs_idle = False needs_idle = False
needs_display_state = False needs_display_state = False
for p in profiles: for a in automations:
if p.enabled: if a.enabled:
for c in p.conditions: for c in a.conditions:
if isinstance(c, ApplicationCondition): if isinstance(c, ApplicationCondition):
match_types_used.add(c.match_type) match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition): elif isinstance(c, SystemIdleCondition):
@@ -151,34 +151,34 @@ class ProfileEngine:
) )
) )
active_profile_ids = set() active_automation_ids = set()
for profile in profiles: for automation in automations:
should_be_active = ( should_be_active = (
profile.enabled automation.enabled
and (len(profile.conditions) == 0 and (len(automation.conditions) == 0
or self._evaluate_conditions( or self._evaluate_conditions(
profile, running_procs, topmost_proc, topmost_fullscreen, automation, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state)) fullscreen_procs, idle_seconds, display_state))
) )
is_active = profile.id in self._active_profiles is_active = automation.id in self._active_automations
if should_be_active and not is_active: if should_be_active and not is_active:
await self._activate_profile(profile) await self._activate_automation(automation)
active_profile_ids.add(profile.id) active_automation_ids.add(automation.id)
elif should_be_active and is_active: elif should_be_active and is_active:
active_profile_ids.add(profile.id) active_automation_ids.add(automation.id)
elif not should_be_active and is_active: elif not should_be_active and is_active:
await self._deactivate_profile(profile.id) await self._deactivate_automation(automation.id)
# Deactivate profiles that were removed from store while active # Deactivate automations that were removed from store while active
for pid in list(self._active_profiles.keys()): for aid in list(self._active_automations.keys()):
if pid not in active_profile_ids: if aid not in active_automation_ids:
await self._deactivate_profile(pid) await self._deactivate_automation(aid)
def _evaluate_conditions( def _evaluate_conditions(
self, profile: Profile, running_procs: Set[str], self, automation: Automation, running_procs: Set[str],
topmost_proc: Optional[str], topmost_fullscreen: bool, topmost_proc: Optional[str], topmost_fullscreen: bool,
fullscreen_procs: Set[str], fullscreen_procs: Set[str],
idle_seconds: Optional[float], display_state: Optional[str], idle_seconds: Optional[float], display_state: Optional[str],
@@ -188,10 +188,10 @@ class ProfileEngine:
c, running_procs, topmost_proc, topmost_fullscreen, c, running_procs, topmost_proc, topmost_fullscreen,
fullscreen_procs, idle_seconds, display_state, fullscreen_procs, idle_seconds, display_state,
) )
for c in profile.conditions for c in automation.conditions
] ]
if profile.condition_logic == "and": if automation.condition_logic == "and":
return all(results) return all(results)
return any(results) # "or" is default return any(results) # "or" is default
@@ -287,116 +287,116 @@ class ProfileEngine:
# Default: "running" # Default: "running"
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_automation(self, automation: Automation) -> None:
if not profile.scene_preset_id: if not automation.scene_preset_id:
# No scene configured — just mark active (conditions matched but nothing to do) # No scene configured — just mark active (conditions matched but nothing to do)
self._active_profiles[profile.id] = True self._active_automations[automation.id] = True
self._last_activated[profile.id] = datetime.now(timezone.utc) self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated") self._fire_event(automation.id, "activated")
logger.info(f"Profile '{profile.name}' activated (no scene configured)") logger.info(f"Automation '{automation.name}' activated (no scene configured)")
return return
if not self._scene_preset_store or not self._target_store or not self._device_store: 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") logger.warning(f"Automation '{automation.name}' matched but scene stores not available")
return return
# Load the scene preset # Load the scene preset
try: try:
preset = self._scene_preset_store.get_preset(profile.scene_preset_id) preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
except ValueError: except ValueError:
logger.warning(f"Profile '{profile.name}': scene preset {profile.scene_preset_id} not found") logger.warning(f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found")
return return
# For "revert" mode, capture current state before activating # For "revert" mode, capture current state before activating
if profile.deactivation_mode == "revert": if automation.deactivation_mode == "revert":
from wled_controller.core.scenes.scene_activator import capture_current_snapshot from wled_controller.core.scenes.scene_activator import capture_current_snapshot
targets, devices, profiles = capture_current_snapshot( targets, devices, automations = capture_current_snapshot(
self._target_store, self._device_store, self._store, self._manager, self._target_store, self._device_store, self._store, self._manager,
) )
self._pre_activation_snapshots[profile.id] = ScenePreset( self._pre_activation_snapshots[automation.id] = ScenePreset(
id=f"_revert_{profile.id}", id=f"_revert_{automation.id}",
name=f"Pre-activation snapshot for {profile.name}", name=f"Pre-activation snapshot for {automation.name}",
targets=targets, targets=targets,
devices=devices, devices=devices,
profiles=profiles, profiles=automations,
) )
# Apply the scene # Apply the scene
from wled_controller.core.scenes.scene_activator import apply_scene_state from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
preset, self._target_store, self._device_store, self._store, preset, self._target_store, self._device_store, self._store,
self, self._manager, skip_profiles=True, self, self._manager, skip_automations=True,
) )
self._active_profiles[profile.id] = True self._active_automations[automation.id] = True
self._last_activated[profile.id] = datetime.now(timezone.utc) self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(profile.id, "activated") self._fire_event(automation.id, "activated")
if errors: if errors:
logger.warning(f"Profile '{profile.name}' activated with errors: {errors}") logger.warning(f"Automation '{automation.name}' activated with errors: {errors}")
else: else:
logger.info(f"Profile '{profile.name}' activated (scene '{preset.name}' applied)") logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")
async def _deactivate_profile(self, profile_id: str) -> None: async def _deactivate_automation(self, automation_id: str) -> None:
was_active = self._active_profiles.pop(profile_id, False) was_active = self._active_automations.pop(automation_id, False)
if not was_active: if not was_active:
return return
# Look up the profile for deactivation settings # Look up the automation for deactivation settings
try: try:
profile = self._store.get_profile(profile_id) automation = self._store.get_automation(automation_id)
except ValueError: except ValueError:
profile = None automation = None
deactivation_mode = profile.deactivation_mode if profile else "none" deactivation_mode = automation.deactivation_mode if automation else "none"
if deactivation_mode == "revert": if deactivation_mode == "revert":
snapshot = self._pre_activation_snapshots.pop(profile_id, None) snapshot = self._pre_activation_snapshots.pop(automation_id, None)
if snapshot and self._target_store and self._device_store: if snapshot and self._target_store and self._device_store:
from wled_controller.core.scenes.scene_activator import apply_scene_state from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
snapshot, self._target_store, self._device_store, self._store, snapshot, self._target_store, self._device_store, self._store,
self, self._manager, skip_profiles=True, self, self._manager, skip_automations=True,
) )
if errors: if errors:
logger.warning(f"Profile {profile_id} revert errors: {errors}") logger.warning(f"Automation {automation_id} revert errors: {errors}")
else: else:
logger.info(f"Profile {profile_id} deactivated (reverted to previous state)") logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
else: else:
logger.warning(f"Profile {profile_id}: no snapshot available for revert") logger.warning(f"Automation {automation_id}: no snapshot available for revert")
elif deactivation_mode == "fallback_scene": elif deactivation_mode == "fallback_scene":
fallback_id = profile.deactivation_scene_preset_id if profile else None fallback_id = automation.deactivation_scene_preset_id if automation else None
if fallback_id and self._scene_preset_store and self._target_store and self._device_store: if fallback_id and self._scene_preset_store and self._target_store and self._device_store:
try: try:
fallback = self._scene_preset_store.get_preset(fallback_id) fallback = self._scene_preset_store.get_preset(fallback_id)
from wled_controller.core.scenes.scene_activator import apply_scene_state from wled_controller.core.scenes.scene_activator import apply_scene_state
status, errors = await apply_scene_state( status, errors = await apply_scene_state(
fallback, self._target_store, self._device_store, self._store, fallback, self._target_store, self._device_store, self._store,
self, self._manager, skip_profiles=True, self, self._manager, skip_automations=True,
) )
if errors: if errors:
logger.warning(f"Profile {profile_id} fallback errors: {errors}") logger.warning(f"Automation {automation_id} fallback errors: {errors}")
else: else:
logger.info(f"Profile {profile_id} deactivated (fallback scene '{fallback.name}' applied)") logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
except ValueError: except ValueError:
logger.warning(f"Profile {profile_id}: fallback scene {fallback_id} not found") logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
else: else:
logger.info(f"Profile {profile_id} deactivated (no fallback scene configured)") logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
else: else:
# "none" mode — just clear active state # "none" mode — just clear active state
logger.info(f"Profile {profile_id} deactivated") logger.info(f"Automation {automation_id} deactivated")
self._last_deactivated[profile_id] = datetime.now(timezone.utc) self._last_deactivated[automation_id] = datetime.now(timezone.utc)
self._fire_event(profile_id, "deactivated") self._fire_event(automation_id, "deactivated")
# Clean up any leftover snapshot # Clean up any leftover snapshot
self._pre_activation_snapshots.pop(profile_id, None) self._pre_activation_snapshots.pop(automation_id, None)
def _fire_event(self, profile_id: str, action: str) -> None: def _fire_event(self, automation_id: str, action: str) -> None:
try: try:
self._manager._fire_event({ self._manager._fire_event({
"type": "profile_state_changed", "type": "automation_state_changed",
"profile_id": profile_id, "automation_id": automation_id,
"action": action, "action": action,
}) })
except Exception: except Exception:
@@ -404,30 +404,30 @@ class ProfileEngine:
# ===== Public query methods (used by API) ===== # ===== Public query methods (used by API) =====
def get_profile_state(self, profile_id: str) -> dict: def get_automation_state(self, automation_id: str) -> dict:
"""Get runtime state of a single profile.""" """Get runtime state of a single automation."""
is_active = profile_id in self._active_profiles is_active = automation_id in self._active_automations
return { return {
"is_active": is_active, "is_active": is_active,
"last_activated_at": self._last_activated.get(profile_id), "last_activated_at": self._last_activated.get(automation_id),
"last_deactivated_at": self._last_deactivated.get(profile_id), "last_deactivated_at": self._last_deactivated.get(automation_id),
} }
def get_all_profile_states(self) -> Dict[str, dict]: def get_all_automation_states(self) -> Dict[str, dict]:
"""Get runtime states of all profiles.""" """Get runtime states of all automations."""
result = {} result = {}
for profile in self._store.get_all_profiles(): for automation in self._store.get_all_automations():
result[profile.id] = self.get_profile_state(profile.id) result[automation.id] = self.get_automation_state(automation.id)
return result return result
async def trigger_evaluate(self) -> None: async def trigger_evaluate(self) -> None:
"""Run a single evaluation cycle immediately (used after enabling a profile).""" """Run a single evaluation cycle immediately (used after enabling an automation)."""
try: try:
await self._evaluate_all() await self._evaluate_all()
except Exception as e: except Exception as e:
logger.error(f"Immediate profile evaluation error: {e}", exc_info=True) logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
async def deactivate_if_active(self, profile_id: str) -> None: async def deactivate_if_active(self, automation_id: str) -> None:
"""Deactivate a profile immediately (used when disabling/deleting).""" """Deactivate an automation immediately (used when disabling/deleting)."""
if profile_id in self._active_profiles: if automation_id in self._active_automations:
await self._deactivate_profile(profile_id) await self._deactivate_automation(automation_id)

View File

@@ -18,7 +18,7 @@ class MQTTService:
Features: Features:
- Publish messages (retained or transient) - Publish messages (retained or transient)
- Subscribe to topics with callback dispatch - Subscribe to topics with callback dispatch
- Topic value cache for synchronous reads (profile condition evaluation) - Topic value cache for synchronous reads (automation condition evaluation)
- Auto-reconnect loop - Auto-reconnect loop
- Birth / will messages for online status - Birth / will messages for online status
""" """
@@ -95,7 +95,7 @@ class MQTTService:
logger.warning(f"MQTT subscribe failed ({topic}): {e}") logger.warning(f"MQTT subscribe failed ({topic}): {e}")
def get_last_value(self, topic: str) -> Optional[str]: def get_last_value(self, topic: str) -> Optional[str]:
"""Get cached last value for a topic (synchronous — for profile evaluation).""" """Get cached last value for a topic (synchronous — for automation evaluation)."""
return self._topic_cache.get(topic) return self._topic_cache.get(topic)
async def _connection_loop(self) -> None: async def _connection_loop(self) -> None:
@@ -170,7 +170,7 @@ class MQTTService:
topic = f"{self._config.base_topic}/target/{target_id}/state" topic = f"{self._config.base_topic}/target/{target_id}/state"
await self.publish(topic, json.dumps(state), retain=True) await self.publish(topic, json.dumps(state), retain=True)
async def publish_profile_state(self, profile_id: str, action: str) -> None: async def publish_automation_state(self, automation_id: str, action: str) -> None:
"""Publish profile state change to MQTT.""" """Publish automation state change to MQTT."""
topic = f"{self._config.base_topic}/profile/{profile_id}/state" topic = f"{self._config.base_topic}/automation/{automation_id}/state"
await self.publish(topic, json.dumps({"action": action}), retain=True) await self.publish(topic, json.dumps({"action": action}), retain=True)

View File

@@ -1 +0,0 @@
"""Profile automation — condition evaluation and target management."""

View File

@@ -1,6 +1,6 @@
"""Reusable scene activation and snapshot capture logic. """Reusable scene activation and snapshot capture logic.
These functions are used by both the scene-presets API route and the profile engine. These functions are used by both the scene-presets API route and the automation engine.
""" """
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -8,10 +8,10 @@ from typing import List, Optional, Tuple
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore from wled_controller.storage 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.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ( from wled_controller.storage.scene_preset import (
AutomationSnapshot,
DeviceBrightnessSnapshot, DeviceBrightnessSnapshot,
ProfileSnapshot,
ScenePreset, ScenePreset,
TargetSnapshot, TargetSnapshot,
) )
@@ -23,12 +23,12 @@ logger = get_logger(__name__)
def capture_current_snapshot( def capture_current_snapshot(
target_store: PictureTargetStore, target_store: PictureTargetStore,
device_store: DeviceStore, device_store: DeviceStore,
profile_store: ProfileStore, automation_store: AutomationStore,
processor_manager: ProcessorManager, processor_manager: ProcessorManager,
) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[ProfileSnapshot]]: ) -> Tuple[List[TargetSnapshot], List[DeviceBrightnessSnapshot], List[AutomationSnapshot]]:
"""Capture current system state as snapshot lists. """Capture current system state as snapshot lists.
Returns (targets, devices, profiles) snapshot tuples. Returns (targets, devices, automations) snapshot tuples.
""" """
targets = [] targets = []
for t in target_store.get_all_targets(): for t in target_store.get_all_targets():
@@ -50,25 +50,25 @@ def capture_current_snapshot(
software_brightness=getattr(d, "software_brightness", 255), software_brightness=getattr(d, "software_brightness", 255),
)) ))
profiles = [] automations = []
for p in profile_store.get_all_profiles(): for a in automation_store.get_all_automations():
profiles.append(ProfileSnapshot( automations.append(AutomationSnapshot(
profile_id=p.id, automation_id=a.id,
enabled=p.enabled, enabled=a.enabled,
)) ))
return targets, devices, profiles return targets, devices, automations
async def apply_scene_state( async def apply_scene_state(
preset: ScenePreset, preset: ScenePreset,
target_store: PictureTargetStore, target_store: PictureTargetStore,
device_store: DeviceStore, device_store: DeviceStore,
profile_store: ProfileStore, automation_store: AutomationStore,
profile_engine, automation_engine,
processor_manager: ProcessorManager, processor_manager: ProcessorManager,
*, *,
skip_profiles: bool = False, skip_automations: bool = False,
) -> Tuple[str, List[str]]: ) -> Tuple[str, List[str]]:
"""Apply a scene preset's state to the system. """Apply a scene preset's state to the system.
@@ -76,11 +76,11 @@ async def apply_scene_state(
preset: The scene preset to activate. preset: The scene preset to activate.
target_store: Target store for reading/updating targets. target_store: Target store for reading/updating targets.
device_store: Device store for reading/updating devices. device_store: Device store for reading/updating devices.
profile_store: Profile store for reading/updating profiles. automation_store: Automation store for reading/updating automations.
profile_engine: Profile engine for deactivation and re-evaluation. automation_engine: Automation engine for deactivation and re-evaluation.
processor_manager: Processor manager for starting/stopping targets. processor_manager: Processor manager for starting/stopping targets.
skip_profiles: If True, skip toggling profile enable states (used when skip_automations: If True, skip toggling automation enable states (used when
called from the profile engine itself to avoid recursion). called from the automation engine itself to avoid recursion).
Returns: Returns:
(status, errors) where status is "activated" or "partial" and (status, errors) where status is "activated" or "partial" and
@@ -88,19 +88,19 @@ async def apply_scene_state(
""" """
errors: List[str] = [] errors: List[str] = []
# 1. Toggle profile enable states # 1. Toggle automation enable states
if not skip_profiles: if not skip_automations:
for ps in preset.profiles: for auto_snap in preset.automations:
try: try:
p = profile_store.get_profile(ps.profile_id) a = automation_store.get_automation(auto_snap.automation_id)
if p.enabled != ps.enabled: if a.enabled != auto_snap.enabled:
if not ps.enabled: if not auto_snap.enabled:
await profile_engine.deactivate_if_active(ps.profile_id) await automation_engine.deactivate_if_active(auto_snap.automation_id)
profile_store.update_profile(ps.profile_id, enabled=ps.enabled) automation_store.update_automation(auto_snap.automation_id, enabled=auto_snap.enabled)
except ValueError: except ValueError:
errors.append(f"Profile {ps.profile_id} not found (skipped)") errors.append(f"Automation {auto_snap.automation_id} not found (skipped)")
except Exception as e: except Exception as e:
errors.append(f"Profile {ps.profile_id}: {e}") errors.append(f"Automation {auto_snap.automation_id}: {e}")
# 2. Stop targets that should be stopped # 2. Stop targets that should be stopped
for ts in preset.targets: for ts in preset.targets:
@@ -172,12 +172,12 @@ async def apply_scene_state(
except Exception as e: except Exception as e:
errors.append(f"Device {ds.device_id} brightness: {e}") errors.append(f"Device {ds.device_id} brightness: {e}")
# Trigger profile re-evaluation after all changes # Trigger automation re-evaluation after all changes
if not skip_profiles: if not skip_automations:
try: try:
await profile_engine.trigger_evaluate() await automation_engine.trigger_evaluate()
except Exception as e: except Exception as e:
errors.append(f"Profile re-evaluation: {e}") errors.append(f"Automation re-evaluation: {e}")
status = "activated" if not errors else "partial" status = "activated" if not errors else "partial"
if errors: if errors:

View File

@@ -27,9 +27,9 @@ from wled_controller.storage.audio_source_store import AudioSourceStore
from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_template_store import AudioTemplateStore
import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration import wled_controller.core.audio # noqa: F401 — trigger engine auto-registration
from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.value_source_store import ValueSourceStore
from wled_controller.storage.profile_store import ProfileStore from wled_controller.storage.automation_store import AutomationStore
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.automations.automation_engine import AutomationEngine
from wled_controller.core.mqtt.mqtt_service import MQTTService from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service from wled_controller.core.devices.mqtt_client import set_mqtt_service
from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -54,7 +54,7 @@ color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
audio_source_store = AudioSourceStore(config.storage.audio_sources_file) audio_source_store = AudioSourceStore(config.storage.audio_sources_file)
audio_template_store = AudioTemplateStore(config.storage.audio_templates_file) audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
value_source_store = ValueSourceStore(config.storage.value_sources_file) value_source_store = ValueSourceStore(config.storage.value_sources_file)
profile_store = ProfileStore(config.storage.profiles_file) automation_store = AutomationStore(config.storage.automations_file)
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file) scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
# Migrate embedded audio config from CSS entities to audio sources # Migrate embedded audio config from CSS entities to audio sources
@@ -108,9 +108,9 @@ 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 + stores for scene activation) # Create automation engine (needs processor_manager + mqtt_service + stores for scene activation)
profile_engine = ProfileEngine( automation_engine = AutomationEngine(
profile_store, processor_manager, automation_store, processor_manager,
mqtt_service=mqtt_service, mqtt_service=mqtt_service,
scene_preset_store=scene_preset_store, scene_preset_store=scene_preset_store,
target_store=picture_target_store, target_store=picture_target_store,
@@ -136,9 +136,9 @@ async def lifespan(app: FastAPI):
audio_source_store=audio_source_store, audio_source_store=audio_source_store,
audio_template_store=audio_template_store, audio_template_store=audio_template_store,
value_source_store=value_source_store, value_source_store=value_source_store,
profile_store=profile_store, automation_store=automation_store,
scene_preset_store=scene_preset_store, scene_preset_store=scene_preset_store,
profile_engine=profile_engine, automation_engine=automation_engine,
auto_backup_engine=auto_backup_engine, auto_backup_engine=auto_backup_engine,
) )
@@ -180,8 +180,8 @@ async def lifespan(app: FastAPI):
# Start MQTT service (broker connection for output, triggers, state) # Start MQTT service (broker connection for output, triggers, state)
await mqtt_service.start() await mqtt_service.start()
# Start profile engine (evaluates conditions and auto-starts/stops targets) # Start automation engine (evaluates conditions and activates scenes)
await profile_engine.start() await automation_engine.start()
# Start auto-backup engine (periodic configuration backups) # Start auto-backup engine (periodic configuration backups)
await auto_backup_engine.start() await auto_backup_engine.start()
@@ -210,12 +210,12 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Error stopping auto-backup engine: {e}") logger.error(f"Error stopping auto-backup engine: {e}")
# Stop profile engine first (deactivates profile-managed targets) # Stop automation engine first (deactivates automation-managed scenes)
try: try:
await profile_engine.stop() await automation_engine.stop()
logger.info("Stopped profile engine") logger.info("Stopped automation engine")
except Exception as e: except Exception as e:
logger.error(f"Error stopping profile engine: {e}") logger.error(f"Error stopping automation engine: {e}")
# Stop all processing # Stop all processing
try: try:

View File

@@ -1,34 +1,34 @@
/* ===== PROFILES ===== */ /* ===== AUTOMATIONS ===== */
.badge-profile-active { .badge-automation-active {
background: var(--success-color); background: var(--success-color);
color: #fff; color: #fff;
} }
.badge-profile-inactive { .badge-automation-inactive {
background: var(--border-color); background: var(--border-color);
color: var(--text-color); color: var(--text-color);
} }
.badge-profile-disabled { .badge-automation-disabled {
background: var(--border-color); background: var(--border-color);
color: var(--text-muted); color: var(--text-muted);
opacity: 0.7; opacity: 0.7;
} }
.profile-status-disabled { .automation-status-disabled {
opacity: 0.6; opacity: 0.6;
} }
.profile-logic-label { .automation-logic-label {
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
padding: 0 4px; padding: 0 4px;
} }
/* Profile condition editor rows */ /* Automation condition editor rows */
.profile-condition-row { .automation-condition-row {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;

View File

@@ -294,7 +294,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.dashboard-profile .dashboard-target-metrics { .dashboard-automation .dashboard-target-metrics {
min-width: 48px; min-width: 48px;
} }

View File

@@ -23,11 +23,11 @@ import {
} from './features/displays.js'; } from './features/displays.js';
import { import {
startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial, startCalibrationTutorial, startDeviceTutorial, startGettingStartedTutorial,
startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startProfilesTutorial, startDashboardTutorial, startTargetsTutorial, startSourcesTutorial, startAutomationsTutorial,
closeTutorial, tutorialNext, tutorialPrev, closeTutorial, tutorialNext, tutorialPrev,
} from './features/tutorials.js'; } from './features/tutorials.js';
// Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, profiles // Layer 4: devices, dashboard, streams, kc-targets, pattern-templates, automations
import { import {
showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal, showSettings, closeDeviceSettingsModal, forceCloseDeviceSettingsModal,
saveDeviceSettings, updateBrightnessLabel, saveCardBrightness, saveDeviceSettings, updateBrightnessLabel, saveCardBrightness,
@@ -36,7 +36,7 @@ import {
} from './features/devices.js'; } from './features/devices.js';
import { import {
loadDashboard, stopUptimeTimer, loadDashboard, stopUptimeTimer,
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll,
toggleDashboardSection, changeDashboardPollInterval, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.js';
import { startEventsWS, stopEventsWS } from './core/events-ws.js'; import { startEventsWS, stopEventsWS } from './core/events-ws.js';
@@ -77,11 +77,11 @@ import {
clonePatternTemplate, clonePatternTemplate,
} from './features/pattern-templates.js'; } from './features/pattern-templates.js';
import { import {
loadProfiles, openProfileEditor, closeProfileEditorModal, loadAutomations, openAutomationEditor, closeAutomationEditorModal,
saveProfileEditor, addProfileCondition, saveAutomationEditor, addAutomationCondition,
toggleProfileEnabled, deleteProfile, toggleAutomationEnabled, deleteAutomation,
expandAllProfileSections, collapseAllProfileSections, expandAllAutomationSections, collapseAllAutomationSections,
} from './features/profiles.js'; } from './features/automations.js';
import { import {
loadScenes, expandAllSceneSections, collapseAllSceneSections, loadScenes, expandAllSceneSections, collapseAllSceneSections,
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor, openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
@@ -185,7 +185,7 @@ Object.assign(window, {
startDashboardTutorial, startDashboardTutorial,
startTargetsTutorial, startTargetsTutorial,
startSourcesTutorial, startSourcesTutorial,
startProfilesTutorial, startAutomationsTutorial,
closeTutorial, closeTutorial,
tutorialNext, tutorialNext,
tutorialPrev, tutorialPrev,
@@ -204,7 +204,7 @@ Object.assign(window, {
// dashboard // dashboard
loadDashboard, loadDashboard,
dashboardToggleProfile, dashboardToggleAutomation,
dashboardStartTarget, dashboardStartTarget,
dashboardStopTarget, dashboardStopTarget,
dashboardToggleAutoStart, dashboardToggleAutoStart,
@@ -300,16 +300,16 @@ Object.assign(window, {
capturePatternBackground, capturePatternBackground,
clonePatternTemplate, clonePatternTemplate,
// profiles // automations
loadProfiles, loadAutomations,
openProfileEditor, openAutomationEditor,
closeProfileEditorModal, closeAutomationEditorModal,
saveProfileEditor, saveAutomationEditor,
addProfileCondition, addAutomationCondition,
toggleProfileEnabled, toggleAutomationEnabled,
deleteProfile, deleteAutomation,
expandAllProfileSections, expandAllAutomationSections,
collapseAllProfileSections, collapseAllAutomationSections,
// scene presets // scene presets
loadScenes, loadScenes,
@@ -440,7 +440,7 @@ document.addEventListener('keydown', (e) => {
// Tab shortcuts: Ctrl+1..5 (skip when typing in inputs) // Tab shortcuts: Ctrl+1..5 (skip when typing in inputs)
if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
const tabMap = { '1': 'dashboard', '2': 'profiles', '3': 'targets', '4': 'streams', '5': 'scenes' }; const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'scenes' };
const tab = tabMap[e.key]; const tab = tabMap[e.key];
if (tab) { if (tab) {
e.preventDefault(); e.preventDefault();

View File

@@ -7,7 +7,7 @@ import { t } from './i18n.js';
import { navigateToCard } from './navigation.js'; import { navigateToCard } from './navigation.js';
import { import {
getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon, getTargetTypeIcon, getPictureSourceIcon, getColorStripIcon, getAudioSourceIcon,
ICON_DEVICE, ICON_TARGET, ICON_PROFILE, ICON_VALUE_SOURCE, ICON_SCENE, ICON_DEVICE, ICON_TARGET, ICON_AUTOMATION, ICON_VALUE_SOURCE, ICON_SCENE,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_PATTERN_TEMPLATE,
} from './icons.js'; } from './icons.js';
@@ -31,7 +31,7 @@ function _mapEntities(data, mapFn) {
} }
function _buildItems(results) { function _buildItems(results) {
const [devices, targets, css, profiles, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results; const [devices, targets, css, automations, capTempl, ppTempl, patTempl, audioSrc, valSrc, streams, scenePresets] = results;
const items = []; const items = [];
_mapEntities(devices, d => items.push({ _mapEntities(devices, d => items.push({
@@ -58,9 +58,9 @@ function _buildItems(results) {
nav: ['targets', 'led', 'led-css', 'data-css-id', c.id], nav: ['targets', 'led', 'led-css', 'data-css-id', c.id],
})); }));
_mapEntities(profiles, p => items.push({ _mapEntities(automations, a => items.push({
name: p.name, detail: p.enabled ? 'enabled' : '', group: 'profiles', icon: ICON_PROFILE, name: a.name, detail: a.enabled ? 'enabled' : '', group: 'automations', icon: ICON_AUTOMATION,
nav: ['profiles', null, 'profiles', 'data-profile-id', p.id], nav: ['automations', null, 'automations', 'data-automation-id', a.id],
})); }));
_mapEntities(capTempl, ct => items.push({ _mapEntities(capTempl, ct => items.push({
@@ -112,7 +112,7 @@ const _responseKeys = [
['/devices', 'devices'], ['/devices', 'devices'],
['/picture-targets', 'targets'], ['/picture-targets', 'targets'],
['/color-strip-sources', 'sources'], ['/color-strip-sources', 'sources'],
['/profiles', 'profiles'], ['/automations', 'automations'],
['/capture-templates', 'templates'], ['/capture-templates', 'templates'],
['/postprocessing-templates','templates'], ['/postprocessing-templates','templates'],
['/pattern-templates', 'templates'], ['/pattern-templates', 'templates'],
@@ -136,7 +136,7 @@ async function _fetchAllEntities() {
// ─── Group ordering ─── // ─── Group ordering ───
const _groupOrder = [ const _groupOrder = [
'devices', 'targets', 'kc_targets', 'css', 'profiles', 'devices', 'targets', 'kc_targets', 'css', 'automations',
'streams', 'capture_templates', 'pp_templates', 'pattern_templates', 'streams', 'capture_templates', 'pp_templates', 'pattern_templates',
'audio', 'value', 'scenes', 'audio', 'value', 'scenes',
]; ];

View File

@@ -2,7 +2,7 @@
* Global events WebSocket — stays connected while logged in, * Global events WebSocket — stays connected while logged in,
* dispatches DOM custom events that feature modules can listen to. * dispatches DOM custom events that feature modules can listen to.
* *
* Events dispatched: server:state_change, server:profile_state_changed * Events dispatched: server:state_change, server:automation_state_changed
*/ */
import { apiKey } from './state.js'; import { apiKey } from './state.js';

View File

@@ -64,7 +64,7 @@ export function getEngineIcon(engineType) {
// ── Entity-kind constants ─────────────────────────────────── // ── Entity-kind constants ───────────────────────────────────
export const ICON_PROFILE = _svg(P.clipboardList); export const ICON_AUTOMATION = _svg(P.clipboardList);
export const ICON_DEVICE = _svg(P.monitor); export const ICON_DEVICE = _svg(P.monitor);
export const ICON_TARGET = _svg(P.zap); export const ICON_TARGET = _svg(P.zap);
export const ICON_VALUE_SOURCE = _svg(P.hash); export const ICON_VALUE_SOURCE = _svg(P.hash);

View File

@@ -7,7 +7,7 @@ import { switchTab } from '../features/tabs.js';
/** /**
* Navigate to a card on any tab/subtab, expanding the section and scrolling to it. * Navigate to a card on any tab/subtab, expanding the section and scrolling to it.
* *
* @param {string} tab Main tab: 'dashboard' | 'profiles' | 'targets' | 'streams' * @param {string} tab Main tab: 'dashboard' | 'automations' | 'targets' | 'streams'
* @param {string|null} subTab Sub-tab key or null * @param {string|null} subTab Sub-tab key or null
* @param {string|null} sectionKey CardSection key to expand, or null * @param {string|null} sectionKey CardSection key to expand, or null
* @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id') * @param {string} cardAttr Data attribute to find the card (e.g. 'data-device-id')
@@ -87,7 +87,7 @@ function _highlightCard(card) {
/** Trigger the tab's data load function (used when card wasn't found in DOM). */ /** Trigger the tab's data load function (used when card wasn't found in DOM). */
function _triggerTabLoad(tab) { function _triggerTabLoad(tab) {
if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard(); if (tab === 'dashboard' && typeof window.loadDashboard === 'function') window.loadDashboard();
else if (tab === 'profiles' && typeof window.loadProfiles === 'function') window.loadProfiles(); else if (tab === 'automations' && typeof window.loadAutomations === 'function') window.loadAutomations();
else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources(); else if (tab === 'streams' && typeof window.loadPictureSources === 'function') window.loadPictureSources();
else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); else if (tab === 'targets' && typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes(); else if (tab === 'scenes' && typeof window.loadScenes === 'function') window.loadScenes();

View File

@@ -128,8 +128,8 @@ export function set_dashboardLoading(v) { _dashboardLoading = v; }
export let _sourcesLoading = false; export let _sourcesLoading = false;
export function set_sourcesLoading(v) { _sourcesLoading = v; } export function set_sourcesLoading(v) { _sourcesLoading = v; }
export let _profilesLoading = false; export let _automationsLoading = false;
export function set_profilesLoading(v) { _profilesLoading = v; } export function set_automationsLoading(v) { _automationsLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage // Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval'; const _POLL_KEY = 'dashboard_poll_interval';
@@ -195,6 +195,6 @@ export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManua
export let _cachedValueSources = []; export let _cachedValueSources = [];
export function set_cachedValueSources(v) { _cachedValueSources = v; } export function set_cachedValueSources(v) { _cachedValueSources = v; }
// Profiles // Automations
export let _profilesCache = null; export let _automationsCache = null;
export function set_profilesCache(v) { _profilesCache = v; } export function set_automationsCache(v) { _automationsCache = v; }

View File

@@ -1,199 +1,199 @@
/** /**
* Profiles profile cards, editor, condition builder, process picker, scene selector. * Automations automation cards, editor, condition builder, process picker, scene selector.
*/ */
import { apiKey, _profilesCache, set_profilesCache, _profilesLoading, set_profilesLoading } from '../core/state.js'; import { apiKey, _automationsCache, set_automationsCache, _automationsLoading, set_automationsLoading } from '../core/state.js';
import { fetchWithAuth, escapeHtml } from '../core/api.js'; import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js'; 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_START, ICON_PAUSE, ICON_CLOCK, ICON_PROFILE, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js';
// ===== Scene presets cache (shared by both selectors) ===== // ===== Scene presets cache (shared by both selectors) =====
let _scenesCache = []; let _scenesCache = [];
class ProfileEditorModal extends Modal { class AutomationEditorModal extends Modal {
constructor() { super('profile-editor-modal'); } constructor() { super('automation-editor-modal'); }
snapshotValues() { snapshotValues() {
return { return {
name: document.getElementById('profile-editor-name').value, name: document.getElementById('automation-editor-name').value,
enabled: document.getElementById('profile-editor-enabled').checked.toString(), enabled: document.getElementById('automation-editor-enabled').checked.toString(),
logic: document.getElementById('profile-editor-logic').value, logic: document.getElementById('automation-editor-logic').value,
conditions: JSON.stringify(getProfileEditorConditions()), conditions: JSON.stringify(getAutomationEditorConditions()),
scenePresetId: document.getElementById('profile-scene-id').value, scenePresetId: document.getElementById('automation-scene-id').value,
deactivationMode: document.getElementById('profile-deactivation-mode').value, deactivationMode: document.getElementById('automation-deactivation-mode').value,
deactivationScenePresetId: document.getElementById('profile-fallback-scene-id').value, deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
}; };
} }
} }
const profileModal = new ProfileEditorModal(); const automationModal = new AutomationEditorModal();
const csProfiles = new CardSection('profiles', { titleKey: 'profiles.title', gridClass: 'devices-grid', addCardOnclick: "openProfileEditor()", keyAttr: 'data-profile-id' }); const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' });
// Re-render profiles when language changes (only if tab is active) // Re-render automations when language changes (only if tab is active)
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles(); if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') loadAutomations();
}); });
// React to real-time profile state changes from global events WS // React to real-time automation state changes from global events WS
document.addEventListener('server:profile_state_changed', () => { document.addEventListener('server:automation_state_changed', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') { if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
loadProfiles(); loadAutomations();
} }
}); });
export async function loadProfiles() { export async function loadAutomations() {
if (_profilesLoading) return; if (_automationsLoading) return;
set_profilesLoading(true); set_automationsLoading(true);
const container = document.getElementById('profiles-content'); const container = document.getElementById('automations-content');
if (!container) { set_profilesLoading(false); return; } if (!container) { set_automationsLoading(false); return; }
setTabRefreshing('profiles-content', true); setTabRefreshing('automations-content', true);
try { try {
const [profilesResp, scenesResp] = await Promise.all([ const [automationsResp, scenesResp] = await Promise.all([
fetchWithAuth('/profiles'), fetchWithAuth('/automations'),
fetchWithAuth('/scene-presets'), fetchWithAuth('/scene-presets'),
]); ]);
if (!profilesResp.ok) throw new Error('Failed to load profiles'); if (!automationsResp.ok) throw new Error('Failed to load automations');
const data = await profilesResp.json(); const data = await automationsResp.json();
const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] }; const scenesData = scenesResp.ok ? await scenesResp.json() : { presets: [] };
_scenesCache = scenesData.presets || []; _scenesCache = scenesData.presets || [];
// Build scene name map for card rendering // Build scene name map for card rendering
const sceneMap = new Map(_scenesCache.map(s => [s.id, s])); const sceneMap = new Map(_scenesCache.map(s => [s.id, s]));
set_profilesCache(data.profiles); set_automationsCache(data.automations);
const activeCount = data.profiles.filter(p => p.is_active).length; const activeCount = data.automations.filter(a => a.is_active).length;
updateTabBadge('profiles', activeCount); updateTabBadge('automations', activeCount);
renderProfiles(data.profiles, sceneMap); renderAutomations(data.automations, 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 automations:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`; container.innerHTML = `<p class="error-message">${error.message}</p>`;
} finally { } finally {
set_profilesLoading(false); set_automationsLoading(false);
setTabRefreshing('profiles-content', false); setTabRefreshing('automations-content', false);
} }
} }
export function expandAllProfileSections() { export function expandAllAutomationSections() {
CardSection.expandAll([csProfiles]); CardSection.expandAll([csAutomations]);
} }
export function collapseAllProfileSections() { export function collapseAllAutomationSections() {
CardSection.collapseAll([csProfiles]); CardSection.collapseAll([csAutomations]);
} }
function renderProfiles(profiles, sceneMap) { function renderAutomations(automations, sceneMap) {
const container = document.getElementById('profiles-content'); const container = document.getElementById('automations-content');
const items = csProfiles.applySortOrder(profiles.map(p => ({ key: p.id, html: createProfileCard(p, sceneMap) }))); const items = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, 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="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
container.innerHTML = toolbar + csProfiles.render(items); container.innerHTML = toolbar + csAutomations.render(items);
csProfiles.bind(); csAutomations.bind();
// Localize data-i18n elements within the profiles container only // Localize data-i18n elements within the automations container only
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, sceneMap = new Map()) { function createAutomationCard(automation, sceneMap = new Map()) {
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive'; const statusClass = !automation.enabled ? 'disabled' : automation.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 = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = ''; let condPills = '';
if (profile.conditions.length === 0) { if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`; condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
} else { } else {
const parts = profile.conditions.map(c => { const parts = automation.conditions.map(c => {
if (c.condition_type === 'always') { if (c.condition_type === 'always') {
return `<span class="stream-card-prop">${ICON_OK} ${t('profiles.condition.always')}</span>`; return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`;
} }
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ');
const matchLabel = t('profiles.condition.application.match_type.' + (c.match_type || 'running')); const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`; return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
} }
if (c.condition_type === 'time_of_day') { if (c.condition_type === 'time_of_day') {
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`; return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`;
} }
if (c.condition_type === 'system_idle') { if (c.condition_type === 'system_idle') {
const mode = c.when_idle !== false ? t('profiles.condition.system_idle.when_idle') : t('profiles.condition.system_idle.when_active'); const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`; return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
} }
if (c.condition_type === 'display_state') { if (c.condition_type === 'display_state') {
const stateLabel = t('profiles.condition.display_state.' + (c.state || 'on')); const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('profiles.condition.display_state')}: ${stateLabel}</span>`; return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
} }
if (c.condition_type === 'mqtt') { if (c.condition_type === 'mqtt') {
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('profiles.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`; return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
} }
return `<span class="stream-card-prop">${c.condition_type}</span>`; return `<span class="stream-card-prop">${c.condition_type}</span>`;
}); });
const logicLabel = profile.condition_logic === 'and' ? t('profiles.logic.and') : t('profiles.logic.or'); const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`); condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
} }
// Scene info // Scene info
const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null; const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected'); const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888'; const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
// Deactivation mode label // Deactivation mode label
let deactivationLabel = ''; let deactivationLabel = '';
if (profile.deactivation_mode === 'revert') { if (automation.deactivation_mode === 'revert') {
deactivationLabel = t('profiles.deactivation_mode.revert'); deactivationLabel = t('automations.deactivation_mode.revert');
} else if (profile.deactivation_mode === 'fallback_scene') { } else if (automation.deactivation_mode === 'fallback_scene') {
const fallback = profile.deactivation_scene_preset_id ? sceneMap.get(profile.deactivation_scene_preset_id) : null; const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
deactivationLabel = fallback ? `${t('profiles.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('profiles.deactivation_mode.fallback_scene'); deactivationLabel = fallback ? `${t('automations.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('automations.deactivation_mode.fallback_scene');
} }
let lastActivityMeta = ''; let lastActivityMeta = '';
if (profile.last_activated_at) { if (automation.last_activated_at) {
const ts = new Date(profile.last_activated_at); const ts = new Date(automation.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`; lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
} }
return ` return `
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}"> <div class="card${!automation.enabled ? ' automation-status-disabled' : ''}" data-automation-id="${automation.id}">
<div class="card-top-actions"> <div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">&#x2715;</button> <button class="card-remove-btn" onclick="deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')" title="${t('common.delete')}">&#x2715;</button>
</div> </div>
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
${escapeHtml(profile.name)} ${escapeHtml(automation.name)}
<span class="badge badge-profile-${statusClass}">${statusText}</span> <span class="badge badge-automation-${statusClass}">${statusText}</span>
</div> </div>
</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">${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')}</span>
<span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span> <span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</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="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</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 ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${profile.enabled ? ICON_PAUSE : ICON_START} ${automation.enabled ? ICON_PAUSE : ICON_START}
</button> </button>
</div> </div>
</div>`; </div>`;
} }
export async function openProfileEditor(profileId) { export async function openAutomationEditor(automationId) {
const modal = document.getElementById('profile-editor-modal'); const modal = document.getElementById('automation-editor-modal');
const titleEl = document.getElementById('profile-editor-title'); const titleEl = document.getElementById('automation-editor-title');
const idInput = document.getElementById('profile-editor-id'); const idInput = document.getElementById('automation-editor-id');
const nameInput = document.getElementById('profile-editor-name'); const nameInput = document.getElementById('automation-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled'); const enabledInput = document.getElementById('automation-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic'); const logicSelect = document.getElementById('automation-editor-logic');
const condList = document.getElementById('profile-conditions-list'); const condList = document.getElementById('automation-conditions-list');
const errorEl = document.getElementById('profile-editor-error'); const errorEl = document.getElementById('automation-editor-error');
errorEl.style.display = 'none'; errorEl.style.display = 'none';
condList.innerHTML = ''; condList.innerHTML = '';
@@ -208,66 +208,66 @@ export async function openProfileEditor(profileId) {
} catch { /* use cached */ } } catch { /* use cached */ }
// Reset deactivation mode // Reset deactivation mode
document.getElementById('profile-deactivation-mode').value = 'none'; document.getElementById('automation-deactivation-mode').value = 'none';
document.getElementById('profile-fallback-scene-group').style.display = 'none'; document.getElementById('automation-fallback-scene-group').style.display = 'none';
if (profileId) { if (automationId) {
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.edit')}`; titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
try { try {
const resp = await fetchWithAuth(`/profiles/${profileId}`); const resp = await fetchWithAuth(`/automations/${automationId}`);
if (!resp.ok) throw new Error('Failed to load profile'); if (!resp.ok) throw new Error('Failed to load automation');
const profile = await resp.json(); const automation = await resp.json();
idInput.value = profile.id; idInput.value = automation.id;
nameInput.value = profile.name; nameInput.value = automation.name;
enabledInput.checked = profile.enabled; enabledInput.checked = automation.enabled;
logicSelect.value = profile.condition_logic; logicSelect.value = automation.condition_logic;
for (const c of profile.conditions) { for (const c of automation.conditions) {
addProfileConditionRow(c); addAutomationConditionRow(c);
} }
// Scene selector // Scene selector
_initSceneSelector('profile-scene', profile.scene_preset_id); _initSceneSelector('automation-scene', automation.scene_preset_id);
// Deactivation mode // Deactivation mode
document.getElementById('profile-deactivation-mode').value = profile.deactivation_mode || 'none'; document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none';
_onDeactivationModeChange(); _onDeactivationModeChange();
_initSceneSelector('profile-fallback-scene', profile.deactivation_scene_preset_id); _initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id);
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
return; return;
} }
} else { } else {
titleEl.innerHTML = `${ICON_PROFILE} ${t('profiles.add')}`; titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
idInput.value = ''; idInput.value = '';
nameInput.value = ''; nameInput.value = '';
enabledInput.checked = true; enabledInput.checked = true;
logicSelect.value = 'or'; logicSelect.value = 'or';
_initSceneSelector('profile-scene', null); _initSceneSelector('automation-scene', null);
_initSceneSelector('profile-fallback-scene', null); _initSceneSelector('automation-fallback-scene', null);
} }
// Wire up deactivation mode change // Wire up deactivation mode change
document.getElementById('profile-deactivation-mode').onchange = _onDeactivationModeChange; document.getElementById('automation-deactivation-mode').onchange = _onDeactivationModeChange;
profileModal.open(); automationModal.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 => { modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder')); el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
}); });
profileModal.snapshot(); automationModal.snapshot();
} }
function _onDeactivationModeChange() { function _onDeactivationModeChange() {
const mode = document.getElementById('profile-deactivation-mode').value; const mode = document.getElementById('automation-deactivation-mode').value;
document.getElementById('profile-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none'; document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none';
} }
export async function closeProfileEditorModal() { export async function closeAutomationEditorModal() {
await profileModal.close(); await automationModal.close();
} }
// ===== Scene selector logic ===== // ===== Scene selector logic =====
@@ -296,7 +296,7 @@ function _initSceneSelector(prefix, selectedId) {
const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache; const filtered = query ? _scenesCache.filter(s => s.name.toLowerCase().includes(query)) : _scenesCache;
if (filtered.length === 0) { if (filtered.length === 0) {
dropdown.innerHTML = `<div class="scene-selector-empty">${t('profiles.scene.none_available')}</div>`; dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
} else { } else {
dropdown.innerHTML = filtered.map(s => { dropdown.innerHTML = filtered.map(s => {
const selected = s.id === hiddenInput.value ? ' selected' : ''; const selected = s.id === hiddenInput.value ? ' selected' : '';
@@ -370,27 +370,27 @@ function _initSceneSelector(prefix, selectedId) {
// ===== Condition editor ===== // ===== Condition editor =====
export function addProfileCondition() { export function addAutomationCondition() {
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' }); addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
} }
function addProfileConditionRow(condition) { function addAutomationConditionRow(condition) {
const list = document.getElementById('profile-conditions-list'); const list = document.getElementById('automation-conditions-list');
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profile-condition-row'; row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application'; const condType = condition.condition_type || 'application';
row.innerHTML = ` row.innerHTML = `
<div class="condition-header"> <div class="condition-header">
<select class="condition-type-select"> <select class="condition-type-select">
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('profiles.condition.always')}</option> <option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('profiles.condition.application')}</option> <option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('profiles.condition.time_of_day')}</option> <option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('profiles.condition.system_idle')}</option> <option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('profiles.condition.display_state')}</option> <option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('profiles.condition.mqtt')}</option> <option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
</select> </select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button> <button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">&#x2715;</button>
</div> </div>
<div class="condition-fields-container"></div> <div class="condition-fields-container"></div>
`; `;
@@ -400,7 +400,7 @@ function addProfileConditionRow(condition) {
function renderFields(type, data) { function renderFields(type, data) {
if (type === 'always') { if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('profiles.condition.always.hint')}</small>`; container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return; return;
} }
if (type === 'time_of_day') { if (type === 'time_of_day') {
@@ -409,14 +409,14 @@ function addProfileConditionRow(condition) {
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.time_of_day.start_time')}</label> <label>${t('automations.condition.time_of_day.start_time')}</label>
<input type="time" class="condition-start-time" value="${startTime}"> <input type="time" class="condition-start-time" value="${startTime}">
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.time_of_day.end_time')}</label> <label>${t('automations.condition.time_of_day.end_time')}</label>
<input type="time" class="condition-end-time" value="${endTime}"> <input type="time" class="condition-end-time" value="${endTime}">
</div> </div>
<small class="condition-always-desc">${t('profiles.condition.time_of_day.overnight_hint')}</small> <small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
</div>`; </div>`;
return; return;
} }
@@ -426,14 +426,14 @@ function addProfileConditionRow(condition) {
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.system_idle.idle_minutes')}</label> <label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}"> <input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.system_idle.mode')}</label> <label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle"> <select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_idle')}</option> <option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('profiles.condition.system_idle.when_active')}</option> <option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
</select> </select>
</div> </div>
</div>`; </div>`;
@@ -444,10 +444,10 @@ function addProfileConditionRow(condition) {
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.display_state.state')}</label> <label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state"> <select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('profiles.condition.display_state.on')}</option> <option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('profiles.condition.display_state.off')}</option> <option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
</select> </select>
</div> </div>
</div>`; </div>`;
@@ -460,19 +460,19 @@ function addProfileConditionRow(condition) {
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.mqtt.topic')}</label> <label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power"> <input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.mqtt.payload')}</label> <label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON"> <input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div> </div>
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.mqtt.match_mode')}</label> <label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode"> <select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.exact')}</option> <option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.contains')}</option> <option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('profiles.condition.mqtt.match_mode.regex')}</option> <option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
</select> </select>
</div> </div>
</div>`; </div>`;
@@ -483,22 +483,22 @@ function addProfileConditionRow(condition) {
container.innerHTML = ` container.innerHTML = `
<div class="condition-fields"> <div class="condition-fields">
<div class="condition-field"> <div class="condition-field">
<label>${t('profiles.condition.application.match_type')}</label> <label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type"> <select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option> <option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option> <option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost_fullscreen')}</option> <option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('profiles.condition.application.match_type.fullscreen')}</option> <option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
</select> </select>
</div> </div>
<div class="condition-field"> <div class="condition-field">
<div class="condition-apps-header"> <div class="condition-apps-header">
<label>${t('profiles.condition.application.apps')}</label> <label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button> <button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
</div> </div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea> <textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none"> <div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off"> <input type="text" class="process-picker-search" placeholder="${t('automations.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div> <div class="process-picker-list"></div>
</div> </div>
</div> </div>
@@ -551,7 +551,7 @@ async function toggleProcessPicker(picker, row) {
function renderProcessPicker(picker, processes, existing) { function renderProcessPicker(picker, processes, existing) {
const listEl = picker.querySelector('.process-picker-list'); const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) { if (processes.length === 0) {
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`; listEl.innerHTML = `<div class="process-picker-loading">${t('automations.condition.application.no_processes')}</div>`;
return; return;
} }
listEl.innerHTML = processes.map(p => { listEl.innerHTML = processes.map(p => {
@@ -562,7 +562,7 @@ function renderProcessPicker(picker, processes, existing) {
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => { listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
item.addEventListener('click', () => { item.addEventListener('click', () => {
const proc = item.dataset.process; const proc = item.dataset.process;
const row = picker.closest('.profile-condition-row'); const row = picker.closest('.automation-condition-row');
const textarea = row.querySelector('.condition-apps'); const textarea = row.querySelector('.condition-apps');
const current = textarea.value.trim(); const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc; textarea.value = current ? current + '\n' + proc : proc;
@@ -579,8 +579,8 @@ function filterProcessPicker(picker) {
renderProcessPicker(picker, filtered, picker._existing || new Set()); renderProcessPicker(picker, filtered, picker._existing || new Set());
} }
function getProfileEditorConditions() { function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row'); const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions = []; const conditions = [];
rows.forEach(row => { rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select'); const typeSelect = row.querySelector('.condition-type-select');
@@ -621,15 +621,15 @@ function getProfileEditorConditions() {
return conditions; return conditions;
} }
export async function saveProfileEditor() { export async function saveAutomationEditor() {
const idInput = document.getElementById('profile-editor-id'); const idInput = document.getElementById('automation-editor-id');
const nameInput = document.getElementById('profile-editor-name'); const nameInput = document.getElementById('automation-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled'); const enabledInput = document.getElementById('automation-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic'); const logicSelect = document.getElementById('automation-editor-logic');
const name = nameInput.value.trim(); const name = nameInput.value.trim();
if (!name) { if (!name) {
profileModal.showError(t('profiles.error.name_required')); automationModal.showError(t('automations.error.name_required'));
return; return;
} }
@@ -637,61 +637,61 @@ export async function saveProfileEditor() {
name, name,
enabled: enabledInput.checked, enabled: enabledInput.checked,
condition_logic: logicSelect.value, condition_logic: logicSelect.value,
conditions: getProfileEditorConditions(), conditions: getAutomationEditorConditions(),
scene_preset_id: document.getElementById('profile-scene-id').value || null, scene_preset_id: document.getElementById('automation-scene-id').value || null,
deactivation_mode: document.getElementById('profile-deactivation-mode').value, deactivation_mode: document.getElementById('automation-deactivation-mode').value,
deactivation_scene_preset_id: document.getElementById('profile-fallback-scene-id').value || null, deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
}; };
const profileId = idInput.value; const automationId = idInput.value;
const isEdit = !!profileId; const isEdit = !!automationId;
try { try {
const url = isEdit ? `/profiles/${profileId}` : '/profiles'; const url = isEdit ? `/automations/${automationId}` : '/automations';
const resp = await fetchWithAuth(url, { const resp = await fetchWithAuth(url, {
method: isEdit ? 'PUT' : 'POST', method: isEdit ? 'PUT' : 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json().catch(() => ({})); const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save profile'); throw new Error(err.detail || 'Failed to save automation');
} }
profileModal.forceClose(); automationModal.forceClose();
showToast(isEdit ? t('profiles.updated') : t('profiles.created'), 'success'); showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
loadProfiles(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
profileModal.showError(e.message); automationModal.showError(e.message);
} }
} }
export async function toggleProfileEnabled(profileId, enable) { export async function toggleAutomationEnabled(automationId, enable) {
try { try {
const action = enable ? 'enable' : 'disable'; const action = enable ? 'enable' : 'disable';
const resp = await fetchWithAuth(`/profiles/${profileId}/${action}`, { const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
method: 'POST', method: 'POST',
}); });
if (!resp.ok) throw new Error(`Failed to ${action} profile`); if (!resp.ok) throw new Error(`Failed to ${action} automation`);
loadProfiles(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');
} }
} }
export async function deleteProfile(profileId, profileName) { export async function deleteAutomation(automationId, automationName) {
const msg = t('profiles.delete.confirm').replace('{name}', profileName); const msg = t('automations.delete.confirm').replace('{name}', automationName);
const confirmed = await showConfirm(msg); const confirmed = await showConfirm(msg);
if (!confirmed) return; if (!confirmed) return;
try { try {
const resp = await fetchWithAuth(`/profiles/${profileId}`, { const resp = await fetchWithAuth(`/automations/${automationId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!resp.ok) throw new Error('Failed to delete profile'); if (!resp.ok) throw new Error('Failed to delete automation');
showToast(t('profiles.deleted'), 'success'); showToast(t('automations.deleted'), 'success');
loadProfiles(); loadAutomations();
} catch (e) { } catch (e) {
if (e.isAuth) return; if (e.isAuth) return;
showToast(e.message, 'error'); showToast(e.message, 'error');

View File

@@ -10,7 +10,7 @@ import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling }
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
getTargetTypeIcon, getTargetTypeIcon,
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, 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';
@@ -252,28 +252,28 @@ function _updateRunningMetrics(enrichedRunning) {
} }
function _updateProfilesInPlace(profiles) { function _updateAutomationsInPlace(automations) {
for (const p of profiles) { for (const a of automations) {
const card = document.querySelector(`[data-profile-id="${p.id}"]`); const card = document.querySelector(`[data-automation-id="${a.id}"]`);
if (!card) continue; if (!card) continue;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped'); const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) { if (badge) {
if (!p.enabled) { if (!a.enabled) {
badge.className = 'dashboard-badge-stopped'; badge.className = 'dashboard-badge-stopped';
badge.textContent = t('profiles.status.disabled'); badge.textContent = t('automations.status.disabled');
} else if (p.is_active) { } else if (a.is_active) {
badge.className = 'dashboard-badge-active'; badge.className = 'dashboard-badge-active';
badge.textContent = t('profiles.status.active'); badge.textContent = t('automations.status.active');
} else { } else {
badge.className = 'dashboard-badge-stopped'; badge.className = 'dashboard-badge-stopped';
badge.textContent = t('profiles.status.inactive'); badge.textContent = t('automations.status.inactive');
} }
} }
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 ${a.enabled ? 'stop' : 'start'}`;
btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`); btn.setAttribute('onclick', `dashboardToggleAutomation('${a.id}', ${!a.enabled})`);
btn.innerHTML = p.enabled ? ICON_STOP_PLAIN : ICON_START; btn.innerHTML = a.enabled ? ICON_STOP_PLAIN : ICON_START;
} }
} }
} }
@@ -368,9 +368,9 @@ export async function loadDashboard(forceFullRender = false) {
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // Fire all requests in a single batch to avoid sequential RTTs
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([ const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([
fetchWithAuth('/picture-targets'), fetchWithAuth('/picture-targets'),
fetchWithAuth('/profiles').catch(() => null), fetchWithAuth('/automations').catch(() => null),
fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/devices').catch(() => null),
fetchWithAuth('/color-strip-sources').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-targets/batch/states').catch(() => null), fetchWithAuth('/picture-targets/batch/states').catch(() => null),
@@ -380,8 +380,8 @@ export async function loadDashboard(forceFullRender = false) {
const targetsData = await targetsResp.json(); const targetsData = await targetsResp.json();
const targets = targetsData.targets || []; const targets = targetsData.targets || [];
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] }; const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
const profiles = profilesData.profiles || []; const automations = automationsData.automations || [];
const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] }; const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] };
const devicesMap = {}; const devicesMap = {};
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; } for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
@@ -392,12 +392,12 @@ export async function loadDashboard(forceFullRender = false) {
const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
// Build dynamic HTML (targets, profiles) // Build dynamic HTML (targets, automations)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds = []; let runningIds = [];
let newAutoStartIds = ''; let newAutoStartIds = '';
if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) { if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
@@ -426,7 +426,7 @@ export async function loadDashboard(forceFullRender = false) {
} }
if (structureUnchanged && forceFullRender) { if (structureUnchanged && forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running); if (running.length > 0) _updateRunningMetrics(running);
_updateProfilesInPlace(profiles); _updateAutomationsInPlace(automations);
_cacheUptimeElements(); _cacheUptimeElements();
_startUptimeTimer(); _startUptimeTimer();
startPerfPolling(); startPerfPolling();
@@ -451,8 +451,8 @@ export async function loadDashboard(forceFullRender = false) {
} }
} }
const statusBadge = isRunning const statusBadge = isRunning
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>` ? `<span class="dashboard-badge-active">${t('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`; : `<span class="dashboard-badge-stopped">${t('automations.status.inactive')}</span>`;
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''; const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
const asNavSub = isLed ? 'led' : 'key_colors'; const asNavSub = isLed ? 'led' : 'key_colors';
const asNavSec = isLed ? 'led-targets' : 'kc-targets'; const asNavSec = isLed ? 'led-targets' : 'kc-targets';
@@ -480,16 +480,16 @@ export async function loadDashboard(forceFullRender = false) {
</div>`; </div>`;
} }
if (profiles.length > 0) { if (automations.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active); const activeAutomations = automations.filter(a => a.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active); const inactiveAutomations = automations.filter(a => !a.is_active);
updateTabBadge('profiles', activeProfiles.length); updateTabBadge('automations', activeAutomations.length);
const sceneMap = new Map(scenePresets.map(s => [s.id, s])); const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p, sceneMap)).join(''); const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join('');
dynamicHtml += `<div class="dashboard-section"> dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} ${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)}
${_sectionContent('profiles', profileItems)} ${_sectionContent('automations', automationItems)}
</div>`; </div>`;
} }
@@ -664,56 +664,56 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
} }
} }
function renderDashboardProfile(profile, sceneMap = new Map()) { function renderDashboardAutomation(automation, sceneMap = new Map()) {
const isActive = profile.is_active; const isActive = automation.is_active;
const isDisabled = !profile.enabled; const isDisabled = !automation.enabled;
let condSummary = ''; let condSummary = '';
if (profile.conditions.length > 0) { if (automation.conditions.length > 0) {
const parts = profile.conditions.map(c => { const parts = automation.conditions.map(c => {
if (c.condition_type === 'application') { if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', '); const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running');
return `${apps} (${matchLabel})`; return `${apps} (${matchLabel})`;
} }
return c.condition_type; return c.condition_type;
}); });
const logic = profile.condition_logic === 'and' ? ' & ' : ' | '; const logic = automation.condition_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic); condSummary = parts.join(logic);
} }
const statusBadge = isDisabled const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>` ? `<span class="dashboard-badge-stopped">${t('automations.status.disabled')}</span>`
: isActive : isActive
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>` ? `<span class="dashboard-badge-active">${t('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`; : `<span class="dashboard-badge-stopped">${t('automations.status.inactive')}</span>`;
// Scene info // Scene info
const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null; const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected'); const sceneName = scene ? escapeHtml(scene.name) : t('automations.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-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_PROFILE}</span> <span class="dashboard-target-icon">${ICON_AUTOMATION}</span>
<div> <div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div> <div class="dashboard-target-name">${escapeHtml(automation.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 class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
</div> </div>
${statusBadge} ${statusBadge}
</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 ${automation.enabled ? 'stop' : 'start'}" onclick="dashboardToggleAutomation('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
${profile.enabled ? ICON_STOP_PLAIN : ICON_START} ${automation.enabled ? ICON_STOP_PLAIN : ICON_START}
</button> </button>
</div> </div>
</div>`; </div>`;
} }
export async function dashboardToggleProfile(profileId, enable) { export async function dashboardToggleAutomation(automationId, enable) {
try { try {
const endpoint = enable ? 'enable' : 'disable'; const endpoint = enable ? 'enable' : 'disable';
const response = await fetchWithAuth(`/profiles/${profileId}/${endpoint}`, { const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, {
method: 'POST', method: 'POST',
}); });
if (response.ok) { if (response.ok) {
@@ -721,7 +721,7 @@ export async function dashboardToggleProfile(profileId, enable) {
} }
} catch (error) { } catch (error) {
if (error.isAuth) return; if (error.isAuth) return;
showToast(t('dashboard.error.profile_toggle_failed'), 'error'); showToast(t('dashboard.error.automation_toggle_failed'), 'error');
} }
} }
@@ -817,7 +817,7 @@ function _debouncedDashboardReload(forceFullRender = false) {
} }
document.addEventListener('server:state_change', () => _debouncedDashboardReload()); document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:profile_state_changed', () => _debouncedDashboardReload(true)); document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
// Re-render dashboard when language changes // Re-render dashboard when language changes
document.addEventListener('languageChanged', () => { document.addEventListener('languageChanged', () => {

View File

@@ -85,13 +85,13 @@ export function collapseAllSceneSections() {
function _createSceneCard(preset) { function _createSceneCard(preset) {
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const deviceCount = (preset.devices || []).length; const deviceCount = (preset.devices || []).length;
const profileCount = (preset.profiles || []).length; const automationCount = (preset.automations || []).length;
const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`; const colorStyle = `border-left: 3px solid ${escapeHtml(preset.color || '#4fc3f7')}`;
const meta = [ const meta = [
targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null, targetCount > 0 ? `${ICON_TARGET} ${targetCount} ${t('scenes.targets_count')}` : null,
deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null, deviceCount > 0 ? `${ICON_SETTINGS} ${deviceCount} ${t('scenes.devices_count')}` : null,
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null, automationCount > 0 ? `${automationCount} ${t('scenes.automations_count')}` : null,
].filter(Boolean); ].filter(Boolean);
const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : ''; const updated = preset.updated_at ? new Date(preset.updated_at).toLocaleString() : '';
@@ -143,12 +143,12 @@ function _renderDashboardPresetCard(preset) {
const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`; const borderStyle = `border-left: 3px solid ${escapeHtml(preset.color)}`;
const targetCount = (preset.targets || []).length; const targetCount = (preset.targets || []).length;
const deviceCount = (preset.devices || []).length; const deviceCount = (preset.devices || []).length;
const profileCount = (preset.profiles || []).length; const automationCount = (preset.automations || []).length;
const subtitle = [ const subtitle = [
targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null, targetCount > 0 ? `${targetCount} ${t('scenes.targets_count')}` : null,
deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null, deviceCount > 0 ? `${deviceCount} ${t('scenes.devices_count')}` : null,
profileCount > 0 ? `${profileCount} ${t('scenes.profiles_count')}` : null, automationCount > 0 ? `${automationCount} ${t('scenes.automations_count')}` : null,
].filter(Boolean).join(' \u00b7 '); ].filter(Boolean).join(' \u00b7 ');
return `<div class="dashboard-target dashboard-scene-preset" data-scene-id="${preset.id}" style="${borderStyle}"> return `<div class="dashboard-target dashboard-scene-preset" data-scene-id="${preset.id}" style="${borderStyle}">

View File

@@ -58,8 +58,8 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') { } else if (name === 'targets') {
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (name === 'profiles') { } else if (name === 'automations') {
if (typeof window.loadProfiles === 'function') window.loadProfiles(); if (typeof window.loadAutomations === 'function') window.loadAutomations();
} else if (name === 'scenes') { } else if (name === 'scenes') {
if (typeof window.loadScenes === 'function') window.loadScenes(); if (typeof window.loadScenes === 'function') window.loadScenes();
} }

View File

@@ -24,7 +24,7 @@ const TOUR_KEY = 'tour_completed';
const gettingStartedSteps = [ const gettingStartedSteps = [
{ selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' }, { selector: 'header .header-title', textKey: 'tour.welcome', position: 'bottom' },
{ selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' }, { selector: '#tab-btn-dashboard', textKey: 'tour.dashboard', position: 'bottom' },
{ selector: '#tab-btn-profiles', textKey: 'tour.profiles', position: 'bottom' }, { selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' }, { selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
@@ -39,7 +39,7 @@ const dashboardTutorialSteps = [
{ selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' }, { selector: '[data-dashboard-section="perf"]', textKey: 'tour.dash.perf', position: 'bottom' },
{ selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' }, { selector: '[data-dashboard-section="running"]', textKey: 'tour.dash.running', position: 'bottom' },
{ selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' }, { selector: '[data-dashboard-section="stopped"]', textKey: 'tour.dash.stopped', position: 'bottom' },
{ selector: '[data-dashboard-section="profiles"]', textKey: 'tour.dash.profiles', position: 'bottom' } { selector: '[data-dashboard-section="automations"]', textKey: 'tour.dash.automations', position: 'bottom' }
]; ];
const targetsTutorialSteps = [ const targetsTutorialSteps = [
@@ -59,10 +59,10 @@ const sourcesTourSteps = [
{ selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' } { selector: '[data-stream-tab="value"]', textKey: 'tour.src.value', position: 'bottom' }
]; ];
const profilesTutorialSteps = [ const automationsTutorialSteps = [
{ selector: '[data-card-section="profiles"]', textKey: 'tour.prof.list', position: 'bottom' }, { selector: '[data-card-section="automations"]', textKey: 'tour.auto.list', position: 'bottom' },
{ selector: '[data-cs-add="profiles"]', textKey: 'tour.prof.add', position: 'bottom' }, { selector: '[data-cs-add="automations"]', textKey: 'tour.auto.add', position: 'bottom' },
{ selector: '.card[data-profile-id]', textKey: 'tour.prof.card', position: 'bottom' } { selector: '.card[data-automation-id]', textKey: 'tour.auto.card', position: 'bottom' }
]; ];
const _fixedResolve = (step) => { const _fixedResolve = (step) => {
@@ -184,9 +184,9 @@ export function startSourcesTutorial() {
}); });
} }
export function startProfilesTutorial() { export function startAutomationsTutorial() {
startTutorial({ startTutorial({
steps: profilesTutorialSteps, steps: automationsTutorialSteps,
overlayId: 'getting-started-overlay', overlayId: 'getting-started-overlay',
mode: 'fixed', mode: 'fixed',
container: null, container: null,

View File

@@ -220,10 +220,10 @@
"calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off", "calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off",
"calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off", "calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off",
"tour.welcome": "Welcome to LED Grab! This quick tour will show you around the interface. Use arrow keys or buttons to navigate.", "tour.welcome": "Welcome to LED Grab! This quick tour will show you around the interface. Use arrow keys or buttons to navigate.",
"tour.dashboard": "Dashboard — live overview of running targets, profiles, and device health at a glance.", "tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.",
"tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.", "tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.",
"tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.", "tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.",
"tour.profiles": "Profiles — group targets and automate switching with time, audio, or value conditions.", "tour.automations": "Automations — automate scene switching with time, audio, or value conditions.",
"tour.settings": "Settings — backup and restore configuration, manage auto-backups.", "tour.settings": "Settings — backup and restore configuration, manage auto-backups.",
"tour.api": "API Docs — interactive REST API documentation powered by Swagger.", "tour.api": "API Docs — interactive REST API documentation powered by Swagger.",
"tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.", "tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.",
@@ -234,7 +234,7 @@
"tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.", "tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.",
"tour.dash.running": "Running targets — live streaming metrics and quick stop control.", "tour.dash.running": "Running targets — live streaming metrics and quick stop control.",
"tour.dash.stopped": "Stopped targets — ready to start with one click.", "tour.dash.stopped": "Stopped targets — ready to start with one click.",
"tour.dash.profiles": "Profiles — active profile status and quick enable/disable toggle.", "tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.",
"tour.tgt.led_tab": "LED tab — standard LED strip targets with device and color strip configuration.", "tour.tgt.led_tab": "LED tab — standard LED strip targets with device and color strip configuration.",
"tour.tgt.devices": "Devices — your WLED controllers discovered on the network.", "tour.tgt.devices": "Devices — your WLED controllers discovered on the network.",
"tour.tgt.css": "Color Strips — define how screen regions map to LED segments.", "tour.tgt.css": "Color Strips — define how screen regions map to LED segments.",
@@ -245,10 +245,10 @@
"tour.src.static": "Static Image — test your setup with image files instead of live capture.", "tour.src.static": "Static Image — test your setup with image files instead of live capture.",
"tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.", "tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.",
"tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.", "tour.src.audio": "Audio — analyze microphone or system audio for reactive LED effects.",
"tour.src.value": "Value — numeric data sources used as conditions in profile automation.", "tour.src.value": "Value — numeric data sources used as conditions in automations.",
"tour.prof.list": "Profiles — automate target control based on time, audio, or value conditions.", "tour.auto.list": "Automations — automate scene activation based on time, audio, or value conditions.",
"tour.prof.add": "Click + to create a new profile with targets and activation conditions.", "tour.auto.add": "Click + to create a new automation with conditions and a scene to activate.",
"tour.prof.card": "Each card shows profile status, conditions, and quick controls to edit or toggle.", "tour.auto.card": "Each card shows automation status, conditions, and quick controls to edit or toggle.",
"calibration.tutorial.start": "Start tutorial", "calibration.tutorial.start": "Start tutorial",
"calibration.overlay_toggle": "Overlay", "calibration.overlay_toggle": "Overlay",
"calibration.start_position": "Starting Position:", "calibration.start_position": "Starting Position:",
@@ -531,7 +531,7 @@
"dashboard.device": "Device", "dashboard.device": "Device",
"dashboard.stop_all": "Stop All", "dashboard.stop_all": "Stop All",
"dashboard.failed": "Failed to load dashboard", "dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles", "dashboard.section.automations": "Automations",
"dashboard.section.scenes": "Scene Presets", "dashboard.section.scenes": "Scene Presets",
"dashboard.targets": "Targets", "dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance", "dashboard.section.performance": "System Performance",
@@ -541,83 +541,83 @@
"dashboard.perf.unavailable": "unavailable", "dashboard.perf.unavailable": "unavailable",
"dashboard.perf.color": "Chart color", "dashboard.perf.color": "Chart color",
"dashboard.poll_interval": "Refresh interval", "dashboard.poll_interval": "Refresh interval",
"profiles.title": "Profiles", "automations.title": "Automations",
"profiles.empty": "No profiles configured. Create one to automate target activation.", "automations.empty": "No automations configured. Create one to automate scene activation.",
"profiles.add": "Add Profile", "automations.add": "Add Automation",
"profiles.edit": "Edit Profile", "automations.edit": "Edit Automation",
"profiles.delete.confirm": "Delete profile \"{name}\"?", "automations.delete.confirm": "Delete automation \"{name}\"?",
"profiles.name": "Name:", "automations.name": "Name:",
"profiles.name.hint": "A descriptive name for this profile", "automations.name.hint": "A descriptive name for this automation",
"profiles.enabled": "Enabled:", "automations.enabled": "Enabled:",
"profiles.enabled.hint": "Disabled profiles won't activate even when conditions are met", "automations.enabled.hint": "Disabled automations won't activate even when conditions are met",
"profiles.condition_logic": "Condition Logic:", "automations.condition_logic": "Condition Logic:",
"profiles.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)", "automations.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)",
"profiles.condition_logic.or": "Any condition (OR)", "automations.condition_logic.or": "Any condition (OR)",
"profiles.condition_logic.and": "All conditions (AND)", "automations.condition_logic.and": "All conditions (AND)",
"profiles.conditions": "Conditions:", "automations.conditions": "Conditions:",
"profiles.conditions.hint": "Rules that determine when this profile activates", "automations.conditions.hint": "Rules that determine when this automation activates",
"profiles.conditions.add": "Add Condition", "automations.conditions.add": "Add Condition",
"profiles.conditions.empty": "No conditions — profile is always active when enabled", "automations.conditions.empty": "No conditions — automation is always active when enabled",
"profiles.condition.always": "Always", "automations.condition.always": "Always",
"profiles.condition.always.hint": "Profile activates immediately when enabled and stays active. Use this to auto-start targets on server startup.", "automations.condition.always.hint": "Automation activates immediately when enabled and stays active. Use this to auto-start scenes on server startup.",
"profiles.condition.application": "Application", "automations.condition.application": "Application",
"profiles.condition.application.apps": "Applications:", "automations.condition.application.apps": "Applications:",
"profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", "automations.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"profiles.condition.application.browse": "Browse", "automations.condition.application.browse": "Browse",
"profiles.condition.application.search": "Filter processes...", "automations.condition.application.search": "Filter processes...",
"profiles.condition.application.no_processes": "No processes found", "automations.condition.application.no_processes": "No processes found",
"profiles.condition.application.match_type": "Match Type:", "automations.condition.application.match_type": "Match Type:",
"profiles.condition.application.match_type.hint": "How to detect the application", "automations.condition.application.match_type.hint": "How to detect the application",
"profiles.condition.application.match_type.running": "Running", "automations.condition.application.match_type.running": "Running",
"profiles.condition.application.match_type.topmost": "Topmost (foreground)", "automations.condition.application.match_type.topmost": "Topmost (foreground)",
"profiles.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen", "automations.condition.application.match_type.topmost_fullscreen": "Topmost + Fullscreen",
"profiles.condition.application.match_type.fullscreen": "Fullscreen", "automations.condition.application.match_type.fullscreen": "Fullscreen",
"profiles.condition.time_of_day": "Time of Day", "automations.condition.time_of_day": "Time of Day",
"profiles.condition.time_of_day.start_time": "Start Time:", "automations.condition.time_of_day.start_time": "Start Time:",
"profiles.condition.time_of_day.end_time": "End Time:", "automations.condition.time_of_day.end_time": "End Time:",
"profiles.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.", "automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:0006:00), set start time after end time.",
"profiles.condition.system_idle": "System Idle", "automations.condition.system_idle": "System Idle",
"profiles.condition.system_idle.idle_minutes": "Idle Timeout (minutes):", "automations.condition.system_idle.idle_minutes": "Idle Timeout (minutes):",
"profiles.condition.system_idle.mode": "Trigger Mode:", "automations.condition.system_idle.mode": "Trigger Mode:",
"profiles.condition.system_idle.when_idle": "When idle", "automations.condition.system_idle.when_idle": "When idle",
"profiles.condition.system_idle.when_active": "When active", "automations.condition.system_idle.when_active": "When active",
"profiles.condition.display_state": "Display State", "automations.condition.display_state": "Display State",
"profiles.condition.display_state.state": "Monitor State:", "automations.condition.display_state.state": "Monitor State:",
"profiles.condition.display_state.on": "On", "automations.condition.display_state.on": "On",
"profiles.condition.display_state.off": "Off (sleeping)", "automations.condition.display_state.off": "Off (sleeping)",
"profiles.condition.mqtt": "MQTT", "automations.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "Topic:", "automations.condition.mqtt.topic": "Topic:",
"profiles.condition.mqtt.payload": "Payload:", "automations.condition.mqtt.payload": "Payload:",
"profiles.condition.mqtt.match_mode": "Match Mode:", "automations.condition.mqtt.match_mode": "Match Mode:",
"profiles.condition.mqtt.match_mode.exact": "Exact", "automations.condition.mqtt.match_mode.exact": "Exact",
"profiles.condition.mqtt.match_mode.contains": "Contains", "automations.condition.mqtt.match_mode.contains": "Contains",
"profiles.condition.mqtt.match_mode.regex": "Regex", "automations.condition.mqtt.match_mode.regex": "Regex",
"profiles.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload", "automations.condition.mqtt.hint": "Activate when an MQTT topic receives a matching payload",
"profiles.scene": "Scene:", "automations.scene": "Scene:",
"profiles.scene.hint": "Scene preset to activate when conditions are met", "automations.scene.hint": "Scene preset to activate when conditions are met",
"profiles.scene.search_placeholder": "Search scenes...", "automations.scene.search_placeholder": "Search scenes...",
"profiles.scene.none_selected": "No scene", "automations.scene.none_selected": "No scene",
"profiles.scene.none_available": "No scenes available", "automations.scene.none_available": "No scenes available",
"profiles.deactivation_mode": "Deactivation:", "automations.deactivation_mode": "Deactivation:",
"profiles.deactivation_mode.hint": "What happens when conditions stop matching", "automations.deactivation_mode.hint": "What happens when conditions stop matching",
"profiles.deactivation_mode.none": "None — keep current state", "automations.deactivation_mode.none": "None — keep current state",
"profiles.deactivation_mode.revert": "Revert to previous state", "automations.deactivation_mode.revert": "Revert to previous state",
"profiles.deactivation_mode.fallback_scene": "Activate fallback scene", "automations.deactivation_mode.fallback_scene": "Activate fallback scene",
"profiles.deactivation_scene": "Fallback Scene:", "automations.deactivation_scene": "Fallback Scene:",
"profiles.deactivation_scene.hint": "Scene to activate when this profile deactivates", "automations.deactivation_scene.hint": "Scene to activate when this automation deactivates",
"profiles.status.active": "Active", "automations.status.active": "Active",
"profiles.status.inactive": "Inactive", "automations.status.inactive": "Inactive",
"profiles.status.disabled": "Disabled", "automations.status.disabled": "Disabled",
"profiles.action.disable": "Disable", "automations.action.disable": "Disable",
"profiles.last_activated": "Last activated", "automations.last_activated": "Last activated",
"profiles.logic.and": " AND ", "automations.logic.and": " AND ",
"profiles.logic.or": " OR ", "automations.logic.or": " OR ",
"profiles.logic.all": "ALL", "automations.logic.all": "ALL",
"profiles.logic.any": "ANY", "automations.logic.any": "ANY",
"profiles.updated": "Profile updated", "automations.updated": "Automation updated",
"profiles.created": "Profile created", "automations.created": "Automation created",
"profiles.deleted": "Profile deleted", "automations.deleted": "Automation deleted",
"profiles.error.name_required": "Name is required", "automations.error.name_required": "Name is required",
"scenes.title": "Scenes", "scenes.title": "Scenes",
"scenes.add": "Capture Scene", "scenes.add": "Capture Scene",
"scenes.edit": "Edit Scene", "scenes.edit": "Edit Scene",
@@ -633,7 +633,7 @@
"scenes.delete": "Delete scene", "scenes.delete": "Delete scene",
"scenes.targets_count": "targets", "scenes.targets_count": "targets",
"scenes.devices_count": "devices", "scenes.devices_count": "devices",
"scenes.profiles_count": "profiles", "scenes.automations_count": "automations",
"scenes.captured": "Scene captured", "scenes.captured": "Scene captured",
"scenes.updated": "Scene updated", "scenes.updated": "Scene updated",
"scenes.activated": "Scene activated", "scenes.activated": "Scene activated",
@@ -1016,7 +1016,7 @@
"search.group.targets": "LED Targets", "search.group.targets": "LED Targets",
"search.group.kc_targets": "Key Colors Targets", "search.group.kc_targets": "Key Colors Targets",
"search.group.css": "Color Strip Sources", "search.group.css": "Color Strip Sources",
"search.group.profiles": "Profiles", "search.group.automations": "Automations",
"search.group.streams": "Picture Streams", "search.group.streams": "Picture Streams",
"search.group.capture_templates": "Capture Templates", "search.group.capture_templates": "Capture Templates",
"search.group.pp_templates": "Post-Processing Templates", "search.group.pp_templates": "Post-Processing Templates",
@@ -1025,7 +1025,7 @@
"search.group.value": "Value Sources", "search.group.value": "Value Sources",
"search.group.scenes": "Scene Presets", "search.group.scenes": "Scene Presets",
"settings.backup.label": "Backup Configuration", "settings.backup.label": "Backup Configuration",
"settings.backup.hint": "Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.", "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.",
"settings.backup.button": "Download Backup", "settings.backup.button": "Download Backup",
"settings.backup.success": "Backup downloaded successfully", "settings.backup.success": "Backup downloaded successfully",
"settings.backup.error": "Backup download failed", "settings.backup.error": "Backup download failed",
@@ -1074,7 +1074,7 @@
"calibration.error.save_failed": "Failed to save calibration", "calibration.error.save_failed": "Failed to save calibration",
"calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count", "calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count",
"calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count", "calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count",
"dashboard.error.profile_toggle_failed": "Failed to toggle profile", "dashboard.error.automation_toggle_failed": "Failed to toggle automation",
"dashboard.error.start_failed": "Failed to start processing", "dashboard.error.start_failed": "Failed to start processing",
"dashboard.error.stop_failed": "Failed to stop processing", "dashboard.error.stop_failed": "Failed to stop processing",
"dashboard.error.autostart_toggle_failed": "Failed to toggle auto-start", "dashboard.error.autostart_toggle_failed": "Failed to toggle auto-start",

View File

@@ -220,10 +220,10 @@
"calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными", "calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными",
"calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными", "calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными",
"tour.welcome": "Добро пожаловать в LED Grab! Этот краткий тур познакомит вас с интерфейсом. Используйте стрелки или кнопки для навигации.", "tour.welcome": "Добро пожаловать в LED Grab! Этот краткий тур познакомит вас с интерфейсом. Используйте стрелки или кнопки для навигации.",
"tour.dashboard": "Дашборд — обзор запущенных целей, профилей и состояния устройств.", "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.",
"tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.",
"tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.",
"tour.profiles": "Профили — группируйте цели и автоматизируйте переключение по расписанию, звуку или значениям.", "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.",
"tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.",
"tour.api": "API Документация — интерактивная документация REST API на базе Swagger.", "tour.api": "API Документация — интерактивная документация REST API на базе Swagger.",
"tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.", "tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.",
@@ -234,7 +234,7 @@
"tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.", "tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.",
"tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.", "tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.",
"tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.", "tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.",
"tour.dash.profiles": "Профили — статус активных профилей и быстрое включение/выключение.", "tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.",
"tour.tgt.led_tab": "LED — стандартные LED-цели с настройкой устройств и цветовых полос.", "tour.tgt.led_tab": "LED — стандартные LED-цели с настройкой устройств и цветовых полос.",
"tour.tgt.devices": "Устройства — ваши WLED-контроллеры, найденные в сети.", "tour.tgt.devices": "Устройства — ваши WLED-контроллеры, найденные в сети.",
"tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.", "tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.",
@@ -245,10 +245,10 @@
"tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.", "tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.",
"tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.",
"tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.", "tour.src.audio": "Аудио — анализ микрофона или системного звука для реактивных LED-эффектов.",
"tour.src.value": "Значения — числовые источники данных для условий автоматизации профилей.", "tour.src.value": "Значения — числовые источники данных для условий автоматизаций.",
"tour.prof.list": "Профили — автоматизируйте управление целями по времени, звуку или значениям.", "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.",
"tour.prof.add": "Нажмите + для создания нового профиля с целями и условиями активации.", "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.",
"tour.prof.card": "Каждая карточка показывает статус профиля, условия и кнопки управления.", "tour.auto.card": "Каждая карточка показывает статус автоматизации, условия и кнопки управления.",
"calibration.tutorial.start": "Начать обучение", "calibration.tutorial.start": "Начать обучение",
"calibration.overlay_toggle": "Оверлей", "calibration.overlay_toggle": "Оверлей",
"calibration.start_position": "Начальная Позиция:", "calibration.start_position": "Начальная Позиция:",
@@ -531,7 +531,7 @@
"dashboard.device": "Устройство", "dashboard.device": "Устройство",
"dashboard.stop_all": "Остановить все", "dashboard.stop_all": "Остановить все",
"dashboard.failed": "Не удалось загрузить обзор", "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили", "dashboard.section.automations": "Автоматизации",
"dashboard.section.scenes": "Пресеты сцен", "dashboard.section.scenes": "Пресеты сцен",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы", "dashboard.section.performance": "Производительность системы",
@@ -541,83 +541,83 @@
"dashboard.perf.unavailable": "недоступно", "dashboard.perf.unavailable": "недоступно",
"dashboard.perf.color": "Цвет графика", "dashboard.perf.color": "Цвет графика",
"dashboard.poll_interval": "Интервал обновления", "dashboard.poll_interval": "Интервал обновления",
"profiles.title": "Профили", "automations.title": "Автоматизации",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", "automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.",
"profiles.add": "Добавить профиль", "automations.add": "Добавить автоматизацию",
"profiles.edit": "Редактировать профиль", "automations.edit": "Редактировать автоматизацию",
"profiles.delete.confirm": "Удалить профиль \"{name}\"?", "automations.delete.confirm": "Удалить автоматизацию \"{name}\"?",
"profiles.name": "Название:", "automations.name": "Название:",
"profiles.name.hint": "Описательное имя для профиля", "automations.name.hint": "Описательное имя для автоматизации",
"profiles.enabled": "Включён:", "automations.enabled": "Включена:",
"profiles.enabled.hint": "Отключённые профили не активируются даже при выполнении условий", "automations.enabled.hint": "Отключённые автоматизации не активируются даже при выполнении условий",
"profiles.condition_logic": "Логика условий:", "automations.condition_logic": "Логика условий:",
"profiles.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)", "automations.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
"profiles.condition_logic.or": "Любое условие (ИЛИ)", "automations.condition_logic.or": "Любое условие (ИЛИ)",
"profiles.condition_logic.and": "Все условия (И)", "automations.condition_logic.and": "Все условия (И)",
"profiles.conditions": "Условия:", "automations.conditions": "Условия:",
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется", "automations.conditions.hint": "Правила, определяющие когда автоматизация активируется",
"profiles.conditions.add": "Добавить условие", "automations.conditions.add": "Добавить условие",
"profiles.conditions.empty": "Нет условий — профиль всегда активен когда включён", "automations.conditions.empty": "Нет условий — автоматизация всегда активна когда включена",
"profiles.condition.always": "Всегда", "automations.condition.always": "Всегда",
"profiles.condition.always.hint": "Профиль активируется сразу при включении и остаётся активным. Используйте для автозапуска целей при старте сервера.", "automations.condition.always.hint": "Автоматизация активируется сразу при включении и остаётся активной. Используйте для автозапуска сцен при старте сервера.",
"profiles.condition.application": "Приложение", "automations.condition.application": "Приложение",
"profiles.condition.application.apps": "Приложения:", "automations.condition.application.apps": "Приложения:",
"profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", "automations.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
"profiles.condition.application.browse": "Обзор", "automations.condition.application.browse": "Обзор",
"profiles.condition.application.search": "Фильтр процессов...", "automations.condition.application.search": "Фильтр процессов...",
"profiles.condition.application.no_processes": "Процессы не найдены", "automations.condition.application.no_processes": "Процессы не найдены",
"profiles.condition.application.match_type": "Тип соответствия:", "automations.condition.application.match_type": "Тип соответствия:",
"profiles.condition.application.match_type.hint": "Как определять наличие приложения", "automations.condition.application.match_type.hint": "Как определять наличие приложения",
"profiles.condition.application.match_type.running": "Запущено", "automations.condition.application.match_type.running": "Запущено",
"profiles.condition.application.match_type.topmost": "На переднем плане", "automations.condition.application.match_type.topmost": "На переднем плане",
"profiles.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран", "automations.condition.application.match_type.topmost_fullscreen": "На переднем плане + Полный экран",
"profiles.condition.application.match_type.fullscreen": "Полный экран", "automations.condition.application.match_type.fullscreen": "Полный экран",
"profiles.condition.time_of_day": "Время суток", "automations.condition.time_of_day": "Время суток",
"profiles.condition.time_of_day.start_time": "Время начала:", "automations.condition.time_of_day.start_time": "Время начала:",
"profiles.condition.time_of_day.end_time": "Время окончания:", "automations.condition.time_of_day.end_time": "Время окончания:",
"profiles.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:0006:00) укажите время начала позже времени окончания.", "automations.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:0006:00) укажите время начала позже времени окончания.",
"profiles.condition.system_idle": "Бездействие системы", "automations.condition.system_idle": "Бездействие системы",
"profiles.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", "automations.condition.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):",
"profiles.condition.system_idle.mode": "Режим срабатывания:", "automations.condition.system_idle.mode": "Режим срабатывания:",
"profiles.condition.system_idle.when_idle": "При бездействии", "automations.condition.system_idle.when_idle": "При бездействии",
"profiles.condition.system_idle.when_active": "При активности", "automations.condition.system_idle.when_active": "При активности",
"profiles.condition.display_state": "Состояние дисплея", "automations.condition.display_state": "Состояние дисплея",
"profiles.condition.display_state.state": "Состояние монитора:", "automations.condition.display_state.state": "Состояние монитора:",
"profiles.condition.display_state.on": "Включён", "automations.condition.display_state.on": "Включён",
"profiles.condition.display_state.off": "Выключен (спящий режим)", "automations.condition.display_state.off": "Выключен (спящий режим)",
"profiles.condition.mqtt": "MQTT", "automations.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "Топик:", "automations.condition.mqtt.topic": "Топик:",
"profiles.condition.mqtt.payload": "Значение:", "automations.condition.mqtt.payload": "Значение:",
"profiles.condition.mqtt.match_mode": "Режим сравнения:", "automations.condition.mqtt.match_mode": "Режим сравнения:",
"profiles.condition.mqtt.match_mode.exact": "Точное совпадение", "automations.condition.mqtt.match_mode.exact": "Точное совпадение",
"profiles.condition.mqtt.match_mode.contains": "Содержит", "automations.condition.mqtt.match_mode.contains": "Содержит",
"profiles.condition.mqtt.match_mode.regex": "Регулярное выражение", "automations.condition.mqtt.match_mode.regex": "Регулярное выражение",
"profiles.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", "automations.condition.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику",
"profiles.scene": "Сцена:", "automations.scene": "Сцена:",
"profiles.scene.hint": "Пресет сцены для активации при выполнении условий", "automations.scene.hint": "Пресет сцены для активации при выполнении условий",
"profiles.scene.search_placeholder": "Поиск сцен...", "automations.scene.search_placeholder": "Поиск сцен...",
"profiles.scene.none_selected": "Нет сцены", "automations.scene.none_selected": "Нет сцены",
"profiles.scene.none_available": "Нет доступных сцен", "automations.scene.none_available": "Нет доступных сцен",
"profiles.deactivation_mode": "Деактивация:", "automations.deactivation_mode": "Деактивация:",
"profiles.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться", "automations.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться",
"profiles.deactivation_mode.none": "Ничего — оставить текущее состояние", "automations.deactivation_mode.none": "Ничего — оставить текущее состояние",
"profiles.deactivation_mode.revert": "Вернуть предыдущее состояние", "automations.deactivation_mode.revert": "Вернуть предыдущее состояние",
"profiles.deactivation_mode.fallback_scene": "Активировать резервную сцену", "automations.deactivation_mode.fallback_scene": "Активировать резервную сцену",
"profiles.deactivation_scene": "Резервная сцена:", "automations.deactivation_scene": "Резервная сцена:",
"profiles.deactivation_scene.hint": "Сцена для активации при деактивации профиля", "automations.deactivation_scene.hint": "Сцена для активации при деактивации автоматизации",
"profiles.status.active": "Активен", "automations.status.active": "Активна",
"profiles.status.inactive": "Неактивен", "automations.status.inactive": "Неактивна",
"profiles.status.disabled": "Отключён", "automations.status.disabled": "Отключена",
"profiles.action.disable": "Отключить", "automations.action.disable": "Отключить",
"profiles.last_activated": "Последняя активация", "automations.last_activated": "Последняя активация",
"profiles.logic.and": " И ", "automations.logic.and": " И ",
"profiles.logic.or": " ИЛИ ", "automations.logic.or": " ИЛИ ",
"profiles.logic.all": "ВСЕ", "automations.logic.all": "ВСЕ",
"profiles.logic.any": "ЛЮБОЕ", "automations.logic.any": "ЛЮБОЕ",
"profiles.updated": "Профиль обновлён", "automations.updated": "Автоматизация обновлена",
"profiles.created": "Профиль создан", "automations.created": "Автоматизация создана",
"profiles.deleted": "Профиль удалён", "automations.deleted": "Автоматизация удалена",
"profiles.error.name_required": "Введите название", "automations.error.name_required": "Введите название",
"scenes.title": "Сцены", "scenes.title": "Сцены",
"scenes.add": "Захватить сцену", "scenes.add": "Захватить сцену",
"scenes.edit": "Редактировать сцену", "scenes.edit": "Редактировать сцену",
@@ -633,7 +633,7 @@
"scenes.delete": "Удалить сцену", "scenes.delete": "Удалить сцену",
"scenes.targets_count": "целей", "scenes.targets_count": "целей",
"scenes.devices_count": "устройств", "scenes.devices_count": "устройств",
"scenes.profiles_count": "профилей", "scenes.automations_count": "автоматизаций",
"scenes.captured": "Сцена захвачена", "scenes.captured": "Сцена захвачена",
"scenes.updated": "Сцена обновлена", "scenes.updated": "Сцена обновлена",
"scenes.activated": "Сцена активирована", "scenes.activated": "Сцена активирована",
@@ -1016,7 +1016,7 @@
"search.group.targets": "LED-цели", "search.group.targets": "LED-цели",
"search.group.kc_targets": "Цели Key Colors", "search.group.kc_targets": "Цели Key Colors",
"search.group.css": "Источники цветных лент", "search.group.css": "Источники цветных лент",
"search.group.profiles": "Профили", "search.group.automations": "Автоматизации",
"search.group.streams": "Потоки изображений", "search.group.streams": "Потоки изображений",
"search.group.capture_templates": "Шаблоны захвата", "search.group.capture_templates": "Шаблоны захвата",
"search.group.pp_templates": "Шаблоны постобработки", "search.group.pp_templates": "Шаблоны постобработки",
@@ -1025,7 +1025,7 @@
"search.group.value": "Источники значений", "search.group.value": "Источники значений",
"search.group.scenes": "Пресеты сцен", "search.group.scenes": "Пресеты сцен",
"settings.backup.label": "Резервное копирование", "settings.backup.label": "Резервное копирование",
"settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, профили) в виде одного JSON-файла.", "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.",
"settings.backup.button": "Скачать резервную копию", "settings.backup.button": "Скачать резервную копию",
"settings.backup.success": "Резервная копия скачана", "settings.backup.success": "Резервная копия скачана",
"settings.backup.error": "Ошибка скачивания резервной копии", "settings.backup.error": "Ошибка скачивания резервной копии",
@@ -1074,7 +1074,7 @@
"calibration.error.save_failed": "Не удалось сохранить калибровку", "calibration.error.save_failed": "Не удалось сохранить калибровку",
"calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства", "calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства",
"calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED", "calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED",
"dashboard.error.profile_toggle_failed": "Не удалось переключить профиль", "dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию",
"dashboard.error.start_failed": "Не удалось запустить обработку", "dashboard.error.start_failed": "Не удалось запустить обработку",
"dashboard.error.stop_failed": "Не удалось остановить обработку", "dashboard.error.stop_failed": "Не удалось остановить обработку",
"dashboard.error.autostart_toggle_failed": "Не удалось переключить автозапуск", "dashboard.error.autostart_toggle_failed": "Не удалось переключить автозапуск",

View File

@@ -220,10 +220,10 @@
"calibration.tip.skip_leds_start": "跳过灯带起始端的 LED — 被跳过的 LED 保持关闭", "calibration.tip.skip_leds_start": "跳过灯带起始端的 LED — 被跳过的 LED 保持关闭",
"calibration.tip.skip_leds_end": "跳过灯带末尾端的 LED — 被跳过的 LED 保持关闭", "calibration.tip.skip_leds_end": "跳过灯带末尾端的 LED — 被跳过的 LED 保持关闭",
"tour.welcome": "欢迎使用 LED Grab快速导览将带您了解界面。使用方向键或按钮进行导航。", "tour.welcome": "欢迎使用 LED Grab快速导览将带您了解界面。使用方向键或按钮进行导航。",
"tour.dashboard": "仪表盘 — 实时查看运行中的目标、配置文件和设备状态。", "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。",
"tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。",
"tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。",
"tour.profiles": "配置文件 — 将目标分组,并通过时间、音频或数值条件自动切换。", "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。",
"tour.settings": "设置 — 备份和恢复配置,管理自动备份。", "tour.settings": "设置 — 备份和恢复配置,管理自动备份。",
"tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。", "tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。",
"tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。", "tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。",
@@ -234,7 +234,7 @@
"tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。", "tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。",
"tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。", "tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。",
"tour.dash.stopped": "已停止的目标 — 一键启动。", "tour.dash.stopped": "已停止的目标 — 一键启动。",
"tour.dash.profiles": "配置文件 — 活动配置文件状态和快速启用/禁用切换。", "tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。",
"tour.tgt.led_tab": "LED 标签 — 标准 LED 灯带目标,包含设备和色带配置。", "tour.tgt.led_tab": "LED 标签 — 标准 LED 灯带目标,包含设备和色带配置。",
"tour.tgt.devices": "设备 — 在网络中发现的 WLED 控制器。", "tour.tgt.devices": "设备 — 在网络中发现的 WLED 控制器。",
"tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。", "tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。",
@@ -245,10 +245,10 @@
"tour.src.static": "静态图片 — 使用图片文件测试您的设置。", "tour.src.static": "静态图片 — 使用图片文件测试您的设置。",
"tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。",
"tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。", "tour.src.audio": "音频 — 分析麦克风或系统音频以实现响应式 LED 效果。",
"tour.src.value": "数值 — 用于配置文件自动化条件的数字数据源。", "tour.src.value": "数值 — 用于自动化条件的数字数据源。",
"tour.prof.list": "配置文件 — 基于时间、音频或数值条件自动控制目标。", "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。",
"tour.prof.add": "点击 + 创建包含目标和激活条件的新配置文件。", "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。",
"tour.prof.card": "每张卡片显示配置文件状态、条件和快速编辑/切换控制。", "tour.auto.card": "每张卡片显示自动化状态、条件和快速编辑/切换控制。",
"calibration.tutorial.start": "开始教程", "calibration.tutorial.start": "开始教程",
"calibration.overlay_toggle": "叠加层", "calibration.overlay_toggle": "叠加层",
"calibration.start_position": "起始位置:", "calibration.start_position": "起始位置:",
@@ -531,7 +531,7 @@
"dashboard.device": "设备", "dashboard.device": "设备",
"dashboard.stop_all": "全部停止", "dashboard.stop_all": "全部停止",
"dashboard.failed": "加载仪表盘失败", "dashboard.failed": "加载仪表盘失败",
"dashboard.section.profiles": "配置文件", "dashboard.section.automations": "自动化",
"dashboard.section.scenes": "场景预设", "dashboard.section.scenes": "场景预设",
"dashboard.targets": "目标", "dashboard.targets": "目标",
"dashboard.section.performance": "系统性能", "dashboard.section.performance": "系统性能",
@@ -541,83 +541,83 @@
"dashboard.perf.unavailable": "不可用", "dashboard.perf.unavailable": "不可用",
"dashboard.perf.color": "图表颜色", "dashboard.perf.color": "图表颜色",
"dashboard.poll_interval": "刷新间隔", "dashboard.poll_interval": "刷新间隔",
"profiles.title": "配置文件", "automations.title": "自动化",
"profiles.empty": "尚未配置配置文件。创建一个以自动化目标激活。", "automations.empty": "尚未配置自动化。创建一个以自动激活场景。",
"profiles.add": "添加配置文件", "automations.add": "添加自动化",
"profiles.edit": "编辑配置文件", "automations.edit": "编辑自动化",
"profiles.delete.confirm": "删除配置文件 \"{name}\"", "automations.delete.confirm": "删除自动化 \"{name}\"",
"profiles.name": "名称:", "automations.name": "名称:",
"profiles.name.hint": "此配置文件的描述性名称", "automations.name.hint": "此自动化的描述性名称",
"profiles.enabled": "启用:", "automations.enabled": "启用:",
"profiles.enabled.hint": "禁用的配置文件即使满足条件也不会激活", "automations.enabled.hint": "禁用的自动化即使满足条件也不会激活",
"profiles.condition_logic": "条件逻辑:", "automations.condition_logic": "条件逻辑:",
"profiles.condition_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)", "automations.condition_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)",
"profiles.condition_logic.or": "任一条件(或)", "automations.condition_logic.or": "任一条件(或)",
"profiles.condition_logic.and": "全部条件(与)", "automations.condition_logic.and": "全部条件(与)",
"profiles.conditions": "条件:", "automations.conditions": "条件:",
"profiles.conditions.hint": "决定此配置文件何时激活的规则", "automations.conditions.hint": "决定此自动化何时激活的规则",
"profiles.conditions.add": "添加条件", "automations.conditions.add": "添加条件",
"profiles.conditions.empty": "无条件 — 启用后配置文件始终处于活动状态", "automations.conditions.empty": "无条件 — 启用后自动化始终处于活动状态",
"profiles.condition.always": "始终", "automations.condition.always": "始终",
"profiles.condition.always.hint": "配置文件启用后立即激活并保持活动。用于服务器启动时自动启动目标。", "automations.condition.always.hint": "自动化启用后立即激活并保持活动。用于服务器启动时自动激活场景。",
"profiles.condition.application": "应用程序", "automations.condition.application": "应用程序",
"profiles.condition.application.apps": "应用程序:", "automations.condition.application.apps": "应用程序:",
"profiles.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe", "automations.condition.application.apps.hint": "进程名,每行一个(例如 firefox.exe",
"profiles.condition.application.browse": "浏览", "automations.condition.application.browse": "浏览",
"profiles.condition.application.search": "筛选进程...", "automations.condition.application.search": "筛选进程...",
"profiles.condition.application.no_processes": "未找到进程", "automations.condition.application.no_processes": "未找到进程",
"profiles.condition.application.match_type": "匹配类型:", "automations.condition.application.match_type": "匹配类型:",
"profiles.condition.application.match_type.hint": "如何检测应用程序", "automations.condition.application.match_type.hint": "如何检测应用程序",
"profiles.condition.application.match_type.running": "运行中", "automations.condition.application.match_type.running": "运行中",
"profiles.condition.application.match_type.topmost": "最前(前台)", "automations.condition.application.match_type.topmost": "最前(前台)",
"profiles.condition.application.match_type.topmost_fullscreen": "最前 + 全屏", "automations.condition.application.match_type.topmost_fullscreen": "最前 + 全屏",
"profiles.condition.application.match_type.fullscreen": "全屏", "automations.condition.application.match_type.fullscreen": "全屏",
"profiles.condition.time_of_day": "时段", "automations.condition.time_of_day": "时段",
"profiles.condition.time_of_day.start_time": "开始时间:", "automations.condition.time_of_day.start_time": "开始时间:",
"profiles.condition.time_of_day.end_time": "结束时间:", "automations.condition.time_of_day.end_time": "结束时间:",
"profiles.condition.time_of_day.overnight_hint": "跨夜时段(如 22:0006:00请将开始时间设为晚于结束时间。", "automations.condition.time_of_day.overnight_hint": "跨夜时段(如 22:0006:00请将开始时间设为晚于结束时间。",
"profiles.condition.system_idle": "系统空闲", "automations.condition.system_idle": "系统空闲",
"profiles.condition.system_idle.idle_minutes": "空闲超时(分钟):", "automations.condition.system_idle.idle_minutes": "空闲超时(分钟):",
"profiles.condition.system_idle.mode": "触发模式:", "automations.condition.system_idle.mode": "触发模式:",
"profiles.condition.system_idle.when_idle": "空闲时", "automations.condition.system_idle.when_idle": "空闲时",
"profiles.condition.system_idle.when_active": "活跃时", "automations.condition.system_idle.when_active": "活跃时",
"profiles.condition.display_state": "显示器状态", "automations.condition.display_state": "显示器状态",
"profiles.condition.display_state.state": "显示器状态:", "automations.condition.display_state.state": "显示器状态:",
"profiles.condition.display_state.on": "开启", "automations.condition.display_state.on": "开启",
"profiles.condition.display_state.off": "关闭(休眠)", "automations.condition.display_state.off": "关闭(休眠)",
"profiles.condition.mqtt": "MQTT", "automations.condition.mqtt": "MQTT",
"profiles.condition.mqtt.topic": "主题:", "automations.condition.mqtt.topic": "主题:",
"profiles.condition.mqtt.payload": "消息内容:", "automations.condition.mqtt.payload": "消息内容:",
"profiles.condition.mqtt.match_mode": "匹配模式:", "automations.condition.mqtt.match_mode": "匹配模式:",
"profiles.condition.mqtt.match_mode.exact": "精确匹配", "automations.condition.mqtt.match_mode.exact": "精确匹配",
"profiles.condition.mqtt.match_mode.contains": "包含", "automations.condition.mqtt.match_mode.contains": "包含",
"profiles.condition.mqtt.match_mode.regex": "正则表达式", "automations.condition.mqtt.match_mode.regex": "正则表达式",
"profiles.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", "automations.condition.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活",
"profiles.scene": "场景:", "automations.scene": "场景:",
"profiles.scene.hint": "条件满足时激活的场景预设", "automations.scene.hint": "条件满足时激活的场景预设",
"profiles.scene.search_placeholder": "搜索场景...", "automations.scene.search_placeholder": "搜索场景...",
"profiles.scene.none_selected": "无场景", "automations.scene.none_selected": "无场景",
"profiles.scene.none_available": "没有可用的场景", "automations.scene.none_available": "没有可用的场景",
"profiles.deactivation_mode": "停用方式:", "automations.deactivation_mode": "停用方式:",
"profiles.deactivation_mode.hint": "条件不再满足时的行为", "automations.deactivation_mode.hint": "条件不再满足时的行为",
"profiles.deactivation_mode.none": "无 — 保持当前状态", "automations.deactivation_mode.none": "无 — 保持当前状态",
"profiles.deactivation_mode.revert": "恢复到之前的状态", "automations.deactivation_mode.revert": "恢复到之前的状态",
"profiles.deactivation_mode.fallback_scene": "激活备用场景", "automations.deactivation_mode.fallback_scene": "激活备用场景",
"profiles.deactivation_scene": "备用场景:", "automations.deactivation_scene": "备用场景:",
"profiles.deactivation_scene.hint": "配置文件停用时激活的场景", "automations.deactivation_scene.hint": "自动化停用时激活的场景",
"profiles.status.active": "活动", "automations.status.active": "活动",
"profiles.status.inactive": "非活动", "automations.status.inactive": "非活动",
"profiles.status.disabled": "已禁用", "automations.status.disabled": "已禁用",
"profiles.action.disable": "禁用", "automations.action.disable": "禁用",
"profiles.last_activated": "上次激活", "automations.last_activated": "上次激活",
"profiles.logic.and": " 与 ", "automations.logic.and": " 与 ",
"profiles.logic.or": " 或 ", "automations.logic.or": " 或 ",
"profiles.logic.all": "全部", "automations.logic.all": "全部",
"profiles.logic.any": "任一", "automations.logic.any": "任一",
"profiles.updated": "配置文件已更新", "automations.updated": "自动化已更新",
"profiles.created": "配置文件已创建", "automations.created": "自动化已创建",
"profiles.deleted": "配置文件已删除", "automations.deleted": "自动化已删除",
"profiles.error.name_required": "名称为必填项", "automations.error.name_required": "名称为必填项",
"scenes.title": "场景", "scenes.title": "场景",
"scenes.add": "捕获场景", "scenes.add": "捕获场景",
"scenes.edit": "编辑场景", "scenes.edit": "编辑场景",
@@ -633,7 +633,7 @@
"scenes.delete": "删除场景", "scenes.delete": "删除场景",
"scenes.targets_count": "目标", "scenes.targets_count": "目标",
"scenes.devices_count": "设备", "scenes.devices_count": "设备",
"scenes.profiles_count": "配置", "scenes.automations_count": "自动化",
"scenes.captured": "场景已捕获", "scenes.captured": "场景已捕获",
"scenes.updated": "场景已更新", "scenes.updated": "场景已更新",
"scenes.activated": "场景已激活", "scenes.activated": "场景已激活",
@@ -1016,7 +1016,7 @@
"search.group.targets": "LED 目标", "search.group.targets": "LED 目标",
"search.group.kc_targets": "关键颜色目标", "search.group.kc_targets": "关键颜色目标",
"search.group.css": "色带源", "search.group.css": "色带源",
"search.group.profiles": "配置文件", "search.group.automations": "自动化",
"search.group.streams": "图片流", "search.group.streams": "图片流",
"search.group.capture_templates": "采集模板", "search.group.capture_templates": "采集模板",
"search.group.pp_templates": "后处理模板", "search.group.pp_templates": "后处理模板",
@@ -1025,7 +1025,7 @@
"search.group.value": "值源", "search.group.value": "值源",
"search.group.scenes": "场景预设", "search.group.scenes": "场景预设",
"settings.backup.label": "备份配置", "settings.backup.label": "备份配置",
"settings.backup.hint": "将所有配置(设备、目标、流、模板、配置文件)下载为单个 JSON 文件。", "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。",
"settings.backup.button": "下载备份", "settings.backup.button": "下载备份",
"settings.backup.success": "备份下载成功", "settings.backup.success": "备份下载成功",
"settings.backup.error": "备份下载失败", "settings.backup.error": "备份下载失败",
@@ -1074,7 +1074,7 @@
"calibration.error.save_failed": "保存校准失败", "calibration.error.save_failed": "保存校准失败",
"calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量", "calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量",
"calibration.error.led_count_exceeded": "校准的LED超过了LED总数", "calibration.error.led_count_exceeded": "校准的LED超过了LED总数",
"dashboard.error.profile_toggle_failed": "切换配置文件失败", "dashboard.error.automation_toggle_failed": "切换自动化失败",
"dashboard.error.start_failed": "启动处理失败", "dashboard.error.start_failed": "启动处理失败",
"dashboard.error.stop_failed": "停止处理失败", "dashboard.error.stop_failed": "停止处理失败",
"dashboard.error.autostart_toggle_failed": "切换自动启动失败", "dashboard.error.autostart_toggle_failed": "切换自动启动失败",

View File

@@ -1,4 +1,4 @@
"""Profile and Condition data models.""" """Automation and Condition data models."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -35,7 +35,7 @@ class Condition:
@dataclass @dataclass
class AlwaysCondition(Condition): class AlwaysCondition(Condition):
"""Always-true condition — profile activates unconditionally when enabled.""" """Always-true condition — automation activates unconditionally when enabled."""
condition_type: str = "always" condition_type: str = "always"
@@ -159,8 +159,8 @@ class MQTTCondition(Condition):
@dataclass @dataclass
class Profile: class Automation:
"""Automation profile that activates a scene preset based on conditions.""" """Automation that activates a scene preset based on conditions."""
id: str id: str
name: str name: str
@@ -188,7 +188,7 @@ class Profile:
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "Profile": def from_dict(cls, data: dict) -> "Automation":
conditions = [] conditions = []
for c_data in data.get("conditions", []): for c_data in data.get("conditions", []):
try: try:

View File

@@ -0,0 +1,158 @@
"""Automation storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.automation import Automation, Condition
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
class AutomationStore:
"""Persistent storage for automations."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._automations: Dict[str, Automation] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Support both old "profiles" key and new "automations" key
automations_data = data.get("automations", data.get("profiles", {}))
loaded = 0
for auto_id, auto_dict in automations_data.items():
try:
automation = Automation.from_dict(auto_dict)
self._automations[auto_id] = automation
loaded += 1
except Exception as e:
logger.error(f"Failed to load automation {auto_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} automations from storage")
except Exception as e:
logger.error(f"Failed to load automations from {self.file_path}: {e}")
raise
logger.info(f"Automation store initialized with {len(self._automations)} automations")
def _save(self) -> None:
try:
data = {
"version": "1.0.0",
"automations": {
aid: a.to_dict() for aid, a in self._automations.items()
},
}
atomic_write_json(self.file_path, data)
except Exception as e:
logger.error(f"Failed to save automations to {self.file_path}: {e}")
raise
def get_all_automations(self) -> List[Automation]:
return list(self._automations.values())
def get_automation(self, automation_id: str) -> Automation:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
return self._automations[automation_id]
def create_automation(
self,
name: str,
enabled: bool = True,
condition_logic: str = "or",
conditions: Optional[List[Condition]] = None,
scene_preset_id: Optional[str] = None,
deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None,
) -> Automation:
for a in self._automations.values():
if a.name == name:
raise ValueError(f"Automation with name '{name}' already exists")
automation_id = f"auto_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
automation = Automation(
id=automation_id,
name=name,
enabled=enabled,
condition_logic=condition_logic,
conditions=conditions or [],
scene_preset_id=scene_preset_id,
deactivation_mode=deactivation_mode,
deactivation_scene_preset_id=deactivation_scene_preset_id,
created_at=now,
updated_at=now,
)
self._automations[automation_id] = automation
self._save()
logger.info(f"Created automation: {name} ({automation_id})")
return automation
def update_automation(
self,
automation_id: str,
name: Optional[str] = None,
enabled: Optional[bool] = None,
condition_logic: Optional[str] = None,
conditions: Optional[List[Condition]] = None,
scene_preset_id: str = "__unset__",
deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__",
) -> Automation:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
automation = self._automations[automation_id]
if name is not None:
for aid, a in self._automations.items():
if aid != automation_id and a.name == name:
raise ValueError(f"Automation with name '{name}' already exists")
automation.name = name
if enabled is not None:
automation.enabled = enabled
if condition_logic is not None:
automation.condition_logic = condition_logic
if conditions is not None:
automation.conditions = conditions
if scene_preset_id != "__unset__":
automation.scene_preset_id = scene_preset_id
if deactivation_mode is not None:
automation.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
automation.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated automation: {automation_id}")
return automation
def delete_automation(self, automation_id: str) -> None:
if automation_id not in self._automations:
raise ValueError(f"Automation not found: {automation_id}")
del self._automations[automation_id]
self._save()
logger.info(f"Deleted automation: {automation_id}")
def count(self) -> int:
return len(self._automations)

View File

@@ -1,157 +0,0 @@
"""Profile storage using JSON files."""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.storage.profile import Condition, Profile
from wled_controller.utils import atomic_write_json, get_logger
logger = get_logger(__name__)
class ProfileStore:
"""Persistent storage for automation profiles."""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self._profiles: Dict[str, Profile] = {}
self._load()
def _load(self) -> None:
if not self.file_path.exists():
return
try:
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
profiles_data = data.get("profiles", {})
loaded = 0
for profile_id, profile_dict in profiles_data.items():
try:
profile = Profile.from_dict(profile_dict)
self._profiles[profile_id] = profile
loaded += 1
except Exception as e:
logger.error(f"Failed to load profile {profile_id}: {e}", exc_info=True)
if loaded > 0:
logger.info(f"Loaded {loaded} profiles from storage")
except Exception as e:
logger.error(f"Failed to load profiles from {self.file_path}: {e}")
raise
logger.info(f"Profile store initialized with {len(self._profiles)} profiles")
def _save(self) -> None:
try:
data = {
"version": "1.0.0",
"profiles": {
pid: p.to_dict() for pid, p in self._profiles.items()
},
}
atomic_write_json(self.file_path, data)
except Exception as e:
logger.error(f"Failed to save profiles to {self.file_path}: {e}")
raise
def get_all_profiles(self) -> List[Profile]:
return list(self._profiles.values())
def get_profile(self, profile_id: str) -> Profile:
if profile_id not in self._profiles:
raise ValueError(f"Profile not found: {profile_id}")
return self._profiles[profile_id]
def create_profile(
self,
name: str,
enabled: bool = True,
condition_logic: str = "or",
conditions: Optional[List[Condition]] = None,
scene_preset_id: Optional[str] = None,
deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None,
) -> Profile:
for p in self._profiles.values():
if p.name == name:
raise ValueError(f"Profile with name '{name}' already exists")
profile_id = f"prof_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
profile = Profile(
id=profile_id,
name=name,
enabled=enabled,
condition_logic=condition_logic,
conditions=conditions or [],
scene_preset_id=scene_preset_id,
deactivation_mode=deactivation_mode,
deactivation_scene_preset_id=deactivation_scene_preset_id,
created_at=now,
updated_at=now,
)
self._profiles[profile_id] = profile
self._save()
logger.info(f"Created profile: {name} ({profile_id})")
return profile
def update_profile(
self,
profile_id: str,
name: Optional[str] = None,
enabled: Optional[bool] = None,
condition_logic: Optional[str] = None,
conditions: Optional[List[Condition]] = None,
scene_preset_id: str = "__unset__",
deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__",
) -> Profile:
if profile_id not in self._profiles:
raise ValueError(f"Profile not found: {profile_id}")
profile = self._profiles[profile_id]
if name is not None:
for pid, p in self._profiles.items():
if pid != profile_id and p.name == name:
raise ValueError(f"Profile with name '{name}' already exists")
profile.name = name
if enabled is not None:
profile.enabled = enabled
if condition_logic is not None:
profile.condition_logic = condition_logic
if conditions is not None:
profile.conditions = conditions
if scene_preset_id != "__unset__":
profile.scene_preset_id = scene_preset_id
if deactivation_mode is not None:
profile.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
profile.deactivation_scene_preset_id = deactivation_scene_preset_id
profile.updated_at = datetime.utcnow()
self._save()
logger.info(f"Updated profile: {profile_id}")
return profile
def delete_profile(self, profile_id: str) -> None:
if profile_id not in self._profiles:
raise ValueError(f"Profile not found: {profile_id}")
del self._profiles[profile_id]
self._save()
logger.info(f"Deleted profile: {profile_id}")
def count(self) -> int:
return len(self._profiles)

View File

@@ -60,22 +60,22 @@ class DeviceBrightnessSnapshot:
@dataclass @dataclass
class ProfileSnapshot: class AutomationSnapshot:
"""Snapshot of a profile's enabled state.""" """Snapshot of an automation's enabled state."""
profile_id: str automation_id: str
enabled: bool = True enabled: bool = True
def to_dict(self) -> dict: def to_dict(self) -> dict:
return { return {
"profile_id": self.profile_id, "automation_id": self.automation_id,
"enabled": self.enabled, "enabled": self.enabled,
} }
@classmethod @classmethod
def from_dict(cls, data: dict) -> "ProfileSnapshot": def from_dict(cls, data: dict) -> "AutomationSnapshot":
return cls( return cls(
profile_id=data["profile_id"], automation_id=data.get("automation_id", data.get("profile_id", "")),
enabled=data.get("enabled", True), enabled=data.get("enabled", True),
) )
@@ -90,7 +90,7 @@ class ScenePreset:
color: str = "#4fc3f7" # accent color for the card color: str = "#4fc3f7" # accent color for the card
targets: List[TargetSnapshot] = field(default_factory=list) targets: List[TargetSnapshot] = field(default_factory=list)
devices: List[DeviceBrightnessSnapshot] = field(default_factory=list) devices: List[DeviceBrightnessSnapshot] = field(default_factory=list)
profiles: List[ProfileSnapshot] = field(default_factory=list) automations: List[AutomationSnapshot] = field(default_factory=list)
order: int = 0 order: int = 0
created_at: datetime = field(default_factory=datetime.utcnow) created_at: datetime = field(default_factory=datetime.utcnow)
updated_at: datetime = field(default_factory=datetime.utcnow) updated_at: datetime = field(default_factory=datetime.utcnow)
@@ -103,7 +103,7 @@ class ScenePreset:
"color": self.color, "color": self.color,
"targets": [t.to_dict() for t in self.targets], "targets": [t.to_dict() for t in self.targets],
"devices": [d.to_dict() for d in self.devices], "devices": [d.to_dict() for d in self.devices],
"profiles": [p.to_dict() for p in self.profiles], "automations": [a.to_dict() for a in self.automations],
"order": self.order, "order": self.order,
"created_at": self.created_at.isoformat(), "created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(), "updated_at": self.updated_at.isoformat(),
@@ -118,7 +118,7 @@ class ScenePreset:
color=data.get("color", "#4fc3f7"), color=data.get("color", "#4fc3f7"),
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])], devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
profiles=[ProfileSnapshot.from_dict(p) for p in data.get("profiles", [])], automations=[AutomationSnapshot.from_dict(a) for a in data.get("automations", data.get("profiles", []))],
order=data.get("order", 0), order=data.get("order", 0),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),

View File

@@ -14,7 +14,7 @@
<link rel="stylesheet" href="/static/css/dashboard.css"> <link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/streams.css"> <link rel="stylesheet" href="/static/css/streams.css">
<link rel="stylesheet" href="/static/css/patterns.css"> <link rel="stylesheet" href="/static/css/patterns.css">
<link rel="stylesheet" href="/static/css/profiles.css"> <link rel="stylesheet" href="/static/css/automations.css">
<link rel="stylesheet" href="/static/css/tutorials.css"> <link rel="stylesheet" href="/static/css/tutorials.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head> </head>
@@ -82,7 +82,7 @@
<div class="tabs"> <div class="tabs">
<div class="tab-bar" role="tablist"> <div class="tab-bar" role="tablist">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button> <button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')" role="tab" aria-selected="false" aria-controls="tab-profiles" id="tab-btn-profiles" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.title">Profiles</span><span class="tab-badge" id="tab-badge-profiles" style="display:none"></span></button> <button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span><span class="tab-badge" id="tab-badge-automations" style="display:none"></span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
<button class="tab-btn" data-tab="scenes" onclick="switchTab('scenes')" role="tab" aria-selected="false" aria-controls="tab-scenes" id="tab-btn-scenes" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.title">Scenes</span><span class="tab-badge" id="tab-badge-scenes" style="display:none"></span></button> <button class="tab-btn" data-tab="scenes" onclick="switchTab('scenes')" role="tab" aria-selected="false" aria-controls="tab-scenes" id="tab-btn-scenes" title="Ctrl+5"><svg class="icon" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg> <span data-i18n="scenes.title">Scenes</span><span class="tab-badge" id="tab-badge-scenes" style="display:none"></span></button>
@@ -94,8 +94,8 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-profiles" role="tabpanel" aria-labelledby="tab-btn-profiles"> <div class="tab-panel" id="tab-automations" role="tabpanel" aria-labelledby="tab-btn-automations">
<div id="profiles-content"> <div id="automations-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
@@ -159,7 +159,7 @@
{% include 'modals/test-pp-template.html' %} {% include 'modals/test-pp-template.html' %}
{% include 'modals/stream.html' %} {% include 'modals/stream.html' %}
{% include 'modals/pp-template.html' %} {% include 'modals/pp-template.html' %}
{% include 'modals/profile-editor.html' %} {% include 'modals/automation-editor.html' %}
{% include 'modals/scene-preset-editor.html' %} {% include 'modals/scene-preset-editor.html' %}
{% include 'modals/audio-source-editor.html' %} {% include 'modals/audio-source-editor.html' %}
{% include 'modals/test-audio-source.html' %} {% include 'modals/test-audio-source.html' %}
@@ -329,7 +329,7 @@
// Clear all tab panels // Clear all tab panels
const loginMsg = `<div class="loading">${t('auth.please_login')}</div>`; const loginMsg = `<div class="loading">${t('auth.please_login')}</div>`;
document.getElementById('dashboard-content').innerHTML = loginMsg; document.getElementById('dashboard-content').innerHTML = loginMsg;
document.getElementById('profiles-content').innerHTML = loginMsg; document.getElementById('automations-content').innerHTML = loginMsg;
document.getElementById('targets-panel-content').innerHTML = loginMsg; document.getElementById('targets-panel-content').innerHTML = loginMsg;
document.getElementById('streams-list').innerHTML = loginMsg; document.getElementById('streams-list').innerHTML = loginMsg;
} }

View File

@@ -0,0 +1,110 @@
<!-- Automation Editor Modal -->
<div id="automation-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="automation-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="automation-editor-title"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.add">Add Automation</span></h2>
<button class="modal-close-btn" onclick="closeAutomationEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="automation-editor-form">
<input type="hidden" id="automation-editor-id">
<div class="form-group">
<div class="label-row">
<label for="automation-editor-name" data-i18n="automations.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.name.hint">A descriptive name for this automation</small>
<input type="text" id="automation-editor-name" required>
</div>
<div class="form-group settings-toggle-group">
<div class="label-row">
<label data-i18n="automations.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when conditions are met</small>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="automation-editor-logic" data-i18n="automations.condition_logic">Condition Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<select id="automation-editor-logic">
<option value="or" data-i18n="automations.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="automations.condition_logic.and">All conditions (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.conditions">Conditions:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.conditions.hint">Rules that determine when this automation activates</small>
<div id="automation-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationCondition()" style="margin-top: 6px;">
+ <span data-i18n="automations.conditions.add">Add Condition</span>
</button>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when conditions are met</small>
<div id="automation-scene-selector" class="scene-selector">
<input type="hidden" id="automation-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="automation-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="automations.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="automation-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="automation-scene-dropdown"></div>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="automation-deactivation-mode" data-i18n="automations.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="automations.deactivation_mode.hint">What happens when conditions stop matching</small>
<select id="automation-deactivation-mode">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option>
<option value="fallback_scene" data-i18n="automations.deactivation_mode.fallback_scene">Activate fallback scene</option>
</select>
</div>
<div class="form-group" id="automation-fallback-scene-group" style="display:none">
<div class="label-row">
<label data-i18n="automations.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="automations.deactivation_scene.hint">Scene to activate when this automation deactivates</small>
<div id="automation-fallback-scene-selector" class="scene-selector">
<input type="hidden" id="automation-fallback-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="automation-fallback-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="automations.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="automation-fallback-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="automation-fallback-scene-dropdown"></div>
</div>
</div>
<div id="automation-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAutomationEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveAutomationEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -1,110 +0,0 @@
<!-- Profile Editor Modal -->
<div id="profile-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="profile-editor-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="profile-editor-title"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="profiles.add">Add Profile</span></h2>
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="profile-editor-form">
<input type="hidden" id="profile-editor-id">
<div class="form-group">
<div class="label-row">
<label for="profile-editor-name" data-i18n="profiles.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.name.hint">A descriptive name for this profile</small>
<input type="text" id="profile-editor-name" required>
</div>
<div class="form-group settings-toggle-group">
<div class="label-row">
<label data-i18n="profiles.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.enabled.hint">Disabled profiles won't activate even when conditions are met</small>
<label class="settings-toggle">
<input type="checkbox" id="profile-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="profile-editor-logic" data-i18n="profiles.condition_logic">Condition Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<select id="profile-editor-logic">
<option value="or" data-i18n="profiles.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="profiles.condition_logic.and">All conditions (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.conditions">Conditions:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.conditions.hint">Rules that determine when this profile activates</small>
<div id="profile-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addProfileCondition()" style="margin-top: 6px;">
+ <span data-i18n="profiles.conditions.add">Add Condition</span>
</button>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.scene.hint">Scene preset to activate when conditions are met</small>
<div id="profile-scene-selector" class="scene-selector">
<input type="hidden" id="profile-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="profile-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="profiles.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="profile-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="profile-scene-dropdown"></div>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="profile-deactivation-mode" data-i18n="profiles.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_mode.hint">What happens when conditions stop matching</small>
<select id="profile-deactivation-mode">
<option value="none" data-i18n="profiles.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="profiles.deactivation_mode.revert">Revert to previous state</option>
<option value="fallback_scene" data-i18n="profiles.deactivation_mode.fallback_scene">Activate fallback scene</option>
</select>
</div>
<div class="form-group" id="profile-fallback-scene-group" style="display:none">
<div class="label-row">
<label data-i18n="profiles.deactivation_scene">Fallback Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_scene.hint">Scene to activate when this profile deactivates</small>
<div id="profile-fallback-scene-selector" class="scene-selector">
<input type="hidden" id="profile-fallback-scene-id">
<div class="scene-selector-input-wrap">
<input type="text" id="profile-fallback-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="profiles.scene.search_placeholder">
<button type="button" class="scene-selector-clear" id="profile-fallback-scene-clear" title="Clear">&times;</button>
</div>
<div class="scene-selector-dropdown" id="profile-fallback-scene-dropdown"></div>
</div>
</div>
<div id="profile-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeProfileEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -12,7 +12,7 @@
<label data-i18n="settings.backup.label">Backup Configuration</label> <label data-i18n="settings.backup.label">Backup Configuration</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="settings.backup.hint">Download all configuration (devices, targets, streams, templates, profiles) as a single JSON file.</small> <small class="input-hint" style="display:none" data-i18n="settings.backup.hint">Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.</small>
<button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button> <button class="btn btn-primary" onclick="downloadBackup()" style="width:100%" data-i18n="settings.backup.button">Download Backup</button>
</div> </div>