Compare commits

...

3 Commits

Author SHA1 Message Date
3bac9c4ed9 Add AmbiLED device backend (client + provider)
AmbiLED protocol: raw RGB bytes (clamped 0-250) + 0xFF show command.
Subclasses Adalight infrastructure, shares serial transport and
discovery. Registered as built-in provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:12:55 +03:00
aa105f3958 Add profiles UI, dashboard improvements, and AmbiLED support
- Profile management tab with cards, condition editor, process browser
- Dashboard: add profiles section, compact layout, type subtitles
- AmbiLED serial device support (raw RGB + 0xFF show command)
- Unified serial device handling (isSerialDevice helper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:12:45 +03:00
29d9b95885 Add profile system for automatic target activation
Profiles monitor running processes and foreground windows to
automatically start/stop targets when conditions are met.
Includes profile engine, platform detector (WMI), REST API,
process browser endpoint, and calibration persistence fix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:12:34 +03:00
23 changed files with 2010 additions and 69 deletions

View File

@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router from .routes.pattern_templates import router as pattern_templates_router
from .routes.picture_targets import router as picture_targets_router from .routes.picture_targets import router as picture_targets_router
from .routes.profiles import router as profiles_router
router = APIRouter() router = APIRouter()
router.include_router(system_router) router.include_router(system_router)
@@ -18,5 +19,6 @@ router.include_router(postprocessing_router)
router.include_router(pattern_templates_router) router.include_router(pattern_templates_router)
router.include_router(picture_sources_router) router.include_router(picture_sources_router)
router.include_router(picture_targets_router) router.include_router(picture_targets_router)
router.include_router(profiles_router)
__all__ = ["router"] __all__ = ["router"]

View File

@@ -7,6 +7,8 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.pattern_template_store import PatternTemplateStore from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
# Global instances (initialized in main.py) # Global instances (initialized in main.py)
_device_store: DeviceStore | None = None _device_store: DeviceStore | None = None
@@ -16,6 +18,8 @@ _pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None _picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None _picture_target_store: PictureTargetStore | None = None
_processor_manager: ProcessorManager | None = None _processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None
def get_device_store() -> DeviceStore: def get_device_store() -> DeviceStore:
@@ -67,6 +71,20 @@ def get_processor_manager() -> ProcessorManager:
return _processor_manager return _processor_manager
def get_profile_store() -> ProfileStore:
"""Get profile store dependency."""
if _profile_store is None:
raise RuntimeError("Profile store not initialized")
return _profile_store
def get_profile_engine() -> ProfileEngine:
"""Get profile engine dependency."""
if _profile_engine is None:
raise RuntimeError("Profile engine not initialized")
return _profile_engine
def init_dependencies( def init_dependencies(
device_store: DeviceStore, device_store: DeviceStore,
template_store: TemplateStore, template_store: TemplateStore,
@@ -75,10 +93,13 @@ def init_dependencies(
pattern_template_store: PatternTemplateStore | None = None, pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None, picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None, picture_target_store: PictureTargetStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
): ):
"""Initialize global dependencies.""" """Initialize global dependencies."""
global _device_store, _template_store, _processor_manager global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _profile_store, _profile_engine
_device_store = device_store _device_store = device_store
_template_store = template_store _template_store = template_store
_processor_manager = processor_manager _processor_manager = processor_manager
@@ -86,3 +107,5 @@ def init_dependencies(
_pattern_template_store = pattern_template_store _pattern_template_store = pattern_template_store
_picture_source_store = picture_source_store _picture_source_store = picture_source_store
_picture_target_store = picture_target_store _picture_target_store = picture_target_store
_profile_store = profile_store
_profile_engine = profile_engine

View 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)

View File

@@ -11,6 +11,7 @@ from wled_controller.api.schemas.system import (
DisplayInfo, DisplayInfo,
DisplayListResponse, DisplayListResponse,
HealthResponse, HealthResponse,
ProcessListResponse,
VersionResponse, VersionResponse,
) )
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
@@ -91,3 +92,24 @@ async def get_displays(_: AuthRequired):
status_code=500, status_code=500,
detail=f"Failed to retrieve display information: {str(e)}" detail=f"Failed to retrieve display information: {str(e)}"
) )
@router.get("/api/v1/system/processes", response_model=ProcessListResponse, tags=["Config"])
async def get_running_processes(_: AuthRequired):
"""Get list of currently running process names.
Returns a sorted list of unique process names for use in profile conditions.
"""
from wled_controller.core.profiles.platform_detector import PlatformDetector
try:
detector = PlatformDetector()
processes = await detector.get_running_processes()
sorted_procs = sorted(processes)
return ProcessListResponse(processes=sorted_procs, count=len(sorted_procs))
except Exception as e:
logger.error(f"Failed to get processes: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to retrieve process list: {str(e)}"
)

