Просмотр исходного кода

Add per-pattern LED effect customization

Implement ability to customize LED effects based on the pattern playing,
supporting both WLED and DW LEDs.

Backend Changes:
- Extended pattern metadata cache to store per-pattern LED effects
- Added helper functions in cache_manager.py for LED effect CRUD operations
- Updated pattern_manager.py to load and apply pattern-specific LED settings
- Pattern execution now checks for custom effects before falling back to globals
- Added REST API endpoints for managing per-pattern LED configurations

API Endpoints:
- GET /api/patterns/{pattern_path}/led_effect - Retrieve LED effect settings
- POST /api/patterns/{pattern_path}/led_effect - Save LED effect settings
- DELETE /api/patterns/{pattern_path}/led_effect - Clear LED effect settings

Features:
- Configure unique "playing" and "idle" effects per pattern
- Supports all DW LED effects (0-15) and palettes (0-58)
- Compatible with WLED network controllers
- Automatic fallback to global LED settings if no custom effect configured
- Works seamlessly with playlists and pattern sequences
- Backward compatible with existing patterns

Storage:
- LED effects stored in metadata_cache.json alongside pattern metadata
- Effect configuration includes: effect_id, palette_id, speed, intensity, colors
- No database changes required - uses existing cache infrastructure

Documentation:
- Added comprehensive API documentation in docs/PER_PATTERN_LED_EFFECTS.md
- Includes usage examples, effect/palette reference, and troubleshooting guide

Testing:
- All Python files pass syntax validation
- Test script included for LED effect storage verification
Claude 2 месяцев назад
Родитель
Сommit
934916ff5f
5 измененных файлов с 712 добавлено и 12 удалено
  1. 303 0
      docs/PER_PATTERN_LED_EFFECTS.md
  2. 112 0
      main.py
  3. 161 2
      modules/core/cache_manager.py
  4. 30 10
      modules/core/pattern_manager.py
  5. 106 0
      test_led_effects.py

+ 303 - 0
docs/PER_PATTERN_LED_EFFECTS.md

