فهرست منبع

Merge remote-tracking branch 'origin/homeassistant-dlc32' into dlc32

Tuan Nguyen 11 ماه پیش
والد
کامیت
217468e6fc

+ 39 - 0
.env.example

@@ -0,0 +1,39 @@
+# MQTT Configuration
+# this configuration is needed to activat ethe integration with Home assistant
+# If you leave MQTT_BROKER empty, the integration will be skipped, and it won't affect the app in any way
+# to correctly configure the integration, you must copy this .env.example to a fine named just .env
+# and then fill all the fields
+
+# the ip address of your mqtt server
+# if you have the mosquitto addon, this is the same as the Home assistant IP
+MQTT_BROKER=
+# the port of the mqtt broker, 1883 is the default port
+MQTT_PORT=1883
+# the username and password can either be the ones of a Home Assistant user (if you're using the addon)
+# or a specific mqtt user (see https://github.com/home-assistant/addons/blob/master/mosquitto/DOCS.md)
+MQTT_USERNAME=
+MQTT_PASSWORD=
+
+# Status update interval in seconds
+# most of the updates are done on demand anyway, so you probably don't need to edit this
+MQTT_STATUS_INTERVAL=30  
+
+# Home Assistant MQTT Discovery
+# this is usually homeassistant
+# if you didn't edit it yourself in your install, leave as is
+MQTT_DISCOVERY_PREFIX=homeassistant
+
+
+
+# unique ids: you need to edit these only if you have multiple tables that you want to connect to Home assistant
+# unique id of the devvice in Home Assistant
+HA_DEVICE_ID=dune_weaver
+# unique id of the device in mqtt
+MQTT_CLIENT_ID=dune_weaver
+# display name of the table in Home assistant
+HA_DEVICE_NAME=Dune Weaver
+
+# MQTT Topics
+# you can probably leave this as is
+MQTT_STATUS_TOPIC=dune_weaver/status
+MQTT_COMMAND_TOPIC=dune_weaver/command

+ 2 - 0
dune_weaver_flask/__init__.py

@@ -0,0 +1,2 @@
+from dotenv import load_dotenv
+load_dotenv()

+ 13 - 9
dune_weaver_flask/app.py

@@ -8,18 +8,20 @@ from dune_weaver_flask.modules.core import pattern_manager
 from dune_weaver_flask.modules.core import playlist_manager
 from .modules.firmware import firmware_manager
 from dune_weaver_flask.modules.core.state import state
+from dune_weaver_flask.modules import mqtt
 
 
 # Configure logging
 logging.basicConfig(
     level=logging.INFO,
-    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    format='%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s',
     handlers=[
         logging.StreamHandler(),
         # disable file logging for now, to not gobble up resources
         # logging.FileHandler('dune_weaver.log')
     ]
 )
+
 logger = logging.getLogger(__name__)
 
 app = Flask(__name__)
@@ -249,9 +251,8 @@ def serial_status():
 
 @app.route('/pause_execution', methods=['POST'])
 def pause_execution():
-    logger.info("Pausing pattern execution")
-    pattern_manager.pause_requested = True
-    return jsonify({'success': True, 'message': 'Execution paused'})
+    if pattern_manager.pause_execution():
+        return jsonify({'success': True, 'message': 'Execution paused'})
 
 @app.route('/status', methods=['GET'])
 def get_status():
@@ -259,11 +260,8 @@ def get_status():
 
 @app.route('/resume_execution', methods=['POST'])
 def resume_execution():
-    logger.info("Resuming pattern execution")
-    with pattern_manager.pause_condition:
-        pattern_manager.pause_requested = False
-        pattern_manager.pause_condition.notify_all()
-    return jsonify({'success': True, 'message': 'Execution resumed'})
+    if pattern_manager.resume_execution():
+        return jsonify({'success': True, 'message': 'Execution resumed'})
 
 # Playlist endpoints
 @app.route("/list_all_playlists", methods=["GET"])
@@ -422,6 +420,7 @@ def on_exit():
     """Function to execute on application shutdown."""
     pattern_manager.stop_actions()
     state.save()
+    mqtt.cleanup_mqtt()
 
 def entrypoint():
     logger.info("Starting Dune Weaver application...")
