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:
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
server/src/wled_controller/core/automations/__init__.py
Normal file
1
server/src/wled_controller/core/automations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Automation engine — condition evaluation and scene activation."""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Profile automation — condition evaluation and target management."""
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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')}">✕</button>
|
<button class="card-remove-btn" onclick="deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')" title="${t('common.delete')}">✕</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}">●</span> ${sceneName}</span>
|
<span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">●</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">✕</button>
|
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">✕</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 chrome.exe">${escapeHtml(appsValue)}</textarea>
|
<textarea class="condition-apps" rows="3" placeholder="firefox.exe 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');
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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}">
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:00–06:00), set start time after end time.",
|
"automations.condition.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06: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",
|
||||||
|
|||||||
@@ -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:00–06:00) укажите время начала позже времени окончания.",
|
"automations.condition.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06: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": "Не удалось переключить автозапуск",
|
||||||
|
|||||||
@@ -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:00–06:00),请将开始时间设为晚于结束时间。",
|
"automations.condition.time_of_day.overnight_hint": "跨夜时段(如 22:00–06: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": "切换自动启动失败",
|
||||||
|
|||||||
@@ -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:
|
||||||
158
server/src/wled_controller/storage/automation_store.py
Normal file
158
server/src/wled_controller/storage/automation_store.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
@@ -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())),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">✕</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">×</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">×</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">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveAutomationEditor()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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">✕</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">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="scene-selector-dropdown" id="profile-scene-dropdown"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="profile-deactivation-mode" data-i18n="profiles.deactivation_mode">Deactivation:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_mode.hint">What happens when conditions stop matching</small>
|
|
||||||
<select id="profile-deactivation-mode">
|
|
||||||
<option value="none" data-i18n="profiles.deactivation_mode.none">None — keep current state</option>
|
|
||||||
<option value="revert" data-i18n="profiles.deactivation_mode.revert">Revert to previous state</option>
|
|
||||||
<option value="fallback_scene" data-i18n="profiles.deactivation_mode.fallback_scene">Activate fallback scene</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group" id="profile-fallback-scene-group" style="display:none">
|
|
||||||
<div class="label-row">
|
|
||||||
<label data-i18n="profiles.deactivation_scene">Fallback Scene:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="profiles.deactivation_scene.hint">Scene to activate when this profile deactivates</small>
|
|
||||||
<div id="profile-fallback-scene-selector" class="scene-selector">
|
|
||||||
<input type="hidden" id="profile-fallback-scene-id">
|
|
||||||
<div class="scene-selector-input-wrap">
|
|
||||||
<input type="text" id="profile-fallback-scene-search" class="scene-selector-input" placeholder="Search scenes..." autocomplete="off" data-i18n-placeholder="profiles.scene.search_placeholder">
|
|
||||||
<button type="button" class="scene-selector-clear" id="profile-fallback-scene-clear" title="Clear">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="scene-selector-dropdown" id="profile-fallback-scene-dropdown"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div 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">✕</button>
|
|
||||||
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save" data-i18n-aria-label="aria.save">✓</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user