@@ -0,0 +1,303 @@
+# Per-Pattern LED Effects
+
+## Overview
+
+The Dune Weaver system now supports customizable LED effects for individual patterns. This allows you to configure different LED behaviors for different patterns, creating a more immersive and customized experience.
+
+## Features
+
+- **Pattern-Specific LED Effects**: Configure unique LED effects (color, speed, intensity, palette) for each pattern
+- **Playing and Idle Effects**: Set different effects for when a pattern is playing vs. when it completes
+- **Global Fallback**: Patterns without custom effects use the global LED settings
+- **WLED and DW LED Support**: Works with both WLED network controllers and DW LED (built-in NeoPixel) systems
+
+## How It Works
+
+### Pattern Execution Flow
+
+1. When a pattern starts playing:
+   - System checks if the pattern has a custom "playing" LED effect configured
+   - If found, uses the pattern-specific effect
+   - If not found, falls back to global "playing" effect
+
+2. When a pattern completes:
+   - System checks if the pattern has a custom "idle" LED effect configured
+   - If found, uses the pattern-specific effect
+   - If not found, falls back to global "idle" effect
+
+### Data Storage
+
+Per-pattern LED effects are stored in the metadata cache (`metadata_cache.json`) alongside other pattern metadata:
+
+```json
+{
+  "data": {
+    "hero_7loop4.thr": {
+      "metadata": {
+        "total_coordinates": 1234,
+        "first_coordinate": {"x": 45.5, "y": 0.25},
+        "last_coordinate": {"x": 350.2, "y": 0.24},
+        "led_effect": {
+          "playing": {
+            "effect_id": 7,
+            "palette_id": 0,
+            "speed": 150,
+            "intensity": 200,
+            "color1": "#ff0000",
+            "color2": "#00ff00",
+            "color3": "#0000ff"
+          },
+          "idle": {
+            "effect_id": 0,
+            "palette_id": 0,
+            "speed": 128,
+            "intensity": 128,
+            "color1": "#ffffff",
+            "color2": "#000000",
+            "color3": "#0000ff"
+          }
+        }
+      }
+    }
+  }
+}
+```
+
+## API Endpoints
+
+### Get Pattern LED Effect
+
+Get the configured LED effect for a specific pattern.
+
+```http
+GET /api/patterns/{pattern_path}/led_effect?effect_type=playing
+```
+
+**Parameters:**
+- `pattern_path` (path): Pattern filename (e.g., `hero_7loop4.thr` or `custom_patterns/mypattern.thr`)
+- `effect_type` (query): `playing` or `idle` (default: `playing`)
+
+**Response:**
+```json
+{
+  "pattern": "hero_7loop4.thr",
+  "effect_type": "playing",
+  "settings": {
+    "effect_id": 7,
+    "palette_id": 0,
+    "speed": 150,
+    "intensity": 200,
+    "color1": "#ff0000",
+    "color2": "#00ff00",
+    "color3": "#0000ff"
+  }
+}
+```
+
+If no custom effect is configured, `settings` will be `null`.
+
+### Set Pattern LED Effect
+
+Configure a custom LED effect for a specific pattern.
+
+```http
+POST /api/patterns/{pattern_path}/led_effect
+```
+
+**Request Body:**
+```json
+{
+  "effect_type": "playing",
+  "effect_id": 7,
+  "palette_id": 0,
+  "speed": 150,
+  "intensity": 200,
+  "color1": "#ff0000",
+  "color2": "#00ff00",
+  "color3": "#0000ff"
+}
+```
+
+**Parameters:**
+- `effect_type`: `playing` or `idle`
+- `effect_id`: Effect ID (0-15 for DW LEDs, 0-101 for WLED)
+- `palette_id`: Palette ID (0-58 for DW LEDs)
+- `speed`: Effect speed (0-255)
+- `intensity`: Effect intensity (0-255)
+- `color1`, `color2`, `color3`: Hex color codes (e.g., `#ff0000`)
+
+**Response:**
+```json
+{
+  "success": true,
+  "pattern": "hero_7loop4.thr",
+  "effect_type": "playing",
+  "settings": { ... }
+}
+```
+
+### Clear Pattern LED Effect
+
+Remove custom LED effect configuration for a pattern (reverts to global settings).
+
+```http
+DELETE /api/patterns/{pattern_path}/led_effect?effect_type=playing
+```
+
+**Parameters:**
+- `pattern_path` (path): Pattern filename
+- `effect_type` (query): `playing`, `idle`, or omit to clear all
+
+**Response:**
+```json
+{
+  "success": true,
+  "pattern": "hero_7loop4.thr",
+  "effect_type": "playing"
+}
+```
+
+## Usage Examples
+
+### Example 1: Set Rainbow Effect for a Pattern
+
+Set the "Rainbow Cycle" effect (ID 7) to play when the `hero_7loop4.thr` pattern runs:
+
+```bash
+curl -X POST "http://localhost:8080/api/patterns/hero_7loop4.thr/led_effect" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "effect_type": "playing",
+    "effect_id": 7,
+    "palette_id": 0,
+    "speed": 150,
+    "intensity": 200,
+    "color1": "#ff0000",
+    "color2": "#00ff00",
+    "color3": "#0000ff"
+  }'
+```
+
+### Example 2: Set Calm Blue Idle Effect
+
+Set a static blue effect when the pattern completes:
+
+```bash
+curl -X POST "http://localhost:8080/api/patterns/hero_7loop4.thr/led_effect" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "effect_type": "idle",
+    "effect_id": 0,
+    "palette_id": 0,
+    "speed": 128,
+    "intensity": 128,
+    "color1": "#0000ff",
+    "color2": "#000000",
+    "color3": "#0000ff"
+  }'
+```
+
+### Example 3: Get Pattern LED Effect
+
+Check if a pattern has custom LED effects configured:
+
+```bash
+curl "http://localhost:8080/api/patterns/hero_7loop4.thr/led_effect?effect_type=playing"
+```
+
+### Example 4: Clear Pattern LED Effect
+
+Remove custom effects and revert to global settings:
+
+```bash
+curl -X DELETE "http://localhost:8080/api/patterns/hero_7loop4.thr/led_effect"
+```
+
+## DW LED Effect IDs
+
+For DW LED controllers, the following effects are available:
+
+| ID | Effect Name | Description |
+|----|-------------|-------------|
+| 0 | Static | Solid color |
+| 1 | Blink | Two colors blinking |
+| 2 | Breath | Breathing/pulsing effect |
+| 3 | Fade | Smooth fade between colors |
+| 4 | Scan | Moving dot |
+| 5 | Dual Scan | Two moving dots |
+| 6 | Rainbow | Cycling hue |
+| 7 | Rainbow Cycle | Rainbow across strip |
+| 8 | Theater Chase | Theater-style chasing lights |
+| 9-15 | Various | Additional effects |
+
+See `/api/dw_leds/effects` for a complete list.
+
+## DW LED Palette IDs
+
+For DW LED controllers, the following color palettes are available:
+
+| ID | Palette Name | Description |
+|----|--------------|-------------|
+| 0 | Default | Use custom colors |
+| 1 | Rainbow | Full spectrum rainbow |
+| 2 | Fire | Warm fire colors |
+| 3 | Ocean | Cool blue/cyan tones |
+| 4-58 | Various | Additional palettes |
+
+See `/api/dw_leds/palettes` for a complete list.
+
+## Implementation Details
+
+### Backend Components
+
+1. **Cache Manager** (`modules/core/cache_manager.py`)
+   - `get_pattern_led_effect()`: Retrieve LED effect settings
+   - `set_pattern_led_effect()`: Save LED effect settings
+   - `clear_pattern_led_effect()`: Remove LED effect settings
+
+2. **Pattern Manager** (`modules/core/pattern_manager.py`)
+   - Loads pattern-specific LED effects before execution
+   - Falls back to global settings if no custom effect exists
+   - Applies effects at pattern start, pause, resume, and completion
+
+3. **API Endpoints** (`main.py`)
+   - RESTful API for managing per-pattern LED settings
+   - Path-based routing for pattern selection
+   - Query parameters for effect type selection
+
+### Compatibility
+
+- ✅ Works with WLED (network-based LED controllers)
+- ✅ Works with DW LEDs (built-in NeoPixel controllers)
+- ✅ Backward compatible (patterns without custom effects use global settings)
+- ✅ Supports custom patterns in subdirectories
+- ✅ Playlist-compatible (each pattern in playlist can have its own effect)
+
+## Troubleshooting
+
+### Pattern Effect Not Applied
+
+1. Check that the pattern path is correct (use forward slashes: `custom_patterns/mypattern.thr`)
+2. Verify the effect was saved: `GET /api/patterns/{pattern}/led_effect`
+3. Check server logs for any errors during pattern execution
+
+### Effects Reverting to Global
+
+If a pattern uses global effects instead of custom ones:
+1. The custom effect may not be configured
+2. The pattern path may not match exactly
+3. Check the metadata cache file for the pattern entry
+
+### API Errors
+
+- **400 Bad Request**: Invalid effect_type or missing required fields
+- **500 Internal Server Error**: Check server logs for details
+
+## Future Enhancements
+
+Potential future improvements:
+- Web UI for configuring pattern LED effects
+- Bulk edit multiple patterns at once
+- Import/export LED effect presets
+- Visual LED effect preview
+- Pattern categories with shared LED themes

