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.update import router as update_router
|
||||||
from .routes.assets import router as assets_router
|
from .routes.assets import router as assets_router
|
||||||
from .routes.home_assistant import router as home_assistant_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
|
from .routes.game_integration import router as game_integration_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -53,6 +54,7 @@ router.include_router(weather_sources_router)
|
|||||||
router.include_router(update_router)
|
router.include_router(update_router)
|
||||||
router.include_router(assets_router)
|
router.include_router(assets_router)
|
||||||
router.include_router(home_assistant_router)
|
router.include_router(home_assistant_router)
|
||||||
|
router.include_router(mqtt_router)
|
||||||
router.include_router(game_integration_router)
|
router.include_router(game_integration_router)
|
||||||
|
|
||||||
__all__ = ["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.core.home_assistant.ha_manager import HomeAssistantManager
|
||||||
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
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")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@@ -153,6 +155,14 @@ def get_game_event_bus() -> GameEventBus:
|
|||||||
return _get("game_event_bus", "Game event bus")
|
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:
|
def get_database() -> Database:
|
||||||
return _get("database", "Database")
|
return _get("database", "Database")
|
||||||
|
|
||||||
@@ -215,6 +225,8 @@ def init_dependencies(
|
|||||||
ha_manager: HomeAssistantManager | None = None,
|
ha_manager: HomeAssistantManager | None = None,
|
||||||
game_integration_store: GameIntegrationStore | None = None,
|
game_integration_store: GameIntegrationStore | None = None,
|
||||||
game_event_bus: GameEventBus | None = None,
|
game_event_bus: GameEventBus | None = None,
|
||||||
|
mqtt_store: MQTTSourceStore | None = None,
|
||||||
|
mqtt_manager: MQTTManager | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
_deps.update(
|
_deps.update(
|
||||||
@@ -246,5 +258,7 @@ def init_dependencies(
|
|||||||
"ha_manager": ha_manager,
|
"ha_manager": ha_manager,
|
||||||
"game_integration_store": game_integration_store,
|
"game_integration_store": game_integration_store,
|
||||||
"game_event_bus": game_event_bus,
|
"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",
|
state=s.state or "on",
|
||||||
),
|
),
|
||||||
"mqtt": lambda: MQTTRule(
|
"mqtt": lambda: MQTTRule(
|
||||||
|
mqtt_source_id=s.mqtt_source_id or "",
|
||||||
topic=s.topic or "",
|
topic=s.topic or "",
|
||||||
payload=s.payload or "",
|
payload=s.payload or "",
|
||||||
match_mode=s.match_mode or "exact",
|
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
|
# Display state rule fields
|
||||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
|
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
|
||||||
# MQTT rule fields
|
# 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)")
|
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)")
|
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
|
||||||
match_mode: Optional[str] = Field(
|
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,
|
target_store=None,
|
||||||
device_store=None,
|
device_store=None,
|
||||||
ha_manager=None,
|
ha_manager=None,
|
||||||
|
mqtt_manager=None,
|
||||||
):
|
):
|
||||||
self._store = automation_store
|
self._store = automation_store
|
||||||
self._manager = processor_manager
|
self._manager = processor_manager
|
||||||
self._poll_interval = poll_interval
|
self._poll_interval = poll_interval
|
||||||
self._detector = PlatformDetector()
|
self._detector = PlatformDetector()
|
||||||
self._mqtt_service = mqtt_service
|
self._mqtt_service = mqtt_service
|
||||||
|
self._mqtt_manager = mqtt_manager
|
||||||
self._scene_preset_store = scene_preset_store
|
self._scene_preset_store = scene_preset_store
|
||||||
self._target_store = target_store
|
self._target_store = target_store
|
||||||
self._device_store = device_store
|
self._device_store = device_store
|
||||||
@@ -63,11 +65,14 @@ class AutomationEngine:
|
|||||||
self._webhook_states: Dict[str, bool] = {}
|
self._webhook_states: Dict[str, bool] = {}
|
||||||
# HA source IDs currently acquired by the engine
|
# HA source IDs currently acquired by the engine
|
||||||
self._ha_acquired: Set[str] = set()
|
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:
|
async def start(self) -> None:
|
||||||
if self._task is not None:
|
if self._task is not None:
|
||||||
return
|
return
|
||||||
await self._sync_ha_runtimes()
|
await self._sync_ha_runtimes()
|
||||||
|
await self._sync_mqtt_runtimes()
|
||||||
self._task = asyncio.create_task(self._poll_loop())
|
self._task = asyncio.create_task(self._poll_loop())
|
||||||
logger.info("Automation engine started")
|
logger.info("Automation engine started")
|
||||||
|
|
||||||
@@ -89,6 +94,8 @@ class AutomationEngine:
|
|||||||
|
|
||||||
# Release all HA runtimes
|
# Release all HA runtimes
|
||||||
await self._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")
|
logger.info("Automation engine stopped")
|
||||||
|
|
||||||
@@ -136,6 +143,48 @@ class AutomationEngine:
|
|||||||
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
|
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
|
||||||
self._ha_acquired = set()
|
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:
|
async def _poll_loop(self) -> None:
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -150,6 +199,7 @@ class AutomationEngine:
|
|||||||
|
|
||||||
async def _evaluate_all(self) -> None:
|
async def _evaluate_all(self) -> None:
|
||||||
await self._sync_ha_runtimes()
|
await self._sync_ha_runtimes()
|
||||||
|
await self._sync_mqtt_runtimes()
|
||||||
async with self._eval_lock:
|
async with self._eval_lock:
|
||||||
await self._evaluate_all_locked()
|
await self._evaluate_all_locked()
|
||||||
|
|
||||||
@@ -345,9 +395,20 @@ class AutomationEngine:
|
|||||||
return display_state == rule.state
|
return display_state == rule.state
|
||||||
|
|
||||||
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
|
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
|
||||||
if self._mqtt_service is None or not self._mqtt_service.is_connected:
|
value = None
|
||||||
return False
|
# Try entity-based manager first (new model)
|
||||||
value = self._mqtt_service.get_last_value(rule.topic)
|
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:
|
if value is None:
|
||||||
return False
|
return False
|
||||||
matchers = {
|
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
|
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.game_integration.community_loader import register_community_adapters
|
||||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
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.devices.mqtt_client import set_mqtt_service
|
||||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||||
from wled_controller.core.processing.os_notification_listener import OsNotificationListener
|
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)
|
weather_manager = WeatherManager(weather_source_store)
|
||||||
ha_store = HomeAssistantStore(db)
|
ha_store = HomeAssistantStore(db)
|
||||||
ha_manager = HomeAssistantManager(ha_store)
|
ha_manager = HomeAssistantManager(ha_store)
|
||||||
|
mqtt_source_store = MQTTSourceStore(db)
|
||||||
game_integration_store = GameIntegrationStore(db)
|
game_integration_store = GameIntegrationStore(db)
|
||||||
game_event_bus = GameEventBus()
|
game_event_bus = GameEventBus()
|
||||||
register_community_adapters()
|
register_community_adapters()
|
||||||
@@ -158,11 +161,14 @@ async def lifespan(app: FastAPI):
|
|||||||
client_labels = ", ".join(config.auth.api_keys.keys())
|
client_labels = ", ".join(config.auth.api_keys.keys())
|
||||||
logger.info(f"Authorized clients: {client_labels}")
|
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)
|
mqtt_service = MQTTService(config.mqtt)
|
||||||
set_mqtt_service(mqtt_service)
|
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_engine = AutomationEngine(
|
||||||
automation_store,
|
automation_store,
|
||||||
processor_manager,
|
processor_manager,
|
||||||
@@ -171,6 +177,7 @@ async def lifespan(app: FastAPI):
|
|||||||
target_store=output_target_store,
|
target_store=output_target_store,
|
||||||
device_store=device_store,
|
device_store=device_store,
|
||||||
ha_manager=ha_manager,
|
ha_manager=ha_manager,
|
||||||
|
mqtt_manager=mqtt_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create auto-backup engine — derive paths from database location so that
|
# Create auto-backup engine — derive paths from database location so that
|
||||||
@@ -222,6 +229,8 @@ async def lifespan(app: FastAPI):
|
|||||||
ha_manager=ha_manager,
|
ha_manager=ha_manager,
|
||||||
game_integration_store=game_integration_store,
|
game_integration_store=game_integration_store,
|
||||||
game_event_bus=game_event_bus,
|
game_event_bus=game_event_bus,
|
||||||
|
mqtt_store=mqtt_source_store,
|
||||||
|
mqtt_manager=mqtt_manager,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register devices in processor manager for health monitoring
|
# Register devices in processor manager for health monitoring
|
||||||
@@ -332,7 +341,13 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping processors: {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:
|
try:
|
||||||
await mqtt_service.stop()
|
await mqtt_service.stop()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ import {
|
|||||||
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
openSettingsModal, closeSettingsModal, switchSettingsTab,
|
||||||
downloadBackup, handleRestoreFileSelected,
|
downloadBackup, handleRestoreFileSelected,
|
||||||
saveAutoBackupSettings, triggerBackupNow, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
saveAutoBackupSettings, triggerBackupNow, restoreSavedBackup, downloadSavedBackup, deleteSavedBackup,
|
||||||
restartServer, saveMqttSettings,
|
restartServer,
|
||||||
loadApiKeysList,
|
loadApiKeysList,
|
||||||
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
connectLogViewer, disconnectLogViewer, clearLogViewer, applyLogFilter,
|
||||||
openLogOverlay, closeLogOverlay,
|
openLogOverlay, closeLogOverlay,
|
||||||
@@ -562,7 +562,6 @@ Object.assign(window, {
|
|||||||
downloadSavedBackup,
|
downloadSavedBackup,
|
||||||
deleteSavedBackup,
|
deleteSavedBackup,
|
||||||
restartServer,
|
restartServer,
|
||||||
saveMqttSettings,
|
|
||||||
loadApiKeysList,
|
loadApiKeysList,
|
||||||
connectLogViewer,
|
connectLogViewer,
|
||||||
disconnectLogViewer,
|
disconnectLogViewer,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
|
|||||||
import type {
|
import type {
|
||||||
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
Device, OutputTarget, ColorStripSource, PatternTemplate,
|
||||||
ValueSource, AudioSource, PictureSource, ScenePreset,
|
ValueSource, AudioSource, PictureSource, ScenePreset,
|
||||||
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
SyncClock, WeatherSource, HomeAssistantSource, MQTTSource, Asset, Automation, Display, FilterDef, EngineInfo,
|
||||||
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
|
||||||
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
|
||||||
GameIntegration, GameAdapterInfo,
|
GameIntegration, GameAdapterInfo,
|
||||||
@@ -279,6 +279,14 @@ export const haSourcesCache = new DataCache<HomeAssistantSource[]>({
|
|||||||
});
|
});
|
||||||
haSourcesCache.subscribe(v => { _cachedHASources = v; });
|
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[]>({
|
export const assetsCache = new DataCache<Asset[]>({
|
||||||
endpoint: '/assets',
|
endpoint: '/assets',
|
||||||
extractData: json => json.assets || [],
|
extractData: json => json.assets || [],
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { startAutoRefresh, updateTabBadge } from './tabs.ts';
|
|||||||
import {
|
import {
|
||||||
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
||||||
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
|
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
|
||||||
|
ICON_PLUG, ICON_HOME, ICON_RADIO,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts';
|
||||||
import { cardColorStyle } from '../core/card-colors.ts';
|
import { cardColorStyle } from '../core/card-colors.ts';
|
||||||
import { createFpsSparkline } from '../core/chart-utils.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 DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
||||||
const MAX_FPS_SAMPLES = 120;
|
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 {
|
function renderDashboardSyncClock(clock: SyncClock): string {
|
||||||
const toggleAction = clock.is_running
|
const toggleAction = clock.is_running
|
||||||
? `dashboardPauseClock('${clock.id}')`
|
? `dashboardPauseClock('${clock.id}')`
|
||||||
@@ -379,7 +463,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Fire all requests in a single batch to avoid sequential RTTs
|
// 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[] => []),
|
outputTargetsCache.fetch().catch((): any[] => []),
|
||||||
fetchWithAuth('/automations').catch(() => null),
|
fetchWithAuth('/automations').catch(() => null),
|
||||||
devicesCache.fetch().catch((): any[] => []),
|
devicesCache.fetch().catch((): any[] => []),
|
||||||
@@ -388,6 +472,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||||
loadScenePresets(),
|
loadScenePresets(),
|
||||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
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: [] };
|
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; }
|
for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
|
||||||
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
|
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
|
||||||
const syncClocks = syncClocksData.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 allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
|
||||||
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
|
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)
|
// Build dynamic HTML (targets, automations)
|
||||||
let dynamicHtml = '';
|
let dynamicHtml = '';
|
||||||
let runningIds: any[] = [];
|
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>`;
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const enriched = targets.map(target => ({
|
const enriched = targets.map(target => ({
|
||||||
@@ -427,6 +519,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
if (structureUnchanged && !forceFullRender && running.length > 0) {
|
if (structureUnchanged && !forceFullRender && running.length > 0) {
|
||||||
_updateRunningMetrics(running);
|
_updateRunningMetrics(running);
|
||||||
_updateSyncClocksInPlace(syncClocks);
|
_updateSyncClocksInPlace(syncClocks);
|
||||||
|
_updateIntegrationsInPlace(haStatus, mqttStatus);
|
||||||
_cacheUptimeElements();
|
_cacheUptimeElements();
|
||||||
_startUptimeTimer();
|
_startUptimeTimer();
|
||||||
startPerfPolling();
|
startPerfPolling();
|
||||||
@@ -437,6 +530,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
if (running.length > 0) _updateRunningMetrics(running);
|
if (running.length > 0) _updateRunningMetrics(running);
|
||||||
_updateAutomationsInPlace(automations);
|
_updateAutomationsInPlace(automations);
|
||||||
_updateSyncClocksInPlace(syncClocks);
|
_updateSyncClocksInPlace(syncClocks);
|
||||||
|
_updateIntegrationsInPlace(haStatus, mqttStatus);
|
||||||
_cacheUptimeElements();
|
_cacheUptimeElements();
|
||||||
_startUptimeTimer();
|
_startUptimeTimer();
|
||||||
startPerfPolling();
|
startPerfPolling();
|
||||||
@@ -444,6 +538,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
|||||||
return;
|
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) {
|
if (automations.length > 0) {
|
||||||
const activeAutomations = automations.filter(a => a.is_active);
|
const activeAutomations = automations.filter(a => a.is_active);
|
||||||
const inactiveAutomations = 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:automation_state_changed', () => _debouncedDashboardReload(true));
|
||||||
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
|
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) => {
|
document.addEventListener('server:entity_changed', (e: Event) => {
|
||||||
const { entity_type } = (e as CustomEvent).detail || {};
|
const { entity_type } = (e as CustomEvent).detail || {};
|
||||||
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
|
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();
|
loadExternalUrl();
|
||||||
loadAutoBackupSettings();
|
loadAutoBackupSettings();
|
||||||
loadBackupList();
|
loadBackupList();
|
||||||
loadMqttSettings();
|
|
||||||
loadLogLevel();
|
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,
|
_cachedSyncClocks,
|
||||||
_cachedWeatherSources,
|
_cachedWeatherSources,
|
||||||
_cachedHASources,
|
_cachedHASources,
|
||||||
|
_cachedMQTTSources, mqttSourcesCache,
|
||||||
_cachedAudioTemplates,
|
_cachedAudioTemplates,
|
||||||
_cachedCSPTemplates,
|
_cachedCSPTemplates,
|
||||||
_csptModalFilters, set_csptModalFilters,
|
_csptModalFilters, set_csptModalFilters,
|
||||||
@@ -54,6 +55,7 @@ import { createValueSourceCard } from './value-sources.ts';
|
|||||||
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
|
||||||
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
|
||||||
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
import { createHASourceCard, initHASourceDelegation } from './home-assistant-sources.ts';
|
||||||
|
import { createMQTTSourceCard, initMQTTSourceDelegation } from './mqtt-sources.ts';
|
||||||
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
import { createAssetCard, initAssetDelegation } from './assets.ts';
|
||||||
import { createColorStripCard } from './color-strips.ts';
|
import { createColorStripCard } from './color-strips.ts';
|
||||||
import { initAudioSourceDelegation } from './audio-sources.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 _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 _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 _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 _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') }];
|
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 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 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 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 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 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') }];
|
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(),
|
syncClocksCache.fetch(),
|
||||||
weatherSourcesCache.fetch(),
|
weatherSourcesCache.fetch(),
|
||||||
haSourcesCache.fetch(),
|
haSourcesCache.fetch(),
|
||||||
|
mqttSourcesCache.fetch(),
|
||||||
assetsCache.fetch(),
|
assetsCache.fetch(),
|
||||||
audioTemplatesCache.fetch(),
|
audioTemplatesCache.fetch(),
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
@@ -352,6 +357,7 @@ const _streamSectionMap = {
|
|||||||
sync: [csSyncClocks],
|
sync: [csSyncClocks],
|
||||||
weather: [csWeatherSources],
|
weather: [csWeatherSources],
|
||||||
home_assistant: [csHASources],
|
home_assistant: [csHASources],
|
||||||
|
mqtt: [csMQTTSources],
|
||||||
game: [csGameIntegrations],
|
game: [csGameIntegrations],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -580,6 +586,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
{ 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: '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: '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: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
|
||||||
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
|
||||||
];
|
];
|
||||||
@@ -634,6 +641,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
children: [
|
children: [
|
||||||
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
|
{ 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: '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 },
|
{ 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 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 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 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 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 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) })));
|
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
|
||||||
@@ -826,6 +835,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
sync: _cachedSyncClocks.length,
|
sync: _cachedSyncClocks.length,
|
||||||
weather: _cachedWeatherSources.length,
|
weather: _cachedWeatherSources.length,
|
||||||
home_assistant: _cachedHASources.length,
|
home_assistant: _cachedHASources.length,
|
||||||
|
mqtt: _cachedMQTTSources.length,
|
||||||
assets: _cachedAssets.length,
|
assets: _cachedAssets.length,
|
||||||
game: _cachedGameIntegrations.length,
|
game: _cachedGameIntegrations.length,
|
||||||
});
|
});
|
||||||
@@ -846,6 +856,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
csSyncClocks.reconcile(syncClockItems);
|
csSyncClocks.reconcile(syncClockItems);
|
||||||
csWeatherSources.reconcile(weatherSourceItems);
|
csWeatherSources.reconcile(weatherSourceItems);
|
||||||
csHASources.reconcile(haSourceItems);
|
csHASources.reconcile(haSourceItems);
|
||||||
|
csMQTTSources.reconcile(mqttSourceItems);
|
||||||
csAssets.reconcile(assetItems);
|
csAssets.reconcile(assetItems);
|
||||||
csGameIntegrations.reconcile(gameIntegrationItems);
|
csGameIntegrations.reconcile(gameIntegrationItems);
|
||||||
} else {
|
} else {
|
||||||
@@ -867,6 +878,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||||
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
|
||||||
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
|
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 === 'assets') panelContent = csAssets.render(assetItems);
|
||||||
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
|
||||||
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
|
||||||
@@ -875,12 +887,13 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = panels;
|
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)
|
// Event delegation for card actions (replaces inline onclick handlers)
|
||||||
initSyncClockDelegation(container);
|
initSyncClockDelegation(container);
|
||||||
initWeatherSourceDelegation(container);
|
initWeatherSourceDelegation(container);
|
||||||
initHASourceDelegation(container);
|
initHASourceDelegation(container);
|
||||||
|
initMQTTSourceDelegation(container);
|
||||||
initAudioSourceDelegation(container);
|
initAudioSourceDelegation(container);
|
||||||
initAssetDelegation(container);
|
initAssetDelegation(container);
|
||||||
|
|
||||||
@@ -901,6 +914,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
'sync-clocks': 'sync',
|
'sync-clocks': 'sync',
|
||||||
'weather-sources': 'weather',
|
'weather-sources': 'weather',
|
||||||
'ha-sources': 'home_assistant',
|
'ha-sources': 'home_assistant',
|
||||||
|
'mqtt-sources': 'mqtt',
|
||||||
'assets': 'assets',
|
'assets': 'assets',
|
||||||
'game-integrations': 'game',
|
'game-integrations': 'game',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -371,7 +371,6 @@ startTargetOverlay: (...args: any[]) => any;
|
|||||||
downloadSavedBackup: (...args: any[]) => any;
|
downloadSavedBackup: (...args: any[]) => any;
|
||||||
deleteSavedBackup: (...args: any[]) => any;
|
deleteSavedBackup: (...args: any[]) => any;
|
||||||
restartServer: (...args: any[]) => any;
|
restartServer: (...args: any[]) => any;
|
||||||
saveMqttSettings: (...args: any[]) => any;
|
|
||||||
loadApiKeysList: (...args: any[]) => any;
|
loadApiKeysList: (...args: any[]) => any;
|
||||||
connectLogViewer: (...args: any[]) => any;
|
connectLogViewer: (...args: any[]) => any;
|
||||||
disconnectLogViewer: (...args: any[]) => any;
|
disconnectLogViewer: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -651,6 +651,55 @@ export interface HomeAssistantSourceListResponse {
|
|||||||
count: number;
|
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 ────────────────────────────────────────────────────
|
// ── Asset ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
|
|||||||
@@ -544,6 +544,12 @@
|
|||||||
"filters.palette_quantization.desc": "Reduce colors to a limited palette",
|
"filters.palette_quantization.desc": "Reduce colors to a limited palette",
|
||||||
"filters.reverse": "Reverse",
|
"filters.reverse": "Reverse",
|
||||||
"filters.reverse.desc": "Reverse the LED order in the strip",
|
"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_label": "Description (optional):",
|
||||||
"postprocessing.description_placeholder": "Describe this template...",
|
"postprocessing.description_placeholder": "Describe this template...",
|
||||||
"postprocessing.created": "Template created successfully",
|
"postprocessing.created": "Template created successfully",
|
||||||
@@ -722,6 +728,9 @@
|
|||||||
"dashboard.section.sync_clocks": "Sync Clocks",
|
"dashboard.section.sync_clocks": "Sync Clocks",
|
||||||
"dashboard.targets": "Targets",
|
"dashboard.targets": "Targets",
|
||||||
"dashboard.section.performance": "System Performance",
|
"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.cpu": "CPU",
|
||||||
"dashboard.perf.ram": "RAM",
|
"dashboard.perf.ram": "RAM",
|
||||||
"dashboard.perf.gpu": "GPU",
|
"dashboard.perf.gpu": "GPU",
|
||||||
@@ -1877,6 +1886,37 @@
|
|||||||
"ha_source.deleted": "Home Assistant source deleted",
|
"ha_source.deleted": "Home Assistant source deleted",
|
||||||
"ha_source.delete.confirm": "Delete this Home Assistant connection?",
|
"ha_source.delete.confirm": "Delete this Home Assistant connection?",
|
||||||
"section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.",
|
"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.title": "Home Assistant",
|
||||||
"ha_light.section.targets": "Light Targets",
|
"ha_light.section.targets": "Light Targets",
|
||||||
"ha_light.add": "Add HA Light Target",
|
"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.message": "LedGrab is free & open-source. If it's useful to you, consider supporting development.",
|
||||||
"donation.support": "Support the project",
|
"donation.support": "Support the project",
|
||||||
"donation.view_source": "View source code",
|
"donation.view_source": "View source code",
|
||||||
|
"donation.about": "About",
|
||||||
"donation.later": "Remind me later",
|
"donation.later": "Remind me later",
|
||||||
"donation.dismiss": "Don't show again",
|
"donation.dismiss": "Don't show again",
|
||||||
"donation.about_title": "About LedGrab",
|
"donation.about_title": "About LedGrab",
|
||||||
|
|||||||
@@ -122,12 +122,14 @@ class MQTTRule(Rule):
|
|||||||
"""Activate based on an MQTT topic value."""
|
"""Activate based on an MQTT topic value."""
|
||||||
|
|
||||||
rule_type: str = "mqtt"
|
rule_type: str = "mqtt"
|
||||||
|
mqtt_source_id: str = "" # references MQTTSource; "" = first available
|
||||||
topic: str = ""
|
topic: str = ""
|
||||||
payload: str = ""
|
payload: str = ""
|
||||||
match_mode: str = "exact" # "exact" | "contains" | "regex"
|
match_mode: str = "exact" # "exact" | "contains" | "regex"
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
|
d["mqtt_source_id"] = self.mqtt_source_id
|
||||||
d["topic"] = self.topic
|
d["topic"] = self.topic
|
||||||
d["payload"] = self.payload
|
d["payload"] = self.payload
|
||||||
d["match_mode"] = self.match_mode
|
d["match_mode"] = self.match_mode
|
||||||
@@ -136,6 +138,7 @@ class MQTTRule(Rule):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "MQTTRule":
|
def from_dict(cls, data: dict) -> "MQTTRule":
|
||||||
return cls(
|
return cls(
|
||||||
|
mqtt_source_id=data.get("mqtt_source_id", ""),
|
||||||
topic=data.get("topic", ""),
|
topic=data.get("topic", ""),
|
||||||
payload=data.get("payload", ""),
|
payload=data.get("payload", ""),
|
||||||
match_mode=data.get("match_mode", "exact"),
|
match_mode=data.get("match_mode", "exact"),
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
|
|||||||
"weather_sources",
|
"weather_sources",
|
||||||
"assets",
|
"assets",
|
||||||
"home_assistant_sources",
|
"home_assistant_sources",
|
||||||
|
"mqtt_sources",
|
||||||
"game_integrations",
|
"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/sync-clock-editor.html' %}
|
||||||
{% include 'modals/weather-source-editor.html' %}
|
{% include 'modals/weather-source-editor.html' %}
|
||||||
{% include 'modals/ha-source-editor.html' %}
|
{% include 'modals/ha-source-editor.html' %}
|
||||||
|
{% include 'modals/mqtt-source-editor.html' %}
|
||||||
{% include 'modals/ha-light-editor.html' %}
|
{% include 'modals/ha-light-editor.html' %}
|
||||||
{% include 'modals/asset-upload.html' %}
|
{% include 'modals/asset-upload.html' %}
|
||||||
{% include 'modals/asset-editor.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">
|
<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 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="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="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="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>
|
<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>
|
||||||
</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 ═══ -->
|
<!-- ═══ Appearance tab ═══ -->
|
||||||
<div id="settings-panel-appearance" class="settings-panel">
|
<div id="settings-panel-appearance" class="settings-panel">
|
||||||
<!-- Rendered dynamically by renderAppearanceTab() -->
|
<!-- Rendered dynamically by renderAppearanceTab() -->
|
||||||
|
|||||||
Reference in New Issue
Block a user