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.pattern_templates import router as pattern_templates_router
|
||||
from .routes.picture_targets import router as picture_targets_router
|
||||
from .routes.profiles import router as profiles_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -18,5 +19,6 @@ router.include_router(postprocessing_router)
|
||||
router.include_router(pattern_templates_router)
|
||||
router.include_router(picture_sources_router)
|
||||
router.include_router(picture_targets_router)
|
||||
router.include_router(profiles_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.picture_source_store import PictureSourceStore
|
||||
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)
|
||||
_device_store: DeviceStore | None = None
|
||||
@@ -16,6 +18,8 @@ _pattern_template_store: PatternTemplateStore | None = None
|
||||
_picture_source_store: PictureSourceStore | None = None
|
||||
_picture_target_store: PictureTargetStore | None = None
|
||||
_processor_manager: ProcessorManager | None = None
|
||||
_profile_store: ProfileStore | None = None
|
||||
_profile_engine: ProfileEngine | None = None
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
@@ -67,6 +71,20 @@ def get_processor_manager() -> ProcessorManager:
|
||||
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(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
@@ -75,10 +93,13 @@ def init_dependencies(
|
||||
pattern_template_store: PatternTemplateStore | None = None,
|
||||
picture_source_store: PictureSourceStore | None = None,
|
||||
picture_target_store: PictureTargetStore | None = None,
|
||||
profile_store: ProfileStore | None = None,
|
||||
profile_engine: ProfileEngine | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||
global _profile_store, _profile_engine
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
@@ -86,3 +107,5 @@ def init_dependencies(
|
||||
_pattern_template_store = pattern_template_store
|
||||
_picture_source_store = picture_source_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,
|
||||
DisplayListResponse,
|
||||
HealthResponse,
|
||||
ProcessListResponse,
|
||||
VersionResponse,
|
||||
)
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
@@ -91,3 +92,24 @@ async def get_displays(_: AuthRequired):
|
||||
status_code=500,
|
||||
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")
|
||||
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_targets_file: str = "data/picture_targets.json"
|
||||
pattern_templates_file: str = "data/pattern_templates.json"
|
||||
profiles_file: str = "data/profiles.json"
|
||||
|
||||
|
||||
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
|
||||
register_provider(AdalightDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
|
||||
register_provider(AmbiLEDDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
@@ -691,22 +691,16 @@ class ProcessorManager:
|
||||
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
|
||||
if reported and reported != state.led_count and self._device_store:
|
||||
old_count = state.led_count
|
||||
logger.info(
|
||||
f"Device {device_id} LED count changed: {old_count} → {reported}, "
|
||||
f"updating calibration"
|
||||
f"Device {device_id} LED count changed: {old_count} → {reported}"
|
||||
)
|
||||
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.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:
|
||||
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.wled_picture_target import WledPictureTarget
|
||||
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
|
||||
|
||||
# Initialize logging
|
||||
@@ -39,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te
|
||||
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||
profile_store = ProfileStore(config.storage.profiles_file)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
picture_source_store=picture_source_store,
|
||||
@@ -138,6 +141,9 @@ async def lifespan(app: FastAPI):
|
||||
# Run migrations
|
||||
_migrate_devices_to_targets()
|
||||
|
||||
# Create profile engine (needs processor_manager)
|
||||
profile_engine = ProfileEngine(profile_store, processor_manager)
|
||||
|
||||
# Initialize API dependencies
|
||||
init_dependencies(
|
||||
device_store, template_store, processor_manager,
|
||||
@@ -145,6 +151,8 @@ async def lifespan(app: FastAPI):
|
||||
pattern_template_store=pattern_template_store,
|
||||
picture_source_store=picture_source_store,
|
||||
picture_target_store=picture_target_store,
|
||||
profile_store=profile_store,
|
||||
profile_engine=profile_engine,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
@@ -201,11 +209,21 @@ async def lifespan(app: FastAPI):
|
||||
# Start background health monitoring for all devices
|
||||
await processor_manager.start_health_monitoring()
|
||||
|
||||
# Start profile engine (evaluates conditions and auto-starts/stops targets)
|
||||
await profile_engine.start()
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
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
|
||||
try:
|
||||
await processor_manager.stop_all()
|
||||
|
||||
@@ -34,6 +34,9 @@ function setupBackdropClose(modal, closeFn) {
|
||||
modal._backdropCloseSetup = true;
|
||||
}
|
||||
|
||||
// Device type helpers
|
||||
function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; }
|
||||
|
||||
// Track logged errors to avoid console spam
|
||||
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||||
|
||||
@@ -598,6 +601,8 @@ function switchTab(name) {
|
||||
loadPictureSources();
|
||||
} else if (name === 'targets') {
|
||||
loadTargetsTab();
|
||||
} else if (name === 'profiles') {
|
||||
loadProfiles();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,7 +772,7 @@ async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const isAdalight = device.device_type === 'adalight';
|
||||
const isAdalight = isSerialDevice(device.device_type);
|
||||
|
||||
// Populate fields
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
@@ -847,7 +852,7 @@ async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
function _getSettingsUrl() {
|
||||
if (settingsInitialValues.device_type === 'adalight') {
|
||||
if (isSerialDevice(settingsInitialValues.device_type)) {
|
||||
return document.getElementById('settings-serial-port').value;
|
||||
}
|
||||
return document.getElementById('settings-device-url').value.trim();
|
||||
@@ -902,7 +907,7 @@ async function saveDeviceSettings() {
|
||||
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
|
||||
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;
|
||||
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
|
||||
}
|
||||
@@ -1044,7 +1049,7 @@ function onDeviceTypeChanged() {
|
||||
|
||||
const baudRateGroup = document.getElementById('device-baud-rate-group');
|
||||
|
||||
if (deviceType === 'adalight') {
|
||||
if (isSerialDevice(deviceType)) {
|
||||
urlGroup.style.display = 'none';
|
||||
urlInput.removeAttribute('required');
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
function _renderFpsHint(hintEl, baudRate, ledCount) {
|
||||
const fps = _computeMaxFps(baudRate, ledCount);
|
||||
function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
|
||||
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
|
||||
if (fps !== null) {
|
||||
hintEl.textContent = `Max FPS ≈ ${fps}`;
|
||||
hintEl.style.display = '';
|
||||
@@ -1101,22 +1108,23 @@ function updateBaudFpsHint() {
|
||||
const hintEl = document.getElementById('baud-fps-hint');
|
||||
const baudRate = parseInt(document.getElementById('device-baud-rate').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() {
|
||||
const hintEl = document.getElementById('settings-baud-fps-hint');
|
||||
const baudRate = parseInt(document.getElementById('settings-baud-rate').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() {
|
||||
const selectedType = document.getElementById('device-type').value;
|
||||
const devices = _discoveryCache[selectedType];
|
||||
|
||||
// Adalight: populate serial port dropdown instead of discovery list
|
||||
if (selectedType === 'adalight') {
|
||||
// Serial devices: populate serial port dropdown instead of discovery list
|
||||
if (isSerialDevice(selectedType)) {
|
||||
_populateSerialPortDropdown(devices || []);
|
||||
return;
|
||||
}
|
||||
@@ -1190,8 +1198,9 @@ function _populateSerialPortDropdown(devices) {
|
||||
|
||||
function onSerialPortFocus() {
|
||||
// Lazy-load: trigger discovery when user opens the serial port dropdown
|
||||
if (!('adalight' in _discoveryCache)) {
|
||||
scanForDevices('adalight');
|
||||
const deviceType = document.getElementById('device-type')?.value || 'adalight';
|
||||
if (!(deviceType in _discoveryCache)) {
|
||||
scanForDevices(deviceType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,7 +1214,8 @@ async function _populateSettingsSerialPorts(currentUrl) {
|
||||
select.appendChild(loadingOpt);
|
||||
|
||||
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()
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
@@ -1279,7 +1289,7 @@ async function scanForDevices(forceType) {
|
||||
const section = document.getElementById('discovery-section');
|
||||
const scanBtn = document.getElementById('scan-network-btn');
|
||||
|
||||
if (scanType === 'adalight') {
|
||||
if (isSerialDevice(scanType)) {
|
||||
// Show loading in the serial port dropdown
|
||||
const select = document.getElementById('device-serial-port');
|
||||
select.innerHTML = '';
|
||||
@@ -1308,7 +1318,7 @@ async function scanForDevices(forceType) {
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
if (scanType !== 'adalight') {
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
@@ -1326,7 +1336,7 @@ async function scanForDevices(forceType) {
|
||||
} catch (err) {
|
||||
loading.style.display = 'none';
|
||||
if (scanBtn) scanBtn.disabled = false;
|
||||
if (scanType !== 'adalight') {
|
||||
if (!isSerialDevice(scanType)) {
|
||||
empty.style.display = 'block';
|
||||
empty.querySelector('small').textContent = t('device.scan.error');
|
||||
}
|
||||
@@ -1343,7 +1353,7 @@ function selectDiscoveredDevice(device) {
|
||||
const typeSelect = document.getElementById('device-type');
|
||||
if (typeSelect) typeSelect.value = device.device_type;
|
||||
onDeviceTypeChanged();
|
||||
if (device.device_type === 'adalight') {
|
||||
if (isSerialDevice(device.device_type)) {
|
||||
document.getElementById('device-serial-port').value = device.url;
|
||||
} else {
|
||||
document.getElementById('device-url').value = device.url;
|
||||
@@ -1356,7 +1366,7 @@ async function handleAddDevice(event) {
|
||||
|
||||
const name = document.getElementById('device-name').value.trim();
|
||||
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-url').value.trim();
|
||||
const error = document.getElementById('add-device-error');
|
||||
@@ -1374,7 +1384,7 @@ async function handleAddDevice(event) {
|
||||
body.led_count = parseInt(ledCountInput.value, 10);
|
||||
}
|
||||
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);
|
||||
}
|
||||
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
|
||||
@@ -1456,13 +1466,19 @@ async function loadDashboard() {
|
||||
if (!container) { _dashboardLoading = false; return; }
|
||||
|
||||
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; }
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
@@ -1487,6 +1503,21 @@ async function loadDashboard() {
|
||||
|
||||
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
|
||||
if (running.length > 0) {
|
||||
html += `<div class="dashboard-section">
|
||||
@@ -1524,9 +1555,10 @@ function renderDashboardTarget(target, isRunning) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
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) {
|
||||
subtitleParts.push(state.device_name);
|
||||
}
|
||||
@@ -1566,8 +1598,7 @@ function renderDashboardTarget(target, isRunning) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-target-actions">
|
||||
<span class="dashboard-status-dot active" title="${t('device.status.processing')}">●</span>
|
||||
<button class="btn btn-icon btn-danger" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏹️</button>
|
||||
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏸</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
@@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
|
||||
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
|
||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||
</div>
|
||||
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
|
||||
</div>
|
||||
<div class="dashboard-target-metrics"></div>
|
||||
<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>`;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
|
||||
@@ -1651,7 +1749,7 @@ function startDashboardWS() {
|
||||
_dashboardWS.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'state_change') {
|
||||
if (data.type === 'state_change' || data.type === 'profile_state_changed') {
|
||||
loadDashboard();
|
||||
}
|
||||
} catch {}
|
||||
@@ -2642,6 +2740,12 @@ document.addEventListener('click', (e) => {
|
||||
closeAddDeviceModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Profile editor modal: close on backdrop
|
||||
if (modalId === 'profile-editor-modal') {
|
||||
closeProfileEditorModal();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
@@ -6539,3 +6643,392 @@ async function capturePatternBackground() {
|
||||
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="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="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="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
|
||||
</div>
|
||||
@@ -46,6 +47,12 @@
|
||||
</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 id="targets-panel-content">
|
||||
<div class="loading-spinner"></div>
|
||||
@@ -650,6 +657,7 @@
|
||||
<select id="device-type" onchange="onDeviceTypeChanged()">
|
||||
<option value="wled">WLED</option>
|
||||
<option value="adalight">Adalight</option>
|
||||
<option value="ambiled">AmbiLED</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -982,6 +990,81 @@
|
||||
</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) -->
|
||||
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
|
||||
@@ -464,5 +464,42 @@
|
||||
"dashboard.errors": "Errors",
|
||||
"dashboard.device": "Device",
|
||||
"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.device": "Устройство",
|
||||
"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-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-section-header {
|
||||
font-size: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.dashboard-section-count {
|
||||
background: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-stop-all {
|
||||
margin-left: auto;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -3259,35 +3261,36 @@ input:-webkit-autofill:focus {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dashboard-target-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-target-icon {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboard-target-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-target-name .health-dot {
|
||||
@@ -3296,7 +3299,7 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.dashboard-target-subtitle {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -3304,24 +3307,25 @@ input:-webkit-autofill:focus {
|
||||
}
|
||||
|
||||
.dashboard-target-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 80px 60px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-metric {
|
||||
text-align: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.dashboard-metric-value {
|
||||
font-size: 1.05rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-metric-label {
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -3330,11 +3334,11 @@ input:-webkit-autofill:focus {
|
||||
.dashboard-target-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-status-dot {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -3345,24 +3349,39 @@ input:-webkit-autofill:focus {
|
||||
|
||||
.dashboard-no-targets {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dashboard-badge-stopped {
|
||||
padding: 3px 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: var(--border-color);
|
||||
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) {
|
||||
.dashboard-target {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.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
|
||||
if led_count is not None:
|
||||
device.led_count = led_count
|
||||
device.calibration = create_default_calibration(led_count)
|
||||
if enabled is not None:
|
||||
device.enabled = enabled
|
||||
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