Compare commits
3 Commits
d6cf45c873
...
3bac9c4ed9
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bac9c4ed9 | |||
| aa105f3958 | |||
| 29d9b95885 |
@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
|
|||||||
from .routes.picture_sources import router as picture_sources_router
|
from .routes.picture_sources import router as picture_sources_router
|
||||||
from .routes.pattern_templates import router as pattern_templates_router
|
from .routes.pattern_templates import router as pattern_templates_router
|
||||||
from .routes.picture_targets import router as picture_targets_router
|
from .routes.picture_targets import router as picture_targets_router
|
||||||
|
from .routes.profiles import router as profiles_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(system_router)
|
router.include_router(system_router)
|
||||||
@@ -18,5 +19,6 @@ router.include_router(postprocessing_router)
|
|||||||
router.include_router(pattern_templates_router)
|
router.include_router(pattern_templates_router)
|
||||||
router.include_router(picture_sources_router)
|
router.include_router(picture_sources_router)
|
||||||
router.include_router(picture_targets_router)
|
router.include_router(picture_targets_router)
|
||||||
|
router.include_router(profiles_router)
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
|
|||||||
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
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.core.profiles.profile_engine import ProfileEngine
|
||||||
|
|
||||||
# Global instances (initialized in main.py)
|
# Global instances (initialized in main.py)
|
||||||
_device_store: DeviceStore | None = None
|
_device_store: DeviceStore | None = None
|
||||||
@@ -16,6 +18,8 @@ _pattern_template_store: PatternTemplateStore | None = None
|
|||||||
_picture_source_store: PictureSourceStore | None = None
|
_picture_source_store: PictureSourceStore | None = None
|
||||||
_picture_target_store: PictureTargetStore | None = None
|
_picture_target_store: PictureTargetStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
|
_profile_store: ProfileStore | None = None
|
||||||
|
_profile_engine: ProfileEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_device_store() -> DeviceStore:
|
def get_device_store() -> DeviceStore:
|
||||||
@@ -67,6 +71,20 @@ def get_processor_manager() -> ProcessorManager:
|
|||||||
return _processor_manager
|
return _processor_manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_store() -> ProfileStore:
|
||||||
|
"""Get profile store dependency."""
|
||||||
|
if _profile_store is None:
|
||||||
|
raise RuntimeError("Profile store not initialized")
|
||||||
|
return _profile_store
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile_engine() -> ProfileEngine:
|
||||||
|
"""Get profile engine dependency."""
|
||||||
|
if _profile_engine is None:
|
||||||
|
raise RuntimeError("Profile engine not initialized")
|
||||||
|
return _profile_engine
|
||||||
|
|
||||||
|
|
||||||
def init_dependencies(
|
def init_dependencies(
|
||||||
device_store: DeviceStore,
|
device_store: DeviceStore,
|
||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
@@ -75,10 +93,13 @@ def init_dependencies(
|
|||||||
pattern_template_store: PatternTemplateStore | None = None,
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
picture_source_store: PictureSourceStore | None = None,
|
picture_source_store: PictureSourceStore | None = None,
|
||||||
picture_target_store: PictureTargetStore | None = None,
|
picture_target_store: PictureTargetStore | None = None,
|
||||||
|
profile_store: ProfileStore | None = None,
|
||||||
|
profile_engine: ProfileEngine | 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 _profile_store, _profile_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
|
||||||
@@ -86,3 +107,5 @@ def init_dependencies(
|
|||||||
_pattern_template_store = pattern_template_store
|
_pattern_template_store = pattern_template_store
|
||||||
_picture_source_store = picture_source_store
|
_picture_source_store = picture_source_store
|
||||||
_picture_target_store = picture_target_store
|
_picture_target_store = picture_target_store
|
||||||
|
_profile_store = profile_store
|
||||||
|
_profile_engine = profile_engine
|
||||||
|
|||||||
255
server/src/wled_controller/api/routes/profiles.py
Normal file
255
server/src/wled_controller/api/routes/profiles.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Profile management API routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_picture_target_store,
|
||||||
|
get_profile_engine,
|
||||||
|
get_profile_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.profiles import (
|
||||||
|
ConditionSchema,
|
||||||
|
ProfileCreate,
|
||||||
|
ProfileListResponse,
|
||||||
|
ProfileResponse,
|
||||||
|
ProfileUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
|
from wled_controller.storage.profile import ApplicationCondition, Condition
|
||||||
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Helpers =====
|
||||||
|
|
||||||
|
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||||
|
if s.condition_type == "application":
|
||||||
|
return ApplicationCondition(
|
||||||
|
apps=s.apps or [],
|
||||||
|
match_type=s.match_type or "running",
|
||||||
|
)
|
||||||
|
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
||||||
|
d = c.to_dict()
|
||||||
|
return ConditionSchema(**d)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_to_response(profile, engine: ProfileEngine) -> ProfileResponse:
|
||||||
|
state = engine.get_profile_state(profile.id)
|
||||||
|
return ProfileResponse(
|
||||||
|
id=profile.id,
|
||||||
|
name=profile.name,
|
||||||
|
enabled=profile.enabled,
|
||||||
|
condition_logic=profile.condition_logic,
|
||||||
|
conditions=[_condition_to_schema(c) for c in profile.conditions],
|
||||||
|
target_ids=profile.target_ids,
|
||||||
|
is_active=state["is_active"],
|
||||||
|
active_target_ids=state["active_target_ids"],
|
||||||
|
last_activated_at=state.get("last_activated_at"),
|
||||||
|
last_deactivated_at=state.get("last_deactivated_at"),
|
||||||
|
created_at=profile.created_at,
|
||||||
|
updated_at=profile.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_condition_logic(logic: str) -> None:
|
||||||
|
if logic not in ("or", "and"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_target_ids(target_ids: list, target_store: PictureTargetStore) -> None:
|
||||||
|
for tid in target_ids:
|
||||||
|
try:
|
||||||
|
target_store.get_target(tid)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Target not found: {tid}")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== CRUD Endpoints =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/profiles",
|
||||||
|
response_model=ProfileResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def create_profile(
|
||||||
|
data: ProfileCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
):
|
||||||
|
"""Create a new profile."""
|
||||||
|
_validate_condition_logic(data.condition_logic)
|
||||||
|
_validate_target_ids(data.target_ids, target_store)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
profile = store.create_profile(
|
||||||
|
name=data.name,
|
||||||
|
enabled=data.enabled,
|
||||||
|
condition_logic=data.condition_logic,
|
||||||
|
conditions=conditions,
|
||||||
|
target_ids=data.target_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/profiles",
|
||||||
|
response_model=ProfileListResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def list_profiles(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
):
|
||||||
|
"""List all profiles."""
|
||||||
|
profiles = store.get_all_profiles()
|
||||||
|
return ProfileListResponse(
|
||||||
|
profiles=[_profile_to_response(p, engine) for p in profiles],
|
||||||
|
count=len(profiles),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/v1/profiles/{profile_id}",
|
||||||
|
response_model=ProfileResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def get_profile(
|
||||||
|
profile_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
):
|
||||||
|
"""Get a single profile."""
|
||||||
|
try:
|
||||||
|
profile = store.get_profile(profile_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/api/v1/profiles/{profile_id}",
|
||||||
|
response_model=ProfileResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def update_profile(
|
||||||
|
profile_id: str,
|
||||||
|
data: ProfileUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
):
|
||||||
|
"""Update a profile."""
|
||||||
|
if data.condition_logic is not None:
|
||||||
|
_validate_condition_logic(data.condition_logic)
|
||||||
|
if data.target_ids is not None:
|
||||||
|
_validate_target_ids(data.target_ids, target_store)
|
||||||
|
|
||||||
|
conditions = None
|
||||||
|
if data.conditions is not None:
|
||||||
|
try:
|
||||||
|
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If disabling, deactivate first
|
||||||
|
if data.enabled is False:
|
||||||
|
await engine.deactivate_if_active(profile_id)
|
||||||
|
|
||||||
|
profile = store.update_profile(
|
||||||
|
profile_id=profile_id,
|
||||||
|
name=data.name,
|
||||||
|
enabled=data.enabled,
|
||||||
|
condition_logic=data.condition_logic,
|
||||||
|
conditions=conditions,
|
||||||
|
target_ids=data.target_ids,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/api/v1/profiles/{profile_id}",
|
||||||
|
status_code=204,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def delete_profile(
|
||||||
|
profile_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
):
|
||||||
|
"""Delete a profile."""
|
||||||
|
# Deactivate first (stop owned targets)
|
||||||
|
await engine.deactivate_if_active(profile_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
store.delete_profile(profile_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Enable/Disable =====
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/profiles/{profile_id}/enable",
|
||||||
|
response_model=ProfileResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def enable_profile(
|
||||||
|
profile_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
):
|
||||||
|
"""Enable a profile."""
|
||||||
|
try:
|
||||||
|
profile = store.update_profile(profile_id=profile_id, enabled=True)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _profile_to_response(profile, engine)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/api/v1/profiles/{profile_id}/disable",
|
||||||
|
response_model=ProfileResponse,
|
||||||
|
tags=["Profiles"],
|
||||||
|
)
|
||||||
|
async def disable_profile(
|
||||||
|
profile_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: ProfileStore = Depends(get_profile_store),
|
||||||
|
engine: ProfileEngine = Depends(get_profile_engine),
|
||||||
|
):
|
||||||
|
"""Disable a profile and stop any targets it owns."""
|
||||||
|
await engine.deactivate_if_active(profile_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = store.update_profile(profile_id=profile_id, enabled=False)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
return _profile_to_response(profile, engine)
|
||||||
@@ -11,6 +11,7 @@ from wled_controller.api.schemas.system import (
|
|||||||
DisplayInfo,
|
DisplayInfo,
|
||||||
DisplayListResponse,
|
DisplayListResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
|
ProcessListResponse,
|
||||||
VersionResponse,
|
VersionResponse,
|
||||||
)
|
)
|
||||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||||
@@ -91,3 +92,24 @@ async def get_displays(_: AuthRequired):
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Failed to retrieve display information: {str(e)}"
|
detail=f"Failed to retrieve display information: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
|
||||||
|
async def get_running_processes(_: AuthRequired):
|
||||||
|
"""Get list of currently running process names.
|
||||||
|
|
||||||
|
Returns a sorted list of unique process names for use in profile conditions.
|
||||||
|
"""
|
||||||
|
from wled_controller.core.profiles.platform_detector import PlatformDetector
|
||||||
|
|
||||||
|
try:
|
||||||
|
detector = PlatformDetector()
|
||||||
|
processes = await detector.get_running_processes()
|
||||||
|
sorted_procs = sorted(processes)
|
||||||
|
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get processes: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to retrieve process list: {str(e)}"
|
||||||
|
)
|
||||||
|
|||||||
58
server/src/wled_controller/api/schemas/profiles.py
Normal file
58
server/src/wled_controller/api/schemas/profiles.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Profile-related schemas."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionSchema(BaseModel):
|
||||||
|
"""A single condition within a profile."""
|
||||||
|
|
||||||
|
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||||
|
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||||
|
match_type: Optional[str] = Field(None, description="'running' or 'topmost' (for application condition)")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileCreate(BaseModel):
|
||||||
|
"""Request to create a profile."""
|
||||||
|
|
||||||
|
name: str = Field(description="Profile name", min_length=1, max_length=100)
|
||||||
|
enabled: bool = Field(default=True, description="Whether the profile is enabled")
|
||||||
|
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
||||||
|
conditions: List[ConditionSchema] = Field(default_factory=list, description="List of conditions")
|
||||||
|
target_ids: List[str] = Field(default_factory=list, description="Target IDs to activate")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
"""Request to update a profile."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Profile name", min_length=1, max_length=100)
|
||||||
|
enabled: Optional[bool] = Field(None, description="Whether the profile is enabled")
|
||||||
|
condition_logic: Optional[str] = Field(None, description="How conditions combine: 'or' or 'and'")
|
||||||
|
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||||
|
target_ids: Optional[List[str]] = Field(None, description="Target IDs to activate")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileResponse(BaseModel):
|
||||||
|
"""Profile information response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Profile ID")
|
||||||
|
name: str = Field(description="Profile name")
|
||||||
|
enabled: bool = Field(description="Whether the profile is enabled")
|
||||||
|
condition_logic: str = Field(description="Condition combination logic")
|
||||||
|
conditions: List[ConditionSchema] = Field(description="List of conditions")
|
||||||
|
target_ids: List[str] = Field(description="Target IDs to activate")
|
||||||
|
is_active: bool = Field(default=False, description="Whether the profile is currently active")
|
||||||
|
active_target_ids: List[str] = Field(default_factory=list, description="Targets currently owned by this profile")
|
||||||
|
last_activated_at: Optional[datetime] = Field(None, description="Last time this profile was activated")
|
||||||
|
last_deactivated_at: Optional[datetime] = Field(None, description="Last time this profile was deactivated")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileListResponse(BaseModel):
|
||||||
|
"""List of profiles response."""
|
||||||
|
|
||||||
|
profiles: List[ProfileResponse] = Field(description="List of profiles")
|
||||||
|
count: int = Field(description="Number of profiles")
|
||||||
@@ -40,3 +40,10 @@ class DisplayListResponse(BaseModel):
|
|||||||
|
|
||||||
displays: List[DisplayInfo] = Field(description="Available displays")
|
displays: List[DisplayInfo] = Field(description="Available displays")
|
||||||
count: int = Field(description="Number of displays")
|
count: int = Field(description="Number of displays")
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessListResponse(BaseModel):
|
||||||
|
"""List of running processes."""
|
||||||
|
|
||||||
|
processes: List[str] = Field(description="Sorted list of unique process names")
|
||||||
|
count: int = Field(description="Number of unique processes")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class StorageConfig(BaseSettings):
|
|||||||
picture_sources_file: str = "data/picture_sources.json"
|
picture_sources_file: str = "data/picture_sources.json"
|
||||||
picture_targets_file: str = "data/picture_targets.json"
|
picture_targets_file: str = "data/picture_targets.json"
|
||||||
pattern_templates_file: str = "data/pattern_templates.json"
|
pattern_templates_file: str = "data/pattern_templates.json"
|
||||||
|
profiles_file: str = "data/profiles.json"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseSettings):
|
class LoggingConfig(BaseSettings):
|
||||||
|
|||||||
49
server/src/wled_controller/core/devices/ambiled_client.py
Normal file
49
server/src/wled_controller/core/devices/ambiled_client.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""AmbiLED serial LED client — sends pixel data using the AmbiLED protocol.
|
||||||
|
|
||||||
|
Protocol: raw RGB bytes (values clamped to 0–250) followed by 0xFF show command.
|
||||||
|
No header or checksum — simpler than Adalight.
|
||||||
|
Reference: https://github.com/flytron/ambiled-hd
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.devices.adalight_client import AdalightClient
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# AmbiLED command byte — triggers display update
|
||||||
|
AMBILED_SHOW_CMD = b"\xff"
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiLEDClient(AdalightClient):
|
||||||
|
"""LED client for AmbiLED serial devices."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, led_count: int = 0, baud_rate=None, **kwargs):
|
||||||
|
super().__init__(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
|
||||||
|
# AmbiLED has no header — clear the Adalight header
|
||||||
|
self._header = b""
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
result = await super().connect()
|
||||||
|
if result:
|
||||||
|
logger.info(
|
||||||
|
f"AmbiLED connected: {self._port} @ {self._baud_rate} baud "
|
||||||
|
f"({self._led_count} LEDs)"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_frame(self, pixels, brightness: int) -> bytes:
|
||||||
|
"""Build an AmbiLED frame: brightness-scaled RGB data + 0xFF show command."""
|
||||||
|
if isinstance(pixels, np.ndarray):
|
||||||
|
arr = pixels.astype(np.uint16)
|
||||||
|
else:
|
||||||
|
arr = np.array(pixels, dtype=np.uint16)
|
||||||
|
|
||||||
|
if brightness < 255:
|
||||||
|
arr = arr * brightness // 255
|
||||||
|
|
||||||
|
# Clamp to 0–250: values >250 are command bytes in AmbiLED protocol
|
||||||
|
np.clip(arr, 0, 250, out=arr)
|
||||||
|
rgb_bytes = arr.astype(np.uint8).tobytes()
|
||||||
|
return rgb_bytes + AMBILED_SHOW_CMD
|
||||||
111
server/src/wled_controller/core/devices/ambiled_provider.py
Normal file
111
server/src/wled_controller/core/devices/ambiled_provider.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""AmbiLED device provider — serial LED controller using AmbiLED protocol."""
|
||||||
|
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
|
||||||
|
from wled_controller.core.devices.led_client import DiscoveredDevice, LEDClient
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiLEDDeviceProvider(AdalightDeviceProvider):
|
||||||
|
"""Provider for AmbiLED serial LED controllers."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_type(self) -> str:
|
||||||
|
return "ambiled"
|
||||||
|
|
||||||
|
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||||
|
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
|
||||||
|
|
||||||
|
led_count = kwargs.pop("led_count", 0)
|
||||||
|
baud_rate = kwargs.pop("baud_rate", None)
|
||||||
|
kwargs.pop("use_ddp", None)
|
||||||
|
return AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate, **kwargs)
|
||||||
|
|
||||||
|
async def validate_device(self, url: str) -> dict:
|
||||||
|
from wled_controller.core.devices.adalight_client import parse_adalight_url
|
||||||
|
|
||||||
|
port, _baud = parse_adalight_url(url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
available_ports = [p.device for p in serial.tools.list_ports.comports()]
|
||||||
|
port_upper = port.upper()
|
||||||
|
if not any(p.upper() == port_upper for p in available_ports):
|
||||||
|
raise ValueError(
|
||||||
|
f"Serial port {port} not found. "
|
||||||
|
f"Available ports: {', '.join(available_ports) or 'none'}"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Failed to enumerate serial ports: {e}")
|
||||||
|
|
||||||
|
logger.info(f"AmbiLED device validated: port {port}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||||
|
try:
|
||||||
|
import serial.tools.list_ports
|
||||||
|
|
||||||
|
ports = serial.tools.list_ports.comports()
|
||||||
|
results = []
|
||||||
|
for port_info in ports:
|
||||||
|
results.append(
|
||||||
|
DiscoveredDevice(
|
||||||
|
name=port_info.description or port_info.device,
|
||||||
|
url=port_info.device,
|
||||||
|
device_type="ambiled",
|
||||||
|
ip=port_info.device,
|
||||||
|
mac="",
|
||||||
|
led_count=None,
|
||||||
|
version=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"AmbiLED serial port scan found {len(results)} port(s)")
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AmbiLED serial port discovery failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def set_power(self, url: str, on: bool, **kwargs) -> None:
|
||||||
|
if on:
|
||||||
|
return
|
||||||
|
|
||||||
|
led_count = kwargs.get("led_count", 0)
|
||||||
|
baud_rate = kwargs.get("baud_rate")
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError("led_count is required to send black frame to AmbiLED device")
|
||||||
|
|
||||||
|
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
|
||||||
|
|
||||||
|
client = AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
black = np.zeros((led_count, 3), dtype=np.uint8)
|
||||||
|
await client.send_pixels(black, brightness=255)
|
||||||
|
logger.info(f"AmbiLED power off: sent black frame to {url}")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
async def set_color(self, url: str, color: Tuple[int, int, int], **kwargs) -> None:
|
||||||
|
led_count = kwargs.get("led_count", 0)
|
||||||
|
baud_rate = kwargs.get("baud_rate")
|
||||||
|
if led_count <= 0:
|
||||||
|
raise ValueError("led_count is required to send color frame to AmbiLED device")
|
||||||
|
|
||||||
|
from wled_controller.core.devices.ambiled_client import AmbiLEDClient
|
||||||
|
|
||||||
|
client = AmbiLEDClient(url, led_count=led_count, baud_rate=baud_rate)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
frame = np.full((led_count, 3), color, dtype=np.uint8)
|
||||||
|
await client.send_pixels(frame, brightness=255)
|
||||||
|
logger.info(f"AmbiLED set_color: sent solid {color} to {url}")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
@@ -276,5 +276,8 @@ def _register_builtin_providers():
|
|||||||
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
|
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
|
||||||
register_provider(AdalightDeviceProvider())
|
register_provider(AdalightDeviceProvider())
|
||||||
|
|
||||||
|
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
|
||||||
|
register_provider(AmbiLEDDeviceProvider())
|
||||||
|
|
||||||
|
|
||||||
_register_builtin_providers()
|
_register_builtin_providers()
|
||||||
|
|||||||
@@ -691,22 +691,16 @@ class ProcessorManager:
|
|||||||
state.device_type, state.device_url, client, state.health,
|
state.device_type, state.device_url, client, state.health,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-sync LED count
|
# Auto-sync LED count (preserve existing calibration)
|
||||||
reported = state.health.device_led_count
|
reported = state.health.device_led_count
|
||||||
if reported and reported != state.led_count and self._device_store:
|
if reported and reported != state.led_count and self._device_store:
|
||||||
old_count = state.led_count
|
old_count = state.led_count
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Device {device_id} LED count changed: {old_count} → {reported}, "
|
f"Device {device_id} LED count changed: {old_count} → {reported}"
|
||||||
f"updating calibration"
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
device = self._device_store.update_device(device_id, led_count=reported)
|
self._device_store.update_device(device_id, led_count=reported)
|
||||||
state.led_count = reported
|
state.led_count = reported
|
||||||
state.calibration = device.calibration
|
|
||||||
# Propagate to WLED processors using this device
|
|
||||||
for proc in self._processors.values():
|
|
||||||
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
|
|
||||||
proc.update_calibration(device.calibration)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to sync LED count for {device_id}: {e}")
|
logger.error(f"Failed to sync LED count for {device_id}: {e}")
|
||||||
|
|
||||||
|
|||||||
1
server/src/wled_controller/core/profiles/__init__.py
Normal file
1
server/src/wled_controller/core/profiles/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Profile automation — condition evaluation and target management."""
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Platform-specific process and window detection.
|
||||||
|
|
||||||
|
Windows: uses wmi for process listing, ctypes for foreground window detection.
|
||||||
|
Non-Windows: graceful degradation (returns empty results).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ctypes
|
||||||
|
import ctypes.wintypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional, Set
|
||||||
|
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformDetector:
|
||||||
|
"""Detect running processes and the foreground window's process."""
|
||||||
|
|
||||||
|
def _get_running_processes_sync(self) -> Set[str]:
|
||||||
|
"""Get set of lowercase process names (blocking, call via executor)."""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pythoncom
|
||||||
|
pythoncom.CoInitialize()
|
||||||
|
try:
|
||||||
|
import wmi
|
||||||
|
w = wmi.WMI()
|
||||||
|
return {p.Name.lower() for p in w.Win32_Process() if p.Name}
|
||||||
|
finally:
|
||||||
|
pythoncom.CoUninitialize()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to enumerate processes: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _get_topmost_process_sync(self) -> Optional[str]:
|
||||||
|
"""Get lowercase process name of the foreground window (blocking, call via executor)."""
|
||||||
|
if not _IS_WINDOWS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user32 = ctypes.windll.user32
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
psapi = ctypes.windll.psapi
|
||||||
|
|
||||||
|
hwnd = user32.GetForegroundWindow()
|
||||||
|
if not hwnd:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pid = ctypes.wintypes.DWORD()
|
||||||
|
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||||
|
if not pid.value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
PROCESS_QUERY_INFORMATION = 0x0400
|
||||||
|
PROCESS_VM_READ = 0x0010
|
||||||
|
handle = kernel32.OpenProcess(
|
||||||
|
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid.value
|
||||||
|
)
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
buf = ctypes.create_unicode_buffer(512)
|
||||||
|
psapi.GetModuleFileNameExW(handle, None, buf, 512)
|
||||||
|
full_path = buf.value
|
||||||
|
if full_path:
|
||||||
|
return os.path.basename(full_path).lower()
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get foreground process: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_running_processes(self) -> Set[str]:
|
||||||
|
"""Get set of lowercase process names (async-safe)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self._get_running_processes_sync)
|
||||||
|
|
||||||
|
async def get_topmost_process(self) -> Optional[str]:
|
||||||
|
"""Get lowercase process name of the foreground window (async-safe)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self._get_topmost_process_sync)
|
||||||
216
server/src/wled_controller/core/profiles/profile_engine.py
Normal file
216
server/src/wled_controller/core/profiles/profile_engine.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""Profile engine — background loop that evaluates conditions and manages targets."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Optional, Set
|
||||||
|
|
||||||
|
from wled_controller.core.profiles.platform_detector import PlatformDetector
|
||||||
|
from wled_controller.storage.profile import ApplicationCondition, Condition, Profile
|
||||||
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileEngine:
|
||||||
|
"""Evaluates profile conditions and starts/stops targets accordingly."""
|
||||||
|
|
||||||
|
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 3.0):
|
||||||
|
self._store = profile_store
|
||||||
|
self._manager = processor_manager
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
self._detector = PlatformDetector()
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# Runtime state (not persisted)
|
||||||
|
# profile_id → set of target_ids that THIS profile started
|
||||||
|
self._active_profiles: Dict[str, Set[str]] = {}
|
||||||
|
# profile_id → datetime of last activation / deactivation
|
||||||
|
self._last_activated: Dict[str, datetime] = {}
|
||||||
|
self._last_deactivated: Dict[str, datetime] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
self._task = asyncio.create_task(self._poll_loop())
|
||||||
|
logger.info("Profile engine started")
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# Deactivate all profiles (stop owned targets)
|
||||||
|
for profile_id in list(self._active_profiles.keys()):
|
||||||
|
await self._deactivate_profile(profile_id)
|
||||||
|
|
||||||
|
logger.info("Profile engine stopped")
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._evaluate_all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Profile evaluation error: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _evaluate_all(self) -> None:
|
||||||
|
profiles = self._store.get_all_profiles()
|
||||||
|
if not profiles:
|
||||||
|
# No profiles — deactivate any stale state
|
||||||
|
for pid in list(self._active_profiles.keys()):
|
||||||
|
await self._deactivate_profile(pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gather platform state once per cycle
|
||||||
|
running_procs = await self._detector.get_running_processes()
|
||||||
|
topmost_proc = await self._detector.get_topmost_process()
|
||||||
|
|
||||||
|
active_profile_ids = set()
|
||||||
|
|
||||||
|
for profile in profiles:
|
||||||
|
should_be_active = (
|
||||||
|
profile.enabled
|
||||||
|
and len(profile.conditions) > 0
|
||||||
|
and self._evaluate_conditions(profile, running_procs, topmost_proc)
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = profile.id in self._active_profiles
|
||||||
|
|
||||||
|
if should_be_active and not is_active:
|
||||||
|
await self._activate_profile(profile)
|
||||||
|
active_profile_ids.add(profile.id)
|
||||||
|
elif should_be_active and is_active:
|
||||||
|
active_profile_ids.add(profile.id)
|
||||||
|
elif not should_be_active and is_active:
|
||||||
|
await self._deactivate_profile(profile.id)
|
||||||
|
|
||||||
|
# Deactivate profiles that were removed from store while active
|
||||||
|
for pid in list(self._active_profiles.keys()):
|
||||||
|
if pid not in active_profile_ids:
|
||||||
|
await self._deactivate_profile(pid)
|
||||||
|
|
||||||
|
def _evaluate_conditions(
|
||||||
|
self, profile: Profile, running_procs: Set[str], topmost_proc: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
results = [
|
||||||
|
self._evaluate_condition(c, running_procs, topmost_proc)
|
||||||
|
for c in profile.conditions
|
||||||
|
]
|
||||||
|
|
||||||
|
if profile.condition_logic == "and":
|
||||||
|
return all(results)
|
||||||
|
return any(results) # "or" is default
|
||||||
|
|
||||||
|
def _evaluate_condition(
|
||||||
|
self, condition: Condition, running_procs: Set[str], topmost_proc: Optional[str]
|
||||||
|
) -> bool:
|
||||||
|
if isinstance(condition, ApplicationCondition):
|
||||||
|
return self._evaluate_app_condition(condition, running_procs, topmost_proc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _evaluate_app_condition(
|
||||||
|
self,
|
||||||
|
condition: ApplicationCondition,
|
||||||
|
running_procs: Set[str],
|
||||||
|
topmost_proc: Optional[str],
|
||||||
|
) -> bool:
|
||||||
|
if not condition.apps:
|
||||||
|
return False
|
||||||
|
|
||||||
|
apps_lower = [a.lower() for a in condition.apps]
|
||||||
|
|
||||||
|
if condition.match_type == "topmost":
|
||||||
|
if topmost_proc is None:
|
||||||
|
return False
|
||||||
|
return any(app == topmost_proc for app in apps_lower)
|
||||||
|
|
||||||
|
# Default: "running"
|
||||||
|
return any(app in running_procs for app in apps_lower)
|
||||||
|
|
||||||
|
async def _activate_profile(self, profile: Profile) -> None:
|
||||||
|
started: Set[str] = set()
|
||||||
|
for target_id in profile.target_ids:
|
||||||
|
try:
|
||||||
|
# Skip targets that are already running (manual or other profile)
|
||||||
|
proc = self._manager._processors.get(target_id)
|
||||||
|
if proc and proc.is_running:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await self._manager.start_processing(target_id)
|
||||||
|
started.add(target_id)
|
||||||
|
logger.info(f"Profile '{profile.name}' started target {target_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}")
|
||||||
|
|
||||||
|
if started:
|
||||||
|
self._active_profiles[profile.id] = started
|
||||||
|
self._last_activated[profile.id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(profile.id, "activated", list(started))
|
||||||
|
logger.info(f"Profile '{profile.name}' activated ({len(started)} targets)")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Profile '{profile.name}' matched but no targets started — will retry")
|
||||||
|
|
||||||
|
async def _deactivate_profile(self, profile_id: str) -> None:
|
||||||
|
owned = self._active_profiles.pop(profile_id, set())
|
||||||
|
stopped = []
|
||||||
|
|
||||||
|
for target_id in owned:
|
||||||
|
try:
|
||||||
|
proc = self._manager._processors.get(target_id)
|
||||||
|
if proc and proc.is_running:
|
||||||
|
await self._manager.stop_processing(target_id)
|
||||||
|
stopped.append(target_id)
|
||||||
|
logger.info(f"Profile {profile_id} stopped target {target_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Profile {profile_id} failed to stop target {target_id}: {e}")
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
self._last_deactivated[profile_id] = datetime.now(timezone.utc)
|
||||||
|
self._fire_event(profile_id, "deactivated", stopped)
|
||||||
|
logger.info(f"Profile {profile_id} deactivated ({len(stopped)} targets stopped)")
|
||||||
|
|
||||||
|
def _fire_event(self, profile_id: str, action: str, target_ids: list) -> None:
|
||||||
|
try:
|
||||||
|
self._manager._fire_event({
|
||||||
|
"type": "profile_state_changed",
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"action": action,
|
||||||
|
"target_ids": target_ids,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ===== Public query methods (used by API) =====
|
||||||
|
|
||||||
|
def get_profile_state(self, profile_id: str) -> dict:
|
||||||
|
"""Get runtime state of a single profile."""
|
||||||
|
is_active = profile_id in self._active_profiles
|
||||||
|
owned = list(self._active_profiles.get(profile_id, set()))
|
||||||
|
return {
|
||||||
|
"is_active": is_active,
|
||||||
|
"active_target_ids": owned,
|
||||||
|
"last_activated_at": self._last_activated.get(profile_id),
|
||||||
|
"last_deactivated_at": self._last_deactivated.get(profile_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_all_profile_states(self) -> Dict[str, dict]:
|
||||||
|
"""Get runtime states of all profiles."""
|
||||||
|
result = {}
|
||||||
|
for profile in self._store.get_all_profiles():
|
||||||
|
result[profile.id] = self.get_profile_state(profile.id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def deactivate_if_active(self, profile_id: str) -> None:
|
||||||
|
"""Deactivate a profile immediately (used when disabling/deleting)."""
|
||||||
|
if profile_id in self._active_profiles:
|
||||||
|
await self._deactivate_profile(profile_id)
|
||||||
@@ -23,6 +23,8 @@ from wled_controller.storage.picture_source_store import PictureSourceStore
|
|||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
|
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
|
||||||
|
from wled_controller.storage.profile_store import ProfileStore
|
||||||
|
from wled_controller.core.profiles.profile_engine import ProfileEngine
|
||||||
from wled_controller.utils import setup_logging, get_logger
|
from wled_controller.utils import setup_logging, get_logger
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
@@ -39,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te
|
|||||||
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||||
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||||
|
profile_store = ProfileStore(config.storage.profiles_file)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
@@ -138,6 +141,9 @@ async def lifespan(app: FastAPI):
|
|||||||
# Run migrations
|
# Run migrations
|
||||||
_migrate_devices_to_targets()
|
_migrate_devices_to_targets()
|
||||||
|
|
||||||
|
# Create profile engine (needs processor_manager)
|
||||||
|
profile_engine = ProfileEngine(profile_store, processor_manager)
|
||||||
|
|
||||||
# Initialize API dependencies
|
# Initialize API dependencies
|
||||||
init_dependencies(
|
init_dependencies(
|
||||||
device_store, template_store, processor_manager,
|
device_store, template_store, processor_manager,
|
||||||
@@ -145,6 +151,8 @@ async def lifespan(app: FastAPI):
|
|||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
picture_target_store=picture_target_store,
|
picture_target_store=picture_target_store,
|
||||||
|
profile_store=profile_store,
|
||||||
|
profile_engine=profile_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -201,11 +209,21 @@ async def lifespan(app: FastAPI):
|
|||||||
# Start background health monitoring for all devices
|
# Start background health monitoring for all devices
|
||||||
await processor_manager.start_health_monitoring()
|
await processor_manager.start_health_monitoring()
|
||||||
|
|
||||||
|
# Start profile engine (evaluates conditions and auto-starts/stops targets)
|
||||||
|
await profile_engine.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down LED Grab")
|
logger.info("Shutting down LED Grab")
|
||||||
|
|
||||||
|
# Stop profile engine first (deactivates profile-managed targets)
|
||||||
|
try:
|
||||||
|
await profile_engine.stop()
|
||||||
|
logger.info("Stopped profile engine")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping profile engine: {e}")
|
||||||
|
|
||||||
# Stop all processing
|
# Stop all processing
|
||||||
try:
|
try:
|
||||||
await processor_manager.stop_all()
|
await processor_manager.stop_all()
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ function setupBackdropClose(modal, closeFn) {
|
|||||||
modal._backdropCloseSetup = true;
|
modal._backdropCloseSetup = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Device type helpers
|
||||||
|
function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; }
|
||||||
|
|
||||||
// Track logged errors to avoid console spam
|
// Track logged errors to avoid console spam
|
||||||
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||||||
|
|
||||||
@@ -598,6 +601,8 @@ function switchTab(name) {
|
|||||||
loadPictureSources();
|
loadPictureSources();
|
||||||
} else if (name === 'targets') {
|
} else if (name === 'targets') {
|
||||||
loadTargetsTab();
|
loadTargetsTab();
|
||||||
|
} else if (name === 'profiles') {
|
||||||
|
loadProfiles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -767,7 +772,7 @@ async function showSettings(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const device = await deviceResponse.json();
|
const device = await deviceResponse.json();
|
||||||
const isAdalight = device.device_type === 'adalight';
|
const isAdalight = isSerialDevice(device.device_type);
|
||||||
|
|
||||||
// Populate fields
|
// Populate fields
|
||||||
document.getElementById('settings-device-id').value = device.id;
|
document.getElementById('settings-device-id').value = device.id;
|
||||||
@@ -847,7 +852,7 @@ async function showSettings(deviceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _getSettingsUrl() {
|
function _getSettingsUrl() {
|
||||||
if (settingsInitialValues.device_type === 'adalight') {
|
if (isSerialDevice(settingsInitialValues.device_type)) {
|
||||||
return document.getElementById('settings-serial-port').value;
|
return document.getElementById('settings-serial-port').value;
|
||||||
}
|
}
|
||||||
return document.getElementById('settings-device-url').value.trim();
|
return document.getElementById('settings-device-url').value.trim();
|
||||||
@@ -902,7 +907,7 @@ async function saveDeviceSettings() {
|
|||||||
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||||
body.led_count = parseInt(ledCountInput.value, 10);
|
body.led_count = parseInt(ledCountInput.value, 10);
|
||||||
}
|
}
|
||||||
if (settingsInitialValues.device_type === 'adalight') {
|
if (isSerialDevice(settingsInitialValues.device_type)) {
|
||||||
const baudVal = document.getElementById('settings-baud-rate').value;
|
const baudVal = document.getElementById('settings-baud-rate').value;
|
||||||
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
||||||
}
|
}
|
||||||
@@ -1044,7 +1049,7 @@ function onDeviceTypeChanged() {
|
|||||||
|
|
||||||
const baudRateGroup = document.getElementById('device-baud-rate-group');
|
const baudRateGroup = document.getElementById('device-baud-rate-group');
|
||||||
|
|
||||||
if (deviceType === 'adalight') {
|
if (isSerialDevice(deviceType)) {
|
||||||
urlGroup.style.display = 'none';
|
urlGroup.style.display = 'none';
|
||||||
urlInput.removeAttribute('required');
|
urlInput.removeAttribute('required');
|
||||||
serialGroup.style.display = '';
|
serialGroup.style.display = '';
|
||||||
@@ -1081,14 +1086,16 @@ function onDeviceTypeChanged() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _computeMaxFps(baudRate, ledCount) {
|
function _computeMaxFps(baudRate, ledCount, deviceType) {
|
||||||
if (!baudRate || !ledCount || ledCount < 1) return null;
|
if (!baudRate || !ledCount || ledCount < 1) return null;
|
||||||
const bitsPerFrame = (ledCount * 3 + 6) * 10;
|
// Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command
|
||||||
|
const overhead = deviceType === 'ambiled' ? 1 : 6;
|
||||||
|
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
|
||||||
return Math.floor(baudRate / bitsPerFrame);
|
return Math.floor(baudRate / bitsPerFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderFpsHint(hintEl, baudRate, ledCount) {
|
function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
|
||||||
const fps = _computeMaxFps(baudRate, ledCount);
|
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
|
||||||
if (fps !== null) {
|
if (fps !== null) {
|
||||||
hintEl.textContent = `Max FPS ≈ ${fps}`;
|
hintEl.textContent = `Max FPS ≈ ${fps}`;
|
||||||
hintEl.style.display = '';
|
hintEl.style.display = '';
|
||||||
@@ -1101,22 +1108,23 @@ function updateBaudFpsHint() {
|
|||||||
const hintEl = document.getElementById('baud-fps-hint');
|
const hintEl = document.getElementById('baud-fps-hint');
|
||||||
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
|
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
|
||||||
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
|
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
|
||||||
_renderFpsHint(hintEl, baudRate, ledCount);
|
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
||||||
|
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSettingsBaudFpsHint() {
|
function updateSettingsBaudFpsHint() {
|
||||||
const hintEl = document.getElementById('settings-baud-fps-hint');
|
const hintEl = document.getElementById('settings-baud-fps-hint');
|
||||||
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
|
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
|
||||||
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
|
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
|
||||||
_renderFpsHint(hintEl, baudRate, ledCount);
|
_renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderDiscoveryList() {
|
function _renderDiscoveryList() {
|
||||||
const selectedType = document.getElementById('device-type').value;
|
const selectedType = document.getElementById('device-type').value;
|
||||||
const devices = _discoveryCache[selectedType];
|
const devices = _discoveryCache[selectedType];
|
||||||
|
|
||||||
// Adalight: populate serial port dropdown instead of discovery list
|
// Serial devices: populate serial port dropdown instead of discovery list
|
||||||
if (selectedType === 'adalight') {
|
if (isSerialDevice(selectedType)) {
|
||||||
_populateSerialPortDropdown(devices || []);
|
_populateSerialPortDropdown(devices || []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1190,8 +1198,9 @@ function _populateSerialPortDropdown(devices) {
|
|||||||
|
|
||||||
function onSerialPortFocus() {
|
function onSerialPortFocus() {
|
||||||
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
||||||
if (!('adalight' in _discoveryCache)) {
|
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
||||||
scanForDevices('adalight');
|
if (!(deviceType in _discoveryCache)) {
|
||||||
|
scanForDevices(deviceType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,7 +1214,8 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
|||||||
select.appendChild(loadingOpt);
|
select.appendChild(loadingOpt);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
|
const discoverType = settingsInitialValues.device_type || 'adalight';
|
||||||
|
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
|
||||||
headers: getHeaders()
|
headers: getHeaders()
|
||||||
});
|
});
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
@@ -1279,7 +1289,7 @@ async function scanForDevices(forceType) {
|
|||||||
const section = document.getElementById('discovery-section');
|
const section = document.getElementById('discovery-section');
|
||||||
const scanBtn = document.getElementById('scan-network-btn');
|
const scanBtn = document.getElementById('scan-network-btn');
|
||||||
|
|
||||||
if (scanType === 'adalight') {
|
if (isSerialDevice(scanType)) {
|
||||||
// Show loading in the serial port dropdown
|
// Show loading in the serial port dropdown
|
||||||
const select = document.getElementById('device-serial-port');
|
const select = document.getElementById('device-serial-port');
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
@@ -1308,7 +1318,7 @@ async function scanForDevices(forceType) {
|
|||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (scanType !== 'adalight') {
|
if (!isSerialDevice(scanType)) {
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
empty.querySelector('small').textContent = t('device.scan.error');
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
}
|
}
|
||||||
@@ -1326,7 +1336,7 @@ async function scanForDevices(forceType) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
loading.style.display = 'none';
|
loading.style.display = 'none';
|
||||||
if (scanBtn) scanBtn.disabled = false;
|
if (scanBtn) scanBtn.disabled = false;
|
||||||
if (scanType !== 'adalight') {
|
if (!isSerialDevice(scanType)) {
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
empty.querySelector('small').textContent = t('device.scan.error');
|
empty.querySelector('small').textContent = t('device.scan.error');
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1353,7 @@ function selectDiscoveredDevice(device) {
|
|||||||
const typeSelect = document.getElementById('device-type');
|
const typeSelect = document.getElementById('device-type');
|
||||||
if (typeSelect) typeSelect.value = device.device_type;
|
if (typeSelect) typeSelect.value = device.device_type;
|
||||||
onDeviceTypeChanged();
|
onDeviceTypeChanged();
|
||||||
if (device.device_type === 'adalight') {
|
if (isSerialDevice(device.device_type)) {
|
||||||
document.getElementById('device-serial-port').value = device.url;
|
document.getElementById('device-serial-port').value = device.url;
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('device-url').value = device.url;
|
document.getElementById('device-url').value = device.url;
|
||||||
@@ -1356,7 +1366,7 @@ async function handleAddDevice(event) {
|
|||||||
|
|
||||||
const name = document.getElementById('device-name').value.trim();
|
const name = document.getElementById('device-name').value.trim();
|
||||||
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
const deviceType = document.getElementById('device-type')?.value || 'wled';
|
||||||
const url = deviceType === 'adalight'
|
const url = isSerialDevice(deviceType)
|
||||||
? document.getElementById('device-serial-port').value
|
? document.getElementById('device-serial-port').value
|
||||||
: document.getElementById('device-url').value.trim();
|
: document.getElementById('device-url').value.trim();
|
||||||
const error = document.getElementById('add-device-error');
|
const error = document.getElementById('add-device-error');
|
||||||
@@ -1374,7 +1384,7 @@ async function handleAddDevice(event) {
|
|||||||
body.led_count = parseInt(ledCountInput.value, 10);
|
body.led_count = parseInt(ledCountInput.value, 10);
|
||||||
}
|
}
|
||||||
const baudRateSelect = document.getElementById('device-baud-rate');
|
const baudRateSelect = document.getElementById('device-baud-rate');
|
||||||
if (deviceType === 'adalight' && baudRateSelect && baudRateSelect.value) {
|
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
|
||||||
body.baud_rate = parseInt(baudRateSelect.value, 10);
|
body.baud_rate = parseInt(baudRateSelect.value, 10);
|
||||||
}
|
}
|
||||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||||
@@ -1456,13 +1466,19 @@ async function loadDashboard() {
|
|||||||
if (!container) { _dashboardLoading = false; return; }
|
if (!container) { _dashboardLoading = false; return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
|
// Fetch targets and profiles in parallel
|
||||||
|
const [targetsResp, profilesResp] = await Promise.all([
|
||||||
|
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||||
|
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
|
||||||
|
]);
|
||||||
if (targetsResp.status === 401) { handle401Error(); return; }
|
if (targetsResp.status === 401) { handle401Error(); return; }
|
||||||
|
|
||||||
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 profiles = profilesData.profiles || [];
|
||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0 && profiles.length === 0) {
|
||||||
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1503,21 @@ async function loadDashboard() {
|
|||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
|
// Profiles section
|
||||||
|
if (profiles.length > 0) {
|
||||||
|
const activeProfiles = profiles.filter(p => p.is_active);
|
||||||
|
const inactiveProfiles = profiles.filter(p => !p.is_active);
|
||||||
|
|
||||||
|
html += `<div class="dashboard-section">
|
||||||
|
<div class="dashboard-section-header">
|
||||||
|
${t('dashboard.section.profiles')}
|
||||||
|
<span class="dashboard-section-count">${profiles.length}</span>
|
||||||
|
</div>
|
||||||
|
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
|
||||||
|
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Running section
|
// Running section
|
||||||
if (running.length > 0) {
|
if (running.length > 0) {
|
||||||
html += `<div class="dashboard-section">
|
html += `<div class="dashboard-section">
|
||||||
@@ -1524,9 +1555,10 @@ function renderDashboardTarget(target, isRunning) {
|
|||||||
const state = target.state || {};
|
const state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
||||||
const icon = isLed ? '💡' : '🎨';
|
const icon = '⚡';
|
||||||
|
const typeLabel = isLed ? 'LED' : 'Key Colors';
|
||||||
|
|
||||||
let subtitleParts = [];
|
let subtitleParts = [typeLabel];
|
||||||
if (isLed && state.device_name) {
|
if (isLed && state.device_name) {
|
||||||
subtitleParts.push(state.device_name);
|
subtitleParts.push(state.device_name);
|
||||||
}
|
}
|
||||||
@@ -1566,8 +1598,7 @@ function renderDashboardTarget(target, isRunning) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-target-actions">
|
<div class="dashboard-target-actions">
|
||||||
<span class="dashboard-status-dot active" title="${t('device.status.processing')}">●</span>
|
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏸</button>
|
||||||
<button class="btn btn-icon btn-danger" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏹️</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
|
|||||||
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
|
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
|
||||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-target-metrics"></div>
|
<div class="dashboard-target-metrics"></div>
|
||||||
<div class="dashboard-target-actions">
|
<div class="dashboard-target-actions">
|
||||||
<button class="btn btn-icon btn-primary" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶️</button>
|
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDashboardProfile(profile) {
|
||||||
|
const isActive = profile.is_active;
|
||||||
|
const isDisabled = !profile.enabled;
|
||||||
|
|
||||||
|
// Condition summary
|
||||||
|
let condSummary = '';
|
||||||
|
if (profile.conditions.length > 0) {
|
||||||
|
const parts = profile.conditions.map(c => {
|
||||||
|
if (c.condition_type === 'application') {
|
||||||
|
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');
|
||||||
|
return `${apps} (${matchLabel})`;
|
||||||
|
}
|
||||||
|
return c.condition_type;
|
||||||
|
});
|
||||||
|
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
|
||||||
|
condSummary = parts.join(logic);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = isDisabled
|
||||||
|
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
|
||||||
|
: isActive
|
||||||
|
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
||||||
|
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
|
||||||
|
|
||||||
|
const targetCount = profile.target_ids.length;
|
||||||
|
const activeCount = (profile.active_target_ids || []).length;
|
||||||
|
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
|
||||||
|
|
||||||
|
return `<div class="dashboard-target dashboard-profile">
|
||||||
|
<div class="dashboard-target-info">
|
||||||
|
<span class="dashboard-target-icon">📋</span>
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
|
||||||
|
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-target-metrics">
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<div class="dashboard-metric-value">${targetsInfo}</div>
|
||||||
|
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-target-actions">
|
||||||
|
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
|
||||||
|
${profile.enabled ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dashboardToggleProfile(profileId, enable) {
|
||||||
|
try {
|
||||||
|
const endpoint = enable ? 'enable' : 'disable';
|
||||||
|
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders()
|
||||||
|
});
|
||||||
|
if (response.status === 401) { handle401Error(); return; }
|
||||||
|
if (response.ok) {
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to toggle profile', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function dashboardStartTarget(targetId) {
|
async function dashboardStartTarget(targetId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
|
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
|
||||||
@@ -1651,7 +1749,7 @@ function startDashboardWS() {
|
|||||||
_dashboardWS.onmessage = (event) => {
|
_dashboardWS.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === 'state_change') {
|
if (data.type === 'state_change' || data.type === 'profile_state_changed') {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -2642,6 +2740,12 @@ document.addEventListener('click', (e) => {
|
|||||||
closeAddDeviceModal();
|
closeAddDeviceModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile editor modal: close on backdrop
|
||||||
|
if (modalId === 'profile-editor-modal') {
|
||||||
|
closeProfileEditorModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
@@ -6539,3 +6643,392 @@ async function capturePatternBackground() {
|
|||||||
showToast('Failed to capture background', 'error');
|
showToast('Failed to capture background', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// PROFILES
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
let _profilesCache = null;
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
const container = document.getElementById('profiles-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load profiles');
|
||||||
|
const data = await resp.json();
|
||||||
|
_profilesCache = data.profiles;
|
||||||
|
renderProfiles(data.profiles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profiles:', error);
|
||||||
|
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfiles(profiles) {
|
||||||
|
const container = document.getElementById('profiles-content');
|
||||||
|
|
||||||
|
let html = '<div class="devices-grid">';
|
||||||
|
for (const p of profiles) {
|
||||||
|
html += createProfileCard(p);
|
||||||
|
}
|
||||||
|
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
</div>`;
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
updateAllText();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProfileCard(profile) {
|
||||||
|
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
|
||||||
|
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
|
||||||
|
|
||||||
|
// Condition summary as pills
|
||||||
|
let condPills = '';
|
||||||
|
if (profile.conditions.length === 0) {
|
||||||
|
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
|
||||||
|
} else {
|
||||||
|
const parts = profile.conditions.map(c => {
|
||||||
|
if (c.condition_type === 'application') {
|
||||||
|
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');
|
||||||
|
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||||||
|
});
|
||||||
|
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
|
||||||
|
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target count pill
|
||||||
|
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
|
||||||
|
|
||||||
|
// Last activation timestamp
|
||||||
|
let lastActivityMeta = '';
|
||||||
|
if (profile.last_activated_at) {
|
||||||
|
const ts = new Date(profile.last_activated_at);
|
||||||
|
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">🕐 ${ts.toLocaleString()}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
|
||||||
|
<div class="card-top-actions">
|
||||||
|
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">
|
||||||
|
${escapeHtml(profile.name)}
|
||||||
|
<span class="badge badge-profile-${statusClass}">${statusText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle">
|
||||||
|
<span class="card-meta">${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}</span>
|
||||||
|
<span class="card-meta">⚡ ${targetCountText}</span>
|
||||||
|
${lastActivityMeta}
|
||||||
|
</div>
|
||||||
|
<div class="stream-card-props">${condPills}</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">⚙️</button>
|
||||||
|
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
|
||||||
|
${profile.enabled ? '⏸' : '▶'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProfileEditor(profileId) {
|
||||||
|
const modal = document.getElementById('profile-editor-modal');
|
||||||
|
const titleEl = document.getElementById('profile-editor-title');
|
||||||
|
const idInput = document.getElementById('profile-editor-id');
|
||||||
|
const nameInput = document.getElementById('profile-editor-name');
|
||||||
|
const enabledInput = document.getElementById('profile-editor-enabled');
|
||||||
|
const logicSelect = document.getElementById('profile-editor-logic');
|
||||||
|
const condList = document.getElementById('profile-conditions-list');
|
||||||
|
const errorEl = document.getElementById('profile-editor-error');
|
||||||
|
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
condList.innerHTML = '';
|
||||||
|
|
||||||
|
// Load available targets for the checklist
|
||||||
|
await loadProfileTargetChecklist([]);
|
||||||
|
|
||||||
|
if (profileId) {
|
||||||
|
// Edit mode
|
||||||
|
titleEl.textContent = t('profiles.edit');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load profile');
|
||||||
|
const profile = await resp.json();
|
||||||
|
|
||||||
|
idInput.value = profile.id;
|
||||||
|
nameInput.value = profile.name;
|
||||||
|
enabledInput.checked = profile.enabled;
|
||||||
|
logicSelect.value = profile.condition_logic;
|
||||||
|
|
||||||
|
// Populate conditions
|
||||||
|
for (const c of profile.conditions) {
|
||||||
|
addProfileConditionRow(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate target checklist
|
||||||
|
await loadProfileTargetChecklist(profile.target_ids);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create mode
|
||||||
|
titleEl.textContent = t('profiles.add');
|
||||||
|
idInput.value = '';
|
||||||
|
nameInput.value = '';
|
||||||
|
enabledInput.checked = true;
|
||||||
|
logicSelect.value = 'or';
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
lockBody();
|
||||||
|
updateAllText();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeProfileEditorModal() {
|
||||||
|
document.getElementById('profile-editor-modal').style.display = 'none';
|
||||||
|
unlockBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfileTargetChecklist(selectedIds) {
|
||||||
|
const container = document.getElementById('profile-targets-list');
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load targets');
|
||||||
|
const data = await resp.json();
|
||||||
|
const targets = data.targets || [];
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = targets.map(tgt => {
|
||||||
|
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
|
||||||
|
return `<label class="profile-target-item">
|
||||||
|
<input type="checkbox" value="${tgt.id}" ${checked}>
|
||||||
|
<span>${escapeHtml(tgt.name)}</span>
|
||||||
|
</label>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProfileCondition() {
|
||||||
|
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProfileConditionRow(condition) {
|
||||||
|
const list = document.getElementById('profile-conditions-list');
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profile-condition-row';
|
||||||
|
|
||||||
|
const appsValue = (condition.apps || []).join('\n');
|
||||||
|
const matchType = condition.match_type || 'running';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="condition-header">
|
||||||
|
<span class="condition-type-label">${t('profiles.condition.application')}</span>
|
||||||
|
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="condition-fields">
|
||||||
|
<div class="condition-field">
|
||||||
|
<label data-i18n="profiles.condition.application.match_type">${t('profiles.condition.application.match_type')}</label>
|
||||||
|
<select class="condition-match-type">
|
||||||
|
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
|
||||||
|
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="condition-field">
|
||||||
|
<div class="condition-apps-header">
|
||||||
|
<label data-i18n="profiles.condition.application.apps">${t('profiles.condition.application.apps')}</label>
|
||||||
|
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
|
||||||
|
</div>
|
||||||
|
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||||
|
<div class="process-picker" style="display:none">
|
||||||
|
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
|
||||||
|
<div class="process-picker-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire up browse button
|
||||||
|
const browseBtn = row.querySelector('.btn-browse-apps');
|
||||||
|
const picker = row.querySelector('.process-picker');
|
||||||
|
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
|
||||||
|
|
||||||
|
// Wire up search filter
|
||||||
|
const searchInput = row.querySelector('.process-picker-search');
|
||||||
|
searchInput.addEventListener('input', () => filterProcessPicker(picker));
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProcessPicker(picker, row) {
|
||||||
|
if (picker.style.display !== 'none') {
|
||||||
|
picker.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listEl = picker.querySelector('.process-picker-list');
|
||||||
|
const searchEl = picker.querySelector('.process-picker-search');
|
||||||
|
searchEl.value = '';
|
||||||
|
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`;
|
||||||
|
picker.style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() });
|
||||||
|
if (resp.status === 401) { handle401Error(); return; }
|
||||||
|
if (!resp.ok) throw new Error('Failed to fetch processes');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
// Get already-added apps to mark them
|
||||||
|
const textarea = row.querySelector('.condition-apps');
|
||||||
|
const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean));
|
||||||
|
|
||||||
|
picker._processes = data.processes;
|
||||||
|
picker._existing = existing;
|
||||||
|
renderProcessPicker(picker, data.processes, existing);
|
||||||
|
searchEl.focus();
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProcessPicker(picker, processes, existing) {
|
||||||
|
const listEl = picker.querySelector('.process-picker-list');
|
||||||
|
if (processes.length === 0) {
|
||||||
|
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = processes.map(p => {
|
||||||
|
const added = existing.has(p.toLowerCase());
|
||||||
|
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ✓' : ''}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Click handler for each item
|
||||||
|
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const proc = item.dataset.process;
|
||||||
|
const row = picker.closest('.profile-condition-row');
|
||||||
|
const textarea = row.querySelector('.condition-apps');
|
||||||
|
const current = textarea.value.trim();
|
||||||
|
textarea.value = current ? current + '\n' + proc : proc;
|
||||||
|
item.classList.add('added');
|
||||||
|
item.textContent = proc + ' ✓';
|
||||||
|
// Update existing set
|
||||||
|
picker._existing.add(proc.toLowerCase());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterProcessPicker(picker) {
|
||||||
|
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
|
||||||
|
const filtered = (picker._processes || []).filter(p => p.includes(query));
|
||||||
|
renderProcessPicker(picker, filtered, picker._existing || new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileEditorConditions() {
|
||||||
|
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
|
||||||
|
const conditions = [];
|
||||||
|
rows.forEach(row => {
|
||||||
|
const matchType = row.querySelector('.condition-match-type').value;
|
||||||
|
const appsText = row.querySelector('.condition-apps').value.trim();
|
||||||
|
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
|
||||||
|
conditions.push({ condition_type: 'application', apps, match_type: matchType });
|
||||||
|
});
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileEditorTargetIds() {
|
||||||
|
const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
|
||||||
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfileEditor() {
|
||||||
|
const idInput = document.getElementById('profile-editor-id');
|
||||||
|
const nameInput = document.getElementById('profile-editor-name');
|
||||||
|
const enabledInput = document.getElementById('profile-editor-enabled');
|
||||||
|
const logicSelect = document.getElementById('profile-editor-logic');
|
||||||
|
const errorEl = document.getElementById('profile-editor-error');
|
||||||
|
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = 'Name is required';
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
enabled: enabledInput.checked,
|
||||||
|
condition_logic: logicSelect.value,
|
||||||
|
conditions: getProfileEditorConditions(),
|
||||||
|
target_ids: getProfileEditorTargetIds(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const profileId = idInput.value;
|
||||||
|
const isEdit = !!profileId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`;
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: isEdit ? 'PUT' : 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Failed to save profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeProfileEditorModal();
|
||||||
|
showToast(isEdit ? 'Profile updated' : 'Profile created', 'success');
|
||||||
|
loadProfiles();
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = e.message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleProfileEnabled(profileId, enable) {
|
||||||
|
try {
|
||||||
|
const action = enable ? 'enable' : 'disable';
|
||||||
|
const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Failed to ${action} profile`);
|
||||||
|
loadProfiles();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProfile(profileId, profileName) {
|
||||||
|
const msg = t('profiles.delete.confirm').replace('{name}', profileName);
|
||||||
|
const confirmed = await showConfirm(msg);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getHeaders(),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to delete profile');
|
||||||
|
showToast('Profile deleted', 'success');
|
||||||
|
loadProfiles();
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-bar">
|
<div class="tab-bar">
|
||||||
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
|
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
|
||||||
|
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')"><span data-i18n="profiles.title">📋 Profiles</span></button>
|
||||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
|
||||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,6 +47,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-panel" id="tab-profiles">
|
||||||
|
<div id="profiles-content">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-targets">
|
<div class="tab-panel" id="tab-targets">
|
||||||
<div id="targets-panel-content">
|
<div id="targets-panel-content">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
@@ -650,6 +657,7 @@
|
|||||||
<select id="device-type" onchange="onDeviceTypeChanged()">
|
<select id="device-type" onchange="onDeviceTypeChanged()">
|
||||||
<option value="wled">WLED</option>
|
<option value="wled">WLED</option>
|
||||||
<option value="adalight">Adalight</option>
|
<option value="adalight">Adalight</option>
|
||||||
|
<option value="ambiled">AmbiLED</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -982,6 +990,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Editor Modal -->
|
||||||
|
<div id="profile-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="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.targets">Targets:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="profiles.targets.hint">Targets to start when this profile activates</small>
|
||||||
|
<div id="profile-targets-list" class="profile-targets-checklist"></div>
|
||||||
|
</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">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Tutorial Overlay (viewport-level) -->
|
<!-- Device Tutorial Overlay (viewport-level) -->
|
||||||
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
||||||
<div class="tutorial-backdrop"></div>
|
<div class="tutorial-backdrop"></div>
|
||||||
|
|||||||
@@ -464,5 +464,42 @@
|
|||||||
"dashboard.errors": "Errors",
|
"dashboard.errors": "Errors",
|
||||||
"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.targets": "Targets",
|
||||||
|
|
||||||
|
"profiles.title": "\uD83D\uDCCB Profiles",
|
||||||
|
"profiles.empty": "No profiles configured. Create one to automate target activation.",
|
||||||
|
"profiles.add": "\uD83D\uDCCB Add Profile",
|
||||||
|
"profiles.edit": "Edit Profile",
|
||||||
|
"profiles.delete.confirm": "Delete profile \"{name}\"?",
|
||||||
|
"profiles.name": "Name:",
|
||||||
|
"profiles.name.hint": "A descriptive name for this profile",
|
||||||
|
"profiles.enabled": "Enabled:",
|
||||||
|
"profiles.enabled.hint": "Disabled profiles won't activate even when conditions are met",
|
||||||
|
"profiles.condition_logic": "Condition Logic:",
|
||||||
|
"profiles.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)",
|
||||||
|
"profiles.condition_logic.or": "Any condition (OR)",
|
||||||
|
"profiles.condition_logic.and": "All conditions (AND)",
|
||||||
|
"profiles.conditions": "Conditions:",
|
||||||
|
"profiles.conditions.hint": "Rules that determine when this profile activates",
|
||||||
|
"profiles.conditions.add": "Add Condition",
|
||||||
|
"profiles.conditions.empty": "No conditions \u2014 profile will never activate automatically",
|
||||||
|
"profiles.condition.application": "Application",
|
||||||
|
"profiles.condition.application.apps": "Applications:",
|
||||||
|
"profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
|
||||||
|
"profiles.condition.application.browse": "Browse",
|
||||||
|
"profiles.condition.application.search": "Filter processes...",
|
||||||
|
"profiles.condition.application.no_processes": "No processes found",
|
||||||
|
"profiles.condition.application.match_type": "Match Type:",
|
||||||
|
"profiles.condition.application.match_type.hint": "How to detect the application",
|
||||||
|
"profiles.condition.application.match_type.running": "Running",
|
||||||
|
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
|
||||||
|
"profiles.targets": "Targets:",
|
||||||
|
"profiles.targets.hint": "Targets to start when this profile activates",
|
||||||
|
"profiles.targets.empty": "No targets available",
|
||||||
|
"profiles.status.active": "Active",
|
||||||
|
"profiles.status.inactive": "Inactive",
|
||||||
|
"profiles.status.disabled": "Disabled",
|
||||||
|
"profiles.last_activated": "Last activated"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,5 +464,42 @@
|
|||||||
"dashboard.errors": "Ошибки",
|
"dashboard.errors": "Ошибки",
|
||||||
"dashboard.device": "Устройство",
|
"dashboard.device": "Устройство",
|
||||||
"dashboard.stop_all": "Остановить все",
|
"dashboard.stop_all": "Остановить все",
|
||||||
"dashboard.failed": "Не удалось загрузить обзор"
|
"dashboard.failed": "Не удалось загрузить обзор",
|
||||||
|
"dashboard.section.profiles": "Профили",
|
||||||
|
"dashboard.targets": "Цели",
|
||||||
|
|
||||||
|
"profiles.title": "\uD83D\uDCCB Профили",
|
||||||
|
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",
|
||||||
|
"profiles.add": "\uD83D\uDCCB Добавить профиль",
|
||||||
|
"profiles.edit": "Редактировать профиль",
|
||||||
|
"profiles.delete.confirm": "Удалить профиль \"{name}\"?",
|
||||||
|
"profiles.name": "Название:",
|
||||||
|
"profiles.name.hint": "Описательное имя для профиля",
|
||||||
|
"profiles.enabled": "Включён:",
|
||||||
|
"profiles.enabled.hint": "Отключённые профили не активируются даже при выполнении условий",
|
||||||
|
"profiles.condition_logic": "Логика условий:",
|
||||||
|
"profiles.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
|
||||||
|
"profiles.condition_logic.or": "Любое условие (ИЛИ)",
|
||||||
|
"profiles.condition_logic.and": "Все условия (И)",
|
||||||
|
"profiles.conditions": "Условия:",
|
||||||
|
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется",
|
||||||
|
"profiles.conditions.add": "Добавить условие",
|
||||||
|
"profiles.conditions.empty": "Нет условий \u2014 профиль не активируется автоматически",
|
||||||
|
"profiles.condition.application": "Приложение",
|
||||||
|
"profiles.condition.application.apps": "Приложения:",
|
||||||
|
"profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
|
||||||
|
"profiles.condition.application.browse": "Обзор",
|
||||||
|
"profiles.condition.application.search": "Фильтр процессов...",
|
||||||
|
"profiles.condition.application.no_processes": "Процессы не найдены",
|
||||||
|
"profiles.condition.application.match_type": "Тип соответствия:",
|
||||||
|
"profiles.condition.application.match_type.hint": "Как определять наличие приложения",
|
||||||
|
"profiles.condition.application.match_type.running": "Запущено",
|
||||||
|
"profiles.condition.application.match_type.topmost": "На переднем плане",
|
||||||
|
"profiles.targets": "Цели:",
|
||||||
|
"profiles.targets.hint": "Цели для запуска при активации профиля",
|
||||||
|
"profiles.targets.empty": "Нет доступных целей",
|
||||||
|
"profiles.status.active": "Активен",
|
||||||
|
"profiles.status.inactive": "Неактивен",
|
||||||
|
"profiles.status.disabled": "Отключён",
|
||||||
|
"profiles.last_activated": "Последняя активация"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3225,32 +3225,34 @@ input:-webkit-autofill:focus {
|
|||||||
/* ── Dashboard ── */
|
/* ── Dashboard ── */
|
||||||
|
|
||||||
.dashboard-section {
|
.dashboard-section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section-header {
|
.dashboard-section-header {
|
||||||
font-size: 1rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section-count {
|
.dashboard-section-count {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1px 8px;
|
padding: 0 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stop-all {
|
.dashboard-stop-all {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 3px 10px;
|
padding: 2px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
@@ -3259,35 +3261,36 @@ input:-webkit-autofill:focus {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 12px 16px;
|
padding: 8px 12px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-info {
|
.dashboard-target-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-icon {
|
.dashboard-target-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-name {
|
.dashboard-target-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-name .health-dot {
|
.dashboard-target-name .health-dot {
|
||||||
@@ -3296,7 +3299,7 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-subtitle {
|
.dashboard-target-subtitle {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -3304,24 +3307,25 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-metrics {
|
.dashboard-target-metrics {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 90px 80px 60px;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric {
|
.dashboard-metric {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
min-width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-value {
|
.dashboard-metric-value {
|
||||||
font-size: 1.05rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-metric-label {
|
.dashboard-metric-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.6rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@@ -3330,11 +3334,11 @@ input:-webkit-autofill:focus {
|
|||||||
.dashboard-target-actions {
|
.dashboard-target-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-status-dot {
|
.dashboard-status-dot {
|
||||||
font-size: 1.2rem;
|
font-size: 1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3345,24 +3349,39 @@ input:-webkit-autofill:focus {
|
|||||||
|
|
||||||
.dashboard-no-targets {
|
.dashboard-no-targets {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px;
|
padding: 32px 16px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-badge-stopped {
|
.dashboard-badge-stopped {
|
||||||
padding: 3px 10px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-badge-active {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--success-color, #28a745);
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-profile .dashboard-target-metrics {
|
||||||
|
min-width: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-target {
|
.dashboard-target {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-target-actions {
|
.dashboard-target-actions {
|
||||||
@@ -3370,3 +3389,189 @@ input:-webkit-autofill:focus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== PROFILES ===== */
|
||||||
|
|
||||||
|
.badge-profile-active {
|
||||||
|
background: var(--success-color, #28a745);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-profile-inactive {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-profile-disabled {
|
||||||
|
background: var(--border-color);
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-status-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-logic-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile condition editor rows */
|
||||||
|
.profile-condition-row {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: var(--bg-secondary, var(--bg-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-type-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-condition {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-field select,
|
||||||
|
.condition-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-apps {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.condition-apps-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-browse-apps {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-browse-apps:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(33, 150, 243, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker {
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-list {
|
||||||
|
max-height: 160px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-item {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-item:hover {
|
||||||
|
background: rgba(33, 150, 243, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-item.added {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-picker-loading {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile target checklist */
|
||||||
|
.profile-targets-checklist {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-target-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-target-item:hover {
|
||||||
|
background: var(--bg-secondary, var(--bg-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-target-item input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ class DeviceStore:
|
|||||||
device.url = url
|
device.url = url
|
||||||
if led_count is not None:
|
if led_count is not None:
|
||||||
device.led_count = led_count
|
device.led_count = led_count
|
||||||
device.calibration = create_default_calibration(led_count)
|
|
||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
device.enabled = enabled
|
device.enabled = enabled
|
||||||
if baud_rate is not None:
|
if baud_rate is not None:
|
||||||
|
|||||||
91
server/src/wled_controller/storage/profile.py
Normal file
91
server/src/wled_controller/storage/profile.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Profile and Condition data models."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Condition:
|
||||||
|
"""Base condition — polymorphic via condition_type discriminator."""
|
||||||
|
|
||||||
|
condition_type: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {"condition_type": self.condition_type}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Condition":
|
||||||
|
"""Factory: dispatch to the correct subclass."""
|
||||||
|
ct = data.get("condition_type", "")
|
||||||
|
if ct == "application":
|
||||||
|
return ApplicationCondition.from_dict(data)
|
||||||
|
raise ValueError(f"Unknown condition type: {ct}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApplicationCondition(Condition):
|
||||||
|
"""Activate when specified applications are running or topmost."""
|
||||||
|
|
||||||
|
condition_type: str = "application"
|
||||||
|
apps: List[str] = field(default_factory=list)
|
||||||
|
match_type: str = "running" # "running" | "topmost"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = super().to_dict()
|
||||||
|
d["apps"] = list(self.apps)
|
||||||
|
d["match_type"] = self.match_type
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ApplicationCondition":
|
||||||
|
return cls(
|
||||||
|
apps=data.get("apps", []),
|
||||||
|
match_type=data.get("match_type", "running"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Profile:
|
||||||
|
"""Automation profile that activates targets based on conditions."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
condition_logic: str # "or" | "and"
|
||||||
|
conditions: List[Condition]
|
||||||
|
target_ids: List[str]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"condition_logic": self.condition_logic,
|
||||||
|
"conditions": [c.to_dict() for c in self.conditions],
|
||||||
|
"target_ids": list(self.target_ids),
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "Profile":
|
||||||
|
conditions = []
|
||||||
|
for c_data in data.get("conditions", []):
|
||||||
|
try:
|
||||||
|
conditions.append(Condition.from_dict(c_data))
|
||||||
|
except ValueError:
|
||||||
|
pass # skip unknown condition types on load
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
enabled=data.get("enabled", True),
|
||||||
|
condition_logic=data.get("condition_logic", "or"),
|
||||||
|
conditions=conditions,
|
||||||
|
target_ids=data.get("target_ids", []),
|
||||||
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
|
)
|
||||||
145
server/src/wled_controller/storage/profile_store.py
Normal file
145
server/src/wled_controller/storage/profile_store.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""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 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:
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"profiles": {
|
||||||
|
pid: p.to_dict() for pid, p in self._profiles.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
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,
|
||||||
|
target_ids: Optional[List[str]] = None,
|
||||||
|
) -> Profile:
|
||||||
|
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 [],
|
||||||
|
target_ids=target_ids or [],
|
||||||
|
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,
|
||||||
|
target_ids: Optional[List[str]] = None,
|
||||||
|
) -> 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:
|
||||||
|
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 target_ids is not None:
|
||||||
|
profile.target_ids = target_ids
|
||||||
|
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user