+ 112 - 0
main.py

@@ -1878,6 +1878,118 @@ async def dw_leds_get_effect_settings():
         "playing_effect": state.dw_led_playing_effect
     }
 
+# Per-pattern LED effect endpoints
+@app.get("/api/patterns/{pattern_path:path}/led_effect")
+async def get_pattern_led_effect(pattern_path: str, effect_type: str = "playing"):
+    """
+    Get LED effect settings for a specific pattern.
+
+    Args:
+        pattern_path: Pattern filename (relative path)
+        effect_type: 'playing' or 'idle' (default: 'playing')
+
+    Returns:
+        LED effect settings or None if not configured
+    """
+    from modules.core.cache_manager import get_pattern_led_effect
+
+    if effect_type not in ["playing", "idle"]:
+        raise HTTPException(status_code=400, detail="effect_type must be 'playing' or 'idle'")
+
+    try:
+        effect_settings = get_pattern_led_effect(pattern_path, effect_type)
+        return {
+            "pattern": pattern_path,
+            "effect_type": effect_type,
+            "settings": effect_settings
+        }
+    except Exception as e:
+        logger.error(f"Failed to get pattern LED effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/patterns/{pattern_path:path}/led_effect")
+async def set_pattern_led_effect(pattern_path: str, request: dict):
+    """
+    Set LED effect settings for a specific pattern.
+
+    Request body:
+    {
+        "effect_type": "playing" or "idle",
+        "effect_id": int,
+        "palette_id": int,
+        "speed": int,
+        "intensity": int,
+        "color1": "#RRGGBB",
+        "color2": "#RRGGBB",
+        "color3": "#RRGGBB"
+    }
+    """
+    from modules.core.cache_manager import set_pattern_led_effect
+
+    effect_type = request.get("effect_type", "playing")
+    if effect_type not in ["playing", "idle"]:
+        raise HTTPException(status_code=400, detail="effect_type must be 'playing' or 'idle'")
+
+    # Extract effect settings from request
+    effect_settings = {
+        "effect_id": request.get("effect_id"),
+        "palette_id": request.get("palette_id"),
+        "speed": request.get("speed"),
+        "intensity": request.get("intensity"),
+        "color1": request.get("color1"),
+        "color2": request.get("color2"),
+        "color3": request.get("color3")
+    }
+
+    # Validate required fields
+    if effect_settings["effect_id"] is None:
+        raise HTTPException(status_code=400, detail="effect_id is required")
+
+    try:
+        success = set_pattern_led_effect(pattern_path, effect_type, effect_settings)
+        if success:
+            logger.info(f"Saved {effect_type} LED effect for pattern: {pattern_path}")
+            return {
+                "success": True,
+                "pattern": pattern_path,
+                "effect_type": effect_type,
+                "settings": effect_settings
+            }
+        else:
+            raise HTTPException(status_code=500, detail="Failed to save LED effect settings")
+    except Exception as e:
+        logger.error(f"Failed to set pattern LED effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.delete("/api/patterns/{pattern_path:path}/led_effect")
+async def clear_pattern_led_effect(pattern_path: str, effect_type: str = None):
+    """
+    Clear LED effect settings for a specific pattern.
+
+    Args:
+        pattern_path: Pattern filename (relative path)
+        effect_type: 'playing', 'idle', or None to clear all (default: None)
+    """
+    from modules.core.cache_manager import clear_pattern_led_effect
+
+    if effect_type and effect_type not in ["playing", "idle"]:
+        raise HTTPException(status_code=400, detail="effect_type must be 'playing', 'idle', or omitted")
+
+    try:
+        success = clear_pattern_led_effect(pattern_path, effect_type)
+        if success:
+            logger.info(f"Cleared LED effect for pattern: {pattern_path} (type: {effect_type or 'all'})")
+            return {
+                "success": True,
+                "pattern": pattern_path,
+                "effect_type": effect_type or "all"
+            }
+        else:
+            raise HTTPException(status_code=500, detail="Failed to clear LED effect settings")
+    except Exception as e:
+        logger.error(f"Failed to clear pattern LED effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
 @app.post("/api/dw_leds/idle_timeout")
 async def dw_leds_set_idle_timeout(request: dict):
     """Configure LED idle timeout settings"""

+ 161 - 2
modules/core/cache_manager.py

@@ -814,7 +814,166 @@ async def list_theta_rho_files_async():
                 relative_path = relative_path.replace(os.sep, '/')
                 files.append(relative_path)
         return files
-    
+
     files = await asyncio.to_thread(_walk_files)
     logger.debug(f"Found {len(files)} theta-rho files")
-    return files  # Already filtered for .thr
+    return files  # Already filtered for .thr
+
+# LED Effect Management for Patterns
+
+def get_pattern_led_effect(pattern_file, effect_type='playing'):
+    """
+    Get LED effect settings for a specific pattern.
+
+    Args:
+        pattern_file: Pattern filename (relative path)
+        effect_type: 'playing' or 'idle' (default: 'playing')
+
+    Returns:
+        dict with LED effect settings, or None if not configured
+        Format: {
+            'effect_id': int,
+            'palette_id': int,
+            'speed': int,
+            'intensity': int,
+            'color1': str (hex),
+            'color2': str (hex),
+            'color3': str (hex)
+        }
+    """
+    try:
+        cache_data = load_metadata_cache()
+        data_section = cache_data.get('data', {})
+
+        if pattern_file not in data_section:
+            return None
+
+        metadata = data_section[pattern_file].get('metadata', {})
+        led_effect = metadata.get('led_effect', {})
+
+        return led_effect.get(effect_type)
+    except Exception as e:
+        logger.warning(f"Failed to get LED effect for {pattern_file}: {str(e)}")
+        return None
+
+async def get_pattern_led_effect_async(pattern_file, effect_type='playing'):
+    """Async version: Get LED effect settings for a specific pattern."""
+    try:
+        cache_data = await load_metadata_cache_async()
+        data_section = cache_data.get('data', {})
+
+        if pattern_file not in data_section:
+            return None
+
+        metadata = data_section[pattern_file].get('metadata', {})
+        led_effect = metadata.get('led_effect', {})
+
+        return led_effect.get(effect_type)
+    except Exception as e:
+        logger.warning(f"Failed to get LED effect for {pattern_file}: {str(e)}")
+        return None
+
+def set_pattern_led_effect(pattern_file, effect_type, effect_settings):
+    """
+    Set LED effect settings for a specific pattern.
+
+    Args:
+        pattern_file: Pattern filename (relative path)
+        effect_type: 'playing' or 'idle'
+        effect_settings: dict with LED effect configuration
+            {
+                'effect_id': int,
+                'palette_id': int,
+                'speed': int,
+                'intensity': int,
+                'color1': str (hex),
+                'color2': str (hex),
+                'color3': str (hex)
+            }
+
+    Returns:
+        bool: True if successful, False otherwise
+    """
+    try:
+        cache_data = load_metadata_cache()
+        data_section = cache_data.get('data', {})
+
+        if pattern_file not in data_section:
+            # Initialize metadata entry if it doesn't exist
+            pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
+            if not os.path.exists(pattern_path):
+                logger.error(f"Pattern file not found: {pattern_path}")
+                return False
+
+            # Create minimal metadata entry
+            file_mtime = os.path.getmtime(pattern_path)
+            data_section[pattern_file] = {
+                'mtime': file_mtime,
+                'metadata': {}
+            }
+
+        # Get or create led_effect section
+        metadata = data_section[pattern_file].get('metadata', {})
+        if 'led_effect' not in metadata:
+            metadata['led_effect'] = {}
+
+        # Set the effect settings
+        metadata['led_effect'][effect_type] = effect_settings
+        data_section[pattern_file]['metadata'] = metadata
+
+        # Save updated cache
+        cache_data['data'] = data_section
+        save_metadata_cache(cache_data)
+
+        logger.info(f"Saved {effect_type} LED effect for pattern: {pattern_file}")
+        return True
+    except Exception as e:
+        logger.error(f"Failed to set LED effect for {pattern_file}: {str(e)}")
+        return False
+
+def clear_pattern_led_effect(pattern_file, effect_type=None):
+    """
+    Clear LED effect settings for a specific pattern.
+
+    Args:
+        pattern_file: Pattern filename (relative path)
+        effect_type: 'playing', 'idle', or None to clear all (default: None)
+
+    Returns:
+        bool: True if successful, False otherwise
+    """
+    try:
+        cache_data = load_metadata_cache()
+        data_section = cache_data.get('data', {})
+
+        if pattern_file not in data_section:
+            return True  # Nothing to clear
+
+        metadata = data_section[pattern_file].get('metadata', {})
+
+        if 'led_effect' not in metadata:
+            return True  # Nothing to clear
+
+        if effect_type is None:
+            # Clear all LED effects
+            del metadata['led_effect']
+            logger.info(f"Cleared all LED effects for pattern: {pattern_file}")
+        else:
+            # Clear specific effect type
+            if effect_type in metadata['led_effect']:
+                del metadata['led_effect'][effect_type]
+                logger.info(f"Cleared {effect_type} LED effect for pattern: {pattern_file}")
+
+            # Remove led_effect section if empty
+            if not metadata['led_effect']:
+                del metadata['led_effect']
+
+        # Save updated cache
+        data_section[pattern_file]['metadata'] = metadata
+        cache_data['data'] = data_section
+        save_metadata_cache(cache_data)
+
+        return True
+    except Exception as e:
+        logger.error(f"Failed to clear LED effect for {pattern_file}: {str(e)}")
+        return False

+ 30 - 10
modules/core/pattern_manager.py

@@ -14,6 +14,7 @@ import json
 # Import for legacy support, but we'll use LED interface through state
 from modules.led.led_controller import effect_playing, effect_idle
 from modules.led.idle_timeout_manager import idle_timeout_manager
+from modules.core.cache_manager import get_pattern_led_effect_async
 import queue
 from dataclasses import dataclass
 from typing import Optional, Callable
@@ -679,11 +680,27 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         logger.info(f"Starting pattern execution: {file_path}")
         logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
         await reset_theta()
-        
+
+        # Get pattern filename (relative to THETA_RHO_DIR) for LED effect lookup
+        pattern_filename = os.path.relpath(file_path, THETA_RHO_DIR)
+        # Normalize path separators for consistency
+        pattern_filename = pattern_filename.replace(os.sep, '/')
+
+        # Load pattern-specific LED effects (if configured)
+        pattern_playing_effect = await get_pattern_led_effect_async(pattern_filename, 'playing')
+        pattern_idle_effect = await get_pattern_led_effect_async(pattern_filename, 'idle')
+
+        # Use pattern-specific effects if available, otherwise fall back to global settings
+        playing_effect = pattern_playing_effect if pattern_playing_effect else state.dw_led_playing_effect
+        idle_effect = pattern_idle_effect if pattern_idle_effect else state.dw_led_idle_effect
+
         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)
+            if pattern_playing_effect:
+                logger.info(f"Setting LED to pattern-specific playing effect for {pattern_filename}")
+            else:
+                logger.info(f"Setting LED to global playing effect")
+            state.led_controller.effect_playing(playing_effect)
             # Cancel idle timeout when playing starts
             idle_timeout_manager.cancel_timeout()
 
@@ -700,7 +717,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)
+                        state.led_controller.effect_idle(idle_effect)
                         start_idle_led_timeout()
                     break
 
