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:
2026-02-28 16:57:42 +03:00
parent bd8d7a019f
commit 2e747b5ece
38 changed files with 2269 additions and 32 deletions

View File

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

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

View 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 []

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

View File

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

View File

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