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