Przeglądaj źródła

add UI for managing MQTT

tuanchris 2 miesięcy temu
rodzic
commit
781c0d8cb6
7 zmienionych plików z 750 dodań i 29 usunięć
  1. 145 2
      main.py
  2. 30 1
      modules/core/state.py
  3. 7 1
      modules/mqtt/base.py
  4. 57 24
      modules/mqtt/handler.py
  5. 6 1
      modules/mqtt/mock.py
  6. 306 0
      static/js/settings.js
  7. 199 0
      templates/settings.html

+ 145 - 2
main.py

@@ -1514,13 +1514,156 @@ async def set_app_name(request: dict):
     app_name = request.get("app_name", "").strip()
     if not app_name:
         app_name = "Dune Weaver"  # Reset to default if empty
-    
+
     state.app_name = app_name
     state.save()
-    
+
     logger.info(f"Application name updated to: {app_name}")
     return {"success": True, "app_name": app_name}
 
+@app.get("/api/mqtt-config")
+async def get_mqtt_config():
+    """Get current MQTT configuration.
+
+    Note: Password is not returned for security reasons.
+    """
+    from modules.mqtt import get_mqtt_handler
+    handler = get_mqtt_handler()
+
+    return {
+        "enabled": state.mqtt_enabled,
+        "broker": state.mqtt_broker,
+        "port": state.mqtt_port,
+        "username": state.mqtt_username,
+        # Password is intentionally omitted for security
+        "has_password": bool(state.mqtt_password),
+        "client_id": state.mqtt_client_id,
+        "discovery_prefix": state.mqtt_discovery_prefix,
+        "device_id": state.mqtt_device_id,
+        "device_name": state.mqtt_device_name,
+        "connected": handler.is_connected if hasattr(handler, 'is_connected') else False,
+        "is_mock": handler.__class__.__name__ == 'MockMQTTHandler'
+    }
+
+@app.post("/api/mqtt-config")
+async def set_mqtt_config(request: dict):
+    """Update MQTT configuration. Requires restart to take effect."""
+    try:
+        # Update state with new values
+        state.mqtt_enabled = request.get("enabled", False)
+        state.mqtt_broker = request.get("broker", "").strip()
+        state.mqtt_port = int(request.get("port", 1883))
+        state.mqtt_username = request.get("username", "").strip()
+        state.mqtt_password = request.get("password", "").strip()
+        state.mqtt_client_id = request.get("client_id", "dune_weaver").strip()
+        state.mqtt_discovery_prefix = request.get("discovery_prefix", "homeassistant").strip()
+        state.mqtt_device_id = request.get("device_id", "dune_weaver").strip()
+        state.mqtt_device_name = request.get("device_name", "Dune Weaver").strip()
+
+        # Validate required fields when enabled
+        if state.mqtt_enabled and not state.mqtt_broker:
+            return JSONResponse(
+                content={"success": False, "message": "Broker address is required when MQTT is enabled"},
+                status_code=400
+            )
+
+        state.save()
+        logger.info(f"MQTT configuration updated. Enabled: {state.mqtt_enabled}, Broker: {state.mqtt_broker}")
+
+        return {
+            "success": True,
+            "message": "MQTT configuration saved. Restart the application for changes to take effect.",
+            "requires_restart": True
+        }
+    except ValueError as e:
+        return JSONResponse(
+            content={"success": False, "message": f"Invalid value: {str(e)}"},
+            status_code=400
+        )
+    except Exception as e:
+        logger.error(f"Failed to update MQTT config: {str(e)}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
+@app.post("/api/mqtt-test")
+async def test_mqtt_connection(request: dict):
+    """Test MQTT connection with provided settings."""
+    import paho.mqtt.client as mqtt_client
+
+    broker = request.get("broker", "").strip()
+    port = int(request.get("port", 1883))
+    username = request.get("username", "").strip()
+    password = request.get("password", "").strip()
+    client_id = request.get("client_id", "dune_weaver_test").strip()
+
+    if not broker:
+        return JSONResponse(
+            content={"success": False, "message": "Broker address is required"},
+            status_code=400
+        )
+
+    try:
+        # Create a test client
+        client = mqtt_client.Client(client_id=client_id + "_test")
+
+        if username:
+            client.username_pw_set(username, password)
+
+        # Connection result
+        connection_result = {"connected": False, "error": None}
+
+        def on_connect(client, userdata, flags, rc):
+            if rc == 0:
+                connection_result["connected"] = True
+            else:
+                error_messages = {
+                    1: "Incorrect protocol version",
+                    2: "Invalid client identifier",
+                    3: "Server unavailable",
+                    4: "Bad username or password",
+                    5: "Not authorized"
+                }
+                connection_result["error"] = error_messages.get(rc, f"Connection failed with code {rc}")
+
+        client.on_connect = on_connect
+
+        # Try to connect with timeout
+        client.connect_async(broker, port, keepalive=10)
+        client.loop_start()
+
+        # Wait for connection result (max 5 seconds)
+        import time
+        start_time = time.time()
+        while time.time() - start_time < 5:
+            if connection_result["connected"] or connection_result["error"]:
+                break
+            await asyncio.sleep(0.1)
+
+        client.loop_stop()
+        client.disconnect()
+
+        if connection_result["connected"]:
+            return {"success": True, "message": "Successfully connected to MQTT broker"}
+        elif connection_result["error"]:
+            return JSONResponse(
+                content={"success": False, "message": connection_result["error"]},
+                status_code=400
+            )
+        else:
+            return JSONResponse(
+                content={"success": False, "message": "Connection timed out. Check broker address and port."},
+                status_code=400
+            )
+
+    except Exception as e:
+        logger.error(f"MQTT test connection failed: {str(e)}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
 @app.post("/preview_thr_batch")
 async def preview_thr_batch(request: dict):
     start = time.time()

+ 30 - 1
modules/core/state.py

@@ -96,7 +96,18 @@ class AppState:
         self.scheduled_pause_enabled = False
         self.scheduled_pause_time_slots = []  # List of time slot dictionaries
         self.scheduled_pause_control_wled = False  # Turn off WLED during pause periods
-        
+
+        # MQTT settings (UI-configurable, overrides .env if set)
+        self.mqtt_enabled = False  # Master enable/disable for MQTT
+        self.mqtt_broker = ""  # MQTT broker IP/hostname
+        self.mqtt_port = 1883  # MQTT broker port
+        self.mqtt_username = ""  # MQTT authentication username
+        self.mqtt_password = ""  # MQTT authentication password
+        self.mqtt_client_id = "dune_weaver"  # MQTT client ID
+        self.mqtt_discovery_prefix = "homeassistant"  # Home Assistant discovery prefix
+        self.mqtt_device_id = "dune_weaver"  # Device ID for Home Assistant
+        self.mqtt_device_name = "Dune Weaver"  # Device display name
+
         self.load()
 
     @property
@@ -244,6 +255,15 @@ class AppState:
             "scheduled_pause_enabled": self.scheduled_pause_enabled,
             "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
+            "mqtt_enabled": self.mqtt_enabled,
+            "mqtt_broker": self.mqtt_broker,
+            "mqtt_port": self.mqtt_port,
+            "mqtt_username": self.mqtt_username,
+            "mqtt_password": self.mqtt_password,
+            "mqtt_client_id": self.mqtt_client_id,
+            "mqtt_discovery_prefix": self.mqtt_discovery_prefix,
+            "mqtt_device_id": self.mqtt_device_id,
+            "mqtt_device_name": self.mqtt_device_name,
         }
 
     def from_dict(self, data):
@@ -314,6 +334,15 @@ class AppState:
         self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
         self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
+        self.mqtt_enabled = data.get("mqtt_enabled", False)
+        self.mqtt_broker = data.get("mqtt_broker", "")
+        self.mqtt_port = data.get("mqtt_port", 1883)
+        self.mqtt_username = data.get("mqtt_username", "")
+        self.mqtt_password = data.get("mqtt_password", "")
+        self.mqtt_client_id = data.get("mqtt_client_id", "dune_weaver")
+        self.mqtt_discovery_prefix = data.get("mqtt_discovery_prefix", "homeassistant")
+        self.mqtt_device_id = data.get("mqtt_device_id", "dune_weaver")
+        self.mqtt_device_name = data.get("mqtt_device_name", "Dune Weaver")
 
     def save(self):
         """Save the current state to a JSON file."""

+ 7 - 1
modules/mqtt/base.py

@@ -36,4 +36,10 @@ class BaseMQTTHandler(ABC):
     @abstractmethod
     def is_enabled(self) -> bool:
         """Return whether MQTT functionality is enabled."""
-        pass 
+        pass
+
+    @property
+    @abstractmethod
+    def is_connected(self) -> bool:
+        """Return whether MQTT client is connected to the broker."""
+        pass

+ 57 - 24
modules/mqtt/handler.py

@@ -18,14 +18,15 @@ logger = logging.getLogger(__name__)
 
 class MQTTHandler(BaseMQTTHandler):
     """Real implementation of MQTT handler."""
-    
+
     def __init__(self, callback_registry: Dict[str, Callable]):
-        # MQTT Configuration from environment variables
-        self.broker = os.getenv('MQTT_BROKER')
-        self.port = int(os.getenv('MQTT_PORT', '1883'))
-        self.username = os.getenv('MQTT_USERNAME')
-        self.password = os.getenv('MQTT_PASSWORD')
-        self.client_id = os.getenv('MQTT_CLIENT_ID', 'dune_weaver')
+        # MQTT Configuration - prioritize state config over environment variables
+        # This allows UI configuration to override .env settings
+        self.broker = state.mqtt_broker if state.mqtt_broker else os.getenv('MQTT_BROKER')
+        self.port = state.mqtt_port if state.mqtt_port else int(os.getenv('MQTT_PORT', '1883'))
+        self.username = state.mqtt_username if state.mqtt_username else os.getenv('MQTT_USERNAME')
+        self.password = state.mqtt_password if state.mqtt_password else os.getenv('MQTT_PASSWORD')
+        self.client_id = state.mqtt_client_id if state.mqtt_client_id else os.getenv('MQTT_CLIENT_ID', 'dune_weaver')
         self.status_topic = os.getenv('MQTT_STATUS_TOPIC', 'dune_weaver/status')
         self.command_topic = os.getenv('MQTT_COMMAND_TOPIC', 'dune_weaver/command')
         self.status_interval = int(os.getenv('MQTT_STATUS_INTERVAL', '30'))
@@ -37,10 +38,10 @@ class MQTTHandler(BaseMQTTHandler):
         self.running = False
         self.status_thread = None
 
-        # Home Assistant MQTT Discovery settings
-        self.discovery_prefix = os.getenv('MQTT_DISCOVERY_PREFIX', 'homeassistant')
-        self.device_name = os.getenv('HA_DEVICE_NAME', 'Dune Weaver')
-        self.device_id = os.getenv('HA_DEVICE_ID', 'dune_weaver')
+        # Home Assistant MQTT Discovery settings - prioritize state config
+        self.discovery_prefix = state.mqtt_discovery_prefix if state.mqtt_discovery_prefix else os.getenv('MQTT_DISCOVERY_PREFIX', 'homeassistant')
+        self.device_name = state.mqtt_device_name if state.mqtt_device_name else os.getenv('HA_DEVICE_NAME', 'Dune Weaver')
+        self.device_id = state.mqtt_device_id if state.mqtt_device_id else os.getenv('HA_DEVICE_ID', 'dune_weaver')
         
         # Additional topics for state
         self.running_state_topic = f"{self.device_id}/state/running"
@@ -66,10 +67,14 @@ class MQTTHandler(BaseMQTTHandler):
         self.patterns = []
         self.playlists = []
 
+        # Track connection state
+        self._connected = False
+
         # Initialize MQTT client if broker is configured
         if self.broker:
             self.client = mqtt.Client(client_id=self.client_id)
             self.client.on_connect = self.on_connect
+            self.client.on_disconnect = self.on_disconnect
             self.client.on_message = self.on_message
 
             if self.username and self.password:
@@ -525,6 +530,7 @@ class MQTTHandler(BaseMQTTHandler):
     def on_connect(self, client, userdata, flags, rc):
         """Callback when connected to MQTT broker."""
         if rc == 0:
+            self._connected = True
             logger.info("MQTT Connection Accepted.")
             # Subscribe to command topics
             client.subscribe([
@@ -547,18 +553,25 @@ class MQTTHandler(BaseMQTTHandler):
             ])
             # Publish discovery configurations
             self.setup_ha_discovery()
-        elif rc == 1:
-            logger.error("MQTT Connection Refused. Protocol level not supported.")
-        elif rc == 2:
-            logger.error("MQTT Connection Refused. The client-identifier is not allowed by the server.")
-        elif rc == 3:
-            logger.error("MQTT Connection Refused. The MQTT service is not available.")
-        elif rc == 4:
-            logger.error("MQTT Connection Refused. The data in the username or password is malformed.")
-        elif rc == 5:
-            logger.error("MQTT Connection Refused. The client is not authorized to connect.")
         else:
-            logger.error(f"MQTT Connection Refused. Unknown error code: {rc}")
+            self._connected = False
+            error_messages = {
+                1: "Protocol level not supported",
+                2: "The client-identifier is not allowed by the server",
+                3: "The MQTT service is not available",
+                4: "The data in the username or password is malformed",
+                5: "The client is not authorized to connect"
+            }
+            error_msg = error_messages.get(rc, f"Unknown error code: {rc}")
+            logger.error(f"MQTT Connection Refused. {error_msg}")
+
+    def on_disconnect(self, client, userdata, rc):
+        """Callback when disconnected from MQTT broker."""
+        self._connected = False
+        if rc == 0:
+            logger.info("MQTT disconnected cleanly")
+        else:
+            logger.warning(f"MQTT disconnected unexpectedly with code: {rc}")
 
     def on_message(self, client, userdata, msg):
         """Callback when message is received."""
@@ -827,5 +840,25 @@ class MQTTHandler(BaseMQTTHandler):
 
     @property
     def is_enabled(self) -> bool:
-        """Return whether MQTT functionality is enabled."""
-        return bool(self.broker) 
+        """Return whether MQTT functionality is enabled.
+
+        MQTT is enabled if:
+        1. A broker address is configured (either via state or env var), AND
+        2. Either state.mqtt_enabled is True, OR no UI config exists (env-only mode)
+        """
+        # If no broker configured, MQTT is disabled
+        if not self.broker:
+            return False
+
+        # If state has mqtt_enabled explicitly set (UI was used), respect that setting
+        # If mqtt_broker is set in state, user configured via UI - use mqtt_enabled
+        if state.mqtt_broker:
+            return state.mqtt_enabled
+
+        # Otherwise, broker came from env vars - enable if broker exists
+        return True
+
+    @property
+    def is_connected(self) -> bool:
+        """Return whether MQTT client is currently connected to the broker."""
+        return self._connected and self.is_enabled 

+ 6 - 1
modules/mqtt/mock.py

@@ -24,7 +24,12 @@ class MockMQTTHandler(BaseMQTTHandler):
     def is_enabled(self) -> bool:
         """Always returns False since this is a mock."""
         return False
-        
+
+    @property
+    def is_connected(self) -> bool:
+        """Always returns False since this is a mock."""
+        return False
+
     def publish_status(self) -> None:
         """Mock status publisher."""
         pass

+ 306 - 0
static/js/settings.js

@@ -1788,3 +1788,309 @@ async function initializeHomingConfig() {
     homingModeSensor.addEventListener('change', updateHomingInfo);
     saveHomingConfigButton.addEventListener('click', saveHomingConfig);
 }
+
+// Toggle password visibility helper
+function togglePasswordVisibility(inputId, button) {
+    const input = document.getElementById(inputId);
+    if (!input || !button) return;
+
+    const icon = button.querySelector('.material-icons');
+    if (input.type === 'password') {
+        input.type = 'text';
+        if (icon) icon.textContent = 'visibility';
+    } else {
+        input.type = 'password';
+        if (icon) icon.textContent = 'visibility_off';
+    }
+}
+
+// MQTT Configuration
+async function initializeMqttConfig() {
+    logMessage('Initializing MQTT configuration', LOG_TYPE.INFO);
+
+    const mqttEnableToggle = document.getElementById('mqttEnableToggle');
+    const mqttSettings = document.getElementById('mqttSettings');
+    const mqttStatusBanner = document.getElementById('mqttStatusBanner');
+    const mqttConnectedBanner = document.getElementById('mqttConnectedBanner');
+    const mqttDisconnectedBanner = document.getElementById('mqttDisconnectedBanner');
+    const mqttBrokerInput = document.getElementById('mqttBrokerInput');
+    const mqttPortInput = document.getElementById('mqttPortInput');
+    const mqttUsernameInput = document.getElementById('mqttUsernameInput');
+    const mqttPasswordInput = document.getElementById('mqttPasswordInput');
+    const mqttDeviceNameInput = document.getElementById('mqttDeviceNameInput');
+    const mqttDeviceIdInput = document.getElementById('mqttDeviceIdInput');
+    const mqttClientIdInput = document.getElementById('mqttClientIdInput');
+    const mqttDiscoveryPrefixInput = document.getElementById('mqttDiscoveryPrefixInput');
+    const testMqttButton = document.getElementById('testMqttConnection');
+    const mqttTestResult = document.getElementById('mqttTestResult');
+    const saveMqttButton = document.getElementById('saveMqttConfig');
+    const mqttRestartNotice = document.getElementById('mqttRestartNotice');
+
+    // Check if elements exist
+    if (!mqttEnableToggle || !mqttSettings || !saveMqttButton) {
+        logMessage('MQTT configuration elements not found, skipping initialization', LOG_TYPE.WARNING);
+        return;
+    }
+
+    logMessage('MQTT configuration elements found successfully', LOG_TYPE.INFO);
+
+    // Track if settings have changed (to show restart notice)
+    let originalConfig = null;
+    let configChanged = false;
+
+    // Function to update UI based on enabled state
+    function updateMqttSettingsVisibility() {
+        mqttSettings.style.display = mqttEnableToggle.checked ? 'block' : 'none';
+        if (mqttStatusBanner) {
+            mqttStatusBanner.classList.toggle('hidden', !mqttEnableToggle.checked);
+        }
+    }
+
+    // Function to update connection status banners
+    function updateConnectionStatus(connected) {
+        if (mqttConnectedBanner && mqttDisconnectedBanner) {
+            if (connected) {
+                mqttConnectedBanner.classList.remove('hidden');
+                mqttDisconnectedBanner.classList.add('hidden');
+            } else {
+                mqttConnectedBanner.classList.add('hidden');
+                mqttDisconnectedBanner.classList.remove('hidden');
+            }
+        }
+    }
+
+    // Function to check if config has changed
+    function checkConfigChanged() {
+        if (!originalConfig) return false;
+
+        const currentConfig = {
+            enabled: mqttEnableToggle.checked,
+            broker: mqttBrokerInput.value,
+            port: parseInt(mqttPortInput.value) || 1883,
+            username: mqttUsernameInput.value,
+            password: mqttPasswordInput.value,
+            device_name: mqttDeviceNameInput.value,
+            device_id: mqttDeviceIdInput.value,
+            client_id: mqttClientIdInput.value,
+            discovery_prefix: mqttDiscoveryPrefixInput.value
+        };
+
+        return JSON.stringify(currentConfig) !== JSON.stringify(originalConfig);
+    }
+
+    // Function to show/hide restart notice
+    function updateRestartNotice() {
+        configChanged = checkConfigChanged();
+        if (mqttRestartNotice) {
+            mqttRestartNotice.classList.toggle('hidden', !configChanged);
+        }
+    }
+
+    // Load current MQTT configuration
+    try {
+        const response = await fetch('/api/mqtt-config');
+        const data = await response.json();
+
+        mqttEnableToggle.checked = data.enabled || false;
+        mqttBrokerInput.value = data.broker || '';
+        mqttPortInput.value = data.port || 1883;
+        mqttUsernameInput.value = data.username || '';
+        // Note: Password is not returned from API for security
+        mqttDeviceNameInput.value = data.device_name || 'Dune Weaver';
+        mqttDeviceIdInput.value = data.device_id || 'dune_weaver';
+        mqttClientIdInput.value = data.client_id || 'dune_weaver';
+        mqttDiscoveryPrefixInput.value = data.discovery_prefix || 'homeassistant';
+
+        // Store original config for change detection
+        originalConfig = {
+            enabled: data.enabled || false,
+            broker: data.broker || '',
+            port: data.port || 1883,
+            username: data.username || '',
+            password: '', // We don't have the original password
+            device_name: data.device_name || 'Dune Weaver',
+            device_id: data.device_id || 'dune_weaver',
+            client_id: data.client_id || 'dune_weaver',
+            discovery_prefix: data.discovery_prefix || 'homeassistant'
+        };
+
+        updateMqttSettingsVisibility();
+
+        // Update connection status if MQTT is enabled
+        if (data.enabled) {
+            updateConnectionStatus(data.connected || false);
+        }
+
+        logMessage(`Loaded MQTT config: enabled=${data.enabled}, broker=${data.broker}`, LOG_TYPE.INFO);
+    } catch (error) {
+        logMessage(`Error loading MQTT configuration: ${error.message}`, LOG_TYPE.ERROR);
+        // Initialize with defaults if load fails
+        mqttEnableToggle.checked = false;
+        updateMqttSettingsVisibility();
+    }
+
+    // Function to save MQTT configuration
+    async function saveMqttConfig() {
+        // Validate required fields if MQTT is enabled
+        if (mqttEnableToggle.checked && !mqttBrokerInput.value.trim()) {
+            showStatusMessage('MQTT broker address is required when MQTT is enabled', 'error');
+            mqttBrokerInput.focus();
+            return;
+        }
+
+        // Update button UI to show loading state
+        const originalButtonHTML = saveMqttButton.innerHTML;
+        saveMqttButton.disabled = true;
+        saveMqttButton.innerHTML = '<span class="material-icons text-lg animate-spin">refresh</span><span class="truncate">Saving...</span>';
+
+        try {
+            const requestBody = {
+                enabled: mqttEnableToggle.checked,
+                broker: mqttBrokerInput.value.trim(),
+                port: parseInt(mqttPortInput.value) || 1883,
+                username: mqttUsernameInput.value.trim() || null,
+                device_name: mqttDeviceNameInput.value.trim() || 'Dune Weaver',
+                device_id: mqttDeviceIdInput.value.trim() || 'dune_weaver',
+                client_id: mqttClientIdInput.value.trim() || 'dune_weaver',
+                discovery_prefix: mqttDiscoveryPrefixInput.value.trim() || 'homeassistant'
+            };
+
+            // Only include password if it was changed (not empty)
+            if (mqttPasswordInput.value) {
+                requestBody.password = mqttPasswordInput.value;
+            }
+
+            const response = await fetch('/api/mqtt-config', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify(requestBody)
+            });
+
+            if (!response.ok) {
+                const errorData = await response.json();
+                throw new Error(errorData.detail || 'Failed to save MQTT configuration');
+            }
+
+            const data = await response.json();
+
+            // Update original config for change detection
+            originalConfig = {
+                enabled: requestBody.enabled,
+                broker: requestBody.broker,
+                port: requestBody.port,
+                username: requestBody.username || '',
+                password: '', // Reset password tracking
+                device_name: requestBody.device_name,
+                device_id: requestBody.device_id,
+                client_id: requestBody.client_id,
+                discovery_prefix: requestBody.discovery_prefix
+            };
+
+            // Clear password field after save
+            mqttPasswordInput.value = '';
+
+            // Show success state temporarily
+            saveMqttButton.innerHTML = '<span class="material-icons text-lg">check</span><span class="truncate">Saved!</span>';
+            showStatusMessage('MQTT configuration saved successfully. Restart the application to apply changes.', 'success');
+
+            // Show restart notice
+            if (mqttRestartNotice) {
+                mqttRestartNotice.classList.remove('hidden');
+            }
+
+            // Restore button after 2 seconds
+            setTimeout(() => {
+                saveMqttButton.innerHTML = originalButtonHTML;
+                saveMqttButton.disabled = false;
+            }, 2000);
+        } catch (error) {
+            logMessage(`Error saving MQTT configuration: ${error.message}`, LOG_TYPE.ERROR);
+            showStatusMessage(`Failed to save MQTT configuration: ${error.message}`, 'error');
+
+            // Restore button immediately on error
+            saveMqttButton.innerHTML = originalButtonHTML;
+            saveMqttButton.disabled = false;
+        }
+    }
+
+    // Function to test MQTT connection
+    async function testMqttConnection() {
+        // Validate broker address
+        if (!mqttBrokerInput.value.trim()) {
+            showStatusMessage('Please enter a broker address to test', 'error');
+            mqttBrokerInput.focus();
+            return;
+        }
+
+        // Update button UI to show loading state
+        const originalButtonHTML = testMqttButton.innerHTML;
+        testMqttButton.disabled = true;
+        testMqttButton.innerHTML = '<span class="material-icons text-lg animate-spin">refresh</span><span class="truncate">Testing...</span>';
+
+        // Clear previous result
+        if (mqttTestResult) {
+            mqttTestResult.innerHTML = '';
+        }
+
+        try {
+            const requestBody = {
+                broker: mqttBrokerInput.value.trim(),
+                port: parseInt(mqttPortInput.value) || 1883,
+                username: mqttUsernameInput.value.trim() || null,
+                password: mqttPasswordInput.value || null
+            };
+
+            const response = await fetch('/api/mqtt-test', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify(requestBody)
+            });
+
+            const data = await response.json();
+
+            if (data.success) {
+                if (mqttTestResult) {
+                    mqttTestResult.innerHTML = '<span class="material-icons text-green-600 mr-1">check_circle</span><span class="text-green-600">Connection successful!</span>';
+                }
+                showStatusMessage('MQTT connection test successful', 'success');
+            } else {
+                if (mqttTestResult) {
+                    mqttTestResult.innerHTML = `<span class="material-icons text-red-600 mr-1">error</span><span class="text-red-600">${data.error || 'Connection failed'}</span>`;
+                }
+                showStatusMessage(`MQTT test failed: ${data.error || 'Connection failed'}`, 'error');
+            }
+        } catch (error) {
+            logMessage(`Error testing MQTT connection: ${error.message}`, LOG_TYPE.ERROR);
+            if (mqttTestResult) {
+                mqttTestResult.innerHTML = `<span class="material-icons text-red-600 mr-1">error</span><span class="text-red-600">Test failed: ${error.message}</span>`;
+            }
+            showStatusMessage(`MQTT test failed: ${error.message}`, 'error');
+        } finally {
+            // Restore button
+            testMqttButton.innerHTML = originalButtonHTML;
+            testMqttButton.disabled = false;
+        }
+    }
+
+    // Event listeners
+    mqttEnableToggle.addEventListener('change', () => {
+        updateMqttSettingsVisibility();
+        updateRestartNotice();
+    });
+
+    // Track changes to show restart notice
+    [mqttBrokerInput, mqttPortInput, mqttUsernameInput, mqttPasswordInput,
+     mqttDeviceNameInput, mqttDeviceIdInput, mqttClientIdInput, mqttDiscoveryPrefixInput].forEach(input => {
+        if (input) {
+            input.addEventListener('input', updateRestartNotice);
+        }
+    });
+
+    testMqttButton.addEventListener('click', testMqttConnection);
+    saveMqttButton.addEventListener('click', saveMqttConfig);
+}
+
+// Initialize MQTT config when DOM is ready
+document.addEventListener('DOMContentLoaded', function() {
+    initializeMqttConfig();
+});