@@ -433,6 +432,11 @@ def entrypoint():
         serial_manager.connect_to_serial()
     except Exception as e:
         logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
+        
+    try:
+        mqtt_handler = mqtt.init_mqtt()
+    except Exception as e:
+        logger.warning(f"Failed to initialize MQTT: {str(e)}")
 
     try:
         logger.info("Starting Flask server on port 8080...")

+ 43 - 16
dune_weaver_flask/modules/core/pattern_manager.py

@@ -20,8 +20,6 @@ CLEAR_PATTERNS = {
     "clear_sideway":  "./patterns/clear_sideway.thr"
 }
 os.makedirs(THETA_RHO_DIR, exist_ok=True)
-current_playlist = []
-current_playing_index = None
 
 def list_theta_rho_files():
     files = []
@@ -166,6 +164,19 @@ def move_polar(theta, rho):
     state.machine_x = new_x_abs
     state.machine_y = new_y_abs
     
+def pause_execution():
+    logger.info("Pausing pattern execution")
+    with state.pause_condition:
+        state.pause_requested = True
+    return True
+
+def resume_execution():
+    logger.info("Resuming pattern execution")
+    with state.pause_condition:
+        state.pause_requested = False
+        state.pause_condition.notify_all()
+    return True
+    
 def reset_theta():
     logger.info('Resetting Theta')
     state.current_theta = 0
@@ -189,8 +200,9 @@ def run_theta_rho_file(file_path, schedule_hours=None):
         return
 
     state.execution_progress = (0, total_coordinates, None)
-
-    stop_actions()
+    
+    # stop actions without resetting the playlist
+    stop_actions(clear_playlist=False)
 
     with serial_manager.serial_lock:
         state.current_playing_file = file_path
@@ -228,27 +240,34 @@ def run_theta_rho_file(file_path, schedule_hours=None):
 
 def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, schedule_hours=None):
     """Run multiple .thr files in sequence with options."""
-    global current_playing_index, current_playlist
     state.stop_requested = False
     
+    # Set initial playlist state
+    state.playlist_mode = run_mode
+    state.current_playlist_index = 0
+    
     if shuffle:
         random.shuffle(file_paths)
         logger.info("Playlist shuffled")
 
-    current_playlist = file_paths
-
     while True:
         for idx, path in enumerate(file_paths):
             logger.info(f"Upcoming pattern: {path}")
-            current_playing_index = idx
+            state.current_playlist_index = idx
             schedule_checker(schedule_hours)
             if state.stop_requested:
                 logger.info("Execution stopped before starting next pattern")
+                state.current_playlist = None
+                state.current_playlist_index = None
+                state.playlist_mode = None
                 return
 
             if clear_pattern:
                 if state.stop_requested:
                     logger.info("Execution stopped before running the next clear pattern")
+                    state.current_playlist = None
+                    state.current_playlist_index = None
+                    state.playlist_mode = None
                     return
 
                 clear_file_path = get_clear_pattern_file(clear_pattern, path)
@@ -256,6 +275,7 @@ def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="
                 run_theta_rho_file(clear_file_path, schedule_hours)
 
             if not state.stop_requested:
+                
                 logger.info(f"Running pattern {idx + 1} of {len(file_paths)}: {path}")
                 run_theta_rho_file(path, schedule_hours)
 
@@ -278,20 +298,27 @@ def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="
             continue
         else:
             logger.info("Playlist completed")
+            state.current_playlist = None
+            state.current_playlist_index = None
+            state.playlist_mode = None
             break
     logger.info("All requested patterns completed (or stopped)")
 
-def stop_actions():
-    """Stop all current pattern execution."""
+def stop_actions(clear_playlist = True):
+    """Stop all current actions."""
     with state.pause_condition:
         state.pause_requested = False
         state.stop_requested = True
-        current_playing_index = None
-        current_playlist = None
-        state.is_clearing = False
         state.current_playing_file = None
         state.execution_progress = None
-    serial_manager.update_machine_position()
+        state.is_clearing = False
+        if clear_playlist:
+            # Clear playlist state
+            state.current_playlist = None
+            state.current_playlist_index = None
+            state.playlist_mode = None
+        state.pause_condition.notify_all()
+        serial_manager.update_machine_position()
 
 def get_status():
     """Get the current execution status."""