View 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")

View File

@@ -40,3 +40,10 @@ class DisplayListResponse(BaseModel):
displays: List[DisplayInfo] = Field(description="Available displays") displays: List[DisplayInfo] = Field(description="Available displays")
count: int = Field(description="Number of displays") count: int = Field(description="Number of displays")
class ProcessListResponse(BaseModel):
"""List of running processes."""
processes: List[str] = Field(description="Sorted list of unique process names")
count: int = Field(description="Number of unique processes")

View File

@@ -33,6 +33,7 @@ class StorageConfig(BaseSettings):
picture_sources_file: str = "data/picture_sources.json" picture_sources_file: str = "data/picture_sources.json"
picture_targets_file: str = "data/picture_targets.json" picture_targets_file: str = "data/picture_targets.json"
pattern_templates_file: str = "data/pattern_templates.json" pattern_templates_file: str = "data/pattern_templates.json"
profiles_file: str = "data/profiles.json"
class LoggingConfig(BaseSettings): class LoggingConfig(BaseSettings):

View File

@@ -0,0 +1,49 @@
"""AmbiLED serial LED client — sends pixel data using the AmbiLED protocol.
Protocol: raw RGB bytes (values clamped to 0250) 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 0250: 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

View 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()

View File

@@ -276,5 +276,8 @@ def _register_builtin_providers():
from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider from wled_controller.core.devices.adalight_provider import AdalightDeviceProvider
register_provider(AdalightDeviceProvider()) register_provider(AdalightDeviceProvider())
from wled_controller.core.devices.ambiled_provider import AmbiLEDDeviceProvider
register_provider(AmbiLEDDeviceProvider())
_register_builtin_providers() _register_builtin_providers()

View File

@@ -691,22 +691,16 @@ class ProcessorManager:
state.device_type, state.device_url, client, state.health, state.device_type, state.device_url, client, state.health,
) )
# Auto-sync LED count # Auto-sync LED count (preserve existing calibration)
reported = state.health.device_led_count reported = state.health.device_led_count
if reported and reported != state.led_count and self._device_store: if reported and reported != state.led_count and self._device_store:
old_count = state.led_count old_count = state.led_count
logger.info( logger.info(
f"Device {device_id} LED count changed: {old_count}{reported}, " f"Device {device_id} LED count changed: {old_count}{reported}"
f"updating calibration"
) )
try: try:
device = self._device_store.update_device(device_id, led_count=reported) self._device_store.update_device(device_id, led_count=reported)
state.led_count = reported state.led_count = reported
state.calibration = device.calibration
# Propagate to WLED processors using this device
for proc in self._processors.values():
if isinstance(proc, WledTargetProcessor) and proc.device_id == device_id:
proc.update_calibration(device.calibration)
except Exception as e: except Exception as e:
logger.error(f"Failed to sync LED count for {device_id}: {e}") logger.error(f"Failed to sync LED count for {device_id}: {e}")

View File

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

View File

@@ -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)

View 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)

View File

@@ -23,6 +23,8 @@ from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
from wled_controller.utils import setup_logging, get_logger from wled_controller.utils import setup_logging, get_logger
# Initialize logging # Initialize logging
@@ -39,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te
picture_source_store = PictureSourceStore(config.storage.picture_sources_file) picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file) picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file) pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
profile_store = ProfileStore(config.storage.profiles_file)
processor_manager = ProcessorManager( processor_manager = ProcessorManager(
picture_source_store=picture_source_store, picture_source_store=picture_source_store,
@@ -138,6 +141,9 @@ async def lifespan(app: FastAPI):
# Run migrations # Run migrations
_migrate_devices_to_targets() _migrate_devices_to_targets()
# Create profile engine (needs processor_manager)
profile_engine = ProfileEngine(profile_store, processor_manager)
# Initialize API dependencies # Initialize API dependencies
init_dependencies( init_dependencies(
device_store, template_store, processor_manager, device_store, template_store, processor_manager,
@@ -145,6 +151,8 @@ async def lifespan(app: FastAPI):
pattern_template_store=pattern_template_store, pattern_template_store=pattern_template_store,
picture_source_store=picture_source_store, picture_source_store=picture_source_store,
picture_target_store=picture_target_store, picture_target_store=picture_target_store,
profile_store=profile_store,
profile_engine=profile_engine,
) )
# Register devices in processor manager for health monitoring # Register devices in processor manager for health monitoring
@@ -201,11 +209,21 @@ async def lifespan(app: FastAPI):
# Start background health monitoring for all devices # Start background health monitoring for all devices
await processor_manager.start_health_monitoring() await processor_manager.start_health_monitoring()
# Start profile engine (evaluates conditions and auto-starts/stops targets)
await profile_engine.start()
yield yield
# Shutdown # Shutdown
logger.info("Shutting down LED Grab") logger.info("Shutting down LED Grab")
# Stop profile engine first (deactivates profile-managed targets)
try:
await profile_engine.stop()
logger.info("Stopped profile engine")
except Exception as e:
logger.error(f"Error stopping profile engine: {e}")
# Stop all processing # Stop all processing
try: try:
await processor_manager.stop_all() await processor_manager.stop_all()

View File

@@ -34,6 +34,9 @@ function setupBackdropClose(modal, closeFn) {
modal._backdropCloseSetup = true; modal._backdropCloseSetup = true;
} }
// Device type helpers
function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; }
// Track logged errors to avoid console spam // Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
@@ -598,6 +601,8 @@ function switchTab(name) {
loadPictureSources(); loadPictureSources();
} else if (name === 'targets') { } else if (name === 'targets') {
loadTargetsTab(); loadTargetsTab();
} else if (name === 'profiles') {
loadProfiles();
} }
} }
} }
@@ -767,7 +772,7 @@ async function showSettings(deviceId) {
} }
const device = await deviceResponse.json(); const device = await deviceResponse.json();
const isAdalight = device.device_type === 'adalight'; const isAdalight = isSerialDevice(device.device_type);
// Populate fields // Populate fields
document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-id').value = device.id;
@@ -847,7 +852,7 @@ async function showSettings(deviceId) {
} }
function _getSettingsUrl() { function _getSettingsUrl() {
if (settingsInitialValues.device_type === 'adalight') { if (isSerialDevice(settingsInitialValues.device_type)) {
return document.getElementById('settings-serial-port').value; return document.getElementById('settings-serial-port').value;
} }
return document.getElementById('settings-device-url').value.trim(); return document.getElementById('settings-device-url').value.trim();
@@ -902,7 +907,7 @@ async function saveDeviceSettings() {
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) { if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10); body.led_count = parseInt(ledCountInput.value, 10);
} }
if (settingsInitialValues.device_type === 'adalight') { if (isSerialDevice(settingsInitialValues.device_type)) {
const baudVal = document.getElementById('settings-baud-rate').value; const baudVal = document.getElementById('settings-baud-rate').value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10); if (baudVal) body.baud_rate = parseInt(baudVal, 10);
} }
@@ -1044,7 +1049,7 @@ function onDeviceTypeChanged() {
const baudRateGroup = document.getElementById('device-baud-rate-group'); const baudRateGroup = document.getElementById('device-baud-rate-group');
if (deviceType === 'adalight') { if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none'; urlGroup.style.display = 'none';
urlInput.removeAttribute('required'); urlInput.removeAttribute('required');
serialGroup.style.display = ''; serialGroup.style.display = '';
@@ -1081,14 +1086,16 @@ function onDeviceTypeChanged() {
} }
} }
function _computeMaxFps(baudRate, ledCount) { function _computeMaxFps(baudRate, ledCount, deviceType) {
if (!baudRate || !ledCount || ledCount < 1) return null; if (!baudRate || !ledCount || ledCount < 1) return null;
const bitsPerFrame = (ledCount * 3 + 6) * 10; // Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command
const overhead = deviceType === 'ambiled' ? 1 : 6;
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
return Math.floor(baudRate / bitsPerFrame); return Math.floor(baudRate / bitsPerFrame);
} }
function _renderFpsHint(hintEl, baudRate, ledCount) { function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
const fps = _computeMaxFps(baudRate, ledCount); const fps = _computeMaxFps(baudRate, ledCount, deviceType);
if (fps !== null) { if (fps !== null) {
hintEl.textContent = `Max FPS ≈ ${fps}`; hintEl.textContent = `Max FPS ≈ ${fps}`;
hintEl.style.display = ''; hintEl.style.display = '';
@@ -1101,22 +1108,23 @@ function updateBaudFpsHint() {
const hintEl = document.getElementById('baud-fps-hint'); const hintEl = document.getElementById('baud-fps-hint');
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10); const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('device-led-count').value, 10); const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
_renderFpsHint(hintEl, baudRate, ledCount); const deviceType = document.getElementById('device-type')?.value || 'adalight';
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
} }
function updateSettingsBaudFpsHint() { function updateSettingsBaudFpsHint() {
const hintEl = document.getElementById('settings-baud-fps-hint'); const hintEl = document.getElementById('settings-baud-fps-hint');
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10); const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10); const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
_renderFpsHint(hintEl, baudRate, ledCount); _renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
} }
function _renderDiscoveryList() { function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value; const selectedType = document.getElementById('device-type').value;
const devices = _discoveryCache[selectedType]; const devices = _discoveryCache[selectedType];
// Adalight: populate serial port dropdown instead of discovery list // Serial devices: populate serial port dropdown instead of discovery list
if (selectedType === 'adalight') { if (isSerialDevice(selectedType)) {
_populateSerialPortDropdown(devices || []); _populateSerialPortDropdown(devices || []);
return; return;
} }
@@ -1190,8 +1198,9 @@ function _populateSerialPortDropdown(devices) {
function onSerialPortFocus() { function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown // Lazy-load: trigger discovery when user opens the serial port dropdown
if (!('adalight' in _discoveryCache)) { const deviceType = document.getElementById('device-type')?.value || 'adalight';
scanForDevices('adalight'); if (!(deviceType in _discoveryCache)) {
scanForDevices(deviceType);
} }
} }
@@ -1205,7 +1214,8 @@ async function _populateSettingsSerialPorts(currentUrl) {
select.appendChild(loadingOpt); select.appendChild(loadingOpt);
try { try {
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, { const discoverType = settingsInitialValues.device_type || 'adalight';
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
headers: getHeaders() headers: getHeaders()
}); });
if (!resp.ok) return; if (!resp.ok) return;
@@ -1279,7 +1289,7 @@ async function scanForDevices(forceType) {
const section = document.getElementById('discovery-section'); const section = document.getElementById('discovery-section');
const scanBtn = document.getElementById('scan-network-btn'); const scanBtn = document.getElementById('scan-network-btn');
if (scanType === 'adalight') { if (isSerialDevice(scanType)) {
// Show loading in the serial port dropdown // Show loading in the serial port dropdown
const select = document.getElementById('device-serial-port'); const select = document.getElementById('device-serial-port');
select.innerHTML = ''; select.innerHTML = '';
@@ -1308,7 +1318,7 @@ async function scanForDevices(forceType) {
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
if (!response.ok) { if (!response.ok) {
if (scanType !== 'adalight') { if (!isSerialDevice(scanType)) {
empty.style.display = 'block'; empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error'); empty.querySelector('small').textContent = t('device.scan.error');
} }
@@ -1326,7 +1336,7 @@ async function scanForDevices(forceType) {
} catch (err) { } catch (err) {
loading.style.display = 'none'; loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false; if (scanBtn) scanBtn.disabled = false;
if (scanType !== 'adalight') { if (!isSerialDevice(scanType)) {
empty.style.display = 'block'; empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error'); empty.querySelector('small').textContent = t('device.scan.error');
} }
@@ -1343,7 +1353,7 @@ function selectDiscoveredDevice(device) {
const typeSelect = document.getElementById('device-type'); const typeSelect = document.getElementById('device-type');
if (typeSelect) typeSelect.value = device.device_type; if (typeSelect) typeSelect.value = device.device_type;
onDeviceTypeChanged(); onDeviceTypeChanged();
if (device.device_type === 'adalight') { if (isSerialDevice(device.device_type)) {
document.getElementById('device-serial-port').value = device.url; document.getElementById('device-serial-port').value = device.url;
} else { } else {
document.getElementById('device-url').value = device.url; document.getElementById('device-url').value = device.url;
@@ -1356,7 +1366,7 @@ async function handleAddDevice(event) {
const name = document.getElementById('device-name').value.trim(); const name = document.getElementById('device-name').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled'; const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = deviceType === 'adalight' const url = isSerialDevice(deviceType)
? document.getElementById('device-serial-port').value ? document.getElementById('device-serial-port').value
: document.getElementById('device-url').value.trim(); : document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error'); const error = document.getElementById('add-device-error');
@@ -1374,7 +1384,7 @@ async function handleAddDevice(event) {
body.led_count = parseInt(ledCountInput.value, 10); body.led_count = parseInt(ledCountInput.value, 10);
} }
const baudRateSelect = document.getElementById('device-baud-rate'); const baudRateSelect = document.getElementById('device-baud-rate');
if (deviceType === 'adalight' && baudRateSelect && baudRateSelect.value) { if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
body.baud_rate = parseInt(baudRateSelect.value, 10); body.baud_rate = parseInt(baudRateSelect.value, 10);
} }
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId'); const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
@@ -1456,13 +1466,19 @@ async function loadDashboard() {
if (!container) { _dashboardLoading = false; return; } if (!container) { _dashboardLoading = false; return; }
try { try {
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); // Fetch targets and profiles in parallel
const [targetsResp, profilesResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
]);
if (targetsResp.status === 401) { handle401Error(); return; } if (targetsResp.status === 401) { handle401Error(); return; }
const targetsData = await targetsResp.json(); const targetsData = await targetsResp.json();
const targets = targetsData.targets || []; const targets = targetsData.targets || [];
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
const profiles = profilesData.profiles || [];
if (targets.length === 0) { if (targets.length === 0 && profiles.length === 0) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
return; return;
} }
@@ -1487,6 +1503,21 @@ async function loadDashboard() {
let html = ''; let html = '';
// Profiles section
if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active);
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.profiles')}
<span class="dashboard-section-count">${profiles.length}</span>
</div>
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
</div>`;
}
// Running section // Running section
if (running.length > 0) { if (running.length > 0) {
html += `<div class="dashboard-section"> html += `<div class="dashboard-section">
@@ -1524,9 +1555,10 @@ function renderDashboardTarget(target, isRunning) {
const state = target.state || {}; const state = target.state || {};
const metrics = target.metrics || {}; const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled'; const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = isLed ? '💡' : '🎨'; const icon = '⚡';
const typeLabel = isLed ? 'LED' : 'Key Colors';
let subtitleParts = []; let subtitleParts = [typeLabel];
if (isLed && state.device_name) { if (isLed && state.device_name) {
subtitleParts.push(state.device_name); subtitleParts.push(state.device_name);
} }
@@ -1566,8 +1598,7 @@ function renderDashboardTarget(target, isRunning) {
</div> </div>
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<span class="dashboard-status-dot active" title="${t('device.status.processing')}"></span> <button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}"></button>
<button class="btn btn-icon btn-danger" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏹️</button>
</div> </div>
</div>`; </div>`;
} else { } else {
@@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
<div class="dashboard-target-name">${escapeHtml(target.name)}</div> <div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''} ${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div> </div>
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
</div> </div>
<div class="dashboard-target-metrics"></div> <div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
<button class="btn btn-icon btn-primary" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button> <button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
</div> </div>
</div>`; </div>`;
} }
} }
function renderDashboardProfile(profile) {
const isActive = profile.is_active;
const isDisabled = !profile.enabled;
// Condition summary
let condSummary = '';
if (profile.conditions.length > 0) {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `${apps} (${matchLabel})`;
}
return c.condition_type;
});
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic);
}
const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length;
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">📋</span>
<div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
</div>
${statusBadge}
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${targetsInfo}</div>
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
async function dashboardToggleProfile(profileId, enable) {
try {
const endpoint = enable ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
loadDashboard();
}
} catch (error) {
showToast('Failed to toggle profile', 'error');
}
}
async function dashboardStartTarget(targetId) { async function dashboardStartTarget(targetId) {
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
@@ -1651,7 +1749,7 @@ function startDashboardWS() {
_dashboardWS.onmessage = (event) => { _dashboardWS.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'state_change') { if (data.type === 'state_change' || data.type === 'profile_state_changed') {
loadDashboard(); loadDashboard();
} }
} catch {} } catch {}
@@ -2642,6 +2740,12 @@ document.addEventListener('click', (e) => {
closeAddDeviceModal(); closeAddDeviceModal();
return; return;
} }
// Profile editor modal: close on backdrop
if (modalId === 'profile-editor-modal') {
closeProfileEditorModal();
return;
}
}); });
// Cleanup on page unload // Cleanup on page unload
@@ -6539,3 +6643,392 @@ async function capturePatternBackground() {
showToast('Failed to capture background', 'error'); showToast('Failed to capture background', 'error');
} }
} }
// =====================================================================
// PROFILES
// =====================================================================
let _profilesCache = null;
async function loadProfiles() {
const container = document.getElementById('profiles-content');
if (!container) return;
try {
const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load profiles');
const data = await resp.json();
_profilesCache = data.profiles;
renderProfiles(data.profiles);
} catch (error) {
console.error('Failed to load profiles:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
function renderProfiles(profiles) {
const container = document.getElementById('profiles-content');
let html = '<div class="devices-grid">';
for (const p of profiles) {
html += createProfileCard(p);
}
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
<div class="add-template-icon">+</div>
</div>`;
html += '</div>';
container.innerHTML = html;
updateAllText();
}
function createProfileCard(profile) {
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
// Condition summary as pills
let condPills = '';
if (profile.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
} else {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`;
});
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
}
// Target count pill
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
// Last activation timestamp
let lastActivityMeta = '';
if (profile.last_activated_at) {
const ts = new Date(profile.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">🕐 ${ts.toLocaleString()}</span>`;
}
return `
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">&#x2715;</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">&#x2715;</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&#10;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');
}
}

View File

@@ -36,6 +36,7 @@
<div class="tabs"> <div class="tabs">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button> <button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')"><span data-i18n="profiles.title">📋 Profiles</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
</div> </div>
@@ -46,6 +47,12 @@
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-profiles">
<div id="profiles-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-targets"> <div class="tab-panel" id="tab-targets">
<div id="targets-panel-content"> <div id="targets-panel-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -650,6 +657,7 @@
<select id="device-type" onchange="onDeviceTypeChanged()"> <select id="device-type" onchange="onDeviceTypeChanged()">
<option value="wled">WLED</option> <option value="wled">WLED</option>
<option value="adalight">Adalight</option> <option value="adalight">Adalight</option>
<option value="ambiled">AmbiLED</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -982,6 +990,81 @@
</div> </div>
</div> </div>
<!-- Profile Editor Modal -->
<div id="profile-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2>
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="profile-editor-form">
<input type="hidden" id="profile-editor-id">
<div class="form-group">
<div class="label-row">
<label for="profile-editor-name" data-i18n="profiles.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.name.hint">A descriptive name for this profile</small>
<input type="text" id="profile-editor-name" required>
</div>
<div class="form-group settings-toggle-group">
<div class="label-row">
<label data-i18n="profiles.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.enabled.hint">Disabled profiles won't activate even when conditions are met</small>
<label class="settings-toggle">
<input type="checkbox" id="profile-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="profile-editor-logic" data-i18n="profiles.condition_logic">Condition Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<select id="profile-editor-logic">
<option value="or" data-i18n="profiles.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="profiles.condition_logic.and">All conditions (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.conditions">Conditions:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.conditions.hint">Rules that determine when this profile activates</small>
<div id="profile-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addProfileCondition()" style="margin-top: 6px;">
+ <span data-i18n="profiles.conditions.add">Add Condition</span>
</button>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.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">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) --> <!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed"> <div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
<div class="tutorial-backdrop"></div> <div class="tutorial-backdrop"></div>

View File

@@ -464,5 +464,42 @@
"dashboard.errors": "Errors", "dashboard.errors": "Errors",
"dashboard.device": "Device", "dashboard.device": "Device",
"dashboard.stop_all": "Stop All", "dashboard.stop_all": "Stop All",
"dashboard.failed": "Failed to load dashboard" "dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles",
"dashboard.targets": "Targets",
"profiles.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.",
"profiles.add": "\uD83D\uDCCB Add Profile",
"profiles.edit": "Edit Profile",
"profiles.delete.confirm": "Delete profile \"{name}\"?",
"profiles.name": "Name:",
"profiles.name.hint": "A descriptive name for this profile",
"profiles.enabled": "Enabled:",
"profiles.enabled.hint": "Disabled profiles won't activate even when conditions are met",
"profiles.condition_logic": "Condition Logic:",
"profiles.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)",
"profiles.condition_logic.or": "Any condition (OR)",
"profiles.condition_logic.and": "All conditions (AND)",
"profiles.conditions": "Conditions:",
"profiles.conditions.hint": "Rules that determine when this profile activates",
"profiles.conditions.add": "Add Condition",
"profiles.conditions.empty": "No conditions \u2014 profile will never activate automatically",
"profiles.condition.application": "Application",
"profiles.condition.application.apps": "Applications:",
"profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"profiles.condition.application.browse": "Browse",
"profiles.condition.application.search": "Filter processes...",
"profiles.condition.application.no_processes": "No processes found",
"profiles.condition.application.match_type": "Match Type:",
"profiles.condition.application.match_type.hint": "How to detect the application",
"profiles.condition.application.match_type.running": "Running",
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
"profiles.targets": "Targets:",
"profiles.targets.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available",
"profiles.status.active": "Active",
"profiles.status.inactive": "Inactive",
"profiles.status.disabled": "Disabled",
"profiles.last_activated": "Last activated"
} }

View File

@@ -464,5 +464,42 @@
"dashboard.errors": "Ошибки", "dashboard.errors": "Ошибки",
"dashboard.device": "Устройство", "dashboard.device": "Устройство",
"dashboard.stop_all": "Остановить все", "dashboard.stop_all": "Остановить все",
"dashboard.failed": "Не удалось загрузить обзор" "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили",
"dashboard.targets": "Цели",
"profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",
"profiles.add": "\uD83D\uDCCB Добавить профиль",
"profiles.edit": "Редактировать профиль",
"profiles.delete.confirm": "Удалить профиль \"{name}\"?",
"profiles.name": "Название:",
"profiles.name.hint": "Описательное имя для профиля",
"profiles.enabled": "Включён:",
"profiles.enabled.hint": "Отключённые профили не активируются даже при выполнении условий",
"profiles.condition_logic": "Логика условий:",
"profiles.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
"profiles.condition_logic.or": "Любое условие (ИЛИ)",
"profiles.condition_logic.and": "Все условия (И)",
"profiles.conditions": "Условия:",
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется",
"profiles.conditions.add": "Добавить условие",
"profiles.conditions.empty": "Нет условий \u2014 профиль не активируется автоматически",
"profiles.condition.application": "Приложение",
"profiles.condition.application.apps": "Приложения:",
"profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
"profiles.condition.application.browse": "Обзор",
"profiles.condition.application.search": "Фильтр процессов...",
"profiles.condition.application.no_processes": "Процессы не найдены",
"profiles.condition.application.match_type": "Тип соответствия:",
"profiles.condition.application.match_type.hint": "Как определять наличие приложения",
"profiles.condition.application.match_type.running": "Запущено",
"profiles.condition.application.match_type.topmost": "На переднем плане",
"profiles.targets": "Цели:",
"profiles.targets.hint": "Цели для запуска при активации профиля",
"profiles.targets.empty": "Нет доступных целей",
"profiles.status.active": "Активен",
"profiles.status.inactive": "Неактивен",
"profiles.status.disabled": "Отключён",
"profiles.last_activated": "Последняя активация"
} }

View File

@@ -3225,32 +3225,34 @@ input:-webkit-autofill:focus {
/* ── Dashboard ── */ /* ── Dashboard ── */
.dashboard-section { .dashboard-section {
margin-bottom: 24px; margin-bottom: 16px;
} }
.dashboard-section-header { .dashboard-section-header {
font-size: 1rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
margin-bottom: 10px; margin-bottom: 6px;
color: var(--text-secondary); color: var(--text-secondary);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.dashboard-section-count { .dashboard-section-count {
background: var(--border-color); background: var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
border-radius: 10px; border-radius: 10px;
padding: 1px 8px; padding: 0 6px;
font-size: 0.8rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
} }
.dashboard-stop-all { .dashboard-stop-all {
margin-left: auto; margin-left: auto;
padding: 3px 10px; padding: 2px 8px;
font-size: 0.75rem; font-size: 0.7rem;
white-space: nowrap; white-space: nowrap;
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -3259,35 +3261,36 @@ input:-webkit-autofill:focus {
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;
align-items: center; align-items: center;
gap: 16px; gap: 12px;
padding: 12px 16px; padding: 8px 12px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 6px;
margin-bottom: 8px; margin-bottom: 4px;
} }
.dashboard-target-info { .dashboard-target-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
.dashboard-target-icon { .dashboard-target-icon {
font-size: 1.2rem; font-size: 1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.dashboard-target-name { .dashboard-target-name {
font-size: 0.85rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
} }
.dashboard-target-name .health-dot { .dashboard-target-name .health-dot {
@@ -3296,7 +3299,7 @@ input:-webkit-autofill:focus {
} }
.dashboard-target-subtitle { .dashboard-target-subtitle {
font-size: 0.75rem; font-size: 0.7rem;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -3304,24 +3307,25 @@ input:-webkit-autofill:focus {
} }
.dashboard-target-metrics { .dashboard-target-metrics {
display: grid; display: flex;
grid-template-columns: 90px 80px 60px;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
} }
.dashboard-metric { .dashboard-metric {
text-align: center; text-align: center;
min-width: 48px;
} }
.dashboard-metric-value { .dashboard-metric-value {
font-size: 1.05rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-color);
line-height: 1.2;
} }
.dashboard-metric-label { .dashboard-metric-label {
font-size: 0.7rem; font-size: 0.6rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.3px; letter-spacing: 0.3px;
@@ -3330,11 +3334,11 @@ input:-webkit-autofill:focus {
.dashboard-target-actions { .dashboard-target-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
} }
.dashboard-status-dot { .dashboard-status-dot {
font-size: 1.2rem; font-size: 1rem;
line-height: 1; line-height: 1;
} }
@@ -3345,24 +3349,39 @@ input:-webkit-autofill:focus {
.dashboard-no-targets { .dashboard-no-targets {
text-align: center; text-align: center;
padding: 40px 20px; padding: 32px 16px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 1rem; font-size: 0.9rem;
} }
.dashboard-badge-stopped { .dashboard-badge-stopped {
padding: 3px 10px; padding: 2px 8px;
border-radius: 10px; border-radius: 10px;
font-size: 0.75rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
background: var(--border-color); background: var(--border-color);
color: var(--text-secondary); color: var(--text-secondary);
flex-shrink: 0;
}
.dashboard-badge-active {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
background: var(--success-color, #28a745);
color: #fff;
flex-shrink: 0;
}
.dashboard-profile .dashboard-target-metrics {
min-width: 48px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-target { .dashboard-target {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 6px;
} }
.dashboard-target-actions { .dashboard-target-actions {
@@ -3370,3 +3389,189 @@ input:-webkit-autofill:focus {
} }
} }
/* ===== PROFILES ===== */
.badge-profile-active {
background: var(--success-color, #28a745);
color: #fff;
}
.badge-profile-inactive {
background: var(--border-color);
color: var(--text-color);
}
.badge-profile-disabled {
background: var(--border-color);
color: var(--text-muted);
opacity: 0.7;
}
.profile-status-disabled {
opacity: 0.6;
}
.profile-logic-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
padding: 0 4px;
}
/* Profile condition editor rows */
.profile-condition-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-secondary, var(--bg-color));
}
.condition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.condition-type-label {
font-weight: 600;
font-size: 0.9rem;
}
.btn-remove-condition {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
}
.condition-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
.condition-field select,
.condition-field textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
}
.condition-apps {
resize: vertical;
min-height: 60px;
}
.condition-apps-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-browse-apps {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.btn-browse-apps:hover {
border-color: var(--primary-color);
background: rgba(33, 150, 243, 0.1);
}
.process-picker {
margin-top: 6px;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.process-picker-search {
width: 100%;
padding: 6px 8px;
border: none;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85rem;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.process-picker-list {
max-height: 160px;
overflow-y: auto;
}
.process-picker-item {
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.process-picker-item:hover {
background: rgba(33, 150, 243, 0.15);
}
.process-picker-item.added {
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.process-picker-loading {
padding: 8px;
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
}
/* Profile target checklist */
.profile-targets-checklist {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px;
}
.profile-target-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
cursor: pointer;
border-radius: 3px;
}
.profile-target-item:hover {
background: var(--bg-secondary, var(--bg-color));
}
.profile-target-item input[type="checkbox"] {
margin: 0;
}

View File

@@ -243,7 +243,6 @@ class DeviceStore:
device.url = url device.url = url
if led_count is not None: if led_count is not None:
device.led_count = led_count device.led_count = led_count
device.calibration = create_default_calibration(led_count)
if enabled is not None: if enabled is not None:
device.enabled = enabled device.enabled = enabled
if baud_rate is not None: if baud_rate is not None:

View 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())),
)

View 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)