feat: refactor MQTT from global config to multi-instance entity model
Lint & Test / test (push) Successful in 1m32s
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:
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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">✕</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">✕</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">✓</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() -->
|
||||
|
||||
Reference in New Issue
Block a user