소스 검색

Quality of life updates (#93)

### New Features

#### Custom Branding
- **Custom Logo & Favicon**: Upload a custom logo image from Settings → Application Settings
  - Favicon is automatically generated with circular crop from your logo
  - Supported formats: PNG, JPG, GIF, WebP, SVG (max 5MB)
  - Custom assets stored locally and excluded from version control

#### Connection Management
- **Preferred Port**: Set a preferred serial port for auto-connect
  - System automatically connects to this port on startup if available
  - Configurable in Settings → Connection

#### Homing Improvements
- **Auto-Home After X Patterns**: Automatically perform homing after a configurable number of patterns during playlist execution
  - Helps maintain positional accuracy over long runs
  - Homing occurs after the clear pattern completes, before the next pattern begins
- Homing section moved higher in Settings UI for better visibility

#### Still Sands (Scheduled Pause) Enhancements
- **Finish Current Pattern**: New option to complete the current pattern before entering scheduled pause, rather than stopping mid-pattern
- **Timezone Selection**: Configure timezone for scheduled pause times
  - Defaults to system timezone if not set
  - Supports all IANA timezone identifiers

#### MQTT Integration UI
- **Full MQTT Configuration Panel**: New Settings section to configure MQTT broker connection directly from the UI
  - Broker address and port
  - Username and password authentication
  - Device name, ID, and client ID customization
  - Home Assistant discovery prefix configuration
  - Real-time connection status indicator
  - Requires application restart after changes

#### Playlist Management
- **Rename Playlists**: Rename existing playlists via right-click context menu or action button
- **Pause Countdown**: Visual countdown timer between patterns showing time remaining before next pattern starts

### Performance & Optimization

#### Unified Settings API
- New `GET /api/settings` endpoint consolidates all settings into a single response
- New `PATCH /api/settings` endpoint for partial updates to any settings
- Reduces Settings page load time by minimizing API calls
- Individual settings endpoints marked as deprecated but remain functional for backwards compatibility

#### Browse Page Optimization
- Fixed preview images unnecessarily re-fetching when searching
- Properly awaits IndexedDB cache checks before triggering batch API requests
- Significantly reduces network requests when browsing cached patterns

### Bug Fixes
- **DW Touch**: Fixed pause functionality in touch interface
- **Application Exit**: Clean shutdown with proper resource cleanup
- **UI**: Various minor interface improvements and fixes

### Other Changes
- **Docker Restart**: Added restart button for Docker container deployments
- **LED Interface**: Improvements to LED controller integration and state management
Tuan Nguyen 2 달 전
부모
커밋
6802237951

+ 4 - 1
.gitignore

@@ -11,4 +11,7 @@ patterns/cached_images/custom_*
 # Node.js and build files
 node_modules/
 *.log
-*.png
+*.png
+# Custom branding assets (user uploads)
+static/custom/*
+!static/custom/.gitkeep

+ 1 - 1
VERSION

@@ -1 +1 @@
-3.5.0
+3.5.1

+ 36 - 2
dune-weaver-touch/backend.py

@@ -71,6 +71,7 @@ class Backend(QObject):
     screenStateChanged = Signal(bool)  # True = on, False = off
     screenTimeoutChanged = Signal(int)  # New signal for timeout changes
     pauseBetweenPatternsChanged = Signal(int)  # New signal for pause changes
+    pausedChanged = Signal(bool)  # Signal when pause state changes
 
     # Backend connection status signals
     backendConnectionChanged = Signal(bool)  # True = backend reachable, False = unreachable
@@ -90,6 +91,7 @@ class Backend(QObject):
         self._current_file = ""
         self._progress = 0
         self._is_running = False
+        self._is_paused = False  # Track pause state separately
         self._is_connected = False
         self._serial_ports = []
         self._serial_connected = False
@@ -188,7 +190,11 @@ class Backend(QObject):
     @Property(bool, notify=statusChanged)
     def isRunning(self):
         return self._is_running
-    
+
+    @Property(bool, notify=pausedChanged)
+    def isPaused(self):
+        return self._is_paused
+
     @Property(bool, notify=connectionChanged)
     def isConnected(self):
         return self._is_connected
@@ -320,6 +326,13 @@ class Backend(QObject):
                 self._current_file = new_file
                 self._is_running = status.get("is_running", False)
 
+                # Handle pause state from WebSocket
+                new_paused = status.get("is_paused", False)
+                if new_paused != self._is_paused:
+                    print(f"⏸️ Pause state changed: {self._is_paused} -> {new_paused}")
+                    self._is_paused = new_paused
+                    self.pausedChanged.emit(new_paused)
+
                 # Handle serial connection status from WebSocket
                 ws_connection_status = status.get("connection_status", False)
                 if ws_connection_status != self._serial_connected:
@@ -658,7 +671,28 @@ class Backend(QObject):
     @Slot()
     def sendHome(self):
         print("🏠 Sending home command...")
-        asyncio.create_task(self._api_call("/send_home"))
+        asyncio.create_task(self._send_home())
+
+    async def _send_home(self):
+        """Send home command without timeout - homing can take up to 90 seconds."""
+        if not self.session:
+            self.errorOccurred.emit("Backend not ready")
+            return
+
+        try:
+            print("🏠 Calling /send_home (no timeout - homing can take up to 90s)...")
+            async with self.session.post(f"{self.base_url}/send_home") as resp:
+                print(f"🏠 Home command response status: {resp.status}")
+                if resp.status == 200:
+                    response_data = await resp.json()
+                    print(f"✅ Home command successful: {response_data}")
+                else:
+                    print(f"❌ Home command failed with status: {resp.status}")
+                    response_text = await resp.text()
+                    self.errorOccurred.emit(f"Home failed: {resp.status} - {response_text}")
+        except Exception as e:
+            print(f"💥 Exception in home command: {e}")
+            self.errorOccurred.emit(str(e))
     
     @Slot()
     def moveToCenter(self):

+ 8 - 6
dune-weaver-touch/qml/pages/ExecutionPage.qml

@@ -314,25 +314,27 @@ Page {
                                         height: parent.height
                                         radius: 6
                                         color: pauseMouseArea.pressed ? "#1e40af" : (backend && backend.currentFile !== "" ? "#2563eb" : "#9ca3af")
-                                        
+
                                         Text {
                                             anchors.centerIn: parent
-                                            text: (backend && backend.isRunning) ? "||" : "▶"
+                                            // Show pause icon when running and not paused, play icon when paused
+                                            text: (backend && backend.isRunning && !backend.isPaused) ? "||" : "▶"
                                             color: "white"
                                             font.pixelSize: 14
                                             font.bold: true
                                         }
-                                        
+
                                         MouseArea {
                                             id: pauseMouseArea
                                             anchors.fill: parent
                                             enabled: backend && backend.currentFile !== ""
                                             onClicked: {
                                                 if (backend) {
-                                                    if (backend.isRunning) {
-                                                        backend.pauseExecution()
-                                                    } else {
+                                                    // If paused, resume; otherwise pause
+                                                    if (backend.isPaused) {
                                                         backend.resumeExecution()
+                                                    } else {
+                                                        backend.pauseExecution()
                                                     }
                                                 }
                                             }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 747 - 53
main.py


+ 11 - 2
modules/connection/connection_manager.py

@@ -220,12 +220,21 @@ def connect_device(homing=True):
 
     ports = list_serial_ports()
 
-    if state.port and state.port in ports:
+    # Priority for auto-connect:
+    # 1. Preferred port (user's explicit choice) if available
+    # 2. Last used port if available
+    # 3. First available port as fallback
+    if state.preferred_port and state.preferred_port in ports:
+        logger.info(f"Connecting to preferred port: {state.preferred_port}")
+        state.conn = SerialConnection(state.preferred_port)
+    elif state.port and state.port in ports:
+        logger.info(f"Connecting to last used port: {state.port}")
         state.conn = SerialConnection(state.port)
     elif ports:
+        logger.info(f"Connecting to first available port: {ports[0]}")
         state.conn = SerialConnection(ports[0])
     else:
-        logger.error("Auto connect failed.")
+        logger.error("Auto connect failed: No serial ports available")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')
 
     if (state.conn.is_connected() if state.conn else False):

+ 99 - 37
modules/core/pattern_manager.py

@@ -35,12 +35,12 @@ pattern_lock = asyncio.Lock()
 # Progress update task
 progress_update_task = None
 
-# Cache timezone at module level - read once per session
+# Cache timezone at module level - read once per session (cleared when user changes timezone)
 _cached_timezone = None
 _cached_zoneinfo = None
 
-def _get_system_timezone():
-    """Get and cache the system timezone. Called once per session."""
+def _get_timezone():
+    """Get and cache the timezone for Still Sands. Uses user-selected timezone if set, otherwise system timezone."""
     global _cached_timezone, _cached_zoneinfo
 
     if _cached_timezone is not None:
@@ -48,25 +48,30 @@ def _get_system_timezone():
 
     user_tz = 'UTC'  # Default fallback
 
-    # Try to read timezone from /etc/host-timezone (mounted from host)
-    try:
-        if os.path.exists('/etc/host-timezone'):
-            with open('/etc/host-timezone', 'r') as f:
-                user_tz = f.read().strip()
-                logger.info(f"Still Sands using timezone: {user_tz} (from host system)")
-        # Fallback to /etc/timezone if host-timezone doesn't exist
-        elif os.path.exists('/etc/timezone'):
-            with open('/etc/timezone', 'r') as f:
-                user_tz = f.read().strip()
-                logger.info(f"Still Sands using timezone: {user_tz} (from container)")
-        # Fallback to TZ environment variable
-        elif os.environ.get('TZ'):
-            user_tz = os.environ.get('TZ')
-            logger.info(f"Still Sands using timezone: {user_tz} (from environment)")
-        else:
-            logger.info("Still Sands using timezone: UTC (default)")
-    except Exception as e:
-        logger.debug(f"Could not read timezone: {e}")
+    # First, check if user has selected a specific timezone in settings
+    if state.scheduled_pause_timezone:
+        user_tz = state.scheduled_pause_timezone
+        logger.info(f"Still Sands using timezone: {user_tz} (user-selected)")
+    else:
+        # Fall back to system timezone detection
+        try:
+            if os.path.exists('/etc/host-timezone'):
+                with open('/etc/host-timezone', 'r') as f:
+                    user_tz = f.read().strip()
+                    logger.info(f"Still Sands using timezone: {user_tz} (from host system)")
+            # Fallback to /etc/timezone if host-timezone doesn't exist
+            elif os.path.exists('/etc/timezone'):
+                with open('/etc/timezone', 'r') as f:
+                    user_tz = f.read().strip()
+                    logger.info(f"Still Sands using timezone: {user_tz} (from container)")
+            # Fallback to TZ environment variable
+            elif os.environ.get('TZ'):
+                user_tz = os.environ.get('TZ')
+                logger.info(f"Still Sands using timezone: {user_tz} (from environment)")
+            else:
+                logger.info("Still Sands using timezone: UTC (system default)")
+        except Exception as e:
+            logger.debug(f"Could not read timezone: {e}")
 
     # Cache the timezone
     _cached_timezone = user_tz
@@ -83,8 +88,8 @@ def is_in_scheduled_pause_period():
     if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
         return False
 
-    # Get cached timezone
-    tz_info = _get_system_timezone()
+    # Get cached timezone (user-selected or system default)
+    tz_info = _get_timezone()
 
     try:
         # Get current time in user's timezone
@@ -683,7 +688,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         start_time = time.time()
         if state.led_controller:
             logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
-            state.led_controller.effect_playing(state.dw_led_playing_effect)
+            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
             # Cancel idle timeout when playing starts
             idle_timeout_manager.cancel_timeout()
 
@@ -700,7 +705,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                 if state.stop_requested:
                     logger.info("Execution stopped by user")
                     if state.led_controller:
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                     break
 
@@ -708,13 +713,14 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                     logger.info("Skipping pattern...")
                     await connection_manager.check_idle_async()
                     if state.led_controller:
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
                     break
 
                 # Wait for resume if paused (manual or scheduled)
                 manual_pause = state.pause_requested
-                scheduled_pause = is_in_scheduled_pause_period()
+                # Only check scheduled pause during pattern if "finish pattern first" is NOT enabled
+                scheduled_pause = is_in_scheduled_pause_period() if not state.scheduled_pause_finish_pattern else False
 
                 if manual_pause or scheduled_pause:
                     if manual_pause and scheduled_pause:
@@ -726,12 +732,12 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         # Turn off LED controller if scheduled pause and control_wled is enabled
                         if state.scheduled_pause_control_wled and state.led_controller:
                             logger.info("Turning off LED lights during Still Sands period")
-                            state.led_controller.set_power(0)
+                            await state.led_controller.set_power_async(0)
 
                     # Only show idle effect if NOT in scheduled pause with LED control
                     # (manual pause always shows idle effect)
                     if state.led_controller and not (scheduled_pause and state.scheduled_pause_control_wled):
-                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
                         start_idle_led_timeout()
 
                     # Remember if we turned off LED controller for scheduled pause
@@ -749,11 +755,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         # Turn LED controller back on if it was turned off for scheduled pause
                         if wled_was_off_for_scheduled:
                             logger.info("Turning LED lights back on as Still Sands period ended")
-                            state.led_controller.set_power(1)
+                            await state.led_controller.set_power_async(1)
                             # CRITICAL: Give LED controller time to fully power on before sending more commands
                             # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
                             await asyncio.sleep(0.5)
-                        state.led_controller.effect_playing(state.dw_led_playing_effect)
+                        await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                         # Cancel idle timeout when resuming from pause
                         idle_timeout_manager.cancel_timeout()
 
@@ -790,7 +796,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         # Set LED back to idle when pattern completes normally (not stopped early)
         if state.led_controller and not state.stop_requested:
             logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
-            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
             start_idle_led_timeout()
             logger.debug("LED effect set to idle after pattern completion")
 
@@ -866,6 +872,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
 
             # Set the playlist to the first pattern
             state.current_playlist = pattern_sequence
+
+            # Reset pattern counter at the start of the playlist
+            state.patterns_since_last_home = 0
+
             # Execute the pattern sequence
             for idx, file_path in enumerate(pattern_sequence):
                 state.current_playlist_index = idx
@@ -873,16 +883,68 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     logger.info("Execution stopped")
                     return
 
+                current_is_clear = is_clear_pattern(file_path)
+
+                # Check if we need to auto-home before this clear pattern
+                # Auto-home happens after pause, before the clear pattern runs
+                if current_is_clear and state.auto_home_enabled:
+                    # Check if we've reached the pattern threshold
+                    if state.patterns_since_last_home >= state.auto_home_after_patterns:
+                        logger.info(f"Auto-homing triggered after {state.patterns_since_last_home} patterns")
+                        try:
+                            # Perform homing using connection_manager
+                            success = await asyncio.to_thread(connection_manager.home)
+                            if success:
+                                logger.info("Auto-homing completed successfully")
+                                state.patterns_since_last_home = 0
+                            else:
+                                logger.warning("Auto-homing failed, continuing with playlist")
+                        except Exception as e:
+                            logger.error(f"Error during auto-homing: {e}")
+
                 # Update state for main patterns only
                 logger.info(f"Running pattern {file_path}")
-                
+
                 # Execute the pattern
                 await run_theta_rho_file(file_path, is_playlist=True)
 
+                # Increment pattern counter only for non-clear patterns
+                if not current_is_clear:
+                    state.patterns_since_last_home += 1
+                    logger.debug(f"Patterns since last home: {state.patterns_since_last_home}")
+
+                # Check for scheduled pause after pattern completes (when "finish pattern first" is enabled)
+                if state.scheduled_pause_finish_pattern and is_in_scheduled_pause_period() and not state.stop_requested:
+                    logger.info("Pattern completed. Entering Still Sands period (finish pattern first mode)...")
+
+                    # Turn off LED controller if control_wled is enabled
+                    wled_was_off_for_scheduled = False
+                    if state.scheduled_pause_control_wled and state.led_controller:
+                        logger.info("Turning off LED lights during Still Sands period")
+                        await state.led_controller.set_power_async(0)
+                        wled_was_off_for_scheduled = True
+                    elif state.led_controller:
+                        await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
+                        start_idle_led_timeout()
+
+                    # Wait until we're outside the scheduled pause period
+                    while is_in_scheduled_pause_period() and not state.stop_requested:
+                        await asyncio.sleep(1)
+
+                    if not state.stop_requested:
+                        logger.info("Still Sands period ended. Resuming playlist...")
+                        if state.led_controller:
+                            if wled_was_off_for_scheduled:
+                                logger.info("Turning LED lights back on as Still Sands period ended")
+                                await state.led_controller.set_power_async(1)
+                                await asyncio.sleep(0.5)  # Critical delay for LED controller
+                            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                            idle_timeout_manager.cancel_timeout()
+
                 # Handle pause between patterns
                 if idx < len(pattern_sequence) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
                     # Check if current pattern is a clear pattern
-                    if is_clear_pattern(file_path):
+                    if current_is_clear:
                         logger.info("Skipping pause after clear pattern")
                     else:
                         logger.info(f"Pausing for {pause_time} seconds")
@@ -895,7 +957,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                                 break
                             await asyncio.sleep(1)
                         state.pause_time_remaining = 0
-                    
+
                 state.skip_requested = False
 
             if run_mode == "indefinite":
@@ -933,7 +995,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
         state.playlist_mode = None
 
         if state.led_controller:
-            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
             start_idle_led_timeout()
 
         logger.info("All requested patterns completed (or stopped) and state cleared")

+ 27 - 0
modules/core/playlist_manager.py

@@ -86,6 +86,33 @@ def add_to_playlist(playlist_name, pattern):
     logger.info(f"Added pattern '{pattern}' to playlist '{playlist_name}'")
     return True
 
+def rename_playlist(old_name, new_name):
+    """Rename an existing playlist."""
+    if not new_name or not new_name.strip():
+        logger.warning("Cannot rename playlist: new name is empty")
+        return False, "New name cannot be empty"
+
+    new_name = new_name.strip()
+
+    playlists_dict = load_playlists()
+    if old_name not in playlists_dict:
+        logger.warning(f"Cannot rename non-existent playlist: {old_name}")
+        return False, "Playlist not found"
+
+    if old_name == new_name:
+        return True, "Name unchanged"
+
+    if new_name in playlists_dict:
+        logger.warning(f"Cannot rename playlist: '{new_name}' already exists")
+        return False, "A playlist with that name already exists"
+
+    # Copy files to new key and delete old key
+    playlists_dict[new_name] = playlists_dict[old_name]
+    del playlists_dict[old_name]
+    save_playlists(playlists_dict)
+    logger.info(f"Renamed playlist '{old_name}' to '{new_name}'")
+    return True, f"Playlist renamed to '{new_name}'"
+
 async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
     """Run a playlist with the given options."""
     if pattern_manager.pattern_lock.locked():

+ 85 - 1
modules/core/state.py

@@ -6,6 +6,10 @@ import logging
 
 logger = logging.getLogger(__name__)
 
+# Debounce timer for state saves (reduces SD card wear on Pi)
+_save_timer = None
+_save_lock = threading.Lock()
+
 class AppState:
     def __init__(self):
         # Private variables for properties
@@ -45,10 +49,17 @@ class AppState:
         # After homing, theta will be set to this value
         self.angular_homing_offset_degrees = 0.0
 
+        # Auto-homing settings for playlists
+        # When enabled, performs homing after X patterns during playlist execution
+        self.auto_home_enabled = False
+        self.auto_home_after_patterns = 5  # Number of patterns after which to auto-home
+        self.patterns_since_last_home = 0  # Counter for patterns played since last home
+
         self.STATE_FILE = "state.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
         self.port = None
+        self.preferred_port = None  # User's preferred port for auto-connect
         self.wled_ip = None
         self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
@@ -82,6 +93,10 @@ class AppState:
         
         # Application name setting
         self.app_name = "Dune Weaver"  # Default app name
+
+        # Custom branding settings (filenames only, files stored in static/custom/)
+        # Favicon is auto-generated from logo as logo-favicon.ico
+        self.custom_logo = None  # Custom logo filename (e.g., "logo-abc123.png")
         
         # auto_play mode settings
         self.auto_play_enabled = False
@@ -95,7 +110,20 @@ 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
-        
+        self.scheduled_pause_finish_pattern = False  # Finish current pattern before pausing
+        self.scheduled_pause_timezone = None  # User-selected timezone (None = use system timezone)
+
+        # 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
@@ -210,6 +238,8 @@ class AppState:
             "gear_ratio": self.gear_ratio,
             "homing": self.homing,
             "angular_homing_offset_degrees": self.angular_homing_offset_degrees,
+            "auto_home_enabled": self.auto_home_enabled,
+            "auto_home_after_patterns": self.auto_home_after_patterns,
             "current_playlist": self._current_playlist,
             "current_playlist_name": self._current_playlist_name,
             "current_playlist_index": self.current_playlist_index,
@@ -220,6 +250,7 @@ class AppState:
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
             "port": self.port,
+            "preferred_port": self.preferred_port,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
             "dw_led_num_leds": self.dw_led_num_leds,
@@ -233,6 +264,7 @@ class AppState:
             "dw_led_idle_timeout_enabled": self.dw_led_idle_timeout_enabled,
             "dw_led_idle_timeout_minutes": self.dw_led_idle_timeout_minutes,
             "app_name": self.app_name,
+            "custom_logo": self.custom_logo,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
             "auto_play_run_mode": self.auto_play_run_mode,
@@ -242,6 +274,17 @@ 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,
+            "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
+            "scheduled_pause_timezone": self.scheduled_pause_timezone,
+            "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):
@@ -261,6 +304,8 @@ class AppState:
         self.gear_ratio = data.get('gear_ratio', 10)
         self.homing = data.get('homing', 0)
         self.angular_homing_offset_degrees = data.get('angular_homing_offset_degrees', 0.0)
+        self.auto_home_enabled = data.get('auto_home_enabled', False)
+        self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
         self._current_playlist = data.get("current_playlist", None)
         self._current_playlist_name = data.get("current_playlist_name", None)
         self.current_playlist_index = data.get("current_playlist_index", None)
@@ -271,6 +316,7 @@ class AppState:
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.port = data.get("port", None)
+        self.preferred_port = data.get("preferred_port", None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
         self.dw_led_num_leds = data.get('dw_led_num_leds', 60)
@@ -302,6 +348,7 @@ class AppState:
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
 
         self.app_name = data.get("app_name", "Dune Weaver")
+        self.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)
         self.auto_play_run_mode = data.get("auto_play_run_mode", "loop")
@@ -311,6 +358,17 @@ 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.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
+        self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
+        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."""
@@ -320,6 +378,32 @@ class AppState:
         except Exception as e:
             print(f"Error saving state to {self.STATE_FILE}: {e}")
 
+    def save_debounced(self, delay: float = 2.0):
+        """
+        Schedule a state save after a delay, coalescing multiple rapid saves.
+        This reduces SD card writes on Raspberry Pi.
+
+        Args:
+            delay: Seconds to wait before saving (default 2.0)
+        """
+        global _save_timer
+        with _save_lock:
+            # Cancel any pending save
+            if _save_timer is not None:
+                _save_timer.cancel()
+            # Schedule new save
+            _save_timer = threading.Timer(delay, self._do_debounced_save)
+            _save_timer.daemon = True  # Don't block shutdown
+            _save_timer.start()
+
+    def _do_debounced_save(self):
+        """Internal method called by debounce timer."""
+        global _save_timer
+        with _save_lock:
+            _save_timer = None
+        self.save()
+        logger.debug("Debounced state save completed")
+
     def load(self):
         """Load state from a JSON file. If the file doesn't exist, create it with default values."""
         if not os.path.exists(self.STATE_FILE):

+ 28 - 0
modules/led/led_interface.py

@@ -2,6 +2,7 @@
 Unified LED interface for different LED control systems
 Provides a common abstraction layer for pattern manager integration.
 """
+import asyncio
 from typing import Optional, Literal
 from modules.led.led_controller import LEDController, effect_loading as wled_loading, effect_idle as wled_idle, effect_connected as wled_connected, effect_playing as wled_playing
 
@@ -147,3 +148,30 @@ class LEDInterface:
     def get_controller(self):
         """Get the underlying controller instance (for advanced usage)"""
         return self._controller
+
+    # Async versions of methods for non-blocking calls from async context
+    # These use asyncio.to_thread() to avoid blocking the event loop
+
+    async def effect_loading_async(self) -> bool:
+        """Show loading effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_loading)
+
+    async def effect_idle_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show idle effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_idle, effect_name)
+
+    async def effect_connected_async(self) -> bool:
+        """Show connected effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_connected)
+
+    async def effect_playing_async(self, effect_name: Optional[str] = None) -> bool:
+        """Show playing effect (non-blocking)"""
+        return await asyncio.to_thread(self.effect_playing, effect_name)
+
+    async def set_power_async(self, state: int) -> dict:
+        """Set power state (non-blocking)"""
+        return await asyncio.to_thread(self.set_power, state)
+
+    async def check_status_async(self) -> dict:
+        """Check controller status (non-blocking)"""
+        return await asyncio.to_thread(self.check_status)

+ 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

+ 0 - 0
static/custom/.gitkeep


+ 24 - 5
static/js/base.js

@@ -119,6 +119,10 @@ let reconnectAttempts = 0;
 const maxReconnectAttempts = 5;
 const reconnectDelay = 3000; // 3 seconds
 let isEditingSpeed = false; // Track if user is editing speed
+
+// WebSocket UI update throttling for Pi performance
+let lastUIUpdate = 0;
+const UI_UPDATE_INTERVAL = 100; // Minimum ms between UI updates (10 updates/sec max)
 let playerPreviewData = null; // Store the current pattern's preview data for modal
 let playerPreviewCtx = null; // Store the canvas context for modal preview
 let playerAnimationId = null; // Store animation frame ID for modal
@@ -179,6 +183,13 @@ function connectWebSocket() {
         try {
             const data = JSON.parse(event.data);
             if (data.type === 'status_update') {
+                // Throttle UI updates for better Pi performance
+                const now = Date.now();
+                if (now - lastUIUpdate < UI_UPDATE_INTERVAL) {
+                    return; // Skip this update, too soon
+                }
+                lastUIUpdate = now;
+
                 // Update modal status with the full data
                 syncModalControls(data.data);
                 
@@ -367,7 +378,8 @@ function setupPlayerPreviewCanvas(ctx) {
     container.style.minHeight = `${finalSize}px`;
     
     // Set the internal canvas size for high-DPI rendering
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Cap at 1.5x for better Pi performance (was 2x forced)
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     canvas.width = finalSize * pixelRatio;
     canvas.height = finalSize * pixelRatio;
     
@@ -421,7 +433,8 @@ function drawLoadingState(ctx) {
     if (!ctx) return;
 
     const canvas = ctx.canvas;
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Must match the pixelRatio used when setting canvas size
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     const containerSize = canvas.width / pixelRatio;
     const center = containerSize / 2;
 
@@ -453,7 +466,8 @@ function drawPlayerPreview(ctx, progress) {
     if (!ctx || !playerPreviewData || playerPreviewData.length === 0) return;
     
     const canvas = ctx.canvas;
-    const pixelRatio = (window.devicePixelRatio || 1) * 2;
+    // Must match the pixelRatio used when setting canvas size
+    const pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5);
     const containerSize = canvas.width / pixelRatio;
     const center = containerSize / 2;
     const scale = (containerSize / 2) - 30;
@@ -840,10 +854,15 @@ function syncModalControls(status) {
         modalPatternPreviewImg.src = previewUrl;
     }
     
-    // ETA
+    // ETA or Pause Countdown
     const modalEta = document.getElementById('modal-eta');
     if (modalEta) {
-        if (status.progress && status.progress.remaining_time !== null) {
+        // Check if we're in a pause between patterns
+        if (status.pause_time_remaining && status.pause_time_remaining > 0) {
+            const minutes = Math.floor(status.pause_time_remaining / 60);
+            const seconds = Math.floor(status.pause_time_remaining % 60);
+            modalEta.textContent = `Next in: ${minutes}:${seconds.toString().padStart(2, '0')}`;
+        } else if (status.progress && status.progress.remaining_time !== null) {
             const minutes = Math.floor(status.progress.remaining_time / 60);
             const seconds = Math.floor(status.progress.remaining_time % 60);
             modalEta.textContent = `ETA: ${minutes}:${seconds.toString().padStart(2, '0')}`;

+ 15 - 8
static/js/index.js

@@ -502,22 +502,29 @@ async function processPendingBatch() {
 }
 
 // Trigger preview loading for currently visible patterns
-function triggerPreviewLoadingForVisible() {
+async function triggerPreviewLoadingForVisible() {
     // Get all pattern cards currently in the DOM
     const patternCards = document.querySelectorAll('.pattern-card');
-    
+
+    // Collect all patterns that need checking
+    const patternsToCheck = [];
     patternCards.forEach(card => {
         const pattern = card.dataset.pattern;
         const previewContainer = card.querySelector('.pattern-preview');
-        
-        // Check if this pattern needs preview loading
+
+        // Check if this pattern needs preview loading (only check in-memory cache here)
         if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
-            // Add to batch for immediate loading
-            addPatternToBatch(pattern, previewContainer);
+            patternsToCheck.push({ pattern, previewContainer });
         }
     });
-    
-    // Process any pending previews immediately
+
+    // Wait for all IndexedDB cache checks to complete before processing batch
+    // This prevents unnecessary API calls for patterns that are already cached in IndexedDB
+    await Promise.all(patternsToCheck.map(({ pattern, previewContainer }) =>
+        addPatternToBatch(pattern, previewContainer)
+    ));
+
+    // Process any pending previews that weren't found in cache
     if (pendingPatterns.size > 0) {
         processPendingBatch();
     }

+ 128 - 15
static/js/playlists.js

@@ -539,12 +539,12 @@ async function loadPlaylists() {
         if (response.ok) {
             allPlaylists = await response.json();
             displayPlaylists();
-            // Auto-select last selected
+            // Auto-select last selected using data attribute
             const last = getLastSelectedPlaylist();
             if (last && allPlaylists.includes(last)) {
                 setTimeout(() => {
                     const nav = document.getElementById('playlistsNav');
-                    const el = Array.from(nav.querySelectorAll('a')).find(a => a.textContent.trim() === last);
+                    const el = nav.querySelector(`a[data-playlist-name="${last}"]`);
                     if (el) el.click();
                 }, 0);
             }
@@ -574,12 +574,13 @@ function displayPlaylists() {
     allPlaylists.forEach(playlist => {
         const playlistItem = document.createElement('a');
         playlistItem.className = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors duration-150 cursor-pointer';
+        playlistItem.dataset.playlistName = playlist; // Add data attribute for easy lookup
         playlistItem.innerHTML = `
             <span class="material-icons text-lg text-gray-500 dark:text-gray-400">queue_music</span>
             <span class="text-sm font-medium flex-1 truncate">${playlist}</span>
             <span class="material-icons text-lg text-gray-400 dark:text-gray-500">chevron_right</span>
         `;
-        
+
         playlistItem.addEventListener('click', () => selectPlaylist(playlist, playlistItem));
         playlistsNav.appendChild(playlistItem);
     });
@@ -600,16 +601,22 @@ async function selectPlaylist(playlistName, element) {
     // Update current playlist
     currentPlaylist = playlistName;
     
-    // Update header with playlist name and delete button
+    // Update header with playlist name, rename and delete buttons
     const header = document.getElementById('currentPlaylistTitle');
     header.innerHTML = `
         <h1 class="text-gray-900 dark:text-gray-100 text-2xl font-semibold leading-tight truncate">${playlistName}</h1>
-        <button id="deletePlaylistBtn" class="p-1 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-500 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 transition-all duration-150 flex-shrink-0" title="Delete playlist">
-            <span class="material-icons text-lg">delete</span>
-        </button>
+        <div class="flex items-center gap-1 flex-shrink-0">
+            <button id="renamePlaylistBtn" class="p-1 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/20 text-gray-500 dark:text-gray-500 hover:text-blue-500 dark:hover:text-blue-400 transition-all duration-150" title="Rename playlist">
+                <span class="material-icons text-lg">edit</span>
+            </button>
+            <button id="deletePlaylistBtn" class="p-1 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-500 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 transition-all duration-150" title="Delete playlist">
+                <span class="material-icons text-lg">delete</span>
+            </button>
+        </div>
     `;
-    
-    // Add delete button event listener
+
+    // Add button event listeners
+    document.getElementById('renamePlaylistBtn').addEventListener('click', () => openRenameModal(playlistName));
     document.getElementById('deletePlaylistBtn').addEventListener('click', () => deletePlaylist(playlistName));
 
     // Enable buttons
@@ -648,7 +655,7 @@ async function loadPlaylistPatterns(playlistName) {
 // Display patterns in the current playlist
 async function displayPlaylistPatterns(patterns) {
     const patternsGrid = document.getElementById('patternsGrid');
-    
+
     if (patterns.length === 0) {
         patternsGrid.innerHTML = `
             <div class="flex items-center justify-center col-span-full py-12 text-gray-500 dark:text-gray-400">
@@ -660,16 +667,16 @@ async function displayPlaylistPatterns(patterns) {
 
     // Clear grid and add all pattern cards
     patternsGrid.innerHTML = '';
-    
+
     patterns.forEach(pattern => {
         const patternCard = createPatternCard(pattern, true);
         patternsGrid.appendChild(patternCard);
         patternCard.dataset.pattern = pattern;
-        
+
         // Set up lazy loading for patterns outside viewport
         intersectionObserver.observe(patternCard);
     });
-    
+
     // After DOM is updated, immediately load previews for visible patterns
     // Use requestAnimationFrame to ensure DOM layout is complete
     requestAnimationFrame(() => {
@@ -719,10 +726,10 @@ async function loadVisiblePlaylistPreviews() {
 function createPatternCard(pattern, showRemove = false) {
     const card = document.createElement('div');
     card.className = 'flex flex-col gap-3 group cursor-pointer relative';
-    
+
     const previewContainer = document.createElement('div');
     previewContainer.className = 'w-full aspect-square bg-cover rounded-full shadow-sm group-hover:shadow-md transition-shadow duration-150 border border-gray-200 dark:border-gray-700 pattern-preview relative';
-    
+
     // Check in-memory cache first
     const previewData = previewCache.get(pattern);
     if (previewData && !previewData.error && previewData.image_data) {
@@ -1515,6 +1522,97 @@ async function deletePlaylist(playlistName) {
     }
 }
 
+// Open rename modal
+function openRenameModal(playlistName) {
+    const modal = document.getElementById('renamePlaylistModal');
+    const input = document.getElementById('renamePlaylistInput');
+
+    // Set the current name
+    input.value = playlistName;
+    input.dataset.oldName = playlistName;
+
+    // Show modal
+    modal.classList.remove('hidden');
+
+    // Focus and select input
+    const focusInput = () => {
+        input.focus();
+        input.select();
+    };
+
+    focusInput();
+    requestAnimationFrame(focusInput);
+    setTimeout(focusInput, 50);
+}
+
+// Close rename modal
+function closeRenameModal() {
+    const modal = document.getElementById('renamePlaylistModal');
+    const input = document.getElementById('renamePlaylistInput');
+
+    modal.classList.add('hidden');
+    input.value = '';
+    delete input.dataset.oldName;
+}
+
+// Rename playlist
+async function renamePlaylist() {
+    const input = document.getElementById('renamePlaylistInput');
+    const oldName = input.dataset.oldName;
+    const newName = input.value.trim();
+
+    if (!newName) {
+        showStatusMessage('Please enter a playlist name', 'warning');
+        return;
+    }
+
+    if (newName === oldName) {
+        closeRenameModal();
+        return;
+    }
+
+    try {
+        const response = await fetch('/rename_playlist', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({
+                old_name: oldName,
+                new_name: newName
+            })
+        });
+
+        if (response.ok) {
+            const data = await response.json();
+            showStatusMessage(`Playlist renamed to "${newName}"`, 'success');
+            closeRenameModal();
+
+            // Update current playlist reference
+            if (currentPlaylist === oldName) {
+                currentPlaylist = newName;
+
+                // Update last selected playlist
+                saveLastSelectedPlaylist(newName);
+            }
+
+            // Reload playlists and reselect
+            await loadPlaylists();
+
+            // Find and click the renamed playlist using data attribute
+            setTimeout(() => {
+                const nav = document.getElementById('playlistsNav');
+                const el = nav.querySelector(`a[data-playlist-name="${newName}"]`);
+                if (el) el.click();
+            }, 100);
+        } else {
+            const data = await response.json();
+            throw new Error(data.detail || 'Failed to rename playlist');
+        }
+    } catch (error) {
+        logMessage(`Error renaming playlist: ${error.message}`, LOG_TYPE.ERROR);
+        showStatusMessage(error.message || 'Failed to rename playlist', 'error');
+    }
+}
+
 // Setup event listeners
 function setupEventListeners() {
     // Mobile back button event listeners
@@ -1638,6 +1736,15 @@ function setupEventListeners() {
         }
     });
 
+    // Rename modal event listeners
+    document.getElementById('cancelRenameBtn').addEventListener('click', closeRenameModal);
+    document.getElementById('confirmRenameBtn').addEventListener('click', renamePlaylist);
+    document.getElementById('renamePlaylistInput').addEventListener('keypress', (e) => {
+        if (e.key === 'Enter') {
+            renamePlaylist();
+        }
+    });
+
     // Close modals when clicking outside
     document.getElementById('addPlaylistModal').addEventListener('click', (e) => {
         if (e.target.id === 'addPlaylistModal') {
@@ -1652,6 +1759,12 @@ function setupEventListeners() {
             document.getElementById('addPatternsModal').classList.add('hidden');
         }
     });
+
+    document.getElementById('renamePlaylistModal').addEventListener('click', (e) => {
+        if (e.target.id === 'renamePlaylistModal') {
+            closeRenameModal();
+        }
+    });
 }
 
 // Initialize playlists page

+ 625 - 66
static/js/settings.js

@@ -112,17 +112,18 @@ async function updateSerialPorts() {
             const ports = await response.json();
             const portsElement = document.getElementById('availablePorts');
             const portSelect = document.getElementById('portSelect');
-            
+            const preferredPortSelect = document.getElementById('preferredPortSelect');
+
             if (portsElement) {
                 portsElement.textContent = ports.length > 0 ? ports.join(', ') : 'No ports available';
             }
-            
+
             if (portSelect) {
                 // Clear existing options except the first one
                 while (portSelect.options.length > 1) {
                     portSelect.remove(1);
                 }
-                
+
                 // Add new options
                 ports.forEach(port => {
                     const option = document.createElement('option');
@@ -141,12 +142,116 @@ async function updateSerialPorts() {
                     }
                 }
             }
+
+            // Also update the preferred port select dropdown
+            if (preferredPortSelect) {
+                // Store current selection
+                const currentPreferred = preferredPortSelect.value;
+
+                // Clear existing options except the first one (no preference)
+                while (preferredPortSelect.options.length > 1) {
+                    preferredPortSelect.remove(1);
+                }
+
+                // Add all available ports
+                ports.forEach(port => {
+                    const option = document.createElement('option');
+                    option.value = port;
+                    option.textContent = port;
+                    preferredPortSelect.appendChild(option);
+                });
+
+                // Restore selection if it's still available
+                if (currentPreferred && ports.includes(currentPreferred)) {
+                    preferredPortSelect.value = currentPreferred;
+                }
+            }
         }
     } catch (error) {
         logMessage(`Error fetching serial ports: ${error.message}`, LOG_TYPE.ERROR);
     }
 }
 
+// Function to load and display preferred port setting
+async function loadPreferredPort() {
+    try {
+        const response = await fetch('/api/preferred-port');
+        if (response.ok) {
+            const data = await response.json();
+            const preferredPortSelect = document.getElementById('preferredPortSelect');
+            const currentPreferredPort = document.getElementById('currentPreferredPort');
+            const preferredPortDisplay = document.getElementById('preferredPortDisplay');
+
+            if (preferredPortSelect && data.preferred_port) {
+                // Check if the preferred port is in the options
+                const optionExists = Array.from(preferredPortSelect.options).some(
+                    opt => opt.value === data.preferred_port
+                );
+
+                if (optionExists) {
+                    preferredPortSelect.value = data.preferred_port;
+                } else {
+                    // Add the preferred port as an option (it might not be currently available)
+                    const option = document.createElement('option');
+                    option.value = data.preferred_port;
+                    option.textContent = `${data.preferred_port} (not currently available)`;
+                    preferredPortSelect.appendChild(option);
+                    preferredPortSelect.value = data.preferred_port;
+                }
+            }
+
+            // Show current preferred port indicator
+            if (currentPreferredPort && preferredPortDisplay && data.preferred_port) {
+                preferredPortDisplay.textContent = `Currently set to: ${data.preferred_port}`;
+                currentPreferredPort.classList.remove('hidden');
+            } else if (currentPreferredPort) {
+                currentPreferredPort.classList.add('hidden');
+            }
+        }
+    } catch (error) {
+        logMessage(`Error loading preferred port: ${error.message}`, LOG_TYPE.ERROR);
+    }
+}
+
+// Function to save preferred port setting
+async function savePreferredPort() {
+    const preferredPortSelect = document.getElementById('preferredPortSelect');
+    if (!preferredPortSelect) return;
+
+    const preferredPort = preferredPortSelect.value || null;
+
+    try {
+        const response = await fetch('/api/settings', {
+            method: 'PATCH',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ connection: { preferred_port: preferredPort } })
+        });
+
+        if (response.ok) {
+            await response.json();
+            const currentPreferredPort = document.getElementById('currentPreferredPort');
+            const preferredPortDisplay = document.getElementById('preferredPortDisplay');
+
+            if (preferredPort) {
+                showStatusMessage(`Preferred port set to: ${preferredPort}`, 'success');
+                if (currentPreferredPort && preferredPortDisplay) {
+                    preferredPortDisplay.textContent = `Currently set to: ${preferredPort}`;
+                    currentPreferredPort.classList.remove('hidden');
+                }
+            } else {
+                showStatusMessage('Preferred port cleared - will auto-detect on startup', 'success');
+                if (currentPreferredPort) {
+                    currentPreferredPort.classList.add('hidden');
+                }
+            }
+        } else {
+            throw new Error('Failed to save preferred port');
+        }
+    } catch (error) {
+        showStatusMessage(`Failed to save preferred port: ${error.message}`, 'error');
+    }
+}
+
 function setWledButtonState(isSet) {
     const saveWledConfig = document.getElementById('saveWledConfig');
     if (!saveWledConfig) return;
@@ -252,35 +357,39 @@ document.addEventListener('DOMContentLoaded', async () => {
         }, 300); // Delay to ensure page is fully loaded
     }
     
-    // Load all data asynchronously
+    // Load all data asynchronously using unified settings endpoint
     Promise.all([
-        // Check connection status
-        fetch('/serial_status').then(response => response.json()).catch(() => ({ connected: false })),
+        // Unified settings endpoint (replaces multiple individual fetches)
+        fetch('/api/settings').then(response => response.json()).catch(() => ({})),
 
-        // Load LED configuration (replaces old WLED-only loading)
-        fetch('/get_led_config').then(response => response.json()).catch(() => ({ provider: 'none', wled_ip: null })),
-        
-        // Load current version and check for updates
+        // Non-settings operational endpoints (kept separate)
+        fetch('/serial_status').then(response => response.json()).catch(() => ({ connected: false })),
         fetch('/api/version').then(response => response.json()).catch(() => ({ current: '1.0.0', latest: '1.0.0', update_available: false })),
-        
-        // Load available serial ports
         fetch('/list_serial_ports').then(response => response.json()).catch(() => []),
-        
-        // Load available pattern files for clear pattern selection
-        getCachedPatternFiles().catch(() => []),
-        
-        // Load current custom clear patterns
-        fetch('/api/custom_clear_patterns').then(response => response.json()).catch(() => ({ custom_clear_from_in: null, custom_clear_from_out: null })),
-        
-        // Load current clear pattern speed
-        fetch('/api/clear_pattern_speed').then(response => response.json()).catch(() => ({ clear_pattern_speed: 200 })),
-        
-        // Load current app name
-        fetch('/api/app-name').then(response => response.json()).catch(() => ({ app_name: 'Dune Weaver' })),
+        getCachedPatternFiles().catch(() => [])
+    ]).then(([settings, statusData, updateData, ports, patterns]) => {
+        // Map unified settings to legacy variable names for backward compatibility with existing UI code
+        const ledConfigData = {
+            provider: settings.led?.provider || 'none',
+            wled_ip: settings.led?.wled_ip || null,
+            dw_led_num_leds: settings.led?.dw_led?.num_leds,
+            dw_led_gpio_pin: settings.led?.dw_led?.gpio_pin,
+            dw_led_pixel_order: settings.led?.dw_led?.pixel_order
+        };
+        const clearPatterns = {
+            custom_clear_from_in: settings.patterns?.custom_clear_from_in,
+            custom_clear_from_out: settings.patterns?.custom_clear_from_out
+        };
+        const clearSpeedData = {
+            clear_pattern_speed: settings.patterns?.clear_pattern_speed,
+            effective_speed: settings.patterns?.clear_pattern_speed // Will be handled by UI
+        };
+        const appNameData = { app_name: settings.app?.name || 'Dune Weaver' };
+        const scheduledPauseData = settings.scheduled_pause || { enabled: false, time_slots: [] };
+        const preferredPortData = { preferred_port: settings.connection?.preferred_port };
 
-        // Load Still Sands settings
-        fetch('/api/scheduled-pause').then(response => response.json()).catch(() => ({ enabled: false, time_slots: [] }))
-    ]).then(([statusData, ledConfigData, updateData, ports, patterns, clearPatterns, clearSpeedData, appNameData, scheduledPauseData]) => {
+        // Store full settings for other initialization functions
+        window.unifiedSettings = settings;
         // Update connection status
         setCachedConnectionStatus(statusData);
         updateConnectionUI(statusData);
@@ -357,7 +466,7 @@ document.addEventListener('DOMContentLoaded', async () => {
             while (portSelect.options.length > 1) {
                 portSelect.remove(1);
             }
-            
+
             // Add new options
             ports.forEach(port => {
                 const option = document.createElement('option');
@@ -371,7 +480,50 @@ document.addEventListener('DOMContentLoaded', async () => {
                 portSelect.value = ports[0];
             }
         }
-        
+
+        // Update preferred port selection
+        const preferredPortSelect = document.getElementById('preferredPortSelect');
+        const currentPreferredPort = document.getElementById('currentPreferredPort');
+        const preferredPortDisplay = document.getElementById('preferredPortDisplay');
+
+        if (preferredPortSelect) {
+            // Clear existing options except the first one (no preference)
+            while (preferredPortSelect.options.length > 1) {
+                preferredPortSelect.remove(1);
+            }
+
+            // Add all available ports
+            ports.forEach(port => {
+                const option = document.createElement('option');
+                option.value = port;
+                option.textContent = port;
+                preferredPortSelect.appendChild(option);
+            });
+
+            // Set the current preferred port value
+            if (preferredPortData && preferredPortData.preferred_port) {
+                // Check if the preferred port is in the available ports
+                const isAvailable = ports.includes(preferredPortData.preferred_port);
+
+                if (isAvailable) {
+                    preferredPortSelect.value = preferredPortData.preferred_port;
+                } else {
+                    // Add the preferred port as an option (it might not be currently available)
+                    const option = document.createElement('option');
+                    option.value = preferredPortData.preferred_port;
+                    option.textContent = `${preferredPortData.preferred_port} (not currently available)`;
+                    preferredPortSelect.appendChild(option);
+                    preferredPortSelect.value = preferredPortData.preferred_port;
+                }
+
+                // Show the current preferred port indicator
+                if (currentPreferredPort && preferredPortDisplay) {
+                    preferredPortDisplay.textContent = `Currently set to: ${preferredPortData.preferred_port}`;
+                    currentPreferredPort.classList.remove('hidden');
+                }
+            }
+        }
+
         // Initialize autocomplete for clear patterns
         const clearFromInInput = document.getElementById('customClearFromInInput');
         const clearFromOutInput = document.getElementById('customClearFromOutInput');
@@ -438,20 +590,20 @@ function setupEventListeners() {
     if (saveAppNameButton && appNameInput) {
         saveAppNameButton.addEventListener('click', async () => {
             const appName = appNameInput.value.trim() || 'Dune Weaver';
-            
+
             try {
-                const response = await fetch('/api/app-name', {
-                    method: 'POST',
+                const response = await fetch('/api/settings', {
+                    method: 'PATCH',
                     headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify({ app_name: appName })
+                    body: JSON.stringify({ app: { name: appName } })
                 });
-                
+
                 if (response.ok) {
-                    const data = await response.json();
+                    await response.json();
                     showStatusMessage('Application name updated successfully. Refresh the page to see changes.', 'success');
-                    
+
                     // Update the page title and header immediately
-                    document.title = `Settings - ${data.app_name}`;
+                    document.title = `Settings - ${appName}`;
                     const headerTitle = document.querySelector('h1.text-gray-800');
                     if (headerTitle) {
                         // Update just the text content, preserving the connection status dot
@@ -630,7 +782,13 @@ function setupEventListeners() {
             }
         });
     }
-    
+
+    // Save preferred port button
+    const savePreferredPortButton = document.getElementById('savePreferredPort');
+    if (savePreferredPortButton) {
+        savePreferredPortButton.addEventListener('click', savePreferredPort);
+    }
+
     // Save custom clear patterns button
     const saveClearPatterns = document.getElementById('saveClearPatterns');
     if (saveClearPatterns) {
@@ -657,15 +815,17 @@ function setupEventListeners() {
             }
             
             try {
-                const response = await fetch('/api/custom_clear_patterns', {
-                    method: 'POST',
+                const response = await fetch('/api/settings', {
+                    method: 'PATCH',
                     headers: { 'Content-Type': 'application/json' },
                     body: JSON.stringify({
-                        custom_clear_from_in: inValue || null,
-                        custom_clear_from_out: outValue || null
+                        patterns: {
+                            custom_clear_from_in: inValue || null,
+                            custom_clear_from_out: outValue || null
+                        }
                     })
                 });
-                
+
                 if (response.ok) {
                     showStatusMessage('Clear patterns saved successfully', 'success');
                 } else {
@@ -703,16 +863,16 @@ function setupEventListeners() {
             }
             
             try {
-                const response = await fetch('/api/clear_pattern_speed', {
-                    method: 'POST',
+                const response = await fetch('/api/settings', {
+                    method: 'PATCH',
                     headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify({ clear_pattern_speed: speed })
+                    body: JSON.stringify({ patterns: { clear_pattern_speed: speed } })
                 });
-                
+
                 if (response.ok) {
-                    const data = await response.json();
+                    await response.json();
                     if (speed === null) {
-                        showStatusMessage(`Clear pattern speed set to default (${data.effective_speed} steps/min)`, 'success');
+                        showStatusMessage('Clear pattern speed set to default', 'success');
                     } else {
                         showStatusMessage(`Clear pattern speed set to ${speed} steps/min`, 'success');
                     }
@@ -1126,8 +1286,18 @@ async function initializeauto_playMode() {
         logMessage(`Error loading auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
     }
     
+    // Get save button
+    const saveAutoPlayButton = document.getElementById('saveAutoPlaySettings');
+
     // Function to save settings
-    async function saveSettings() {
+    async function saveSettings(showFeedback = false) {
+        const originalButtonHTML = saveAutoPlayButton ? saveAutoPlayButton.innerHTML : '';
+
+        if (showFeedback && saveAutoPlayButton) {
+            saveAutoPlayButton.disabled = true;
+            saveAutoPlayButton.innerHTML = '<span class="material-icons text-lg animate-spin">refresh</span><span class="truncate">Saving...</span>';
+        }
+
         try {
             const response = await fetch('/api/auto_play-mode', {
                 method: 'POST',
@@ -1141,28 +1311,42 @@ async function initializeauto_playMode() {
                     shuffle: auto_playShuffleToggle.checked
                 })
             });
-            
+
             if (!response.ok) {
                 throw new Error('Failed to save settings');
             }
+
+            if (showFeedback && saveAutoPlayButton) {
+                saveAutoPlayButton.innerHTML = '<span class="material-icons text-lg">check</span><span class="truncate">Saved!</span>';
+                showStatusMessage('Auto-play settings saved successfully', 'success');
+
+                setTimeout(() => {
+                    saveAutoPlayButton.innerHTML = originalButtonHTML;
+                    saveAutoPlayButton.disabled = false;
+                }, 2000);
+            }
         } catch (error) {
             logMessage(`Error saving auto_play settings: ${error.message}`, LOG_TYPE.ERROR);
+            if (showFeedback && saveAutoPlayButton) {
+                showStatusMessage(`Failed to save settings: ${error.message}`, 'error');
+                saveAutoPlayButton.innerHTML = originalButtonHTML;
+                saveAutoPlayButton.disabled = false;
+            }
         }
     }
-    
+
     // Toggle auto_play settings visibility and save
     auto_playToggle.addEventListener('change', async () => {
         auto_playSettings.style.display = auto_playToggle.checked ? 'block' : 'none';
-        await saveSettings();
+        await saveSettings(false); // Auto-save toggle state without full feedback
+        const statusText = auto_playToggle.checked ? 'enabled' : 'disabled';
+        showStatusMessage(`Auto-play ${statusText}`, 'success');
     });
-    
-    // Save when any setting changes
-    auto_playPlaylistSelect.addEventListener('change', saveSettings);
-    auto_playRunModeSelect.addEventListener('change', saveSettings);
-    auto_playPauseTimeInput.addEventListener('change', saveSettings);
-    auto_playPauseTimeInput.addEventListener('input', saveSettings); // Save as user types
-    auto_playClearPatternSelect.addEventListener('change', saveSettings);
-    auto_playShuffleToggle.addEventListener('change', saveSettings);
+
+    // Save button click handler
+    if (saveAutoPlayButton) {
+        saveAutoPlayButton.addEventListener('click', () => saveSettings(true));
+    }
 }
 
 // Initialize auto_play mode when DOM is ready
@@ -1182,6 +1366,8 @@ async function initializeStillSandsMode() {
     const saveStillSandsButton = document.getElementById('savePauseSettings');
     const timeSlotsContainer = document.getElementById('timeSlotsContainer');
     const wledControlToggle = document.getElementById('stillSandsWledControl');
+    const finishPatternToggle = document.getElementById('stillSandsFinishPattern');
+    const timezoneSelect = document.getElementById('stillSandsTimezone');
 
     // Check if elements exist
     if (!stillSandsToggle || !stillSandsSettings || !addTimeSlotButton || !saveStillSandsButton || !timeSlotsContainer) {
@@ -1220,6 +1406,16 @@ async function initializeStillSandsMode() {
             wledControlToggle.checked = data.control_wled || false;
         }
 
+        // Load finish pattern setting
+        if (finishPatternToggle) {
+            finishPatternToggle.checked = data.finish_pattern || false;
+        }
+
+        // Load timezone setting
+        if (timezoneSelect) {
+            timezoneSelect.value = data.timezone || '';
+        }
+
         // Load existing time slots
         timeSlots = data.time_slots || [];
 
@@ -1444,6 +1640,8 @@ async function initializeStillSandsMode() {
                 body: JSON.stringify({
                     enabled: stillSandsToggle.checked,
                     control_wled: wledControlToggle ? wledControlToggle.checked : false,
+                    finish_pattern: finishPatternToggle ? finishPatternToggle.checked : false,
+                    timezone: timezoneSelect ? (timezoneSelect.value || null) : null,
                     time_slots: timeSlots.map(slot => ({
                         start_time: slot.start_time,
                         end_time: slot.end_time,
@@ -1507,6 +1705,24 @@ async function initializeStillSandsMode() {
             await saveStillSandsSettings();
         });
     }
+
+    // Add listener for finish pattern toggle
+    if (finishPatternToggle) {
+        finishPatternToggle.addEventListener('change', async () => {
+            logMessage(`Finish pattern toggle changed: ${finishPatternToggle.checked}`, LOG_TYPE.INFO);
+            // Auto-save when finish pattern setting changes
+            await saveStillSandsSettings();
+        });
+    }
+
+    // Add listener for timezone select
+    if (timezoneSelect) {
+        timezoneSelect.addEventListener('change', async () => {
+            logMessage(`Timezone changed: ${timezoneSelect.value || 'System Default'}`, LOG_TYPE.INFO);
+            // Auto-save when timezone changes
+            await saveStillSandsSettings();
+        });
+    }
 }
 
 // Homing Configuration
@@ -1519,6 +1735,9 @@ async function initializeHomingConfig() {
     const compassOffsetContainer = document.getElementById('compassOffsetContainer');
     const saveHomingConfigButton = document.getElementById('saveHomingConfig');
     const homingInfoContent = document.getElementById('homingInfoContent');
+    const autoHomeEnabledToggle = document.getElementById('autoHomeEnabledToggle');
+    const autoHomeSettings = document.getElementById('autoHomeSettings');
+    const autoHomeAfterPatternsInput = document.getElementById('autoHomeAfterPatternsInput');
 
     // Check if elements exist
     if (!homingModeCrash || !homingModeSensor || !angularOffsetInput || !saveHomingConfigButton || !homingInfoContent || !compassOffsetContainer) {
@@ -1574,14 +1793,28 @@ async function initializeHomingConfig() {
         }
 
         angularOffsetInput.value = data.angular_homing_offset_degrees || 0;
+
+        // Load auto-home settings
+        if (autoHomeEnabledToggle) {
+            autoHomeEnabledToggle.checked = data.auto_home_enabled || false;
+            if (autoHomeSettings) {
+                autoHomeSettings.style.display = data.auto_home_enabled ? 'block' : 'none';
+            }
+        }
+        if (autoHomeAfterPatternsInput) {
+            autoHomeAfterPatternsInput.value = data.auto_home_after_patterns || 5;
+        }
+
         updateHomingInfo();
 
-        logMessage(`Loaded homing config: mode=${data.homing_mode}, offset=${data.angular_homing_offset_degrees}°`, LOG_TYPE.INFO);
+        logMessage(`Loaded homing config: mode=${data.homing_mode}, offset=${data.angular_homing_offset_degrees}°, auto_home=${data.auto_home_enabled}, after=${data.auto_home_after_patterns}`, LOG_TYPE.INFO);
     } catch (error) {
         logMessage(`Error loading homing configuration: ${error.message}`, LOG_TYPE.ERROR);
         // Initialize with defaults if load fails
         homingModeCrash.checked = true;
         angularOffsetInput.value = 0;
+        if (autoHomeEnabledToggle) autoHomeEnabledToggle.checked = false;
+        if (autoHomeAfterPatternsInput) autoHomeAfterPatternsInput.value = 5;
         updateHomingInfo();
     }
 
@@ -1593,13 +1826,26 @@ async function initializeHomingConfig() {
         saveHomingConfigButton.innerHTML = '<span class="material-icons text-lg animate-spin">refresh</span><span class="truncate">Saving...</span>';
 
         try {
+            const requestBody = {
+                homing_mode: getSelectedMode(),
+                angular_homing_offset_degrees: parseFloat(angularOffsetInput.value) || 0
+            };
+
+            // Include auto-home settings if elements exist
+            if (autoHomeEnabledToggle) {
+                requestBody.auto_home_enabled = autoHomeEnabledToggle.checked;
+            }
+            if (autoHomeAfterPatternsInput) {
+                const afterPatterns = parseInt(autoHomeAfterPatternsInput.value);
+                if (!isNaN(afterPatterns) && afterPatterns >= 1) {
+                    requestBody.auto_home_after_patterns = afterPatterns;
+                }
+            }
+
             const response = await fetch('/api/homing-config', {
                 method: 'POST',
                 headers: { 'Content-Type': 'application/json' },
-                body: JSON.stringify({
-                    homing_mode: getSelectedMode(),
-                    angular_homing_offset_degrees: parseFloat(angularOffsetInput.value) || 0
-                })
+                body: JSON.stringify(requestBody)
             });
 
             if (!response.ok) {
@@ -1630,4 +1876,317 @@ async function initializeHomingConfig() {
     homingModeCrash.addEventListener('change', updateHomingInfo);
     homingModeSensor.addEventListener('change', updateHomingInfo);
     saveHomingConfigButton.addEventListener('click', saveHomingConfig);
+
+    // Auto-home toggle event listener
+    if (autoHomeEnabledToggle && autoHomeSettings) {
+        autoHomeEnabledToggle.addEventListener('change', () => {
+            autoHomeSettings.style.display = autoHomeEnabledToggle.checked ? 'block' : 'none';
+        });
+    }
+}
+
+// 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();
+});

+ 75 - 2
templates/base.html

@@ -22,10 +22,16 @@
     <link rel="preload" href="/static/fonts/material-icons/MaterialIcons-Regular.woff2" as="font" type="font/woff2" crossorigin>
     <link rel="preload" href="/static/fonts/material-icons/MaterialIconsOutlined-Regular.woff2" as="font" type="font/woff2" crossorigin>
     <title>{% block title %}{{ app_name or 'Dune Weaver' }}{% endblock %}</title>
+    {% if custom_logo %}
+    {# Favicon is auto-generated from logo as favicon.ico #}
+    <link rel="apple-touch-icon" sizes="180x180" href="/static/custom/{{ custom_logo }}">
+    <link rel="icon" type="image/x-icon" href="/static/custom/favicon.ico">
+    {% else %}
     <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
     <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
     <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
     <link rel="icon" type="image/x-icon" href="/static/favicon.ico">
+    {% endif %}
     <link rel="manifest" href="/static/site.webmanifest?v=2">
     <link rel="stylesheet" href="/static/css/tailwind.css">
     <link rel="stylesheet" href="/static/css/material-icons.css">
@@ -116,6 +122,9 @@
       .dark #shutdown-button:hover {
         background-color: #404040;
       }
+      .dark #restart-button:hover {
+        background-color: #404040;
+      }
       .dark #theme-toggle:hover {
         background-color: #404040;
       }
@@ -257,8 +266,8 @@
         >
           <div class="flex items-center gap-3 text-gray-800">
             <a href="/" class="flex items-center gap-3 text-gray-800 hover:opacity-80 transition-opacity">
-            <div class="text-blue-600 w-9 h-9 rounded-full shadow">
-              <img src="/static/apple-touch-icon.png" alt="Dune Weaver Logo"/>
+            <div class="text-blue-600 w-9 h-9 rounded-full shadow overflow-hidden">
+              <img src="{% if custom_logo %}/static/custom/{{ custom_logo }}{% else %}/static/apple-touch-icon.png{% endif %}" alt="{{ app_name or 'Dune Weaver' }} Logo" class="w-full h-full object-cover"/>
             </div>
             <h1
               class="text-gray-800 text-xl font-bold leading-tight tracking-tight flex items-center gap-2"
@@ -289,6 +298,14 @@
             >
               <span class="material-icons" id="theme-toggle-icon">dark_mode</span>
             </button>
+            <button
+              id="restart-button"
+              class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500"
+              aria-label="Restart system"
+              title="Restart Docker Containers"
+            >
+              <span class="material-icons text-amber-600">restart_alt</span>
+            </button>
             <button
               id="shutdown-button"
               class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-red-500"
@@ -733,6 +750,62 @@
         });
       });
 
+      // Restart button functionality
+      document.addEventListener('DOMContentLoaded', function() {
+        const restartButton = document.getElementById('restart-button');
+
+        // Restart button click handler
+        restartButton.addEventListener('click', async () => {
+          const confirmed = confirm('Are you sure you want to restart the application?\n\nThis will restart the Docker containers.\nThe page will reload automatically when the service is back online.');
+
+          if (!confirmed) return;
+
+          try {
+            showStatusMessage('Initiating restart...', 'warning');
+
+            const response = await fetch('/api/system/restart', { method: 'POST' });
+            const data = await response.json();
+
+            if (data.success) {
+              showStatusMessage('System is restarting... Page will reload when ready.', 'success');
+
+              // Start checking if the server is back online
+              setTimeout(() => {
+                checkServerAndReload();
+              }, 3000);
+            } else {
+              showStatusMessage('Restart failed: ' + data.message, 'error');
+            }
+          } catch (error) {
+            showStatusMessage('Failed to restart: ' + error.message, 'error');
+          }
+        });
+
+        // Function to check if server is back online and reload
+        function checkServerAndReload() {
+          const checkInterval = setInterval(async () => {
+            try {
+              const response = await fetch('/api/version', { method: 'GET' });
+              if (response.ok) {
+                clearInterval(checkInterval);
+                showStatusMessage('Server is back online. Reloading...', 'success');
+                setTimeout(() => {
+                  window.location.reload();
+                }, 1000);
+              }
+            } catch (error) {
+              // Server not ready yet, keep checking
+              console.log('Server not ready yet, retrying...');
+            }
+          }, 2000);
+
+          // Stop checking after 60 seconds
+          setTimeout(() => {
+            clearInterval(checkInterval);
+          }, 60000);
+        }
+      });
+
       // Update indicator functionality
       document.addEventListener('DOMContentLoaded', async function() {
         const updateIndicator = document.getElementById('update-indicator');

+ 17 - 0
templates/playlists.html

@@ -298,6 +298,23 @@ html:not(.dark) #availablePatternsGrid .text-xs {
   </div>
 </div>
 
+<!-- Rename Playlist Modal -->
+<div id="renamePlaylistModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
+  <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
+    <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Rename Playlist</h3>
+    <div class="space-y-4">
+      <div>
+        <label for="renamePlaylistInput" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Name</label>
+        <input id="renamePlaylistInput" type="text" class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 px-3 py-2" placeholder="Enter new playlist name">
+      </div>
+      <div class="flex gap-3 justify-end">
+        <button id="cancelRenameBtn" class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors duration-150">Cancel</button>
+        <button id="confirmRenameBtn" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors duration-150">Rename</button>
+      </div>
+    </div>
+  </div>
+</div>
+
 <!-- Add Patterns Modal -->
 <div id="addPatternsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
   <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-4xl mx-4 max-h-[80vh] overflow-hidden flex flex-col">

+ 463 - 8
templates/settings.html

@@ -33,7 +33,7 @@ endblock %}
   border-color: #404040;
 }
 .dark .divide-slate-100 {
-  border-color: #404040;
+  border-color: #333333;
 }
 .dark .bg-slate-50 {
   background-color: #262626;
@@ -258,6 +258,36 @@ input:checked + .slider:before {
 .dark .text-amber-600 {
   color: #f1f5f9;
 }
+
+/* Sky box dark mode - grey theme (Still Sands options) */
+.dark .bg-sky-50 {
+  background-color: #1f1f1f;
+}
+
+.dark .border-sky-200 {
+  border-color: #404040;
+}
+
+/* Select dropdown dark mode */
+.dark select {
+  background-color: #1f1f1f;
+  border-color: #404040;
+  color: #e5e5e5;
+}
+
+.dark select:focus {
+  border-color: #0c7ff2;
+}
+
+.dark select option {
+  background-color: #1f1f1f;
+  color: #e5e5e5;
+}
+
+.dark select optgroup {
+  background-color: #262626;
+  color: #9ca3af;
+}
 {% endblock %}
 
 {% block content %}
@@ -277,7 +307,7 @@ input:checked + .slider:before {
     >
       Device Connection
     </h2>
-    <div class="divide-y divide-slate-100">
+    <div>
       <div
         class="flex items-center gap-4 px-6 py-5 hover:bg-slate-50 transition-colors"
       >
@@ -299,7 +329,7 @@ input:checked + .slider:before {
         </div>
         <button
           id="disconnectButton"
-          class="text-xs font-medium text-slate-600 bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1.5 rounded-md transition-colors"
+          class="text-xs font-medium bg-red-100 hover:bg-red-200 text-red-700 dark:bg-red-900 dark:hover:bg-red-800 dark:text-red-200 px-3 py-1.5 rounded-md transition-colors"
           hidden
         >
           Disconnect
@@ -330,6 +360,37 @@ input:checked + .slider:before {
           </p>
         </label>
       </div>
+      <!-- Preferred Port Configuration -->
+      <div class="px-6 py-5">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal flex items-center gap-2">
+            <span class="material-icons text-slate-600 text-base">star</span>
+            Preferred Port for Auto-Connect
+          </span>
+          <div class="flex gap-3 items-center">
+            <select
+              id="preferredPortSelect"
+              class="form-select flex-1 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-medium leading-normal transition-colors"
+            >
+              <option value="">No preference (auto-detect)</option>
+            </select>
+            <button
+              id="savePreferredPort"
+              class="flex items-center justify-center gap-2 min-w-[100px] 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 flex-shrink-0"
+            >
+              <span class="material-icons text-lg">save</span>
+              <span class="truncate">Save</span>
+            </button>
+          </div>
+          <p class="text-xs text-slate-500 mt-2">
+            When multiple ports are available, this port will be used automatically on startup. If set to "No preference", the system will try the last connected port or the first available port.
+          </p>
+          <p id="currentPreferredPort" class="text-xs text-sky-600 mt-1 hidden">
+            <span class="material-icons text-xs align-middle">check_circle</span>
+            <span id="preferredPortDisplay"></span>
+          </p>
+        </label>
+      </div>
     </div>
   </section>
   <!-- Homing Configuration Section -->
@@ -421,6 +482,44 @@ input:checked + .slider:before {
         </div>
       </div>
 
+      <!-- Auto-Home During Playlists -->
+      <div class="bg-slate-50 rounded-lg p-4 space-y-4">
+        <div class="flex items-center justify-between">
+          <div class="flex-1">
+            <h3 class="text-slate-800 text-base font-semibold flex items-center gap-2">
+              <span class="material-icons text-slate-600 text-base">autorenew</span>
+              Auto-Home During Playlists
+            </h3>
+            <p class="text-xs text-slate-500 mt-1">
+              Automatically perform homing after a certain number of patterns during playlist playback to maintain accuracy.
+            </p>
+          </div>
+          <label class="switch">
+            <input type="checkbox" id="autoHomeEnabledToggle">
+            <span class="slider round"></span>
+          </label>
+        </div>
+
+        <div id="autoHomeSettings" style="display: none;">
+          <label class="flex flex-col gap-1.5">
+            <span class="text-slate-700 text-sm font-medium leading-normal">Home after every X patterns</span>
+            <input
+              type="number"
+              id="autoHomeAfterPatternsInput"
+              min="1"
+              max="100"
+              step="1"
+              value="5"
+              class="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-sm"
+              placeholder="5"
+            />
+            <p class="text-xs text-slate-500">
+              Homing will occur right after the clear pattern completes, before the next actual pattern begins.
+            </p>
+          </label>
+        </div>
+      </div>
+
       <div class="flex justify-end">
         <button
           id="saveHomingConfig"
@@ -473,6 +572,43 @@ input:checked + .slider:before {
           This name will appear in the browser tab and at the top of every page.
         </p>
       </label>
+
+      <!-- Custom Logo Section -->
+      <div class="border-t border-slate-200 pt-6">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Custom Logo & Favicon</span>
+          <p class="text-xs text-slate-500 mb-2">
+            Upload a custom logo to replace the default. The favicon (browser tab icon) will be automatically generated from your logo. Recommended size: 180x180 pixels. Supported formats: PNG, JPG, GIF, WebP, SVG.
+          </p>
+          <div class="flex gap-4 items-start">
+            <!-- Logo Preview -->
+            <div class="flex-shrink-0">
+              <div id="logoPreviewContainer" class="w-16 h-16 rounded-full shadow border border-slate-200 overflow-hidden bg-slate-100 flex items-center justify-center">
+                <img id="logoPreview" src="{% if custom_logo %}/static/custom/{{ custom_logo }}{% else %}/static/apple-touch-icon.png{% endif %}" alt="Logo Preview" class="w-full h-full object-cover"/>
+              </div>
+            </div>
+            <!-- Upload Controls -->
+            <div class="flex-1 space-y-2">
+              <div class="flex gap-2 flex-wrap">
+                <label class="flex items-center justify-center gap-2 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">upload</span>
+                  <span>Upload Logo</span>
+                  <input type="file" id="logoFileInput" accept=".png,.jpg,.jpeg,.gif,.webp,.svg" class="hidden" />
+                </label>
+                <button
+                  type="button"
+                  id="resetLogoBtn"
+                  class="flex items-center justify-center gap-2 cursor-pointer rounded-lg h-10 px-4 border border-slate-300 hover:bg-slate-50 text-slate-700 text-sm font-medium leading-normal transition-colors {% if not custom_logo %}hidden{% endif %}"
+                >
+                  <span class="material-icons text-lg">restart_alt</span>
+                  <span>Reset to Default</span>
+                </button>
+              </div>
+              <p id="logoUploadStatus" class="text-xs text-slate-500"></p>
+            </div>
+          </div>
+        </label>
+      </div>
     </div>
   </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">
@@ -725,6 +861,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"
+    >
+      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"
@@ -822,6 +1157,16 @@ input:checked + .slider:before {
             </label>
           </div>
         </div>
+
+        <div class="flex justify-end">
+          <button
+            id="saveAutoPlaySettings"
+            class="flex items-center justify-center gap-2 min-w-[140px] 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 Auto-play</span>
+          </button>
+        </div>
       </div>
     </div>
   </section>
@@ -846,12 +1191,31 @@ input:checked + .slider:before {
       </div>
 
       <div id="scheduledPauseSettings" class="space-y-4" style="display: none;">
+        <!-- Finish Current Pattern Option -->
+        <div class="bg-sky-50 rounded-lg p-4 border border-sky-200">
+          <div class="flex items-center justify-between">
+            <div class="flex-1">
+              <h4 class="text-slate-800 text-sm font-medium flex items-center gap-2">
+                <span class="material-icons text-slate-800 dark:text-slate-200 text-base">hourglass_bottom</span>
+                Finish Current Pattern
+              </h4>
+              <p class="text-xs text-slate-600 mt-1">
+                Let the current pattern complete before entering still mode
+              </p>
+            </div>
+            <label class="switch">
+              <input type="checkbox" id="stillSandsFinishPattern">
+              <span class="slider round"></span>
+            </label>
+          </div>
+        </div>
+
         <!-- WLED Control Option -->
-        <div class="bg-amber-50 rounded-lg p-4 border border-amber-200">
+        <div class="bg-sky-50 rounded-lg p-4 border border-sky-200">
           <div class="flex items-center justify-between">
             <div class="flex-1">
               <h4 class="text-slate-800 text-sm font-medium flex items-center gap-2">
-                <span class="material-icons text-amber-600 text-base">lightbulb</span>
+                <span class="material-icons text-slate-800 dark:text-slate-200 text-base">lightbulb</span>
                 Control WLED Lights
               </h4>
               <p class="text-xs text-slate-600 mt-1">
@@ -865,6 +1229,96 @@ input:checked + .slider:before {
           </div>
         </div>
 
+        <!-- Timezone Selection -->
+        <div class="bg-sky-50 rounded-lg p-4 border border-sky-200">
+          <div class="flex items-center justify-between">
+            <div class="flex-1">
+              <h4 class="text-slate-800 text-sm font-medium flex items-center gap-2">
+                <span class="material-icons text-slate-800 dark:text-slate-200 text-base">schedule</span>
+                Timezone
+              </h4>
+              <p class="text-xs text-slate-600 mt-1">
+                Select a timezone for still periods (defaults to system timezone)
+              </p>
+            </div>
+            <select id="stillSandsTimezone" class="h-10 px-3 rounded-lg border border-slate-300 bg-white text-slate-800 text-sm min-w-[200px]">
+              <option value="">System Default</option>
+              <optgroup label="Americas">
+                <option value="America/New_York">Eastern Time (New York)</option>
+                <option value="America/Chicago">Central Time (Chicago)</option>
+                <option value="America/Denver">Mountain Time (Denver)</option>
+                <option value="America/Los_Angeles">Pacific Time (Los Angeles)</option>
+                <option value="America/Anchorage">Alaska (Anchorage)</option>
+                <option value="Pacific/Honolulu">Hawaii (Honolulu)</option>
+                <option value="America/Toronto">Toronto</option>
+                <option value="America/Vancouver">Vancouver</option>
+                <option value="America/Mexico_City">Mexico City</option>
+                <option value="America/Sao_Paulo">São Paulo</option>
+                <option value="America/Buenos_Aires">Buenos Aires</option>
+              </optgroup>
+              <optgroup label="Europe">
+                <option value="Europe/London">London</option>
+                <option value="Europe/Paris">Paris</option>
+                <option value="Europe/Berlin">Berlin</option>
+                <option value="Europe/Amsterdam">Amsterdam</option>
+                <option value="Europe/Rome">Rome</option>
+                <option value="Europe/Madrid">Madrid</option>
+                <option value="Europe/Zurich">Zurich</option>
+                <option value="Europe/Stockholm">Stockholm</option>
+                <option value="Europe/Moscow">Moscow</option>
+              </optgroup>
+              <optgroup label="Asia & Pacific">
+                <option value="Asia/Tokyo">Tokyo</option>
+                <option value="Asia/Shanghai">Shanghai</option>
+                <option value="Asia/Hong_Kong">Hong Kong</option>
+                <option value="Asia/Singapore">Singapore</option>
+                <option value="Asia/Seoul">Seoul</option>
+                <option value="Asia/Dubai">Dubai</option>
+                <option value="Asia/Kolkata">India (Kolkata)</option>
+                <option value="Asia/Bangkok">Bangkok</option>
+                <option value="Australia/Sydney">Sydney</option>
+                <option value="Australia/Melbourne">Melbourne</option>
+                <option value="Australia/Perth">Perth</option>
+                <option value="Pacific/Auckland">Auckland</option>
+              </optgroup>
+              <optgroup label="Africa">
+                <option value="Africa/Cairo">Cairo</option>
+                <option value="Africa/Johannesburg">Johannesburg</option>
+                <option value="Africa/Lagos">Lagos</option>
+              </optgroup>
+              <optgroup label="GMT Offsets">
+                <option value="Etc/GMT+12">GMT-12</option>
+                <option value="Etc/GMT+11">GMT-11</option>
+                <option value="Etc/GMT+10">GMT-10</option>
+                <option value="Etc/GMT+9">GMT-9</option>
+                <option value="Etc/GMT+8">GMT-8</option>
+                <option value="Etc/GMT+7">GMT-7</option>
+                <option value="Etc/GMT+6">GMT-6</option>
+                <option value="Etc/GMT+5">GMT-5</option>
+                <option value="Etc/GMT+4">GMT-4</option>
+                <option value="Etc/GMT+3">GMT-3</option>
+                <option value="Etc/GMT+2">GMT-2</option>
+                <option value="Etc/GMT+1">GMT-1</option>
+                <option value="Etc/GMT">GMT / UTC</option>
+                <option value="Etc/GMT-1">GMT+1</option>
+                <option value="Etc/GMT-2">GMT+2</option>
+                <option value="Etc/GMT-3">GMT+3</option>
+                <option value="Etc/GMT-4">GMT+4</option>
+                <option value="Etc/GMT-5">GMT+5</option>
+                <option value="Etc/GMT-6">GMT+6</option>
+                <option value="Etc/GMT-7">GMT+7</option>
+                <option value="Etc/GMT-8">GMT+8</option>
+                <option value="Etc/GMT-9">GMT+9</option>
+                <option value="Etc/GMT-10">GMT+10</option>
+                <option value="Etc/GMT-11">GMT+11</option>
+                <option value="Etc/GMT-12">GMT+12</option>
+                <option value="Etc/GMT-13">GMT+13</option>
+                <option value="Etc/GMT-14">GMT+14</option>
+              </optgroup>
+            </select>
+          </div>
+        </div>
+
         <div class="bg-slate-50 rounded-lg p-4 space-y-4">
           <div class="flex items-center justify-between">
             <h4 class="text-slate-800 text-base font-semibold">Still Periods</h4>
@@ -890,8 +1344,9 @@ input:checked + .slider:before {
               <div>
                 <p class="font-medium text-blue-800">Important Notes:</p>
                 <ul class="mt-1 space-y-1 text-blue-700">
-                  <li>• Times are based on your system's local time zone</li>
-                  <li>• Currently running patterns will pause immediately when entering a still period</li>
+                  <li>• Times are based on the selected timezone (or system default if not set)</li>
+                  <li>• By default, patterns pause immediately when entering a still period</li>
+                  <li>• Enable "Finish Current Pattern" to let patterns complete first</li>
                   <li>• Patterns will resume automatically when exiting a still period</li>
                   <li>• Still periods that span midnight (e.g., 22:00 to 06:00) are supported</li>
                 </ul>
@@ -918,7 +1373,7 @@ input:checked + .slider:before {
     >
       Software Version
     </h2>
-    <div class="divide-y divide-slate-100">
+    <div>
       <div class="flex items-center gap-4 px-6 py-5">
         <div
           class="text-slate-600 flex items-center justify-center rounded-lg bg-slate-100 shrink-0 size-12"

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.