Add static color support, HAOS light entity, and real-time profile updates
- Add static_color capability to WLED and serial providers with native set_color() dispatch (WLED uses JSON API, serial uses idle client) - Encapsulate device-specific logic in providers instead of device_type checks in ProcessorManager and API routes - Add HAOS light entity for devices with brightness_control + static_color (Adalight/AmbiLED get light entity, WLED keeps number entity) - Fix serial device brightness and turn-off: pass software_brightness through provider chain, clear device on color=null, re-send static color after brightness change - Add global events WebSocket (events-ws.js) replacing per-tab WS, enabling real-time profile state updates on both dashboard and profiles tabs - Fix profile activation: mark active when all targets already running, add asyncio.Lock to prevent concurrent evaluation races, skip process enumeration when no profile has conditions, trigger immediate evaluation on enable/create/update for instant target startup - Add reliable server restart script (restart.ps1) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ class ProfileEngine:
|
||||
self._poll_interval = poll_interval
|
||||
self._detector = PlatformDetector()
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._eval_lock = asyncio.Lock()
|
||||
|
||||
# Runtime state (not persisted)
|
||||
# profile_id → set of target_ids that THIS profile started
|
||||
@@ -64,6 +65,10 @@ class ProfileEngine:
|
||||
pass
|
||||
|
||||
async def _evaluate_all(self) -> None:
|
||||
async with self._eval_lock:
|
||||
await self._evaluate_all_locked()
|
||||
|
||||
async def _evaluate_all_locked(self) -> None:
|
||||
profiles = self._store.get_all_profiles()
|
||||
if not profiles:
|
||||
# No profiles — deactivate any stale state
|
||||
@@ -71,9 +76,18 @@ class ProfileEngine:
|
||||
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()
|
||||
# Only enumerate processes when at least one enabled profile has conditions
|
||||
needs_detection = any(
|
||||
p.enabled and len(p.conditions) > 0
|
||||
for p in profiles
|
||||
)
|
||||
|
||||
if needs_detection:
|
||||
running_procs = await self._detector.get_running_processes()
|
||||
topmost_proc = await self._detector.get_topmost_process()
|
||||
else:
|
||||
running_procs = set()
|
||||
topmost_proc = None
|
||||
|
||||
active_profile_ids = set()
|
||||
|
||||
@@ -139,6 +153,7 @@ class ProfileEngine:
|
||||
|
||||
async def _activate_profile(self, profile: Profile) -> None:
|
||||
started: Set[str] = set()
|
||||
failed = False
|
||||
for target_id in profile.target_ids:
|
||||
try:
|
||||
# Skip targets that are already running (manual or other profile)
|
||||
@@ -150,15 +165,17 @@ class ProfileEngine:
|
||||
started.add(target_id)
|
||||
logger.info(f"Profile '{profile.name}' started target {target_id}")
|
||||
except Exception as e:
|
||||
failed = True
|
||||
logger.warning(f"Profile '{profile.name}' failed to start target {target_id}: {e}")
|
||||
|
||||
if started:
|
||||
if started or not failed:
|
||||
# Active: either we started targets, or all were already running
|
||||
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)")
|
||||
logger.info(f"Profile '{profile.name}' activated ({len(started)} targets started)")
|
||||
else:
|
||||
logger.debug(f"Profile '{profile.name}' matched but no targets started — will retry")
|
||||
logger.debug(f"Profile '{profile.name}' matched but targets failed to start — will retry")
|
||||
|
||||
async def _deactivate_profile(self, profile_id: str) -> None:
|
||||
owned = self._active_profiles.pop(profile_id, set())
|
||||
@@ -210,6 +227,13 @@ class ProfileEngine:
|
||||
result[profile.id] = self.get_profile_state(profile.id)
|
||||
return result
|
||||
|
||||
async def trigger_evaluate(self) -> None:
|
||||
"""Run a single evaluation cycle immediately (used after enabling a profile)."""
|
||||
try:
|
||||
await self._evaluate_all()
|
||||
except Exception as e:
|
||||
logger.error(f"Immediate profile evaluation error: {e}", exc_info=True)
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user