@@ -307,7 +334,7 @@ def get_status():
         "pause_requested": state.pause_requested,
         "current_playing_file": state.current_playing_file,
         "execution_progress": state.execution_progress,
-        "current_playing_index": current_playing_index,
-        "current_playlist": current_playlist,
+        "current_playing_index": state.current_playlist_index,
+        "current_playlist": state.current_playlist,
         "is_clearing": state.is_clearing
     }

+ 2 - 0
dune_weaver_flask/modules/core/playlist_manager.py

@@ -3,6 +3,7 @@ import os
 import threading
 import logging
 from dune_weaver_flask.modules.core import pattern_manager
+from dune_weaver_flask.modules.core.state import state
 
 # Configure logging
 logger = logging.getLogger(__name__)
@@ -99,6 +100,7 @@ def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="sing
 
     try:
         logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
+        state.current_playlist = playlist_name
         threading.Thread(
             target=pattern_manager.run_theta_rho_files,
             args=(file_paths,),

+ 74 - 12
dune_weaver_flask/modules/core/state.py

@@ -5,16 +5,21 @@ import os
 
 class AppState:
     def __init__(self):
-        # Execution state variables
+        # Private variables for properties
+        self._current_playing_file = None
+        self._pause_requested = False
+        self._speed = 250
+        self._current_playlist = None
+        
+        # Regular state variables
         self.stop_requested = False
-        self.pause_requested = False
         self.pause_condition = threading.Condition()
-        self.current_playing_file = None
         self.execution_progress = None
         self.is_clearing = False
         self.current_theta = 0
         self.current_rho = 0
-        self.speed = 250
+        self.current_playlist_index = 0
+        self.playlist_mode = None
         
         # Machine position variables
         self.machine_x = 0.0
@@ -22,43 +27,100 @@ class AppState:
         self.x_steps_per_mm = 0.0
         self.y_steps_per_mm = 0.0
         self.gear_ratio = 10
-
+        
         self.STATE_FILE = "state.json"
+        self.mqtt_handler = None  # Will be set by the MQTT handler
         self.load()
 
+    @property
+    def current_playing_file(self):
+        return self._current_playing_file
+
+    @current_playing_file.setter
+    def current_playing_file(self, value):
+        self._current_playing_file = value
+        
+        # force an empty string (and not None) if we need to unset
+        if value == None:
+            value = ""
+        if self.mqtt_handler:
+            is_running = bool(value and not self._pause_requested)
+            self.mqtt_handler.update_state(current_file=value, is_running=is_running)
+
+    @property
+    def pause_requested(self):
+        return self._pause_requested
+
+    @pause_requested.setter
+    def pause_requested(self, value):
+        self._pause_requested = value
+        if self.mqtt_handler:
+            is_running = bool(self._current_playing_file and not value)
+            self.mqtt_handler.update_state(is_running=is_running)
+
+    @property
+    def speed(self):
+        return self._speed
+
+    @speed.setter
+    def speed(self, value):
+        self._speed = value
+        if self.mqtt_handler and self.mqtt_handler.is_enabled:
+            self.mqtt_handler.client.publish(f"{self.mqtt_handler.speed_topic}/state", value, retain=True)
+
+    @property
+    def current_playlist(self):
+        return self._current_playlist
+
+    @current_playlist.setter
+    def current_playlist(self, value):
+        self._current_playlist = value
+        
+        # force an empty string (and not None) if we need to unset
+        if value == None:
+            value = ""
+        if self.mqtt_handler:
+            self.mqtt_handler.update_state(playlist=value)
+
     def to_dict(self):
         """Return a dictionary representation of the state."""
         return {
             "stop_requested": self.stop_requested,
-            "pause_requested": self.pause_requested,
-            "current_playing_file": self.current_playing_file,
+            "pause_requested": self._pause_requested,
+            "current_playing_file": self._current_playing_file,
             "execution_progress": self.execution_progress,
             "is_clearing": self.is_clearing,
             "current_theta": self.current_theta,
             "current_rho": self.current_rho,
-            "speed": self.speed,
+            "speed": self._speed,
             "machine_x": self.machine_x,
             "machine_y": self.machine_y,
             "x_steps_per_mm": self.x_steps_per_mm,
             "y_steps_per_mm": self.y_steps_per_mm,
-            "gear_ratio": self.gear_ratio
+            "gear_ratio": self.gear_ratio,
+            "current_playlist": self._current_playlist,
+            "current_playlist_index": self.current_playlist_index,
+            "playlist_mode": self.playlist_mode
         }
 
     def from_dict(self, data):
         """Update state from a dictionary."""
         self.stop_requested = data.get("stop_requested", False)
-        self.pause_requested = data.get("pause_requested", False)
-        self.current_playing_file = data.get("current_playing_file")
+        self._pause_requested = data.get("pause_requested", False)
+        self._current_playing_file = data.get("current_playing_file")
         self.execution_progress = data.get("execution_progress")
         self.is_clearing = data.get("is_clearing", False)
         self.current_theta = data.get("current_theta", 0)
         self.current_rho = data.get("current_rho", 0)
-        self.speed = data.get("speed", 250)
+        self._speed = data.get("speed", 250)
         self.machine_x = data.get("machine_x", 0.0)
         self.machine_y = data.get("machine_y", 0.0)
         self.x_steps_per_mm = data.get("x_steps_per_mm", 0.0)
         self.y_steps_per_mm = data.get("y_steps_per_mm", 0.0)
         self.gear_ratio = data.get('gear_ratio', 10)
+        self._current_playlist = data.get("current_playlist")
+        self.current_playlist_index = data.get("current_playlist_index")
+        self.playlist_mode = data.get("playlist_mode")
 
     def save(self):
         """Save the current state to a JSON file."""

+ 30 - 0
dune_weaver_flask/modules/mqtt/__init__.py

@@ -0,0 +1,30 @@
+"""MQTT module for Dune Weaver Flask application."""
+from .factory import create_mqtt_handler
+import logging
+
+logger = logging.getLogger(__name__)
+# Global MQTT handler instance
+mqtt_handler = None
+
+def init_mqtt():
+    """Initialize the MQTT handler."""
+    global mqtt_handler
+    logger.info("initializing mqtt module")
+    if mqtt_handler is None:
+        mqtt_handler = create_mqtt_handler()
+        mqtt_handler.start()
+    return mqtt_handler
+
+def get_mqtt_handler():
+    """Get the MQTT handler instance."""
+    global mqtt_handler
+    if mqtt_handler is None:
+        mqtt_handler = init_mqtt()
+    return mqtt_handler
+
+def cleanup_mqtt():
+    """Clean up MQTT handler resources."""
+    global mqtt_handler
+    if mqtt_handler is not None:
+        mqtt_handler.stop()
+        mqtt_handler = None 

+ 39 - 0
dune_weaver_flask/modules/mqtt/base.py

@@ -0,0 +1,39 @@
+"""Base MQTT handler interface."""
+from abc import ABC, abstractmethod
+from typing import Dict, Callable, List, Optional, Any
+
+class BaseMQTTHandler(ABC):
+    """Abstract base class for MQTT handlers."""
+    
+    @abstractmethod
+    def start(self) -> None:
+        """Start the MQTT handler."""
+        pass
+    
+    @abstractmethod
+    def stop(self) -> None:
+        """Stop the MQTT handler."""
+        pass
+    
+    @abstractmethod
+    def update_state(self, is_running: Optional[bool] = None, 
+                    current_file: Optional[str] = None,
+                    patterns: Optional[List[str]] = None, 
+                    serial: Optional[str] = None,
+                    playlist: Optional[Dict[str, Any]] = None) -> None:
+        """Update the state of the sand table and publish to MQTT.
+        
+        Args:
+            is_running: Whether the table is currently running a pattern
+            current_file: The currently playing file
+            patterns: List of available pattern files
+            serial: Serial connection status
+            playlist: Current playlist information if in playlist mode
+        """
+        pass
+    
+    @property
+    @abstractmethod
+    def is_enabled(self) -> bool:
+        """Return whether MQTT functionality is enabled."""
+        pass 

+ 25 - 0
dune_weaver_flask/modules/mqtt/factory.py

@@ -0,0 +1,25 @@
+"""Factory for creating MQTT handlers."""
+import os
+from typing import Dict, Callable
+from .base import BaseMQTTHandler
+from .handler import MQTTHandler
+from .mock import MockMQTTHandler
+from .utils import create_mqtt_callbacks
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+def create_mqtt_handler() -> BaseMQTTHandler:
+    """Create and return an appropriate MQTT handler based on configuration.
+    
+    Returns:
+        BaseMQTTHandler: Either a real MQTTHandler if MQTT_BROKER is configured,
+                        or a MockMQTTHandler if not.
+    """
+    if os.getenv('MQTT_BROKER'):
+        logger.info("Got MQTT configuration, instantiating MQTTHandler")
+        return MQTTHandler(create_mqtt_callbacks())
+    
+    logger.info("MQTT Not going to be used, instantiating MockMQTTHandler")
+    return MockMQTTHandler() 

+ 401 - 0
dune_weaver_flask/modules/mqtt/handler.py

@@ -0,0 +1,401 @@
+"""Real MQTT handler implementation."""
+import os
+import threading
+import time
+import json
+from typing import Dict, Callable, List, Optional, Any
+import paho.mqtt.client as mqtt
+import logging
+
+from .base import BaseMQTTHandler
+from dune_weaver_flask.modules.core.state import state
+from dune_weaver_flask.modules.core.pattern_manager import list_theta_rho_files
+from dune_weaver_flask.modules.core.playlist_manager import list_all_playlists
+from dune_weaver_flask.modules.serial.serial_manager import is_connected, get_port
+
+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')
+        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'))
+
+        # Store callback registry
+        self.callback_registry = callback_registry
+
+        # Threading control
+        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')
+        
+        # Additional topics for state
+        self.running_state_topic = f"{self.device_id}/state/running"
+        self.serial_state_topic = f"{self.device_id}/state/serial"
+        self.pattern_select_topic = f"{self.device_id}/pattern/set"
+        self.playlist_select_topic = f"{self.device_id}/playlist/set"
+        self.speed_topic = f"{self.device_id}/speed/set"
+
+        # Store current state
+        self.current_file = ""
+        self.is_running_state = False
+        self.serial_state = ""
+        self.patterns = []
+        self.playlists = []
+
+        # 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_message = self.on_message
+
+            if self.username and self.password:
+                self.client.username_pw_set(self.username, self.password)
+
+        self.state = state
+        self.state.mqtt_handler = self  # Set reference to self in state, needed so that state setters can update the state
+
+    def setup_ha_discovery(self):
+        """Publish Home Assistant MQTT discovery configurations."""
+        if not self.is_enabled:
+            return
+
+        base_device = {
+            "identifiers": [self.device_id],
+            "name": self.device_name,
+            "model": "Dune Weaver",
+            "manufacturer": "DIY"
+        }
+        
+        # Serial State Sensor
+        serial_config = {
+            "name": f"{self.device_name} Serial State",
+            "unique_id": f"{self.device_id}_serial_state",
+            "state_topic": self.serial_state_topic,
+            "device": base_device,
+            "icon": "mdi:serial-port",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "serial_state", serial_config)
+
+        # Running State Sensor
+        running_config = {
+            "name": f"{self.device_name} Running State",
+            "unique_id": f"{self.device_id}_running_state",
+            "state_topic": self.running_state_topic,
+            "device": base_device,
+            "icon": "mdi:machine",
+            "entity_category": "diagnostic"
+        }
+        self._publish_discovery("sensor", "running_state", running_config)
+
+        # Stop Button
+        stop_config = {
+            "name": f"Stop pattern execution",
+            "unique_id": f"{self.device_id}_stop",
+            "command_topic": f"{self.device_id}/command/stop",
+            "device": base_device,
+            "icon": "mdi:stop",
+            "entity_category": "config"
+        }
+        self._publish_discovery("button", "stop", stop_config)
+
+        # Pause Button
+        pause_config = {
+            "name": f"Pause pattern execution",
+            "unique_id": f"{self.device_id}_pause",
+            "command_topic": f"{self.device_id}/command/pause",
+            "state_topic": f"{self.device_id}/command/pause/state",
+            "device": base_device,
+            "icon": "mdi:pause",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/pause/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "pause", pause_config)
+
+        # Play Button
+        play_config = {
+            "name": f"Resume pattern execution",
+            "unique_id": f"{self.device_id}_play",
+            "command_topic": f"{self.device_id}/command/play",
+            "state_topic": f"{self.device_id}/command/play/state",
+            "device": base_device,
+            "icon": "mdi:play",
+            "entity_category": "config",
+            "enabled_by_default": True,
+            "availability": {
+                "topic": f"{self.device_id}/command/play/available",
+                "payload_available": "true",
+                "payload_not_available": "false"
+            }
+        }
+        self._publish_discovery("button", "play", play_config)
+
+        # Speed Control
+        speed_config = {
+            "name": f"{self.device_name} Speed",
+            "unique_id": f"{self.device_id}_speed",
+            "command_topic": self.speed_topic,
+            "state_topic": f"{self.speed_topic}/state",
+            "device": base_device,
+            "icon": "mdi:speedometer",
+            "min": 50,
+            "max": 1000,
+            "step": 50
+        }
+        self._publish_discovery("number", "speed", speed_config)
+
+        # Pattern Select
+        pattern_config = {
+            "name": f"{self.device_name} Pattern",
+            "unique_id": f"{self.device_id}_pattern",
+            "command_topic": self.pattern_select_topic,
+            "state_topic": f"{self.pattern_select_topic}/state",
+            "options": self.patterns,
+            "device": base_device,
+            "icon": "mdi:draw"
+        }
+        self._publish_discovery("select", "pattern", pattern_config)
+
+        # Playlist Select
+        playlist_config = {
+            "name": f"{self.device_name} Playlist",
+            "unique_id": f"{self.device_id}_playlist",
+            "command_topic": self.playlist_select_topic,
+            "state_topic": f"{self.playlist_select_topic}/state",
+            "options": self.playlists,
+            "device": base_device,
+            "icon": "mdi:playlist-play"
+        }
+        self._publish_discovery("select", "playlist", playlist_config)
+
+    def _publish_discovery(self, component: str, config_type: str, config: dict):
+        """Helper method to publish HA discovery configs."""
+        if not self.is_enabled:
+            return
+            
+        discovery_topic = f"{self.discovery_prefix}/{component}/{self.device_id}/{config_type}/config"
+        self.client.publish(discovery_topic, json.dumps(config), retain=True)
+
+    def _publish_running_state(self, running_state=None):
+        """Helper to publish running state and button availability."""
+        if running_state is None:
+            if not self.state.current_playing_file:
+                running_state = "idle"
+            elif self.state.pause_requested:
+                running_state = "paused"
+            else:
+                running_state = "running"
+                
+        self.client.publish(self.running_state_topic, running_state, retain=True)
+        
+        # Update button availability based on state
+        self.client.publish(f"{self.device_id}/command/pause/available", 
+                          "true" if running_state == "running" else "false", 
+                          retain=True)
+        self.client.publish(f"{self.device_id}/command/play/available", 
+                          "true" if running_state == "paused" else "false", 
+                          retain=True)
+                          
+    def _publish_pattern_state(self, current_file=None):
+        """Helper to publish pattern state."""
+        if current_file is None:
+            current_file = self.state.current_playing_file
+            
+        if current_file:
+            if current_file.startswith('./patterns/'):
+                current_file = current_file[len('./patterns/'):]
+            else:
+                current_file = current_file.split("/")[-1].split("\\")[-1]
+            self.client.publish(f"{self.pattern_select_topic}/state", current_file, retain=True)
+        else:
+            self.client.publish(f"{self.pattern_select_topic}/state", "None", retain=True)
+            
+    def _publish_playlist_state(self, playlist=None):
+        """Helper to publish playlist state."""
+        if playlist is None:
+            playlist = self.state.current_playlist
+            
+        if playlist:
+            self.client.publish(f"{self.playlist_select_topic}/state", playlist, retain=True)
+        else:
+            self.client.publish(f"{self.playlist_select_topic}/state", "None", retain=True)
+            
+    def _publish_serial_state(self):
+        """Helper to publish serial state."""
+        serial_connected = is_connected()
+        serial_port = get_port() if serial_connected else None
+        serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
+        self.client.publish(self.serial_state_topic, serial_status, retain=True)
+
+    def update_state(self, current_file=None, is_running=None, playlist=None):
+        """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
+        if not self.is_enabled:
+            return
+
+        # Update pattern state if current_file is provided
+        if current_file is not None:
+            self._publish_pattern_state(current_file)
+        
+        # Update running state and button availability if is_running is provided
+        if is_running is not None:
+            running_state = "running" if is_running else "paused" if self.state.current_playing_file else "idle"
+            self._publish_running_state(running_state)
+        
+        # Update playlist state if playlist info is provided
+        if playlist is not None:
+            self._publish_playlist_state(playlist)
+
+    def on_connect(self, client, userdata, flags, rc):
+        """Callback when connected to MQTT broker."""
+        logger.info(f"Connected to MQTT broker with result code {rc}")
+        # Subscribe to command topics
+        client.subscribe([
+            (self.command_topic, 0),
+            (self.pattern_select_topic, 0),
+            (self.playlist_select_topic, 0),
+            (self.speed_topic, 0),
+            (f"{self.device_id}/command/stop", 0),
+            (f"{self.device_id}/command/pause", 0),
+            (f"{self.device_id}/command/play", 0)
+        ])
+        # Publish discovery configurations
+        self.setup_ha_discovery()
+
+    def on_message(self, client, userdata, msg):
+        """Callback when message is received."""
+        try:
+            if msg.topic == self.pattern_select_topic:
+                # Handle pattern selection
+                pattern_name = msg.payload.decode()
+                if pattern_name in self.patterns:
+                    self.callback_registry['run_pattern'](file_path=f"{pattern_name}")
+                    self.client.publish(f"{self.pattern_select_topic}/state", pattern_name, retain=True)
+            elif msg.topic == self.playlist_select_topic:
+                # Handle playlist selection
+                playlist_name = msg.payload.decode()
+                if playlist_name in self.playlists:
+                    self.callback_registry['run_playlist'](playlist_name=playlist_name)
+                    self.client.publish(f"{self.playlist_select_topic}/state", playlist_name, retain=True)
+            elif msg.topic == self.speed_topic:
+                speed = int(msg.payload.decode())
+                self.callback_registry['set_speed'](speed)
+            elif msg.topic == f"{self.device_id}/command/stop":
+                # Handle stop command
+                self.callback_registry['stop']()
+            elif msg.topic == f"{self.device_id}/command/pause":
+                # Handle pause command - only if in running state
+                if bool(self.state.current_playing_file) and not self.state.pause_requested:
+                    self.callback_registry['pause']()
+            elif msg.topic == f"{self.device_id}/command/play":
+                # Handle play command - only if in paused state
+                if bool(self.state.current_playing_file) and self.state.pause_requested:
+                    self.callback_registry['resume']()
+            else:
+                # Handle other commands
+                payload = json.loads(msg.payload.decode())
+                command = payload.get('command')
+                params = payload.get('params', {})
+
+                if command in self.callback_registry:
+                    self.callback_registry[command](**params)
+                else:
+                    logger.error(f"Unknown command received: {command}")
+
+        except json.JSONDecodeError:
+            logger.error(f"Invalid JSON payload received: {msg.payload}")
+        except Exception as e:
+            logger.error(f"Error processing MQTT message: {e}")
+
+    def publish_status(self):
+        """Publish status updates periodically."""
+        while self.running:
+            try:
+                # Update all states
+                self._publish_running_state()
+                self._publish_pattern_state()
+                self._publish_playlist_state()
+                self._publish_serial_state()
+                
+                # Update speed state
+                self.client.publish(f"{self.speed_topic}/state", self.state.speed, retain=True)
+                
+                # Publish keepalive status
+                status = {
+                    "timestamp": time.time(),
+                    "client_id": self.client_id
+                }
+                self.client.publish(self.status_topic, json.dumps(status))
+                
+                # Wait for next interval
+                time.sleep(self.status_interval)
+            except Exception as e:
+                logger.error(f"Error publishing status: {e}")
+                time.sleep(5)  # Wait before retry
+
+    def start(self) -> None:
+        """Start the MQTT handler."""
+        if not self.is_enabled:
+            return
+        
+        try:
+            self.client.connect(self.broker, self.port)
+            self.client.loop_start()
+            
+            # Start status publishing thread
+            self.running = True
+            self.status_thread = threading.Thread(target=self.publish_status, daemon=True)
+            self.status_thread.start()
+            
+            # Get initial pattern and playlist lists
+            self.patterns = list_theta_rho_files()
+            self.playlists = list_all_playlists()
+
+            # Wait a bit for MQTT connection to establish
+            time.sleep(1)
+            
+            # Publish initial states
+            self._publish_running_state()
+            self._publish_pattern_state()
+            self._publish_playlist_state()
+            self._publish_serial_state()
+            
+            # Setup Home Assistant discovery
+            self.setup_ha_discovery()
+            
+            logger.info("MQTT Handler started successfully")
+        except Exception as e:
+            logger.error(f"Failed to start MQTT Handler: {e}")
+
+    def stop(self) -> None:
+        """Stop the MQTT handler."""
+        if not self.is_enabled:
+            return
+
+        self.running = False
+        if self.status_thread:
+            self.status_thread.join(timeout=1)
+        self.client.loop_stop()
+        self.client.disconnect()
+
+    @property
+    def is_enabled(self) -> bool:
+        """Return whether MQTT functionality is enabled."""
+        return bool(self.broker) 

+ 34 - 0
dune_weaver_flask/modules/mqtt/mock.py

@@ -0,0 +1,34 @@
+"""Mock MQTT handler implementation."""
+from typing import Dict, Callable
+from .base import BaseMQTTHandler
+from dune_weaver_flask.modules.core.state import state
+
+
+
+class MockMQTTHandler(BaseMQTTHandler):
+    """Mock implementation of MQTT handler that does nothing."""
+    
+    def start(self) -> None:
+        """No-op start."""
+        pass
+    
+    def stop(self) -> None:
+        """No-op stop."""
+        pass
+    
+    def update_state(self, **kwargs) -> None:
+        """No-op state update."""
+        pass
+    
+    @property
+    def is_enabled(self) -> bool:
+        """Always returns False since this is a mock."""
+        return False
+        
+    def publish_status(self) -> None:
+        """Mock status publisher."""
+        pass
+        
+    def setup_ha_discovery(self) -> None:
+        """Mock discovery setup."""
+        pass 

+ 56 - 0
dune_weaver_flask/modules/mqtt/utils.py

@@ -0,0 +1,56 @@
+"""MQTT utilities and callback management."""
+import os
+from typing import Dict, Callable
+from dune_weaver_flask.modules.core.pattern_manager import (
+    run_theta_rho_file, stop_actions, pause_execution,
+    resume_execution, THETA_RHO_DIR,
+    run_theta_rho_files, list_theta_rho_files
+)
+from dune_weaver_flask.modules.core.playlist_manager import get_playlist, run_playlist
+from dune_weaver_flask.modules.serial.serial_manager import (
+    is_connected, get_port, home
+)
+from dune_weaver_flask.modules.core.state import state
+
+def create_mqtt_callbacks() -> Dict[str, Callable]:
+    """Create and return the MQTT callback registry."""
+    def set_speed(speed):
+        state.speed = speed
+
+    return {
+        'run_pattern': lambda file_path: run_theta_rho_file(file_path),
+        'run_playlist': lambda playlist_name: run_playlist(
+            playlist_name,
+            run_mode="loop",  # Default to loop mode
+            pause_time=0,  # No pause between patterns
+            clear_pattern=None  # No clearing between patterns
+        ),
+        'stop': stop_actions,
+        'pause': pause_execution,
+        'resume': resume_execution,
+        'home': home,
+        'set_speed': set_speed
+    }
+
+def get_mqtt_state():
+    """Get the current state for MQTT updates."""
+    # Get list of pattern files
+    patterns = list_theta_rho_files()
+    
+    # Get current execution status
+    is_running = bool(state.current_playing_file) and not state.stop_requested
+    
+    # Get serial status
+    serial_connected = is_connected()
+    serial_port = get_port() if serial_connected else None
+    serial_status = f"connected to {serial_port}" if serial_connected else "disconnected"
+    
+    return {
+        'is_running': is_running,
+        'current_file': state.current_playing_file or '',
+        'patterns': sorted(patterns),
+        'serial': serial_status,
+        'current_playlist': state.current_playlist,
+        'current_playlist_index': state.current_playlist_index,
+        'playlist_mode': state.playlist_mode
+    } 

+ 2 - 0
requirements.txt

@@ -2,3 +2,5 @@ flask
 pyserial
 esptool
 tqdm
+paho-mqtt
+python-dotenv