From c59107c7c7673dba0ea3d041a80ff3f6db5e64b4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 31 Mar 2026 18:02:19 +0300 Subject: [PATCH] feat: refactor MQTT from global config to multi-instance entity model MQTT broker connections are now managed as entities (like HA sources) instead of a single global config. Each MQTTSource gets its own runtime with auto-reconnect, ref-counted via MQTTManager. Backend: - MQTTSource dataclass + MQTTSourceStore (SQLite) - MQTTRuntime (per-broker connection, refactored from MQTTService) - MQTTManager (ref-counted pool, same pattern as HAManager) - CRUD API at /api/v1/mqtt/sources + test + status endpoints - MQTTRule gains mqtt_source_id field for source selection - Automation engine acquires/releases MQTT runtimes automatically - Legacy MQTTService kept for backward compat during transition Frontend: - MQTT source cards in Streams > Integrations tab - Create/edit modal with test button - Dashboard integration cards with health-dot indicators - Removed MQTT tab from settings modal --- server/src/wled_controller/api/__init__.py | 2 + .../src/wled_controller/api/dependencies.py | 14 + .../wled_controller/api/routes/automations.py | 1 + server/src/wled_controller/api/routes/mqtt.py | 235 +++++++++++++ .../api/schemas/automations.py | 1 + .../src/wled_controller/api/schemas/mqtt.py | 83 +++++ .../core/automations/automation_engine.py | 67 +++- .../wled_controller/core/mqtt/mqtt_manager.py | 167 ++++++++++ .../wled_controller/core/mqtt/mqtt_runtime.py | 226 +++++++++++++ server/src/wled_controller/main.py | 21 +- server/src/wled_controller/static/js/app.ts | 3 +- .../wled_controller/static/js/core/state.ts | 10 +- .../static/js/features/dashboard.ts | 115 ++++++- .../static/js/features/mqtt-sources.ts | 312 ++++++++++++++++++ .../static/js/features/settings.ts | 54 --- .../static/js/features/streams.ts | 16 +- .../src/wled_controller/static/js/global.d.ts | 1 - server/src/wled_controller/static/js/types.ts | 49 +++ .../wled_controller/static/locales/en.json | 41 +++ .../src/wled_controller/storage/automation.py | 3 + .../src/wled_controller/storage/database.py | 1 + .../wled_controller/storage/mqtt_source.py | 79 +++++ .../storage/mqtt_source_store.py | 105 ++++++ .../src/wled_controller/templates/index.html | 1 + .../templates/modals/mqtt-source-editor.html | 98 ++++++ .../templates/modals/settings.html | 55 --- 26 files changed, 1636 insertions(+), 124 deletions(-) create mode 100644 server/src/wled_controller/api/routes/mqtt.py create mode 100644 server/src/wled_controller/api/schemas/mqtt.py create mode 100644 server/src/wled_controller/core/mqtt/mqtt_manager.py create mode 100644 server/src/wled_controller/core/mqtt/mqtt_runtime.py create mode 100644 server/src/wled_controller/static/js/features/mqtt-sources.ts create mode 100644 server/src/wled_controller/storage/mqtt_source.py create mode 100644 server/src/wled_controller/storage/mqtt_source_store.py create mode 100644 server/src/wled_controller/templates/modals/mqtt-source-editor.html diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 7571307..5bbe086 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -26,6 +26,7 @@ from .routes.weather_sources import router as weather_sources_router from .routes.update import router as update_router from .routes.assets import router as assets_router from .routes.home_assistant import router as home_assistant_router +from .routes.mqtt import router as mqtt_router from .routes.game_integration import router as game_integration_router router = APIRouter() @@ -53,6 +54,7 @@ router.include_router(weather_sources_router) router.include_router(update_router) router.include_router(assets_router) router.include_router(home_assistant_router) +router.include_router(mqtt_router) router.include_router(game_integration_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index f1ea338..a2aa69b 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -35,6 +35,8 @@ from wled_controller.storage.home_assistant_store import HomeAssistantStore from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager from wled_controller.storage.game_integration_store import GameIntegrationStore from wled_controller.core.game_integration.event_bus import GameEventBus +from wled_controller.storage.mqtt_source_store import MQTTSourceStore +from wled_controller.core.mqtt.mqtt_manager import MQTTManager T = TypeVar("T") @@ -153,6 +155,14 @@ def get_game_event_bus() -> GameEventBus: return _get("game_event_bus", "Game event bus") +def get_mqtt_store() -> MQTTSourceStore: + return _get("mqtt_store", "MQTT source store") + + +def get_mqtt_manager() -> MQTTManager: + return _get("mqtt_manager", "MQTT manager") + + def get_database() -> Database: return _get("database", "Database") @@ -215,6 +225,8 @@ def init_dependencies( ha_manager: HomeAssistantManager | None = None, game_integration_store: GameIntegrationStore | None = None, game_event_bus: GameEventBus | None = None, + mqtt_store: MQTTSourceStore | None = None, + mqtt_manager: MQTTManager | None = None, ): """Initialize global dependencies.""" _deps.update( @@ -246,5 +258,7 @@ def init_dependencies( "ha_manager": ha_manager, "game_integration_store": game_integration_store, "game_event_bus": game_event_bus, + "mqtt_store": mqtt_store, + "mqtt_manager": mqtt_manager, } ) diff --git a/server/src/wled_controller/api/routes/automations.py b/server/src/wled_controller/api/routes/automations.py index 4a0fd89..776a7d6 100644 --- a/server/src/wled_controller/api/routes/automations.py +++ b/server/src/wled_controller/api/routes/automations.py @@ -60,6 +60,7 @@ def _rule_from_schema(s: RuleSchema) -> Rule: state=s.state or "on", ), "mqtt": lambda: MQTTRule( + mqtt_source_id=s.mqtt_source_id or "", topic=s.topic or "", payload=s.payload or "", match_mode=s.match_mode or "exact", diff --git a/server/src/wled_controller/api/routes/mqtt.py b/server/src/wled_controller/api/routes/mqtt.py new file mode 100644 index 0000000..7d3bd3a --- /dev/null +++ b/server/src/wled_controller/api/routes/mqtt.py @@ -0,0 +1,235 @@ +"""MQTT source routes: CRUD + test + status.""" + +import asyncio + +import aiomqtt +from fastapi import APIRouter, Depends, HTTPException + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + fire_entity_event, + get_mqtt_manager, + get_mqtt_store, +) +from wled_controller.api.schemas.mqtt import ( + MQTTConnectionStatus, + MQTTSourceCreate, + MQTTSourceListResponse, + MQTTSourceResponse, + MQTTSourceUpdate, + MQTTStatusResponse, + MQTTTestResponse, +) +from wled_controller.core.mqtt.mqtt_manager import MQTTManager +from wled_controller.storage.base_store import EntityNotFoundError +from wled_controller.storage.mqtt_source import MQTTSource +from wled_controller.storage.mqtt_source_store import MQTTSourceStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _to_response(source: MQTTSource, manager: MQTTManager) -> MQTTSourceResponse: + runtime = manager.get_runtime(source.id) + return MQTTSourceResponse( + id=source.id, + name=source.name, + broker_host=source.broker_host, + broker_port=source.broker_port, + username=source.username, + password_set=bool(source.password), + client_id=source.client_id, + base_topic=source.base_topic, + connected=runtime.is_connected if runtime else False, + description=source.description, + tags=source.tags, + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +@router.get( + "/api/v1/mqtt/sources", + response_model=MQTTSourceListResponse, + tags=["MQTT"], +) +async def list_mqtt_sources( + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + sources = store.get_all_sources() + return MQTTSourceListResponse( + sources=[_to_response(s, manager) for s in sources], + count=len(sources), + ) + + +@router.post( + "/api/v1/mqtt/sources", + response_model=MQTTSourceResponse, + status_code=201, + tags=["MQTT"], +) +async def create_mqtt_source( + data: MQTTSourceCreate, + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + try: + source = store.create_source( + name=data.name, + broker_host=data.broker_host, + broker_port=data.broker_port, + username=data.username, + password=data.password, + client_id=data.client_id, + base_topic=data.base_topic, + description=data.description, + tags=data.tags, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + fire_entity_event("mqtt_source", "created", source.id) + return _to_response(source, manager) + + +@router.get( + "/api/v1/mqtt/sources/{source_id}", + response_model=MQTTSourceResponse, + tags=["MQTT"], +) +async def get_mqtt_source( + source_id: str, + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + try: + source = store.get_source(source_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") + return _to_response(source, manager) + + +@router.put( + "/api/v1/mqtt/sources/{source_id}", + response_model=MQTTSourceResponse, + tags=["MQTT"], +) +async def update_mqtt_source( + source_id: str, + data: MQTTSourceUpdate, + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + try: + source = store.update_source( + source_id, + name=data.name, + broker_host=data.broker_host, + broker_port=data.broker_port, + username=data.username, + password=data.password, + client_id=data.client_id, + base_topic=data.base_topic, + description=data.description, + tags=data.tags, + ) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + await manager.update_source(source_id) + fire_entity_event("mqtt_source", "updated", source.id) + return _to_response(source, manager) + + +@router.delete("/api/v1/mqtt/sources/{source_id}", status_code=204, tags=["MQTT"]) +async def delete_mqtt_source( + source_id: str, + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + try: + store.delete_source(source_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") + # Release any active runtime + await manager.release(source_id) + fire_entity_event("mqtt_source", "deleted", source_id) + + +@router.post( + "/api/v1/mqtt/sources/{source_id}/test", + response_model=MQTTTestResponse, + tags=["MQTT"], +) +async def test_mqtt_source( + source_id: str, + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), +): + """Test connection to an MQTT broker.""" + try: + source = store.get_source(source_id) + except EntityNotFoundError: + raise HTTPException(status_code=404, detail=f"MQTT source {source_id} not found") + + try: + async with aiomqtt.Client( + hostname=source.broker_host, + port=source.broker_port, + username=source.username or None, + password=source.password or None, + identifier=f"{source.client_id}-test", + timeout=10.0, + ): + return MQTTTestResponse(success=True) + except asyncio.TimeoutError: + return MQTTTestResponse(success=False, error="Connection timed out") + except Exception as e: + return MQTTTestResponse(success=False, error=str(e)) + + +@router.get( + "/api/v1/mqtt/status", + response_model=MQTTStatusResponse, + tags=["MQTT"], +) +async def get_mqtt_status( + _auth: AuthRequired, + store: MQTTSourceStore = Depends(get_mqtt_store), + manager: MQTTManager = Depends(get_mqtt_manager), +): + """Get overall MQTT integration status (for dashboard indicators).""" + all_sources = store.get_all_sources() + statuses = manager.get_all_sources_status() + status_map = {s["source_id"]: s for s in statuses} + + connections = [] + connected_count = 0 + for source in all_sources: + status = status_map.get(source.id) + connected = status["connected"] if status else False + if connected: + connected_count += 1 + connections.append( + MQTTConnectionStatus( + source_id=source.id, + name=source.name, + connected=connected, + broker=f"{source.broker_host}:{source.broker_port}", + ) + ) + + return MQTTStatusResponse( + connections=connections, + total_sources=len(all_sources), + connected_count=connected_count, + ) diff --git a/server/src/wled_controller/api/schemas/automations.py b/server/src/wled_controller/api/schemas/automations.py index 8a60afd..b116968 100644 --- a/server/src/wled_controller/api/schemas/automations.py +++ b/server/src/wled_controller/api/schemas/automations.py @@ -28,6 +28,7 @@ class RuleSchema(BaseModel): # Display state rule fields state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)") # MQTT rule fields + mqtt_source_id: Optional[str] = Field(None, description="MQTT source ID (for mqtt rule)") topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)") payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)") match_mode: Optional[str] = Field( diff --git a/server/src/wled_controller/api/schemas/mqtt.py b/server/src/wled_controller/api/schemas/mqtt.py new file mode 100644 index 0000000..ed33451 --- /dev/null +++ b/server/src/wled_controller/api/schemas/mqtt.py @@ -0,0 +1,83 @@ +"""MQTT source schemas (CRUD + test + status).""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class MQTTSourceCreate(BaseModel): + """Request to create an MQTT source.""" + + name: str = Field(description="Source name", min_length=1, max_length=100) + broker_host: str = Field(description="MQTT broker hostname or IP", min_length=1) + broker_port: int = Field(default=1883, description="MQTT broker port", ge=1, le=65535) + username: str = Field(default="", description="Broker username (optional)") + password: str = Field(default="", description="Broker password (optional)") + client_id: str = Field(default="ledgrab", description="MQTT client ID") + base_topic: str = Field(default="ledgrab", description="Base topic prefix") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") + + +class MQTTSourceUpdate(BaseModel): + """Request to update an MQTT source.""" + + name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) + broker_host: Optional[str] = Field(None, description="MQTT broker hostname or IP", min_length=1) + broker_port: Optional[int] = Field(None, description="MQTT broker port", ge=1, le=65535) + username: Optional[str] = Field(None, description="Broker username") + password: Optional[str] = Field(None, description="Broker password") + client_id: Optional[str] = Field(None, description="MQTT client ID") + base_topic: Optional[str] = Field(None, description="Base topic prefix") + description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: Optional[List[str]] = None + + +class MQTTSourceResponse(BaseModel): + """MQTT source response.""" + + id: str = Field(description="Source ID") + name: str = Field(description="Source name") + broker_host: str = Field(description="Broker hostname or IP") + broker_port: int = Field(description="Broker port") + username: str = Field(default="", description="Broker username") + password_set: bool = Field(default=False, description="Whether a password is configured") + client_id: str = Field(description="MQTT client ID") + base_topic: str = Field(description="Base topic prefix") + connected: bool = Field(default=False, description="Whether the broker connection is active") + description: Optional[str] = Field(None, description="Description") + tags: List[str] = Field(default_factory=list, description="User-defined tags") + created_at: datetime = Field(description="Creation timestamp") + updated_at: datetime = Field(description="Last update timestamp") + + +class MQTTSourceListResponse(BaseModel): + """List of MQTT sources.""" + + sources: List[MQTTSourceResponse] = Field(description="List of MQTT sources") + count: int = Field(description="Number of sources") + + +class MQTTTestResponse(BaseModel): + """Connection test result.""" + + success: bool = Field(description="Whether broker connection succeeded") + error: Optional[str] = Field(None, description="Error message if connection failed") + + +class MQTTConnectionStatus(BaseModel): + """Connection status for dashboard indicators.""" + + source_id: str + name: str + connected: bool + broker: str + + +class MQTTStatusResponse(BaseModel): + """Overall MQTT integration status for dashboard.""" + + connections: List[MQTTConnectionStatus] + total_sources: int + connected_count: int diff --git a/server/src/wled_controller/core/automations/automation_engine.py b/server/src/wled_controller/core/automations/automation_engine.py index eca017e..4970acf 100644 --- a/server/src/wled_controller/core/automations/automation_engine.py +++ b/server/src/wled_controller/core/automations/automation_engine.py @@ -38,12 +38,14 @@ class AutomationEngine: target_store=None, device_store=None, ha_manager=None, + mqtt_manager=None, ): self._store = automation_store self._manager = processor_manager self._poll_interval = poll_interval self._detector = PlatformDetector() self._mqtt_service = mqtt_service + self._mqtt_manager = mqtt_manager self._scene_preset_store = scene_preset_store self._target_store = target_store self._device_store = device_store @@ -63,11 +65,14 @@ class AutomationEngine: self._webhook_states: Dict[str, bool] = {} # HA source IDs currently acquired by the engine self._ha_acquired: Set[str] = set() + # MQTT source IDs currently acquired by the engine + self._mqtt_acquired: Set[str] = set() async def start(self) -> None: if self._task is not None: return await self._sync_ha_runtimes() + await self._sync_mqtt_runtimes() self._task = asyncio.create_task(self._poll_loop()) logger.info("Automation engine started") @@ -89,6 +94,8 @@ class AutomationEngine: # Release all HA runtimes await self._release_all_ha_runtimes() + # Release all MQTT runtimes + await self._release_all_mqtt_runtimes() logger.info("Automation engine stopped") @@ -136,6 +143,48 @@ class AutomationEngine: logger.warning("Failed to release HA runtime %s: %s", source_id, e) self._ha_acquired = set() + def _get_needed_mqtt_sources(self) -> Set[str]: + """Collect MQTT source IDs referenced by enabled automations.""" + needed: Set[str] = set() + if self._mqtt_manager is None: + return needed + for a in self._store.get_all_automations(): + if a.enabled: + for r in a.rules: + if isinstance(r, MQTTRule) and r.mqtt_source_id: + needed.add(r.mqtt_source_id) + return needed + + async def _sync_mqtt_runtimes(self) -> None: + """Acquire/release MQTT runtimes to match current automation rules.""" + if self._mqtt_manager is None: + return + needed = self._get_needed_mqtt_sources() + for source_id in self._mqtt_acquired - needed: + try: + await self._mqtt_manager.release(source_id) + logger.debug("Released MQTT runtime for automation: %s", source_id) + except Exception as e: + logger.warning("Failed to release MQTT runtime %s: %s", source_id, e) + for source_id in needed - self._mqtt_acquired: + try: + await self._mqtt_manager.acquire(source_id) + logger.debug("Acquired MQTT runtime for automation: %s", source_id) + except Exception as e: + logger.warning("Failed to acquire MQTT runtime %s: %s", source_id, e) + self._mqtt_acquired = needed + + async def _release_all_mqtt_runtimes(self) -> None: + """Release all MQTT runtimes held by the engine.""" + if self._mqtt_manager is None: + return + for source_id in self._mqtt_acquired: + try: + await self._mqtt_manager.release(source_id) + except Exception as e: + logger.warning("Failed to release MQTT runtime %s: %s", source_id, e) + self._mqtt_acquired = set() + async def _poll_loop(self) -> None: try: while True: @@ -150,6 +199,7 @@ class AutomationEngine: async def _evaluate_all(self) -> None: await self._sync_ha_runtimes() + await self._sync_mqtt_runtimes() async with self._eval_lock: await self._evaluate_all_locked() @@ -345,9 +395,20 @@ class AutomationEngine: return display_state == rule.state def _evaluate_mqtt(self, rule: MQTTRule) -> bool: - if self._mqtt_service is None or not self._mqtt_service.is_connected: - return False - value = self._mqtt_service.get_last_value(rule.topic) + value = None + # Try entity-based manager first (new model) + if self._mqtt_manager is not None and rule.mqtt_source_id: + runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id) + if runtime and runtime.is_connected: + value = runtime.get_last_value(rule.topic) + elif self._mqtt_manager is not None: + # No source specified — try first available runtime + runtime = self._mqtt_manager.get_first_runtime() + if runtime: + value = runtime.get_last_value(rule.topic) + # Fallback to legacy global service + if value is None and self._mqtt_service is not None and self._mqtt_service.is_connected: + value = self._mqtt_service.get_last_value(rule.topic) if value is None: return False matchers = { diff --git a/server/src/wled_controller/core/mqtt/mqtt_manager.py b/server/src/wled_controller/core/mqtt/mqtt_manager.py new file mode 100644 index 0000000..ad1a5e2 --- /dev/null +++ b/server/src/wled_controller/core/mqtt/mqtt_manager.py @@ -0,0 +1,167 @@ +"""MQTT runtime manager — ref-counted pool of MQTT broker connections. + +Follows the HomeAssistantManager pattern: multiple consumers (MQTT devices, +automation rules, state publishing) sharing the same broker connection per +MQTTSource instance. +""" + +import asyncio +from typing import Any, Dict, List, Optional + +from wled_controller.core.mqtt.mqtt_runtime import MQTTRuntime +from wled_controller.storage.mqtt_source_store import MQTTSourceStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MQTTManager: + """Ref-counted pool of MQTT runtimes. + + Each MQTT source gets at most one runtime (broker connection). + Multiple consumers share the same runtime via acquire/release. + """ + + def __init__(self, store: MQTTSourceStore) -> None: + self._store = store + # source_id -> (runtime, ref_count) + self._runtimes: Dict[str, tuple] = {} + self._lock = asyncio.Lock() + + async def acquire(self, source_id: str) -> MQTTRuntime: + """Get or create a runtime for the given MQTT source. Increments ref count.""" + async with self._lock: + if source_id in self._runtimes: + runtime, count = self._runtimes[source_id] + self._runtimes[source_id] = (runtime, count + 1) + return runtime + + source = self._store.get(source_id) + runtime = MQTTRuntime(source) + await runtime.start() + self._runtimes[source_id] = (runtime, 1) + return runtime + + async def release(self, source_id: str) -> None: + """Decrement ref count; stop runtime when it reaches zero.""" + async with self._lock: + if source_id not in self._runtimes: + return + runtime, count = self._runtimes[source_id] + if count <= 1: + await runtime.stop() + del self._runtimes[source_id] + else: + self._runtimes[source_id] = (runtime, count - 1) + + def get_last_value(self, source_id: str, topic: str) -> Optional[str]: + """Get cached topic value from a running runtime (synchronous).""" + entry = self._runtimes.get(source_id) + if entry is None: + return None + runtime, _count = entry + return runtime.get_last_value(topic) + + def get_runtime(self, source_id: str) -> Optional[MQTTRuntime]: + """Get a running runtime without changing ref count (for read-only access).""" + entry = self._runtimes.get(source_id) + if entry is None: + return None + runtime, _count = entry + return runtime + + async def ensure_runtime(self, source_id: str) -> MQTTRuntime: + """Get or create a runtime (for API endpoints that need a connection).""" + async with self._lock: + if source_id in self._runtimes: + runtime, _count = self._runtimes[source_id] + return runtime + + source = self._store.get(source_id) + runtime = MQTTRuntime(source) + await runtime.start() + async with self._lock: + if source_id not in self._runtimes: + self._runtimes[source_id] = (runtime, 0) + else: + await runtime.stop() + runtime, _count = self._runtimes[source_id] + return runtime + + async def update_source(self, source_id: str) -> None: + """Hot-update runtime config when the source is edited.""" + entry = self._runtimes.get(source_id) + if entry is None: + return + runtime, _count = entry + try: + source = self._store.get(source_id) + runtime.update_config(source) + except Exception as e: + logger.warning("Failed to update MQTT runtime %s: %s", source_id, e) + + def get_connection_status(self) -> List[Dict[str, Any]]: + """Get status of all active MQTT connections (for dashboard indicators).""" + result = [] + for source_id, (runtime, ref_count) in self._runtimes.items(): + try: + source = self._store.get(source_id) + name = source.name + except Exception: + name = source_id + result.append( + { + "source_id": source_id, + "name": name, + "connected": runtime.is_connected, + "ref_count": ref_count, + "subscribed_topics": runtime.get_subscribed_topics(), + } + ) + return result + + def get_all_sources_status(self) -> List[Dict[str, Any]]: + """Get status for ALL MQTT sources (connected or not), for the dashboard.""" + result = [] + for source in self._store.get_all(): + entry = self._runtimes.get(source.id) + if entry: + runtime, ref_count = entry + result.append( + { + "source_id": source.id, + "name": source.name, + "connected": runtime.is_connected, + "ref_count": ref_count, + "broker": f"{source.broker_host}:{source.broker_port}", + } + ) + else: + result.append( + { + "source_id": source.id, + "name": source.name, + "connected": False, + "ref_count": 0, + "broker": f"{source.broker_host}:{source.broker_port}", + } + ) + return result + + def get_first_runtime(self) -> Optional[MQTTRuntime]: + """Get the first available connected runtime (fallback for unlinked consumers).""" + for _source_id, (runtime, _count) in self._runtimes.items(): + if runtime.is_connected: + return runtime + # Return any runtime even if disconnected + for _source_id, (runtime, _count) in self._runtimes.items(): + return runtime + return None + + async def shutdown(self) -> None: + """Stop all runtimes.""" + async with self._lock: + for source_id, (runtime, _count) in list(self._runtimes.items()): + await runtime.stop() + self._runtimes.clear() + logger.info("MQTT manager shut down") diff --git a/server/src/wled_controller/core/mqtt/mqtt_runtime.py b/server/src/wled_controller/core/mqtt/mqtt_runtime.py new file mode 100644 index 0000000..8b5c401 --- /dev/null +++ b/server/src/wled_controller/core/mqtt/mqtt_runtime.py @@ -0,0 +1,226 @@ +"""Per-broker MQTT runtime — maintains a persistent connection, topic cache, and pub/sub. + +Each MQTTSource entity gets its own MQTTRuntime instance, managed by MQTTManager. +Refactored from the former singleton MQTTService. +""" + +import asyncio +import json +from typing import Callable, Dict, Optional, Set + +import aiomqtt + +from wled_controller.storage.mqtt_source import MQTTSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MQTTRuntime: + """Persistent MQTT broker connection for a single MQTTSource. + + Features: + - Publish messages (retained or transient) + - Subscribe to topics with callback dispatch + - Topic value cache for synchronous reads (automation rule evaluation) + - Auto-reconnect loop + - Birth / will messages for online status + """ + + _RECONNECT_DELAY = 5.0 # seconds between reconnect attempts + + def __init__(self, source: MQTTSource) -> None: + self._source_id = source.id + self._broker_host = source.broker_host + self._broker_port = source.broker_port + self._username = source.username + self._password = source.password + self._client_id = source.client_id + self._base_topic = source.base_topic + + 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 source_id(self) -> str: + return self._source_id + + @property + def base_topic(self) -> str: + return self._base_topic + + async def start(self) -> None: + """Start the MQTT connection loop.""" + if self._task is not None: + return + self._task = asyncio.create_task(self._connection_loop()) + logger.info( + "MQTT runtime started: %s -> %s:%d", + self._source_id, + self._broker_host, + self._broker_port, + ) + + async def stop(self) -> None: + """Stop the MQTT connection.""" + if self._task is None: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + logger.debug("MQTT runtime task cancelled: %s", self._source_id) + self._task = None + self._connected = False + logger.info("MQTT runtime stopped: %s", self._source_id) + + def update_config(self, source: MQTTSource) -> None: + """Hot-update config. Connection will use new values on next reconnect.""" + self._broker_host = source.broker_host + self._broker_port = source.broker_port + self._username = source.username + self._password = source.password + self._client_id = source.client_id + self._base_topic = source.base_topic + + 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 self._connected and self._client: + try: + await self._client.publish(topic, payload, retain=retain, qos=qos) + return + except Exception as e: + logger.warning("MQTT publish failed (%s/%s): %s", self._source_id, topic, e) + # Queue for retry + try: + self._publish_queue.put_nowait((topic, payload, retain, qos)) + except asyncio.QueueFull: + logger.warning( + "MQTT publish queue full (%s), dropping message for topic %s", + self._source_id, + topic, + ) + + 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("MQTT subscribe failed (%s/%s): %s", self._source_id, topic, e) + + def get_last_value(self, topic: str) -> Optional[str]: + """Get cached last value for a topic (synchronous — for automation evaluation).""" + return self._topic_cache.get(topic) + + def get_subscribed_topics(self) -> int: + """Return number of subscribed topics.""" + return len(self._subscriptions) + + async def _connection_loop(self) -> None: + """Persistent connection loop with auto-reconnect.""" + while True: + will_topic = f"{self._base_topic}/status" + try: + async with aiomqtt.Client( + hostname=self._broker_host, + port=self._broker_port, + username=self._username or None, + password=self._password or None, + identifier=self._client_id, + will=aiomqtt.Will( + topic=will_topic, + payload="offline", + retain=True, + ), + ) as client: + self._client = client + self._connected = True + logger.info( + "MQTT connected: %s -> %s:%d", + self._source_id, + self._broker_host, + self._broker_port, + ) + + # 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( + "MQTT callback error (%s/%s): %s", + self._source_id, + topic_str, + e, + ) + + except asyncio.CancelledError: + break + except Exception as e: + self._connected = False + self._client = None + logger.warning( + "MQTT connection lost (%s): %s. Reconnecting in %.0fs...", + self._source_id, + e, + self._RECONNECT_DELAY, + ) + await asyncio.sleep(self._RECONNECT_DELAY) + + # ===== 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._base_topic}/target/{target_id}/state" + await self.publish(topic, json.dumps(state), retain=True) + + async def publish_automation_state(self, automation_id: str, action: str) -> None: + """Publish automation state change to MQTT.""" + topic = f"{self._base_topic}/automation/{automation_id}/state" + await self.publish(topic, json.dumps({"action": action}), retain=True) diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 46dee02..ff1fb0c 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -49,6 +49,8 @@ from wled_controller.core.game_integration.event_bus import GameEventBus import wled_controller.core.game_integration.adapters # noqa: F401 — register built-in adapters from wled_controller.core.game_integration.community_loader import register_community_adapters from wled_controller.core.mqtt.mqtt_service import MQTTService +from wled_controller.core.mqtt.mqtt_manager import MQTTManager +from wled_controller.storage.mqtt_source_store import MQTTSourceStore from wled_controller.core.devices.mqtt_client import set_mqtt_service from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.processing.os_notification_listener import OsNotificationListener @@ -101,6 +103,7 @@ sync_clock_manager = SyncClockManager(sync_clock_store) weather_manager = WeatherManager(weather_source_store) ha_store = HomeAssistantStore(db) ha_manager = HomeAssistantManager(ha_store) +mqtt_source_store = MQTTSourceStore(db) game_integration_store = GameIntegrationStore(db) game_event_bus = GameEventBus() register_community_adapters() @@ -158,11 +161,14 @@ async def lifespan(app: FastAPI): client_labels = ", ".join(config.auth.api_keys.keys()) logger.info(f"Authorized clients: {client_labels}") - # Create MQTT service (shared broker connection) + # Create MQTT service (shared broker connection — legacy, used by MQTTLEDClient) mqtt_service = MQTTService(config.mqtt) set_mqtt_service(mqtt_service) - # Create automation engine (needs processor_manager + mqtt_service + stores for scene activation) + # Create MQTT manager (multi-source, ref-counted — new entity-based model) + mqtt_manager = MQTTManager(mqtt_source_store) + + # Create automation engine (needs processor_manager + mqtt + stores for scene activation) automation_engine = AutomationEngine( automation_store, processor_manager, @@ -171,6 +177,7 @@ async def lifespan(app: FastAPI): target_store=output_target_store, device_store=device_store, ha_manager=ha_manager, + mqtt_manager=mqtt_manager, ) # Create auto-backup engine — derive paths from database location so that @@ -222,6 +229,8 @@ async def lifespan(app: FastAPI): ha_manager=ha_manager, game_integration_store=game_integration_store, game_event_bus=game_event_bus, + mqtt_store=mqtt_source_store, + mqtt_manager=mqtt_manager, ) # Register devices in processor manager for health monitoring @@ -332,7 +341,13 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Error stopping processors: {e}") - # Stop MQTT service + # Stop MQTT manager (entity-based broker connections) + try: + await mqtt_manager.shutdown() + except Exception as e: + logger.error(f"Error stopping MQTT manager: {e}") + + # Stop MQTT service (legacy global connection) try: await mqtt_service.stop() except Exception as e: diff --git a/server/src/wled_controller/static/js/app.ts b/server/src/wled_controller/static/js/app.ts index 36438f4..18c2765 100644 --- a/server/src/wled_controller/static/js/app.ts +++ b/server/src/wled_controller/static/js/app.ts @@ -194,7 +194,7 @@ import { openSettingsModal, closeSettingsModal, switchSettingsTab, downloadBackup, handleRestoreFileSelected, saveAutoBackupSettings, triggerBackupNow, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup, - restartServer, saveMqttSettings, + restartServer, loadApiKeysList, connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter, openLogOverlay, closeLogOverlay, @@ -562,7 +562,6 @@ Object.assign(window, { downloadSavedBackup, deleteSavedBackup, restartServer, - saveMqttSettings, loadApiKeysList, connectLogViewer, disconnectLogViewer, diff --git a/server/src/wled_controller/static/js/core/state.ts b/server/src/wled_controller/static/js/core/state.ts index 33f3e62..73e355c 100644 --- a/server/src/wled_controller/static/js/core/state.ts +++ b/server/src/wled_controller/static/js/core/state.ts @@ -10,7 +10,7 @@ import { DataCache } from './cache.ts'; import type { Device, OutputTarget, ColorStripSource, PatternTemplate, ValueSource, AudioSource, PictureSource, ScenePreset, - SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo, + SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo, CaptureTemplate, PostprocessingTemplate, AudioTemplate, ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle, GameIntegration, GameAdapterInfo, @@ -279,6 +279,14 @@ export const haSourcesCache = new DataCache({ }); haSourcesCache.subscribe(v => { _cachedHASources = v; }); +export let _cachedMQTTSources: MQTTSource[] = []; + +export const mqttSourcesCache = new DataCache({ + endpoint: '/mqtt/sources', + extractData: json => json.sources || [], +}); +mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; }); + export const assetsCache = new DataCache({ endpoint: '/assets', extractData: json => json.assets || [], diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index 9992443..b3a16e2 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -11,11 +11,12 @@ import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, + ICON_PLUG, ICON_HOME, ICON_RADIO, } from '../core/icons.ts'; import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts'; import { cardColorStyle } from '../core/card-colors.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; -import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation } from '../types.ts'; +import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; @@ -255,6 +256,89 @@ function _updateSyncClocksInPlace(syncClocks: SyncClock[]): void { } } +function _renderIntegrationCard(conn: HomeAssistantConnectionStatus): string { + const healthClass = conn.connected ? 'health-online' : 'health-offline'; + const healthTitle = conn.connected + ? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}` + : t('ha_source.disconnected'); + const statusDot = ``; + const subtitle = conn.connected + ? `${conn.entity_count} ${t('dashboard.integrations.entities')}` + : t('ha_source.disconnected'); + + return ``; +} + +function _renderMQTTIntegrationCard(conn: MQTTConnectionStatus): string { + const healthClass = conn.connected ? 'health-online' : 'health-offline'; + const healthTitle = conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected'); + const statusDot = ``; + const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected'); + + return ``; +} + +function _updateIntegrationsInPlace(haStatus: HomeAssistantStatusResponse, mqttStatus?: MQTTStatusResponse): void { + // Update health dots and subtitles for each integration card + for (const conn of haStatus.connections) { + const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`); + if (!card) continue; + const dot = card.querySelector('.health-dot'); + if (dot) { + dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`; + dot.setAttribute('title', conn.connected + ? `${t('ha_source.connected')} — ${conn.entity_count} ${t('dashboard.integrations.entities')}` + : t('ha_source.disconnected')); + } + const subtitle = card.querySelector('.dashboard-target-subtitle'); + if (subtitle) { + subtitle.textContent = conn.connected + ? `${conn.entity_count} ${t('dashboard.integrations.entities')}` + : t('ha_source.disconnected'); + } + } + // Update MQTT integration cards + if (mqttStatus) { + for (const conn of mqttStatus.connections) { + const card = document.querySelector(`[data-integration-id="${CSS.escape(conn.source_id)}"]`); + if (!card) continue; + const dot = card.querySelector('.health-dot'); + if (dot) { + dot.className = `health-dot ${conn.connected ? 'health-online' : 'health-offline'}`; + dot.setAttribute('title', conn.connected ? t('mqtt_source.connected') : t('mqtt_source.disconnected')); + } + const subtitle = card.querySelector('.dashboard-target-subtitle'); + if (subtitle) { + subtitle.textContent = conn.connected ? conn.broker : t('mqtt_source.disconnected'); + } + } + } + // Update section count badge + const totalSources = haStatus.total_sources + (mqttStatus?.total_sources || 0); + const totalConnected = haStatus.connected_count + (mqttStatus?.connected_count || 0); + const header = document.querySelector('[data-dashboard-section="integrations"]'); + if (header) { + const countEl = header.querySelector('.dashboard-section-count'); + if (countEl) countEl.textContent = `${totalConnected}/${totalSources}`; + } +} + function renderDashboardSyncClock(clock: SyncClock): string { const toggleAction = clock.is_running ? `dashboardPauseClock('${clock.id}')` @@ -379,7 +463,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise []), fetchWithAuth('/automations').catch(() => null), devicesCache.fetch().catch((): any[] => []), @@ -388,6 +472,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise null), loadScenePresets(), fetchWithAuth('/sync-clocks').catch(() => null), + fetchWithAuth('/home-assistant/status').catch(() => null), + fetchWithAuth('/mqtt/status').catch(() => null), ]); const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] }; @@ -398,6 +484,12 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${t('dashboard.no_targets')}`; } else { const enriched = targets.map(target => ({ @@ -427,6 +519,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { _updateRunningMetrics(running); _updateSyncClocksInPlace(syncClocks); + _updateIntegrationsInPlace(haStatus, mqttStatus); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); @@ -437,6 +530,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) _updateRunningMetrics(running); _updateAutomationsInPlace(automations); _updateSyncClocksInPlace(syncClocks); + _updateIntegrationsInPlace(haStatus, mqttStatus); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); @@ -444,6 +538,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { + const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join(''); + const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join(''); + const intGrid = `
${haCards}${mqttCards}
`; + dynamicHtml += `
+ ${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)} + ${_sectionContent('integrations', intGrid)} +
`; + } + if (automations.length > 0) { const activeAutomations = automations.filter(a => a.is_active); const inactiveAutomations = automations.filter(a => !a.is_active); @@ -821,7 +928,7 @@ document.addEventListener('server:state_change', () => _debouncedDashboardReload document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true)); document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload()); -const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']); +const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device', 'home_assistant_source']); document.addEventListener('server:entity_changed', (e: Event) => { const { entity_type } = (e as CustomEvent).detail || {}; if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true); diff --git a/server/src/wled_controller/static/js/features/mqtt-sources.ts b/server/src/wled_controller/static/js/features/mqtt-sources.ts new file mode 100644 index 0000000..28eae6f --- /dev/null +++ b/server/src/wled_controller/static/js/features/mqtt-sources.ts @@ -0,0 +1,312 @@ +/** + * MQTT Sources — CRUD, test, cards. + */ + +import { mqttSourcesCache } from '../core/state.ts'; +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { Modal } from '../core/modal.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts'; +import * as P from '../core/icon-paths.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import type { MQTTSource } from '../types.ts'; + +const ICON_MQTT = `${P.radio}`; + +// ── Modal ── + +let _mqttTagsInput: TagInput | null = null; + +class MQTTSourceModal extends Modal { + constructor() { super('mqtt-source-modal'); } + + onForceClose() { + if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; } + } + + snapshotValues() { + return { + name: (document.getElementById('mqtt-source-name') as HTMLInputElement).value, + host: (document.getElementById('mqtt-source-host') as HTMLInputElement).value, + port: (document.getElementById('mqtt-source-port') as HTMLInputElement).value, + username: (document.getElementById('mqtt-source-username') as HTMLInputElement).value, + password: (document.getElementById('mqtt-source-password') as HTMLInputElement).value, + client_id: (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value, + base_topic: (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value, + description: (document.getElementById('mqtt-source-description') as HTMLInputElement).value, + tags: JSON.stringify(_mqttTagsInput ? _mqttTagsInput.getValue() : []), + }; + } +} + +const mqttSourceModal = new MQTTSourceModal(); + +// ── Show / Close ── + +export async function showMQTTSourceModal(editData: MQTTSource | null = null): Promise { + const isEdit = !!editData; + const titleKey = isEdit ? 'mqtt_source.edit' : 'mqtt_source.add'; + document.getElementById('mqtt-source-modal-title')!.innerHTML = `${ICON_MQTT} ${t(titleKey)}`; + (document.getElementById('mqtt-source-id') as HTMLInputElement).value = editData?.id || ''; + (document.getElementById('mqtt-source-error') as HTMLElement).style.display = 'none'; + + if (isEdit) { + (document.getElementById('mqtt-source-name') as HTMLInputElement).value = editData.name || ''; + (document.getElementById('mqtt-source-host') as HTMLInputElement).value = editData.broker_host || ''; + (document.getElementById('mqtt-source-port') as HTMLInputElement).value = String(editData.broker_port ?? 1883); + (document.getElementById('mqtt-source-username') as HTMLInputElement).value = editData.username || ''; + (document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; // never expose + (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = editData.client_id || 'ledgrab'; + (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = editData.base_topic || 'ledgrab'; + (document.getElementById('mqtt-source-description') as HTMLInputElement).value = editData.description || ''; + } else { + (document.getElementById('mqtt-source-name') as HTMLInputElement).value = ''; + (document.getElementById('mqtt-source-host') as HTMLInputElement).value = ''; + (document.getElementById('mqtt-source-port') as HTMLInputElement).value = '1883'; + (document.getElementById('mqtt-source-username') as HTMLInputElement).value = ''; + (document.getElementById('mqtt-source-password') as HTMLInputElement).value = ''; + (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value = 'ledgrab'; + (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value = 'ledgrab'; + (document.getElementById('mqtt-source-description') as HTMLInputElement).value = ''; + } + + // Tags + if (_mqttTagsInput) { _mqttTagsInput.destroy(); _mqttTagsInput = null; } + _mqttTagsInput = new TagInput(document.getElementById('mqtt-source-tags-container'), { placeholder: t('tags.placeholder') }); + _mqttTagsInput.setValue(isEdit ? (editData.tags || []) : []); + + // Show/hide test button based on edit mode + const testBtn = document.getElementById('mqtt-source-test-btn'); + if (testBtn) testBtn.style.display = isEdit ? '' : 'none'; + + // Password hint + const pwHint = document.getElementById('mqtt-source-password-hint'); + if (pwHint) pwHint.style.display = isEdit ? '' : 'none'; + + mqttSourceModal.open(); + mqttSourceModal.snapshot(); +} + +export async function closeMQTTSourceModal(): Promise { + await mqttSourceModal.close(); +} + +// ── Save ── + +export async function saveMQTTSource(): Promise { + const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value; + const name = (document.getElementById('mqtt-source-name') as HTMLInputElement).value.trim(); + const broker_host = (document.getElementById('mqtt-source-host') as HTMLInputElement).value.trim(); + const broker_port = parseInt((document.getElementById('mqtt-source-port') as HTMLInputElement).value, 10) || 1883; + const username = (document.getElementById('mqtt-source-username') as HTMLInputElement).value.trim(); + const password = (document.getElementById('mqtt-source-password') as HTMLInputElement).value; + const client_id = (document.getElementById('mqtt-source-client-id') as HTMLInputElement).value.trim() || 'ledgrab'; + const base_topic = (document.getElementById('mqtt-source-base-topic') as HTMLInputElement).value.trim() || 'ledgrab'; + const description = (document.getElementById('mqtt-source-description') as HTMLInputElement).value.trim() || null; + + if (!name) { + mqttSourceModal.showError(t('mqtt_source.error.name_required')); + return; + } + if (!id && !broker_host) { + mqttSourceModal.showError(t('mqtt_source.error.host_required')); + return; + } + + const payload: Record = { + name, broker_port, username, client_id, base_topic, description, + tags: _mqttTagsInput ? _mqttTagsInput.getValue() : [], + }; + if (broker_host) payload.broker_host = broker_host; + // Only send password if provided (edit mode may leave blank to keep) + if (password) payload.password = password; + + try { + const method = id ? 'PUT' : 'POST'; + const url = id ? `/mqtt/sources/${id}` : '/mqtt/sources'; + const resp = await fetchWithAuth(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t(id ? 'mqtt_source.updated' : 'mqtt_source.created'), 'success'); + mqttSourceModal.forceClose(); + mqttSourcesCache.invalidate(); + } catch (e: any) { + if (e.isAuth) return; + mqttSourceModal.showError(e.message); + } +} + +// ── Edit / Clone / Delete ── + +export async function editMQTTSource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); + if (!resp.ok) throw new Error(t('mqtt_source.error.load')); + const data = await resp.json(); + await showMQTTSourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function cloneMQTTSource(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`); + if (!resp.ok) throw new Error(t('mqtt_source.error.load')); + const data = await resp.json(); + delete data.id; + data.name = data.name + ' (copy)'; + await showMQTTSourceModal(data); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +export async function deleteMQTTSource(sourceId: string): Promise { + const confirmed = await showConfirm(t('mqtt_source.delete.confirm')); + if (!confirmed) return; + try { + const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}`, { method: 'DELETE' }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + showToast(t('mqtt_source.deleted'), 'success'); + mqttSourcesCache.invalidate(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Test ── + +export async function testMQTTSource(): Promise { + const id = (document.getElementById('mqtt-source-id') as HTMLInputElement).value; + if (!id) return; + + const testBtn = document.getElementById('mqtt-source-test-btn'); + if (testBtn) testBtn.classList.add('loading'); + + try { + const resp = await fetchWithAuth(`/mqtt/sources/${id}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.success) { + showToast(t('mqtt_source.test.success'), 'success'); + } else { + showToast(`${t('mqtt_source.test.failed')}: ${data.error}`, 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } finally { + if (testBtn) testBtn.classList.remove('loading'); + } +} + +async function _testMQTTSourceFromCard(sourceId: string): Promise { + try { + const resp = await fetchWithAuth(`/mqtt/sources/${sourceId}/test`, { method: 'POST' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.success) { + showToast(t('mqtt_source.test.success'), 'success'); + } else { + showToast(`${t('mqtt_source.test.failed')}: ${data.error}`, 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message, 'error'); + } +} + +// ── Card rendering ── + +export function createMQTTSourceCard(source: MQTTSource) { + let healthClass: string, healthTitle: string; + if (source.connected) { + healthClass = 'health-online'; + healthTitle = t('mqtt_source.connected'); + } else { + healthClass = 'health-offline'; + healthTitle = t('mqtt_source.disconnected'); + } + const statusDot = ``; + + return wrapCard({ + type: 'template-card', + dataAttr: 'data-id', + id: source.id, + removeOnclick: `deleteMQTTSource('${source.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
${ICON_MQTT} ${statusDot} ${escapeHtml(source.name)}
+
+
+ + ${P.wifi} ${escapeHtml(source.broker_host)}:${source.broker_port} + + + ${P.hash} ${escapeHtml(source.base_topic)} + +
+ ${renderTagChips(source.tags)} + ${source.description ? `
${escapeHtml(source.description)}
` : ''}`, + actions: ` + + + `, + }); +} + +// ── Event delegation ── + +const _mqttSourceActions: Record void> = { + test: (id) => _testMQTTSourceFromCard(id), + clone: cloneMQTTSource, + edit: editMQTTSource, +}; + +export function initMQTTSourceDelegation(container: HTMLElement): void { + container.addEventListener('click', (e: MouseEvent) => { + const btn = (e.target as HTMLElement).closest('[data-action]'); + if (!btn) return; + + const section = btn.closest('[data-card-section="mqtt-sources"]'); + if (!section) return; + const card = btn.closest('[data-id]'); + if (!card) return; + + const action = btn.dataset.action; + const id = card.getAttribute('data-id'); + if (!action || !id) return; + + const handler = _mqttSourceActions[action]; + if (handler) { + e.stopPropagation(); + handler(id); + } + }); +} + +// ── Expose to global scope for HTML template onclick handlers ── + +window.showMQTTSourceModal = showMQTTSourceModal; +window.closeMQTTSourceModal = closeMQTTSourceModal; +window.saveMQTTSource = saveMQTTSource; +window.editMQTTSource = editMQTTSource; +window.cloneMQTTSource = cloneMQTTSource; +window.deleteMQTTSource = deleteMQTTSource; +window.testMQTTSource = testMQTTSource; diff --git a/server/src/wled_controller/static/js/features/settings.ts b/server/src/wled_controller/static/js/features/settings.ts index c2c491b..40accba 100644 --- a/server/src/wled_controller/static/js/features/settings.ts +++ b/server/src/wled_controller/static/js/features/settings.ts @@ -290,7 +290,6 @@ export function openSettingsModal(): void { loadExternalUrl(); loadAutoBackupSettings(); loadBackupList(); - loadMqttSettings(); loadLogLevel(); } @@ -629,56 +628,3 @@ export async function setLogLevel(): Promise { } } -// ─── MQTT settings ──────────────────────────────────────────── - -export async function loadMqttSettings(): Promise { - try { - const resp = await fetchWithAuth('/system/mqtt/settings'); - if (!resp.ok) return; - const data = await resp.json(); - - (document.getElementById('mqtt-enabled') as HTMLInputElement).checked = data.enabled; - (document.getElementById('mqtt-host') as HTMLInputElement).value = data.broker_host; - (document.getElementById('mqtt-port') as HTMLInputElement).value = data.broker_port; - (document.getElementById('mqtt-username') as HTMLInputElement).value = data.username; - (document.getElementById('mqtt-password') as HTMLInputElement).value = ''; - (document.getElementById('mqtt-client-id') as HTMLInputElement).value = data.client_id; - (document.getElementById('mqtt-base-topic') as HTMLInputElement).value = data.base_topic; - - const hint = document.getElementById('mqtt-password-hint'); - if (hint) hint.style.display = data.password_set ? '' : 'none'; - } catch (err) { - console.error('Failed to load MQTT settings:', err); - } -} - -export async function saveMqttSettings(): Promise { - const enabled = (document.getElementById('mqtt-enabled') as HTMLInputElement).checked; - const broker_host = (document.getElementById('mqtt-host') as HTMLInputElement).value.trim(); - const broker_port = parseInt((document.getElementById('mqtt-port') as HTMLInputElement).value, 10); - const username = (document.getElementById('mqtt-username') as HTMLInputElement).value; - const password = (document.getElementById('mqtt-password') as HTMLInputElement).value; - const client_id = (document.getElementById('mqtt-client-id') as HTMLInputElement).value.trim(); - const base_topic = (document.getElementById('mqtt-base-topic') as HTMLInputElement).value.trim(); - - if (!broker_host) { - showToast(t('settings.mqtt.error_host_required'), 'error'); - return; - } - - try { - const resp = await fetchWithAuth('/system/mqtt/settings', { - method: 'PUT', - body: JSON.stringify({ enabled, broker_host, broker_port, username, password, client_id, base_topic }), - }); - if (!resp.ok) { - const err = await resp.json().catch(() => ({})); - throw new Error(err.detail || `HTTP ${resp.status}`); - } - showToast(t('settings.mqtt.saved'), 'success'); - loadMqttSettings(); - } catch (err) { - console.error('Failed to save MQTT settings:', err); - showToast(t('settings.mqtt.save_error') + ': ' + err.message, 'error'); - } -} diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 97feed2..004534c 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -24,6 +24,7 @@ import { _cachedSyncClocks, _cachedWeatherSources, _cachedHASources, + _cachedMQTTSources, mqttSourcesCache, _cachedAudioTemplates, _cachedCSPTemplates, _csptModalFilters, set_csptModalFilters, @@ -54,6 +55,7 @@ import { createValueSourceCard } from './value-sources.ts'; import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts'; import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts'; import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts'; +import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts'; import { createAssetCard, initAssetDelegation } from './assets.ts'; import { createColorStripCard } from './color-strips.ts'; import { initAudioSourceDelegation } from './audio-sources.ts'; @@ -105,6 +107,7 @@ const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }]; const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }]; const _haSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('home-assistant/sources', haSourcesCache, 'ha_source.deleted') }]; +const _mqttSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('mqtt/sources', mqttSourcesCache, 'mqtt_source.deleted') }]; const _assetDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('assets', assetsCache, 'asset.deleted') }]; const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }]; @@ -176,6 +179,7 @@ const csValueSources = new CardSection('value-sources', { titleKey: 'value_sourc const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction }); const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction }); const csHASources = new CardSection('ha-sources', { titleKey: 'ha_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showHASourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.ha_sources', bulkActions: _haSourceDeleteAction }); +const csMQTTSources = new CardSection('mqtt-sources', { titleKey: 'mqtt_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showMQTTSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.mqtt_sources', bulkActions: _mqttSourceDeleteAction }); const csAssets = new CardSection('assets', { titleKey: 'asset.group.title', gridClass: 'templates-grid', addCardOnclick: "showAssetUploadModal()", keyAttr: 'data-id', emptyKey: 'section.empty.assets', bulkActions: _assetDeleteAction }); const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction }); const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }]; @@ -291,6 +295,7 @@ export async function loadPictureSources() { syncClocksCache.fetch(), weatherSourcesCache.fetch(), haSourcesCache.fetch(), + mqttSourcesCache.fetch(), assetsCache.fetch(), audioTemplatesCache.fetch(), colorStripSourcesCache.fetch(), @@ -352,6 +357,7 @@ const _streamSectionMap = { sync: [csSyncClocks], weather: [csWeatherSources], home_assistant: [csHASources], + mqtt: [csMQTTSources], game: [csGameIntegrations], }; @@ -580,6 +586,7 @@ function renderPictureSourcesList(streams: any) { { key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length }, { key: 'weather', icon: `${P.cloudSun}`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, { key: 'home_assistant', icon: `${P.home}`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length }, + { key: 'mqtt', icon: `${P.radio}`, titleKey: 'streams.group.mqtt', count: _cachedMQTTSources.length }, { key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length }, { key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length }, ]; @@ -634,6 +641,7 @@ function renderPictureSourcesList(streams: any) { children: [ { key: 'weather', titleKey: 'streams.group.weather', icon: `${P.cloudSun}`, count: _cachedWeatherSources.length }, { key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `${P.home}`, count: _cachedHASources.length }, + { key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `${P.radio}`, count: _cachedMQTTSources.length }, { key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length }, ] }, @@ -804,6 +812,7 @@ function renderPictureSourcesList(streams: any) { const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) }))); const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) }))); const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) }))); + const mqttSourceItems = csMQTTSources.applySortOrder(_cachedMQTTSources.map(s => ({ key: s.id, html: createMQTTSourceCard(s) }))); const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) }))); const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) }))); const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) }))); @@ -826,6 +835,7 @@ function renderPictureSourcesList(streams: any) { sync: _cachedSyncClocks.length, weather: _cachedWeatherSources.length, home_assistant: _cachedHASources.length, + mqtt: _cachedMQTTSources.length, assets: _cachedAssets.length, game: _cachedGameIntegrations.length, }); @@ -846,6 +856,7 @@ function renderPictureSourcesList(streams: any) { csSyncClocks.reconcile(syncClockItems); csWeatherSources.reconcile(weatherSourceItems); csHASources.reconcile(haSourceItems); + csMQTTSources.reconcile(mqttSourceItems); csAssets.reconcile(assetItems); csGameIntegrations.reconcile(gameIntegrationItems); } else { @@ -867,6 +878,7 @@ function renderPictureSourcesList(streams: any) { else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems); else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems); + else if (tab.key === 'mqtt') panelContent = csMQTTSources.render(mqttSourceItems); else if (tab.key === 'assets') panelContent = csAssets.render(assetItems); else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); @@ -875,12 +887,13 @@ function renderPictureSourcesList(streams: any) { }).join(''); container.innerHTML = panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets, csGameIntegrations]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csMQTTSources, csAssets, csGameIntegrations]); // Event delegation for card actions (replaces inline onclick handlers) initSyncClockDelegation(container); initWeatherSourceDelegation(container); initHASourceDelegation(container); + initMQTTSourceDelegation(container); initAudioSourceDelegation(container); initAssetDelegation(container); @@ -901,6 +914,7 @@ function renderPictureSourcesList(streams: any) { 'sync-clocks': 'sync', 'weather-sources': 'weather', 'ha-sources': 'home_assistant', + 'mqtt-sources': 'mqtt', 'assets': 'assets', 'game-integrations': 'game', }); diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 89866b5..6712953 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -371,7 +371,6 @@ startTargetOverlay: (...args: any[]) => any; downloadSavedBackup: (...args: any[]) => any; deleteSavedBackup: (...args: any[]) => any; restartServer: (...args: any[]) => any; - saveMqttSettings: (...args: any[]) => any; loadApiKeysList: (...args: any[]) => any; connectLogViewer: (...args: any[]) => any; disconnectLogViewer: (...args: any[]) => any; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index 1822ad8..c24677f 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -651,6 +651,55 @@ export interface HomeAssistantSourceListResponse { count: number; } +export interface HomeAssistantConnectionStatus { + source_id: string; + name: string; + connected: boolean; + entity_count: number; +} + +export interface HomeAssistantStatusResponse { + connections: HomeAssistantConnectionStatus[]; + total_sources: number; + connected_count: number; +} + +// ── MQTT Source ──────────────────────────────────────────────── + +export interface MQTTSource { + id: string; + name: string; + broker_host: string; + broker_port: number; + username: string; + password_set: boolean; + client_id: string; + base_topic: string; + connected: boolean; + description?: string; + tags: string[]; + created_at: string; + updated_at: string; +} + +export interface MQTTSourceListResponse { + sources: MQTTSource[]; + count: number; +} + +export interface MQTTConnectionStatus { + source_id: string; + name: string; + connected: boolean; + broker: string; +} + +export interface MQTTStatusResponse { + connections: MQTTConnectionStatus[]; + total_sources: number; + connected_count: number; +} + // ── Asset ──────────────────────────────────────────────────── export interface Asset { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index c0deea7..190e8fd 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -544,6 +544,12 @@ "filters.palette_quantization.desc": "Reduce colors to a limited palette", "filters.reverse": "Reverse", "filters.reverse.desc": "Reverse the LED order in the strip", + "filters.hsl_shift": "HSL Shift", + "filters.hsl_shift.desc": "Shift hue, saturation, and lightness values", + "filters.contrast": "Contrast", + "filters.contrast.desc": "Adjust image contrast around mid-gray", + "filters.temporal_blur": "Temporal Blur", + "filters.temporal_blur.desc": "Smooth color transitions over time", "postprocessing.description_label": "Description (optional):", "postprocessing.description_placeholder": "Describe this template...", "postprocessing.created": "Template created successfully", @@ -722,6 +728,9 @@ "dashboard.section.sync_clocks": "Sync Clocks", "dashboard.targets": "Targets", "dashboard.section.performance": "System Performance", + "dashboard.section.integrations": "Integrations", + "dashboard.integrations.entities": "entities", + "dashboard.integrations.no_sources": "No integration sources configured", "dashboard.perf.cpu": "CPU", "dashboard.perf.ram": "RAM", "dashboard.perf.gpu": "GPU", @@ -1877,6 +1886,37 @@ "ha_source.deleted": "Home Assistant source deleted", "ha_source.delete.confirm": "Delete this Home Assistant connection?", "section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.", + "streams.group.mqtt": "MQTT", + "mqtt_source.group.title": "MQTT Sources", + "mqtt_source.add": "Add MQTT Source", + "mqtt_source.edit": "Edit MQTT Source", + "mqtt_source.name": "Name:", + "mqtt_source.name.placeholder": "My MQTT Broker", + "mqtt_source.name.hint": "A descriptive name for this MQTT broker connection", + "mqtt_source.broker_host": "Broker Host:", + "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", + "mqtt_source.broker_port": "Port:", + "mqtt_source.username": "Username (optional):", + "mqtt_source.password": "Password (optional):", + "mqtt_source.password.edit_hint": "Leave blank to keep the current password", + "mqtt_source.client_id": "Client ID:", + "mqtt_source.client_id.hint": "Unique MQTT client identifier. Change if running multiple instances.", + "mqtt_source.base_topic": "Base Topic:", + "mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", + "mqtt_source.description": "Description (optional):", + "mqtt_source.test": "Test Connection", + "mqtt_source.test.success": "Connected to broker", + "mqtt_source.test.failed": "Connection failed", + "mqtt_source.connected": "Connected", + "mqtt_source.disconnected": "Disconnected", + "mqtt_source.error.name_required": "Name is required", + "mqtt_source.error.host_required": "Broker host is required", + "mqtt_source.error.load": "Failed to load MQTT source", + "mqtt_source.created": "MQTT source created", + "mqtt_source.updated": "MQTT source updated", + "mqtt_source.deleted": "MQTT source deleted", + "mqtt_source.delete.confirm": "Delete this MQTT broker connection?", + "section.empty.mqtt_sources": "No MQTT sources yet. Click + to add one.", "ha_light.section.title": "Home Assistant", "ha_light.section.targets": "Light Targets", "ha_light.add": "Add HA Light Target", @@ -2145,6 +2185,7 @@ "donation.message": "LedGrab is free & open-source. If it's useful to you, consider supporting development.", "donation.support": "Support the project", "donation.view_source": "View source code", + "donation.about": "About", "donation.later": "Remind me later", "donation.dismiss": "Don't show again", "donation.about_title": "About LedGrab", diff --git a/server/src/wled_controller/storage/automation.py b/server/src/wled_controller/storage/automation.py index 92826f0..166e3e4 100644 --- a/server/src/wled_controller/storage/automation.py +++ b/server/src/wled_controller/storage/automation.py @@ -122,12 +122,14 @@ class MQTTRule(Rule): """Activate based on an MQTT topic value.""" rule_type: str = "mqtt" + mqtt_source_id: str = "" # references MQTTSource; "" = first available topic: str = "" payload: str = "" match_mode: str = "exact" # "exact" | "contains" | "regex" def to_dict(self) -> dict: d = super().to_dict() + d["mqtt_source_id"] = self.mqtt_source_id d["topic"] = self.topic d["payload"] = self.payload d["match_mode"] = self.match_mode @@ -136,6 +138,7 @@ class MQTTRule(Rule): @classmethod def from_dict(cls, data: dict) -> "MQTTRule": return cls( + mqtt_source_id=data.get("mqtt_source_id", ""), topic=data.get("topic", ""), payload=data.get("payload", ""), match_mode=data.get("match_mode", "exact"), diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index a7dc8b5..d0b1f7e 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -56,6 +56,7 @@ _ENTITY_TABLES = [ "weather_sources", "assets", "home_assistant_sources", + "mqtt_sources", "game_integrations", ] diff --git a/server/src/wled_controller/storage/mqtt_source.py b/server/src/wled_controller/storage/mqtt_source.py new file mode 100644 index 0000000..ec616f2 --- /dev/null +++ b/server/src/wled_controller/storage/mqtt_source.py @@ -0,0 +1,79 @@ +"""MQTT source data model. + +An MQTTSource represents a connection to an MQTT broker. +Referenced by MQTTLEDClient (MQTT devices), MQTTRule (automations), +and state publishing. +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional + + +def _parse_common(data: dict) -> dict: + """Extract common fields from a dict, parsing timestamps.""" + created = data.get("created_at", "") + updated = data.get("updated_at", "") + return { + "id": data["id"], + "name": data["name"], + "created_at": ( + datetime.fromisoformat(created) + if isinstance(created, str) and created + else datetime.now(timezone.utc) + ), + "updated_at": ( + datetime.fromisoformat(updated) + if isinstance(updated, str) and updated + else datetime.now(timezone.utc) + ), + "description": data.get("description"), + "tags": data.get("tags") or [], + } + + +@dataclass +class MQTTSource: + """MQTT broker connection configuration.""" + + id: str + name: str + created_at: datetime + updated_at: datetime + broker_host: str = "localhost" + broker_port: int = 1883 + username: str = "" + password: str = "" + client_id: str = "ledgrab" + base_topic: str = "ledgrab" + description: Optional[str] = None + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "broker_host": self.broker_host, + "broker_port": self.broker_port, + "username": self.username, + "password": self.password, + "client_id": self.client_id, + "base_topic": self.base_topic, + "description": self.description, + "tags": self.tags, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @staticmethod + def from_dict(data: dict) -> "MQTTSource": + common = _parse_common(data) + return MQTTSource( + **common, + broker_host=data.get("broker_host", "localhost"), + broker_port=int(data.get("broker_port", 1883)), + username=data.get("username", ""), + password=data.get("password", ""), + client_id=data.get("client_id", "ledgrab"), + base_topic=data.get("base_topic", "ledgrab"), + ) diff --git a/server/src/wled_controller/storage/mqtt_source_store.py b/server/src/wled_controller/storage/mqtt_source_store.py new file mode 100644 index 0000000..042c5cf --- /dev/null +++ b/server/src/wled_controller/storage/mqtt_source_store.py @@ -0,0 +1,105 @@ +"""MQTT source storage using SQLite.""" + +import uuid +from datetime import datetime, timezone +from typing import List, Optional + +from wled_controller.storage.base_sqlite_store import BaseSqliteStore +from wled_controller.storage.database import Database +from wled_controller.storage.mqtt_source import MQTTSource +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class MQTTSourceStore(BaseSqliteStore[MQTTSource]): + """Persistent storage for MQTT sources (broker connections).""" + + _table_name = "mqtt_sources" + _entity_name = "MQTT source" + + def __init__(self, db: Database): + super().__init__(db, MQTTSource.from_dict) + + # Backward-compatible aliases + get_all_sources = BaseSqliteStore.get_all + get_source = BaseSqliteStore.get + delete_source = BaseSqliteStore.delete + + def create_source( + self, + name: str, + broker_host: str, + broker_port: int = 1883, + username: str = "", + password: str = "", + client_id: str = "ledgrab", + base_topic: str = "ledgrab", + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> MQTTSource: + if not broker_host: + raise ValueError("broker_host is required") + + self._check_name_unique(name) + + sid = f"mqs_{uuid.uuid4().hex[:8]}" + now = datetime.now(timezone.utc) + + source = MQTTSource( + id=sid, + name=name, + created_at=now, + updated_at=now, + broker_host=broker_host, + broker_port=broker_port, + username=username, + password=password, + client_id=client_id, + base_topic=base_topic, + description=description, + tags=tags or [], + ) + + self._items[sid] = source + self._save_item(sid, source) + logger.info(f"Created MQTT source: {name} ({sid})") + return source + + def update_source( + self, + source_id: str, + name: Optional[str] = None, + broker_host: Optional[str] = None, + broker_port: Optional[int] = None, + username: Optional[str] = None, + password: Optional[str] = None, + client_id: Optional[str] = None, + base_topic: Optional[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> MQTTSource: + existing = self.get(source_id) + + if name is not None and name != existing.name: + self._check_name_unique(name) + + updated = MQTTSource( + id=existing.id, + name=name if name is not None else existing.name, + created_at=existing.created_at, + updated_at=datetime.now(timezone.utc), + broker_host=broker_host if broker_host is not None else existing.broker_host, + broker_port=broker_port if broker_port is not None else existing.broker_port, + username=username if username is not None else existing.username, + password=password if password is not None else existing.password, + client_id=client_id if client_id is not None else existing.client_id, + base_topic=base_topic if base_topic is not None else existing.base_topic, + description=description if description is not None else existing.description, + tags=tags if tags is not None else existing.tags, + ) + + self._items[source_id] = updated + self._save_item(source_id, updated) + logger.info(f"Updated MQTT source: {updated.name} ({source_id})") + return updated diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index 3443302..d3f1d0e 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -213,6 +213,7 @@ {% include 'modals/sync-clock-editor.html' %} {% include 'modals/weather-source-editor.html' %} {% include 'modals/ha-source-editor.html' %} + {% include 'modals/mqtt-source-editor.html' %} {% include 'modals/ha-light-editor.html' %} {% include 'modals/asset-upload.html' %} {% include 'modals/asset-editor.html' %} diff --git a/server/src/wled_controller/templates/modals/mqtt-source-editor.html b/server/src/wled_controller/templates/modals/mqtt-source-editor.html new file mode 100644 index 0000000..7414901 --- /dev/null +++ b/server/src/wled_controller/templates/modals/mqtt-source-editor.html @@ -0,0 +1,98 @@ + + diff --git a/server/src/wled_controller/templates/modals/settings.html b/server/src/wled_controller/templates/modals/settings.html index 4723fc2..abc9f83 100644 --- a/server/src/wled_controller/templates/modals/settings.html +++ b/server/src/wled_controller/templates/modals/settings.html @@ -10,7 +10,6 @@
- @@ -149,60 +148,6 @@
- -
-
-
-
- - -
- - -
- - -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
-