Add profile conditions, scene presets, MQTT integration, and Scenes tab
Feature 1 — Profile Conditions: time-of-day, system idle (Win32 GetLastInputInfo), and display state (GUID_CONSOLE_DISPLAY_STATE) condition types for automatic profile activation. Feature 2 — Scene Presets: snapshot/restore system that captures target running states, device brightness, and profile enables. Server-side capture with 5-step activation order. Dedicated Scenes tab with CardSection-based card grid, command palette integration, and dashboard quick-activate section. Feature 3 — MQTT Integration: MQTTService singleton with aiomqtt, MQTTLEDClient device provider for pixel output, MQTT profile condition type with topic/payload matching, and frontend support for MQTT device type and condition editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -282,5 +282,8 @@ def _register_builtin_providers():
|
||||
from wled_controller.core.devices.mock_provider import MockDeviceProvider
|
||||
register_provider(MockDeviceProvider())
|
||||
|
||||
from wled_controller.core.devices.mqtt_provider import MQTTDeviceProvider
|
||||
register_provider(MQTTDeviceProvider())
|
||||
|
||||
|
||||
_register_builtin_providers()
|
||||
|
||||
98
server/src/wled_controller/core/devices/mqtt_client.py
Normal file
98
server/src/wled_controller/core/devices/mqtt_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""MQTT LED client — publishes pixel data to an MQTT topic."""
|
||||
|
||||
import json
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import DeviceHealth, LEDClient
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Singleton reference — injected from main.py at startup
|
||||
_mqtt_service = None
|
||||
|
||||
|
||||
def set_mqtt_service(service) -> None:
|
||||
global _mqtt_service
|
||||
_mqtt_service = service
|
||||
|
||||
|
||||
def get_mqtt_service():
|
||||
return _mqtt_service
|
||||
|
||||
|
||||
def parse_mqtt_url(url: str) -> str:
|
||||
"""Extract topic from an mqtt:// URL.
|
||||
|
||||
Format: mqtt://topic/path (broker connection is global via config)
|
||||
"""
|
||||
if url.startswith("mqtt://"):
|
||||
return url[7:]
|
||||
return url
|
||||
|
||||
|
||||
class MQTTLEDClient(LEDClient):
|
||||
"""Publishes JSON pixel data to an MQTT topic via the shared service."""
|
||||
|
||||
def __init__(self, url: str, led_count: int = 0, **kwargs):
|
||||
self._topic = parse_mqtt_url(url)
|
||||
self._led_count = led_count
|
||||
self._connected = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_enabled:
|
||||
raise ConnectionError("MQTT service not available")
|
||||
if not svc.is_connected:
|
||||
raise ConnectionError("MQTT service not connected to broker")
|
||||
self._connected = True
|
||||
return True
|
||||
|
||||
async def close(self) -> None:
|
||||
self._connected = False
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected and _mqtt_service is not None and _mqtt_service.is_connected
|
||||
|
||||
async def send_pixels(
|
||||
self,
|
||||
pixels: Union[List[Tuple[int, int, int]], np.ndarray],
|
||||
brightness: int = 255,
|
||||
) -> bool:
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_connected:
|
||||
return False
|
||||
|
||||
if isinstance(pixels, np.ndarray):
|
||||
pixel_list = pixels.tolist()
|
||||
else:
|
||||
pixel_list = list(pixels)
|
||||
|
||||
payload = json.dumps({
|
||||
"pixels": pixel_list,
|
||||
"brightness": brightness,
|
||||
"led_count": len(pixel_list),
|
||||
})
|
||||
|
||||
await svc.publish(self._topic, payload, retain=False, qos=0)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def check_health(
|
||||
cls,
|
||||
url: str,
|
||||
http_client,
|
||||
prev_health=None,
|
||||
) -> DeviceHealth:
|
||||
from datetime import datetime
|
||||
svc = _mqtt_service
|
||||
if svc is None or not svc.is_enabled:
|
||||
return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow())
|
||||
return DeviceHealth(
|
||||
online=svc.is_connected,
|
||||
last_checked=datetime.utcnow(),
|
||||
error=None if svc.is_connected else "MQTT broker disconnected",
|
||||
)
|
||||
51
server/src/wled_controller/core/devices/mqtt_provider.py
Normal file
51
server/src/wled_controller/core/devices/mqtt_provider.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""MQTT device provider — factory, validation, health checks."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from wled_controller.core.devices.led_client import (
|
||||
DeviceHealth,
|
||||
DiscoveredDevice,
|
||||
LEDClient,
|
||||
LEDDeviceProvider,
|
||||
)
|
||||
from wled_controller.core.devices.mqtt_client import (
|
||||
MQTTLEDClient,
|
||||
get_mqtt_service,
|
||||
parse_mqtt_url,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MQTTDeviceProvider(LEDDeviceProvider):
|
||||
"""Provider for MQTT-based LED devices."""
|
||||
|
||||
@property
|
||||
def device_type(self) -> str:
|
||||
return "mqtt"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set:
|
||||
return {"manual_led_count"}
|
||||
|
||||
def create_client(self, url: str, **kwargs) -> LEDClient:
|
||||
return MQTTLEDClient(url, **kwargs)
|
||||
|
||||
async def check_health(
|
||||
self, url: str, http_client, prev_health=None,
|
||||
) -> DeviceHealth:
|
||||
return await MQTTLEDClient.check_health(url, http_client, prev_health)
|
||||
|
||||
async def validate_device(self, url: str) -> dict:
|
||||
"""Validate MQTT device URL (topic path)."""
|
||||
topic = parse_mqtt_url(url)
|
||||
if not topic or topic == "/":
|
||||
raise ValueError("MQTT topic cannot be empty")
|
||||
# Can't auto-detect LED count — require manual entry
|
||||
return {}
|
||||
|
||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||
# MQTT devices are not auto-discoverable
|
||||
return []
|
||||
0
server/src/wled_controller/core/mqtt/__init__.py
Normal file
0
server/src/wled_controller/core/mqtt/__init__.py
Normal file
176
server/src/wled_controller/core/mqtt/mqtt_service.py
Normal file
176
server/src/wled_controller/core/mqtt/mqtt_service.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Singleton async MQTT service — shared broker connection for all features."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Callable, Dict, Optional, Set
|
||||
|
||||
import aiomqtt
|
||||
|
||||
from wled_controller.config import MQTTConfig
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class MQTTService:
|
||||
"""Manages a persistent MQTT broker connection.
|
||||
|
||||
Features:
|
||||
- Publish messages (retained or transient)
|
||||
- Subscribe to topics with callback dispatch
|
||||
- Topic value cache for synchronous reads (profile condition evaluation)
|
||||
- Auto-reconnect loop
|
||||
- Birth / will messages for online status
|
||||
"""
|
||||
|
||||
def __init__(self, config: MQTTConfig):
|
||||
self._config = config
|
||||
self._client: Optional[aiomqtt.Client] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._connected = False
|
||||
|
||||
# Subscription management
|
||||
self._subscriptions: Dict[str, Set[Callable]] = {} # topic -> set of callbacks
|
||||
self._topic_cache: Dict[str, str] = {} # topic -> last payload string
|
||||
|
||||
# Pending publishes queued while disconnected
|
||||
self._publish_queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self._config.enabled
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._config.enabled:
|
||||
logger.info("MQTT service disabled in configuration")
|
||||
return
|
||||
if self._task is not None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._connection_loop())
|
||||
logger.info(f"MQTT service starting — broker {self._config.broker_host}:{self._config.broker_port}")
|
||||
|
||||
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
|
||||
self._connected = False
|
||||
logger.info("MQTT service stopped")
|
||||
|
||||
async def publish(self, topic: str, payload: str, retain: bool = False, qos: int = 0) -> None:
|
||||
"""Publish a message. If disconnected, queue for later delivery."""
|
||||
if not self._config.enabled:
|
||||
return
|
||||
if self._connected and self._client:
|
||||
try:
|
||||
await self._client.publish(topic, payload, retain=retain, qos=qos)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"MQTT publish failed ({topic}): {e}")
|
||||
# Queue for retry
|
||||
try:
|
||||
self._publish_queue.put_nowait((topic, payload, retain, qos))
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
async def subscribe(self, topic: str, callback: Callable) -> None:
|
||||
"""Subscribe to a topic. Callback receives (topic: str, payload: str)."""
|
||||
if topic not in self._subscriptions:
|
||||
self._subscriptions[topic] = set()
|
||||
self._subscriptions[topic].add(callback)
|
||||
|
||||
# Subscribe on the live client if connected
|
||||
if self._connected and self._client:
|
||||
try:
|
||||
await self._client.subscribe(topic)
|
||||
except Exception as e:
|
||||
logger.warning(f"MQTT subscribe failed ({topic}): {e}")
|
||||
|
||||
def get_last_value(self, topic: str) -> Optional[str]:
|
||||
"""Get cached last value for a topic (synchronous — for profile evaluation)."""
|
||||
return self._topic_cache.get(topic)
|
||||
|
||||
async def _connection_loop(self) -> None:
|
||||
"""Persistent connection loop with auto-reconnect."""
|
||||
base_topic = self._config.base_topic
|
||||
will_topic = f"{base_topic}/status"
|
||||
will_payload = "offline"
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=self._config.broker_host,
|
||||
port=self._config.broker_port,
|
||||
username=self._config.username or None,
|
||||
password=self._config.password or None,
|
||||
identifier=self._config.client_id,
|
||||
will=aiomqtt.Will(
|
||||
topic=will_topic,
|
||||
payload=will_payload,
|
||||
retain=True,
|
||||
),
|
||||
) as client:
|
||||
self._client = client
|
||||
self._connected = True
|
||||
logger.info("MQTT connected to broker")
|
||||
|
||||
# Publish birth message
|
||||
await client.publish(will_topic, "online", retain=True)
|
||||
|
||||
# Re-subscribe to all registered topics
|
||||
for topic in self._subscriptions:
|
||||
await client.subscribe(topic)
|
||||
|
||||
# Drain pending publishes
|
||||
while not self._publish_queue.empty():
|
||||
try:
|
||||
t, p, r, q = self._publish_queue.get_nowait()
|
||||
await client.publish(t, p, retain=r, qos=q)
|
||||
except Exception:
|
||||
break
|
||||
|
||||
# Message receive loop
|
||||
async for msg in client.messages:
|
||||
topic_str = str(msg.topic)
|
||||
payload_str = msg.payload.decode("utf-8", errors="replace") if msg.payload else ""
|
||||
self._topic_cache[topic_str] = payload_str
|
||||
|
||||
# Dispatch to callbacks
|
||||
for sub_topic, callbacks in self._subscriptions.items():
|
||||
if aiomqtt.Topic(sub_topic).matches(msg.topic):
|
||||
for cb in callbacks:
|
||||
try:
|
||||
if asyncio.iscoroutinefunction(cb):
|
||||
asyncio.create_task(cb(topic_str, payload_str))
|
||||
else:
|
||||
cb(topic_str, payload_str)
|
||||
except Exception as e:
|
||||
logger.error(f"MQTT callback error ({topic_str}): {e}")
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
self._client = None
|
||||
logger.warning(f"MQTT connection lost: {e}. Reconnecting in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# ===== State exposure helpers =====
|
||||
|
||||
async def publish_target_state(self, target_id: str, state: dict) -> None:
|
||||
"""Publish target state to MQTT (called from event handler)."""
|
||||
topic = f"{self._config.base_topic}/target/{target_id}/state"
|
||||
await self.publish(topic, json.dumps(state), retain=True)
|
||||
|
||||
async def publish_profile_state(self, profile_id: str, action: str) -> None:
|
||||
"""Publish profile state change to MQTT."""
|
||||
topic = f"{self._config.base_topic}/profile/{profile_id}/state"
|
||||
await self.publish(topic, json.dumps({"action": action}), retain=True)
|
||||
@@ -9,6 +9,7 @@ import ctypes
|
||||
import ctypes.wintypes
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from typing import Optional, Set
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
@@ -21,6 +22,148 @@ _IS_WINDOWS = sys.platform == "win32"
|
||||
class PlatformDetector:
|
||||
"""Detect running processes and the foreground window's process."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._display_on: bool = True
|
||||
self._display_listener_started = False
|
||||
if _IS_WINDOWS:
|
||||
t = threading.Thread(target=self._display_power_listener, daemon=True)
|
||||
t.start()
|
||||
|
||||
# ---- Display power state (event-driven) ----
|
||||
|
||||
def _display_power_listener(self) -> None:
|
||||
"""Background thread: hidden window that receives display power events."""
|
||||
try:
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
WNDPROC = ctypes.WINFUNCTYPE(
|
||||
ctypes.c_long,
|
||||
ctypes.wintypes.HWND,
|
||||
ctypes.c_uint,
|
||||
ctypes.wintypes.WPARAM,
|
||||
ctypes.wintypes.LPARAM,
|
||||
)
|
||||
|
||||
WM_POWERBROADCAST = 0x0218
|
||||
PBT_POWERSETTINGCHANGE = 0x8013
|
||||
|
||||
class POWERBROADCAST_SETTING(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PowerSetting", ctypes.c_ubyte * 16), # GUID
|
||||
("DataLength", ctypes.wintypes.DWORD),
|
||||
("Data", ctypes.c_ubyte * 1),
|
||||
]
|
||||
|
||||
# GUID_CONSOLE_DISPLAY_STATE = {6FE69556-704A-47A0-8F24-C28D936FDA47}
|
||||
GUID_CONSOLE_DISPLAY_STATE = (ctypes.c_ubyte * 16)(
|
||||
0x56, 0x95, 0xE6, 0x6F, 0x4A, 0x70, 0xA0, 0x47,
|
||||
0x8F, 0x24, 0xC2, 0x8D, 0x93, 0x6F, 0xDA, 0x47,
|
||||
)
|
||||
|
||||
def wnd_proc(hwnd, msg, wparam, lparam):
|
||||
if msg == WM_POWERBROADCAST and wparam == PBT_POWERSETTINGCHANGE:
|
||||
try:
|
||||
setting = ctypes.cast(
|
||||
lparam, ctypes.POINTER(POWERBROADCAST_SETTING)
|
||||
).contents
|
||||
# Data: 0=off, 1=on, 2=dimmed (treat dimmed as on)
|
||||
value = setting.Data[0]
|
||||
self._display_on = value != 0
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
return user32.DefWindowProcW(hwnd, msg, wparam, lparam)
|
||||
|
||||
wnd_proc_cb = WNDPROC(wnd_proc)
|
||||
|
||||
# Register window class
|
||||
class WNDCLASSEXW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", ctypes.c_uint),
|
||||
("style", ctypes.c_uint),
|
||||
("lpfnWndProc", WNDPROC),
|
||||
("cbClsExtra", ctypes.c_int),
|
||||
("cbWndExtra", ctypes.c_int),
|
||||
("hInstance", ctypes.wintypes.HINSTANCE),
|
||||
("hIcon", ctypes.wintypes.HICON),
|
||||
("hCursor", ctypes.wintypes.HANDLE),
|
||||
("hbrBackground", ctypes.wintypes.HBRUSH),
|
||||
("lpszMenuName", ctypes.wintypes.LPCWSTR),
|
||||
("lpszClassName", ctypes.wintypes.LPCWSTR),
|
||||
("hIconSm", ctypes.wintypes.HICON),
|
||||
]
|
||||
|
||||
wc = WNDCLASSEXW()
|
||||
wc.cbSize = ctypes.sizeof(WNDCLASSEXW)
|
||||
wc.lpfnWndProc = wnd_proc_cb
|
||||
wc.lpszClassName = "LedGrabDisplayMonitor"
|
||||
wc.hInstance = ctypes.windll.kernel32.GetModuleHandleW(None)
|
||||
|
||||
atom = user32.RegisterClassExW(ctypes.byref(wc))
|
||||
if not atom:
|
||||
logger.warning("Failed to register display monitor window class")
|
||||
return
|
||||
|
||||
HWND_MESSAGE = ctypes.wintypes.HWND(-3)
|
||||
hwnd = user32.CreateWindowExW(
|
||||
0, wc.lpszClassName, "LedGrab Display Monitor",
|
||||
0, 0, 0, 0, 0, HWND_MESSAGE, None, wc.hInstance, None,
|
||||
)
|
||||
if not hwnd:
|
||||
logger.warning("Failed to create display monitor hidden window")
|
||||
return
|
||||
|
||||
# Register for display power notifications
|
||||
user32.RegisterPowerSettingNotification(
|
||||
hwnd, ctypes.byref(GUID_CONSOLE_DISPLAY_STATE), 0
|
||||
)
|
||||
|
||||
self._display_listener_started = True
|
||||
logger.debug("Display power listener started")
|
||||
|
||||
# Message pump
|
||||
msg = ctypes.wintypes.MSG()
|
||||
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0:
|
||||
user32.TranslateMessage(ctypes.byref(msg))
|
||||
user32.DispatchMessageW(ctypes.byref(msg))
|
||||
except Exception as e:
|
||||
logger.error(f"Display power listener failed: {e}")
|
||||
|
||||
def _get_display_power_state_sync(self) -> Optional[str]:
|
||||
"""Get display power state: 'on' or 'off'. Returns None if unavailable."""
|
||||
if not _IS_WINDOWS:
|
||||
return None
|
||||
return "on" if self._display_on else "off"
|
||||
|
||||
# ---- System idle detection ----
|
||||
|
||||
def _get_idle_seconds_sync(self) -> Optional[float]:
|
||||
"""Get system idle time in seconds (keyboard/mouse inactivity).
|
||||
|
||||
Returns None if detection is unavailable.
|
||||
"""
|
||||
if not _IS_WINDOWS:
|
||||
return None
|
||||
|
||||
try:
|
||||
class LASTINPUTINFO(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cbSize", ctypes.c_uint),
|
||||
("dwTime", ctypes.c_uint),
|
||||
]
|
||||
|
||||
lii = LASTINPUTINFO()
|
||||
lii.cbSize = ctypes.sizeof(LASTINPUTINFO)
|
||||
if not ctypes.windll.user32.GetLastInputInfo(ctypes.byref(lii)):
|
||||
return None
|
||||
millis = ctypes.windll.kernel32.GetTickCount() - lii.dwTime
|
||||
return millis / 1000.0
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get idle time: {e}")
|
||||
return None
|
||||
|
||||
# ---- Process detection ----
|
||||
|
||||
def _get_running_processes_sync(self) -> Set[str]:
|
||||
"""Get set of lowercase process names via Win32 EnumProcesses.
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
"""Profile engine — background loop that evaluates conditions and manages targets."""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
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 AlwaysCondition, ApplicationCondition, Condition, Profile
|
||||
from wled_controller.storage.profile import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
Condition,
|
||||
DisplayStateCondition,
|
||||
MQTTCondition,
|
||||
Profile,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
)
|
||||
from wled_controller.storage.profile_store import ProfileStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -15,11 +25,13 @@ 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 = 1.0):
|
||||
def __init__(self, profile_store: ProfileStore, processor_manager, poll_interval: float = 1.0,
|
||||
mqtt_service=None):
|
||||
self._store = profile_store
|
||||
self._manager = processor_manager
|
||||
self._poll_interval = poll_interval
|
||||
self._detector = PlatformDetector()
|
||||
self._mqtt_service = mqtt_service
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._eval_lock = asyncio.Lock()
|
||||
|
||||
@@ -70,11 +82,12 @@ class ProfileEngine:
|
||||
|
||||
def _detect_all_sync(
|
||||
self, needs_running: bool, needs_topmost: bool, needs_fullscreen: bool,
|
||||
needs_idle: bool, needs_display_state: bool,
|
||||
) -> tuple:
|
||||
"""Run all platform detection in a single thread call.
|
||||
|
||||
Batching the three detection calls into one executor submission reduces
|
||||
event-loop wake-ups from 3 to 1, minimising asyncio.sleep() jitter in
|
||||
Batching detection calls into one executor submission reduces
|
||||
event-loop wake-ups, minimising asyncio.sleep() jitter in
|
||||
latency-sensitive processing loops.
|
||||
"""
|
||||
running_procs = self._detector._get_running_processes_sync() if needs_running else set()
|
||||
@@ -83,7 +96,9 @@ class ProfileEngine:
|
||||
else:
|
||||
topmost_proc, topmost_fullscreen = None, False
|
||||
fullscreen_procs = self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
|
||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
|
||||
idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
|
||||
display_state = self._detector._get_display_power_state_sync() if needs_display_state else None
|
||||
return running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs, idle_seconds, display_state
|
||||
|
||||
async def _evaluate_all_locked(self) -> None:
|
||||
profiles = self._store.get_all_profiles()
|
||||
@@ -95,23 +110,30 @@ class ProfileEngine:
|
||||
|
||||
# Determine which detection methods are actually needed
|
||||
match_types_used: set = set()
|
||||
needs_idle = False
|
||||
needs_display_state = False
|
||||
for p in profiles:
|
||||
if p.enabled:
|
||||
for c in p.conditions:
|
||||
mt = getattr(c, "match_type", "running")
|
||||
match_types_used.add(mt)
|
||||
if isinstance(c, ApplicationCondition):
|
||||
match_types_used.add(c.match_type)
|
||||
elif isinstance(c, SystemIdleCondition):
|
||||
needs_idle = True
|
||||
elif isinstance(c, DisplayStateCondition):
|
||||
needs_display_state = True
|
||||
|
||||
needs_running = "running" in match_types_used
|
||||
needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
|
||||
needs_fullscreen = "fullscreen" in match_types_used
|
||||
|
||||
# Single executor call for all platform detection (avoids 3 separate
|
||||
# event-loop roundtrips that can jitter processing-loop timing)
|
||||
# Single executor call for all platform detection
|
||||
loop = asyncio.get_event_loop()
|
||||
running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs = (
|
||||
(running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state) = (
|
||||
await loop.run_in_executor(
|
||||
None, self._detect_all_sync,
|
||||
needs_running, needs_topmost, needs_fullscreen,
|
||||
needs_idle, needs_display_state,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -121,7 +143,9 @@ class ProfileEngine:
|
||||
should_be_active = (
|
||||
profile.enabled
|
||||
and (len(profile.conditions) == 0
|
||||
or self._evaluate_conditions(profile, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs))
|
||||
or self._evaluate_conditions(
|
||||
profile, running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state))
|
||||
)
|
||||
|
||||
is_active = profile.id in self._active_profiles
|
||||
@@ -143,9 +167,13 @@ class ProfileEngine:
|
||||
self, profile: Profile, running_procs: Set[str],
|
||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float], display_state: Optional[str],
|
||||
) -> bool:
|
||||
results = [
|
||||
self._evaluate_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
||||
self._evaluate_condition(
|
||||
c, running_procs, topmost_proc, topmost_fullscreen,
|
||||
fullscreen_procs, idle_seconds, display_state,
|
||||
)
|
||||
for c in profile.conditions
|
||||
]
|
||||
|
||||
@@ -157,11 +185,63 @@ class ProfileEngine:
|
||||
self, condition: Condition, running_procs: Set[str],
|
||||
topmost_proc: Optional[str], topmost_fullscreen: bool,
|
||||
fullscreen_procs: Set[str],
|
||||
idle_seconds: Optional[float], display_state: Optional[str],
|
||||
) -> bool:
|
||||
if isinstance(condition, AlwaysCondition):
|
||||
return True
|
||||
if isinstance(condition, ApplicationCondition):
|
||||
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
||||
if isinstance(condition, TimeOfDayCondition):
|
||||
return self._evaluate_time_of_day(condition)
|
||||
if isinstance(condition, SystemIdleCondition):
|
||||
return self._evaluate_idle(condition, idle_seconds)
|
||||
if isinstance(condition, DisplayStateCondition):
|
||||
return self._evaluate_display_state(condition, display_state)
|
||||
if isinstance(condition, MQTTCondition):
|
||||
return self._evaluate_mqtt(condition)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
||||
now = datetime.now()
|
||||
current = now.hour * 60 + now.minute
|
||||
parts_s = condition.start_time.split(":")
|
||||
parts_e = condition.end_time.split(":")
|
||||
start = int(parts_s[0]) * 60 + int(parts_s[1])
|
||||
end = int(parts_e[0]) * 60 + int(parts_e[1])
|
||||
if start <= end:
|
||||
return start <= current <= end
|
||||
# Overnight range (e.g. 22:00 → 06:00)
|
||||
return current >= start or current <= end
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
|
||||
if idle_seconds is None:
|
||||
return False
|
||||
is_idle = idle_seconds >= (condition.idle_minutes * 60)
|
||||
return is_idle if condition.when_idle else not is_idle
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_display_state(condition: DisplayStateCondition, display_state: Optional[str]) -> bool:
|
||||
if display_state is None:
|
||||
return False
|
||||
return display_state == condition.state
|
||||
|
||||
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
|
||||
if self._mqtt_service is None or not self._mqtt_service.is_connected:
|
||||
return False
|
||||
value = self._mqtt_service.get_last_value(condition.topic)
|
||||
if value is None:
|
||||
return False
|
||||
if condition.match_mode == "exact":
|
||||
return value == condition.payload
|
||||
if condition.match_mode == "contains":
|
||||
return condition.payload in value
|
||||
if condition.match_mode == "regex":
|
||||
try:
|
||||
return bool(re.search(condition.payload, value))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
def _evaluate_app_condition(
|
||||
|
||||
Reference in New Issue
Block a user