+ 199 - 0
templates/settings.html

@@ -756,6 +756,205 @@ input:checked + .slider:before {
       </button>
     </div>
   </section>
+
+  <!-- MQTT Configuration Section -->
+  <section class="bg-white rounded-xl shadow-sm overflow-hidden">
+    <h2
+      class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"
+    >
+      MQTT / Home Assistant Integration
+    </h2>
+    <div class="px-6 py-5 space-y-6">
+      <!-- MQTT Enable Toggle -->
+      <div class="flex items-center justify-between">
+        <div class="flex-1">
+          <h3 class="text-slate-700 text-base font-medium leading-normal">Enable MQTT</h3>
+          <p class="text-xs text-slate-500 mt-1">
+            Connect to an MQTT broker for Home Assistant integration and remote control.
+          </p>
+        </div>
+        <label class="switch">
+          <input type="checkbox" id="mqttEnableToggle">
+          <span class="slider round"></span>
+        </label>
+      </div>
+
+      <!-- Connection Status -->
+      <div id="mqttStatusBanner" class="hidden">
+        <div id="mqttConnectedBanner" class="bg-green-50 border border-green-200 rounded-lg p-3 hidden">
+          <div class="flex items-center gap-2">
+            <span class="material-icons text-green-600 text-base">check_circle</span>
+            <span class="text-sm text-green-700 font-medium">Connected to MQTT broker</span>
+          </div>
+        </div>
+        <div id="mqttDisconnectedBanner" class="bg-amber-50 border border-amber-200 rounded-lg p-3 hidden">
+          <div class="flex items-center gap-2">
+            <span class="material-icons text-amber-600 text-base">warning</span>
+            <span class="text-sm text-amber-700 font-medium">MQTT is enabled but not connected. Check your settings or restart the application.</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- MQTT Settings (shown when enabled) -->
+      <div id="mqttSettings" class="space-y-4" style="display: none;">
+        <!-- Broker Settings -->
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Broker Address <span class="text-red-500">*</span></span>
+            <input
+              id="mqttBrokerInput"
+              type="text"
+              class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+              placeholder="e.g., 192.168.1.100 or mqtt.local"
+            />
+            <p class="text-xs text-slate-500">IP address or hostname of your MQTT broker</p>
+          </label>
+
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Port</span>
+            <input
+              id="mqttPortInput"
+              type="number"
+              class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+              placeholder="1883"
+              value="1883"
+            />
+            <p class="text-xs text-slate-500">Default: 1883 (or 8883 for TLS)</p>
+          </label>
+        </div>
+
+        <!-- Authentication -->
+        <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Username</span>
+            <input
+              id="mqttUsernameInput"
+              type="text"
+              class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+              placeholder="Optional"
+            />
+            <p class="text-xs text-slate-500">Leave empty if no authentication required</p>
+          </label>
+
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Password</span>
+            <div class="relative">
+              <input
+                id="mqttPasswordInput"
+                type="password"
+                class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 pr-10 text-base font-normal leading-normal transition-colors"
+                placeholder="Optional"
+              />
+              <button
+                type="button"
+                onclick="togglePasswordVisibility('mqttPasswordInput', this)"
+                class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700"
+                aria-label="Toggle password visibility"
+              >
+                <span class="material-icons text-xl">visibility_off</span>
+              </button>
+            </div>
+            <p class="text-xs text-slate-500">Leave empty if no authentication required</p>
+          </label>
+        </div>
+
+        <!-- Home Assistant Discovery Settings -->
+        <div class="border-t border-slate-200 pt-4">
+          <h4 class="text-slate-700 text-sm font-medium mb-3">Home Assistant Discovery</h4>
+          <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <label class="flex flex-col gap-1.5">
+              <span class="text-slate-700 text-sm font-medium leading-normal">Device Name</span>
+              <input
+                id="mqttDeviceNameInput"
+                type="text"
+                class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+                placeholder="Dune Weaver"
+                value="Dune Weaver"
+              />
+              <p class="text-xs text-slate-500">Display name in Home Assistant</p>
+            </label>
+
+            <label class="flex flex-col gap-1.5">
+              <span class="text-slate-700 text-sm font-medium leading-normal">Device ID</span>
+              <input
+                id="mqttDeviceIdInput"
+                type="text"
+                class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+                placeholder="dune_weaver"
+                value="dune_weaver"
+              />
+              <p class="text-xs text-slate-500">Unique identifier (no spaces)</p>
+            </label>
+          </div>
+        </div>
+
+        <!-- Advanced Settings (Collapsible) -->
+        <details class="border-t border-slate-200 pt-4">
+          <summary class="text-slate-700 text-sm font-medium cursor-pointer hover:text-slate-900">
+            Advanced Settings
+          </summary>
+          <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+            <label class="flex flex-col gap-1.5">
+              <span class="text-slate-700 text-sm font-medium leading-normal">Client ID</span>
+              <input
+                id="mqttClientIdInput"
+                type="text"
+                class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+                placeholder="dune_weaver"
+                value="dune_weaver"
+              />
+              <p class="text-xs text-slate-500">MQTT client identifier</p>
+            </label>
+
+            <label class="flex flex-col gap-1.5">
+              <span class="text-slate-700 text-sm font-medium leading-normal">Discovery Prefix</span>
+              <input
+                id="mqttDiscoveryPrefixInput"
+                type="text"
+                class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 text-base font-normal leading-normal transition-colors"
+                placeholder="homeassistant"
+                value="homeassistant"
+              />
+              <p class="text-xs text-slate-500">Home Assistant discovery topic prefix</p>
+            </label>
+          </div>
+        </details>
+
+        <!-- Test Connection Button -->
+        <div class="flex flex-wrap gap-3 pt-2">
+          <button
+            id="testMqttConnection"
+            class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-10 px-4 bg-slate-100 hover:bg-slate-200 text-slate-700 text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+          >
+            <span class="material-icons text-lg">wifi_tethering</span>
+            <span class="truncate">Test Connection</span>
+          </button>
+          <span id="mqttTestResult" class="flex items-center text-sm"></span>
+        </div>
+      </div>
+
+      <!-- Save Button -->
+      <button
+        id="saveMqttConfig"
+        class="flex items-center justify-center gap-2 w-full sm:w-auto cursor-pointer rounded-lg h-10 px-4 bg-sky-600 hover:bg-sky-700 text-white text-sm font-medium leading-normal tracking-[0.015em] transition-colors"
+      >
+        <span class="material-icons text-lg">save</span>
+        <span class="truncate">Save MQTT Configuration</span>
+      </button>
+
+      <!-- Restart Notice -->
+      <div id="mqttRestartNotice" class="bg-blue-50 border border-blue-200 rounded-lg p-3 hidden">
+        <div class="flex items-start gap-2">
+          <span class="material-icons text-blue-600 text-base">info</span>
+          <div class="text-xs text-blue-700">
+            <p class="font-medium text-blue-800">Restart Required</p>
+            <p class="mt-1">MQTT configuration changes require a restart to take effect. Use the restart button in the header to apply changes.</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
     <h2
       class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em] px-6 py-4 border-b border-slate-200"