feat: refactor MQTT from global config to multi-instance entity model
Lint & Test / test (push) Successful in 1m32s

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
This commit is contained in:
2026-03-31 18:02:19 +03:00
parent e7c9a568dc
commit c59107c7c7
26 changed files with 1636 additions and 124 deletions
@@ -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"]
@@ -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,
}
)
@@ -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",
@@ -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,
)
@@ -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(
@@ -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
@@ -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 = {
@@ -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")
@@ -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)
+18 -3
View File
@@ -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:
+1 -2
View File
@@ -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,
@@ -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<HomeAssistantSource[]>({
});
haSourcesCache.subscribe(v => { _cachedHASources = v; });
export let _cachedMQTTSources: MQTTSource[] = [];
export const mqttSourcesCache = new DataCache<MQTTSource[]>({
endpoint: '/mqtt/sources',
extractData: json => json.sources || [],
});
mqttSourcesCache.subscribe(v => { _cachedMQTTSources = v; });
export const assetsCache = new DataCache<Asset[]>({
endpoint: '/assets',
extractData: json => json.assets || [],
@@ -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 = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected
? `${conn.entity_count} ${t('dashboard.integrations.entities')}`
: t('ha_source.disconnected');
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','home_assistant','ha-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_HOME}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
</div>
</div>
</div>`;
}
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 = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
const subtitle = conn.connected ? escapeHtml(conn.broker) : t('mqtt_source.disconnected');
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-integration-id="${conn.source_id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','mqtt','mqtt-sources','data-id','${conn.source_id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_RADIO}</span>
<div>
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(conn.name)}</span>${statusDot}</div>
<div class="dashboard-target-subtitle">${escapeHtml(subtitle)}</div>
</div>
</div>
</div>`;
}
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<v
try {
// Fire all requests in a single batch to avoid sequential RTTs
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp, haStatusResp, mqttStatusResp] = await Promise.all([
outputTargetsCache.fetch().catch((): any[] => []),
fetchWithAuth('/automations').catch(() => null),
devicesCache.fetch().catch((): any[] => []),
@@ -388,6 +472,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
fetchWithAuth('/output-targets/batch/metrics').catch(() => 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<v
for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
const syncClocks = syncClocksData.clocks || [];
const haStatus: HomeAssistantStatusResponse = haStatusResp && haStatusResp.ok
? await haStatusResp.json()
: { connections: [], total_sources: 0, connected_count: 0 };
const mqttStatus: MQTTStatusResponse = mqttStatusResp && mqttStatusResp.ok
? await mqttStatusResp.json()
: { connections: [], total_sources: 0, connected_count: 0 };
const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
@@ -405,7 +497,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
// Build dynamic HTML (targets, automations)
let dynamicHtml = '';
let runningIds: any[] = [];
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0 && haStatus.total_sources === 0 && mqttStatus.total_sources === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else {
const enriched = targets.map(target => ({
@@ -427,6 +519,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks);
_updateIntegrationsInPlace(haStatus, mqttStatus);
_cacheUptimeElements();
_startUptimeTimer();
startPerfPolling();
@@ -437,6 +530,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
if (running.length > 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<v
return;
}
// Integrations section (HA + MQTT sources)
const totalIntSources = haStatus.total_sources + mqttStatus.total_sources;
const totalIntConnected = haStatus.connected_count + mqttStatus.connected_count;
if (totalIntSources > 0) {
const haCards = haStatus.connections.map(c => _renderIntegrationCard(c)).join('');
const mqttCards = mqttStatus.connections.map(c => _renderMQTTIntegrationCard(c)).join('');
const intGrid = `<div class="dashboard-integrations-grid">${haCards}${mqttCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('integrations', t('dashboard.section.integrations'), `${totalIntConnected}/${totalIntSources}`)}
${_sectionContent('integrations', intGrid)}
</div>`;
}
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);
@@ -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 = `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`;
// ── 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<void> {
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<void> {
await mqttSourceModal.close();
}
// ── Save ──
export async function saveMQTTSource(): Promise<void> {
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<string, any> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 = `<span class="health-dot ${healthClass}" title="${escapeHtml(healthTitle)}" role="status"></span>`;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteMQTTSource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_MQTT} ${statusDot} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.wifi}</svg> ${escapeHtml(source.broker_host)}:${source.broker_port}
</span>
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.hash}</svg> ${escapeHtml(source.base_topic)}
</span>
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('mqtt_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _mqttSourceActions: Record<string, (id: string) => 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<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="mqtt-sources"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[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;
@@ -290,7 +290,6 @@ export function openSettingsModal(): void {
loadExternalUrl();
loadAutoBackupSettings();
loadBackupList();
loadMqttSettings();
loadLogLevel();
}
@@ -629,56 +628,3 @@ export async function setLogLevel(): Promise<void> {
}
}
// ─── MQTT settings ────────────────────────────────────────────
export async function loadMqttSettings(): Promise<void> {
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<void> {
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');
}
}
@@ -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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
{ key: 'mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, 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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
{ key: 'mqtt', titleKey: 'streams.group.mqtt', icon: `<svg class="icon" viewBox="0 0 24 24">${P.radio}</svg>`, 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',
});
-1
View File
@@ -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;
@@ -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 {
@@ -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",
@@ -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"),
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
"weather_sources",
"assets",
"home_assistant_sources",
"mqtt_sources",
"game_integrations",
]
@@ -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"),
)
@@ -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
@@ -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' %}
@@ -0,0 +1,98 @@
<!-- MQTT Source Editor Modal -->
<div id="mqtt-source-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="mqtt-source-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="mqtt-source-modal-title" data-i18n="mqtt_source.add">Add MQTT Source</h2>
<button class="modal-close-btn" onclick="closeMQTTSourceModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="mqtt-source-form" onsubmit="return false;">
<input type="hidden" id="mqtt-source-id">
<div id="mqtt-source-error" class="error-message" style="display: none;"></div>
<!-- Name -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-name" data-i18n="mqtt_source.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.name.hint">A descriptive name for this MQTT broker connection</small>
<input type="text" id="mqtt-source-name" data-i18n-placeholder="mqtt_source.name.placeholder" placeholder="My MQTT Broker" required>
<div id="mqtt-source-tags-container"></div>
</div>
<!-- Broker Host -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-host" data-i18n="mqtt_source.broker_host">Broker Host:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.broker_host.hint">MQTT broker hostname or IP address, e.g. 192.168.1.100</small>
<input type="text" id="mqtt-source-host" placeholder="192.168.1.100" required>
</div>
<!-- Broker Port -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-port" data-i18n="mqtt_source.broker_port">Port:</label>
</div>
<input type="number" id="mqtt-source-port" value="1883" min="1" max="65535">
</div>
<!-- Username -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-username" data-i18n="mqtt_source.username">Username (optional):</label>
</div>
<input type="text" id="mqtt-source-username" placeholder="">
</div>
<!-- Password -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-password" data-i18n="mqtt_source.password">Password (optional):</label>
</div>
<small id="mqtt-source-password-hint" class="input-hint" style="display:none" data-i18n="mqtt_source.password.edit_hint">Leave blank to keep the current password</small>
<input type="password" id="mqtt-source-password" placeholder="">
</div>
<!-- Client ID -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-client-id" data-i18n="mqtt_source.client_id">Client ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.client_id.hint">Unique MQTT client identifier. Change if running multiple instances.</small>
<input type="text" id="mqtt-source-client-id" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Base Topic -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-base-topic" data-i18n="mqtt_source.base_topic">Base Topic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="mqtt_source.base_topic.hint">Prefix for status and state topics, e.g. ledgrab/status</small>
<input type="text" id="mqtt-source-base-topic" value="ledgrab" placeholder="ledgrab">
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="mqtt-source-description" data-i18n="mqtt_source.description">Description (optional):</label>
</div>
<input type="text" id="mqtt-source-description" placeholder="">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeMQTTSourceModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-secondary" id="mqtt-source-test-btn" onclick="testMQTTSource()" title="Test" data-i18n-title="mqtt_source.test" style="display:none">
<svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg>
</button>
<button class="btn btn-icon btn-primary" onclick="saveMQTTSource()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
@@ -10,7 +10,6 @@
<div class="settings-tab-bar">
<button class="settings-tab-btn active" data-settings-tab="general" onclick="switchSettingsTab('general')" data-i18n="settings.tab.general">General</button>
<button class="settings-tab-btn" data-settings-tab="backup" onclick="switchSettingsTab('backup')" data-i18n="settings.tab.backup">Backup</button>
<button class="settings-tab-btn" data-settings-tab="mqtt" onclick="switchSettingsTab('mqtt')" data-i18n="settings.tab.mqtt">MQTT</button>
<button class="settings-tab-btn" data-settings-tab="appearance" onclick="switchSettingsTab('appearance')" data-i18n="settings.tab.appearance">Appearance</button>
<button class="settings-tab-btn" data-settings-tab="updates" onclick="switchSettingsTab('updates')" data-i18n="settings.tab.updates">Updates</button>
<button class="settings-tab-btn" data-settings-tab="about" onclick="switchSettingsTab('about')" data-i18n="settings.tab.about">About</button>
@@ -149,60 +148,6 @@
</div>
</div>
<!-- ═══ MQTT tab ═══ -->
<div id="settings-panel-mqtt" class="settings-panel">
<form onsubmit="saveMqttSettings(); return false;" autocomplete="off">
<div class="form-group">
<div class="label-row">
<label data-i18n="settings.mqtt.label">MQTT</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="settings.mqtt.hint">Configure MQTT broker connection for automation conditions and triggers.</small>
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
<input type="checkbox" id="mqtt-enabled">
<label for="mqtt-enabled" style="margin:0" data-i18n="settings.mqtt.enabled">Enable MQTT</label>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-host" style="font-size:0.85rem" data-i18n="settings.mqtt.host_label">Broker Host</label>
<input type="text" id="mqtt-host" placeholder="localhost" style="width:100%">
</div>
<div style="width:90px">
<label for="mqtt-port" style="font-size:0.85rem" data-i18n="settings.mqtt.port_label">Port</label>
<input type="number" id="mqtt-port" min="1" max="65535" value="1883" style="width:100%">
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.5rem;">
<div style="flex:1">
<label for="mqtt-username" style="font-size:0.85rem" data-i18n="settings.mqtt.username_label">Username</label>
<input type="text" id="mqtt-username" placeholder="" autocomplete="off" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-password" style="font-size:0.85rem" data-i18n="settings.mqtt.password_label">Password</label>
<input type="password" id="mqtt-password" placeholder="" autocomplete="new-password" style="width:100%">
<small id="mqtt-password-hint" style="display:none;font-size:0.75rem;color:var(--text-muted)" data-i18n="settings.mqtt.password_set_hint">Password is set — leave blank to keep</small>
</div>
</div>
<div style="display:flex; gap:0.5rem; margin-bottom:0.75rem;">
<div style="flex:1">
<label for="mqtt-client-id" style="font-size:0.85rem" data-i18n="settings.mqtt.client_id_label">Client ID</label>
<input type="text" id="mqtt-client-id" placeholder="ledgrab" style="width:100%">
</div>
<div style="flex:1">
<label for="mqtt-base-topic" style="font-size:0.85rem" data-i18n="settings.mqtt.base_topic_label">Base Topic</label>
<input type="text" id="mqtt-base-topic" placeholder="ledgrab" style="width:100%">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%" data-i18n="settings.mqtt.save">Save MQTT Settings</button>
</div>
</form>
</div>
<!-- ═══ Appearance tab ═══ -->
<div id="settings-panel-appearance" class="settings-panel">
<!-- Rendered dynamically by renderAppearanceTab() -->