@@ -708,7 +725,7 @@ 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)
+                        state.led_controller.effect_idle(idle_effect)
                         start_idle_led_timeout()
                     break
 
@@ -731,7 +748,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                     # 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)
+                        state.led_controller.effect_idle(idle_effect)
                         start_idle_led_timeout()
 
                     # Remember if we turned off LED controller for scheduled pause
@@ -753,7 +770,7 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                             # 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)
+                        state.led_controller.effect_playing(playing_effect)
                         # Cancel idle timeout when resuming from pause
                         idle_timeout_manager.cancel_timeout()
 
@@ -786,11 +803,14 @@ async def run_theta_rho_file(file_path, is_playlist=False):
             return
             
         await connection_manager.check_idle_async()
-        
+
         # 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)
+            if pattern_idle_effect:
+                logger.info(f"Setting LED to pattern-specific idle effect for {pattern_filename}")
+            else:
+                logger.info(f"Setting LED to global idle effect")
+            state.led_controller.effect_idle(idle_effect)
             start_idle_led_timeout()
             logger.debug("LED effect set to idle after pattern completion")
 

+ 106 - 0
test_led_effects.py

@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+"""Test script for per-pattern LED effect functionality."""
+import os
+import sys
+import json
+
+# Add the parent directory to the path
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from modules.core.cache_manager import (
+    get_pattern_led_effect,
+    set_pattern_led_effect,
+    clear_pattern_led_effect,
+    load_metadata_cache,
+    save_metadata_cache
+)
+
+def test_led_effect_storage():
+    """Test storing and retrieving LED effects for patterns."""
+    print("Testing LED effect storage...")
+
+    # Test pattern
+    test_pattern = "test_pattern.thr"
+
+    # Test effect settings
+    test_effect = {
+        "effect_id": 8,
+        "palette_id": 0,
+        "speed": 150,
+        "intensity": 200,
+        "color1": "#ff0000",
+        "color2": "#00ff00",
+        "color3": "#0000ff"
+    }
+
+    # Test 1: Set playing effect
+    print("\n1. Setting playing effect for pattern...")
+    success = set_pattern_led_effect(test_pattern, "playing", test_effect)
+    print(f"   Result: {'✓ Success' if success else '✗ Failed'}")
+
+    # Test 2: Retrieve playing effect
+    print("\n2. Retrieving playing effect for pattern...")
+    retrieved_effect = get_pattern_led_effect(test_pattern, "playing")
+    if retrieved_effect == test_effect:
+        print(f"   Result: ✓ Success - Effect matches")
+        print(f"   Retrieved: {json.dumps(retrieved_effect, indent=2)}")
+    else:
+        print(f"   Result: ✗ Failed - Effect doesn't match")
+        print(f"   Expected: {json.dumps(test_effect, indent=2)}")
+        print(f"   Retrieved: {json.dumps(retrieved_effect, indent=2)}")
+
+    # Test 3: Set idle effect
+    test_idle_effect = {
+        "effect_id": 0,
+        "palette_id": 0,
+        "speed": 128,
+        "intensity": 128,
+        "color1": "#ffffff",
+        "color2": "#000000",
+        "color3": "#0000ff"
+    }
+    print("\n3. Setting idle effect for pattern...")
+    success = set_pattern_led_effect(test_pattern, "idle", test_idle_effect)
+    print(f"   Result: {'✓ Success' if success else '✗ Failed'}")
+
+    # Test 4: Retrieve both effects
+    print("\n4. Verifying both effects are stored...")
+    playing = get_pattern_led_effect(test_pattern, "playing")
+    idle = get_pattern_led_effect(test_pattern, "idle")
+    if playing == test_effect and idle == test_idle_effect:
+        print(f"   Result: ✓ Success - Both effects stored correctly")
+    else:
+        print(f"   Result: ✗ Failed - Effects don't match")
+
+    # Test 5: Clear specific effect
+    print("\n5. Clearing playing effect...")
+    success = clear_pattern_led_effect(test_pattern, "playing")
+    print(f"   Result: {'✓ Success' if success else '✗ Failed'}")
+
+    playing = get_pattern_led_effect(test_pattern, "playing")
+    idle = get_pattern_led_effect(test_pattern, "idle")
+    if playing is None and idle == test_idle_effect:
+        print(f"   Result: ✓ Success - Playing cleared, idle remains")
+    else:
+        print(f"   Result: ✗ Failed")
+        print(f"   Playing (should be None): {playing}")
+        print(f"   Idle (should exist): {idle}")
+
+    # Test 6: Clear all effects
+    print("\n6. Clearing all effects...")
+    success = clear_pattern_led_effect(test_pattern)
+    print(f"   Result: {'✓ Success' if success else '✗ Failed'}")
+
+    playing = get_pattern_led_effect(test_pattern, "playing")
+    idle = get_pattern_led_effect(test_pattern, "idle")
+    if playing is None and idle is None:
+        print(f"   Result: ✓ Success - All effects cleared")
+    else:
+        print(f"   Result: ✗ Failed - Effects still exist")
+        print(f"   Playing: {playing}")
+        print(f"   Idle: {idle}")
+
+    print("\n✓ LED effect storage tests completed!")
+
+if __name__ == "__main__":
+    test_led_effect_storage()