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

Merge main into new_reed_homing

- Integrated LED interface refactor (DW LEDs support)
- Combined Desert Compass (reed switch) with main LED features
- Resolved conflicts in connection_manager.py (kept both LED interface and Desert Compass)
- Resolved conflicts in requirements.txt (combined GPIO libraries)
- Resolved conflicts in docker-compose.yml (kept systemd mounts and GPIO access)
- Maintained 90s homing timeout for Desert Compass calibration
tuanchris 3 месяцев назад
Родитель
Сommit
cbceb0944a

+ 7 - 3
Dockerfile

@@ -10,13 +10,17 @@ WORKDIR /app
 
 COPY requirements.txt ./
 RUN apt-get update && apt-get install -y --no-install-recommends \
-        gcc libjpeg-dev zlib1g-dev git \
+        gcc g++ make libjpeg-dev zlib1g-dev git \
+        # GPIO/NeoPixel support for DW LEDs
+        python3-dev python3-pip \
+        libgpiod2 libgpiod-dev \
+        scons \
     && pip install --upgrade pip \
     && pip install --no-cache-dir -r requirements.txt \
-    && apt-get purge -y gcc \
+    && apt-get purge -y gcc g++ make scons \
     && rm -rf /var/lib/apt/lists/*
 
 COPY . .
 
 EXPOSE 8080
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

+ 9 - 0
README.md

@@ -109,11 +109,20 @@ The project exposes RESTful APIs for various actions. Here are some key endpoint
    ```
 
 2. **Install dependencies**:
+
+   **On Raspberry Pi (full hardware support):**
    ```bash
    pip install -r requirements.txt
    npm install
    ```
 
+   **On Windows/Linux/macOS (development/testing):**
+   ```bash
+   pip install -r requirements-nonrpi.txt
+   npm install
+   ```
+   > **Note**: The development installation excludes Raspberry Pi GPIO libraries. The application will run fully but DW LED features will be disabled. WLED integration will still work.
+
 3. **Build CSS**:
    ```bash
    npm run build-css

+ 1 - 1
VERSION

@@ -1 +1 @@
-3.3.3
+3.4.3

+ 14 - 6
docker-compose.yml

@@ -3,15 +3,23 @@ services:
     build: . # Uncomment this if you need to build
     image: ghcr.io/tuanchris/dune-weaver:main # Use latest production image
     restart: always
-    ports:
-      - "8080:8080" # Map port 8080 of the container to 8080 of the host (access via http://localhost:8080)
+    # ports:
+    #   - "8080:8080" # Map port 8080 of the container to 8080 of the host (access via http://localhost:8080)
+    network_mode: "host" # Use host network for device access
     volumes:
       - .:/app
       # Mount timezone file from host for Still Sands scheduling
       - /etc/timezone:/etc/host-timezone:ro
-      # Mount GPIO memory for hardware access
-      - /dev/gpiomem:/dev/gpiomem
+      # Mount systemd to allow host shutdown
+      - /bin/systemctl:/bin/systemctl:ro
+      - /run/systemd/system:/run/systemd/system:ro
+      - /var/run/dbus/system_bus_socket:/var/run/dbus/system_bus_socket:ro
+      - /sys/fs/cgroup:/sys/fs/cgroup:ro
+      # Mount GPIO for DW LEDs and Desert Compass (reed switch)
+      - /sys:/sys
     devices:
-      - "/dev/ttyACM0:/dev/ttyACM0"
-    privileged: true
+      - "/dev/ttyACM0:/dev/ttyACM0"  # Serial device for stepper motors
+      - "/dev/gpiomem:/dev/gpiomem"  # GPIO memory access for DW LEDs
+      - "/dev/mem:/dev/mem"          # Direct memory access for PWM
+    privileged: true  # Required for GPIO/PWM access
     container_name: dune-weaver

+ 7 - 0
firmware/dune_weaver/config.yaml

@@ -91,3 +91,10 @@ spi:
   miso_pin: gpio.12
   mosi_pin: gpio.13
   sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 100 - 0
firmware/dune_weaver_mini_pro/config.yaml

@@ -0,0 +1,100 @@
+board: MKS-DLC32 V2.1
+name: Dune Weaver Mini Pro
+meta: By Tuan Nguyen (2025-10-22)
+kinematics: {}
+stepping:
+  engine: I2S_STATIC
+  idle_ms: 0
+  pulse_us: 4
+  dir_delay_us: 1
+  disable_delay_us: 0
+axes:
+  shared_stepper_disable_pin: i2so.0
+  x:
+    steps_per_mm: 200
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 325
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.36
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.1
+        direction_pin: i2so.2
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+  y:
+    steps_per_mm: 164
+    max_rate_mm_per_min: 500
+    acceleration_mm_per_sec2: 10
+    max_travel_mm: 6.25
+    soft_limits: false
+    motor0:
+      limit_neg_pin: gpio.35
+      hard_limits: false
+      pulloff_mm: 2
+      stepstick:
+        step_pin: i2so.5
+        direction_pin: i2so.6:low
+        disable_pin: NO_PIN
+        ms1_pin: NO_PIN
+        ms2_pin: NO_PIN
+        ms3_pin: NO_PIN
+      limit_pos_pin: NO_PIN
+      limit_all_pin: NO_PIN
+i2so:
+  bck_pin: gpio.16
+  data_pin: gpio.21
+  ws_pin: gpio.17
+sdcard:
+  cs_pin: gpio.15
+  card_detect_pin: NO_PIN
+control:
+  safety_door_pin: NO_PIN
+  reset_pin: NO_PIN
+  feed_hold_pin: NO_PIN
+  cycle_start_pin: NO_PIN
+  macro0_pin: gpio.33:pu:low
+  macro1_pin: NO_PIN
+  macro2_pin: NO_PIN
+  macro3_pin: NO_PIN
+  fault_pin: NO_PIN
+  estop_pin: NO_PIN
+macros:
+  macro0: G90
+coolant:
+  flood_pin: NO_PIN
+  mist_pin: NO_PIN
+  delay_ms: 0
+user_outputs:
+  analog0_pin: NO_PIN
+  analog1_pin: NO_PIN
+  analog2_pin: NO_PIN
+  analog3_pin: NO_PIN
+  analog0_hz: 5000
+  analog1_hz: 5000
+  analog2_hz: 5000
+  analog3_hz: 5000
+  digital0_pin: NO_PIN
+  digital1_pin: NO_PIN
+  digital2_pin: NO_PIN
+  digital3_pin: NO_PIN
+start:
+  must_home: false
+spi:
+  miso_pin: gpio.12
+  mosi_pin: gpio.13
+  sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 7 - 0
firmware/dune_weaver_pro/config (old gear).yaml

@@ -90,3 +90,10 @@ spi:
   miso_pin: gpio.12
   mosi_pin: gpio.13
   sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 7 - 0
firmware/dune_weaver_pro/config (updated gear 2025-05-30).yaml

@@ -90,3 +90,10 @@ spi:
   miso_pin: gpio.12
   mosi_pin: gpio.13
   sck_pin: gpio.14
+uart1:
+  txd_pin: gpio.19
+  rxd_pin: gpio.18
+  baud: 115200
+  mode: 8N1
+uart_channel1:
+  uart_num: 1

+ 609 - 6
main.py

@@ -20,6 +20,8 @@ import sys
 import asyncio
 from contextlib import asynccontextmanager
 from modules.led.led_controller import LEDController, effect_idle
+from modules.led.led_interface import LEDInterface
+from modules.led.idle_timeout_manager import idle_timeout_manager
 import math
 from modules.core.cache_manager import generate_all_image_previews, get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
@@ -29,6 +31,8 @@ import time
 import argparse
 from concurrent.futures import ProcessPoolExecutor
 import multiprocessing
+import subprocess
+import platform
 
 # Get log level from environment variable, default to INFO
 log_level_str = os.getenv('LOG_LEVEL', 'INFO').upper()
@@ -51,6 +55,25 @@ logging.basicConfig(
 
 logger = logging.getLogger(__name__)
 
+
+async def _check_table_is_idle() -> bool:
+    """Helper function to check if table is idle."""
+    return not state.current_playing_file or state.pause_requested
+
+
+def _start_idle_led_timeout():
+    """Start idle LED timeout if enabled."""
+    if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
+        return
+
+    logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
+    idle_timeout_manager.start_idle_timeout(
+        timeout_minutes=state.dw_led_idle_timeout_minutes,
+        state=state,
+        check_idle_callback=_check_table_is_idle
+    )
+
+
 def normalize_file_path(file_path: str) -> str:
     """Normalize file path separators for consistent cross-platform handling."""
     if not file_path:
@@ -84,7 +107,41 @@ async def lifespan(app: FastAPI):
         connection_manager.connect_device()
     except Exception as e:
         logger.warning(f"Failed to auto-connect to serial port: {str(e)}")
-    
+
+    # Initialize LED controller based on saved configuration
+    try:
+        # Auto-detect provider for backward compatibility with existing installations
+        if not state.led_provider or state.led_provider == "none":
+            if state.wled_ip:
+                state.led_provider = "wled"
+                logger.info("Auto-detected WLED provider from existing configuration")
+
+        # Initialize the appropriate controller
+        if state.led_provider == "wled" and state.wled_ip:
+            state.led_controller = LEDInterface("wled", state.wled_ip)
+            logger.info(f"LED controller initialized: WLED at {state.wled_ip}")
+        elif state.led_provider == "dw_leds":
+            state.led_controller = LEDInterface(
+                "dw_leds",
+                num_leds=state.dw_led_num_leds,
+                gpio_pin=state.dw_led_gpio_pin,
+                pixel_order=state.dw_led_pixel_order,
+                brightness=state.dw_led_brightness / 100.0,
+                speed=state.dw_led_speed,
+                intensity=state.dw_led_intensity
+            )
+            logger.info(f"LED controller initialized: DW LEDs ({state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order})")
+        else:
+            state.led_controller = None
+            logger.info("LED controller not configured")
+
+        # Save if provider was auto-detected
+        if state.led_provider and state.wled_ip:
+            state.save()
+    except Exception as e:
+        logger.warning(f"Failed to initialize LED controller: {str(e)}")
+        state.led_controller = None
+
     # Check if auto_play mode is enabled and auto-play playlist (right after connection attempt)
     if state.auto_play_enabled and state.auto_play_playlist:
         logger.info(f"auto_play mode enabled, checking for connection before auto-playing playlist: {state.auto_play_playlist}")
@@ -128,6 +185,53 @@ async def lifespan(app: FastAPI):
     # Start cache check in background immediately
     asyncio.create_task(delayed_cache_check())
 
+    # Start idle timeout monitor
+    async def idle_timeout_monitor():
+        """Monitor LED idle timeout and turn off LEDs when timeout expires."""
+        import time
+        while True:
+            try:
+                await asyncio.sleep(30)  # Check every 30 seconds
+
+                if not state.dw_led_idle_timeout_enabled:
+                    continue
+
+                if not state.led_controller or not state.led_controller.is_configured:
+                    continue
+
+                # Check if we're currently playing a pattern
+                is_playing = bool(state.current_playing_file or state.current_playlist)
+                if is_playing:
+                    # Reset activity time when playing
+                    state.dw_led_last_activity_time = time.time()
+                    continue
+
+                # If no activity time set, initialize it
+                if state.dw_led_last_activity_time is None:
+                    state.dw_led_last_activity_time = time.time()
+                    continue
+
+                # Calculate idle duration
+                idle_seconds = time.time() - state.dw_led_last_activity_time
+                timeout_seconds = state.dw_led_idle_timeout_minutes * 60
+
+                # Turn off LEDs if timeout expired
+                if idle_seconds >= timeout_seconds:
+                    status = state.led_controller.check_status()
+                    # Check both "power" (WLED) and "power_on" (DW LEDs) keys
+                    is_powered_on = status.get("power", False) or status.get("power_on", False)
+                    if is_powered_on:  # Only turn off if currently on
+                        logger.info(f"Idle timeout ({state.dw_led_idle_timeout_minutes} minutes) expired, turning off LEDs")
+                        state.led_controller.set_power(0)
+                        # Reset activity time to prevent repeated turn-off attempts
+                        state.dw_led_last_activity_time = time.time()
+
+            except Exception as e:
+                logger.error(f"Error in idle timeout monitor: {e}")
+                await asyncio.sleep(60)  # Wait longer on error
+
+    asyncio.create_task(idle_timeout_monitor())
+
     yield  # This separates startup from shutdown code
 
     # Shutdown
@@ -192,6 +296,15 @@ class SpeedRequest(BaseModel):
 class WLEDRequest(BaseModel):
     wled_ip: Optional[str] = None
 
+class LEDConfigRequest(BaseModel):
+    provider: str  # "wled", "dw_leds", or "none"
+    ip_address: Optional[str] = None  # For WLED only
+    # DW LED specific fields
+    num_leds: Optional[int] = None
+    gpio_pin: Optional[int] = None
+    pixel_order: Optional[str] = None
+    brightness: Optional[int] = None
+
 class DeletePlaylistRequest(BaseModel):
     playlist_name: str
 
@@ -1115,19 +1228,151 @@ async def update_software():
 
 @app.post("/set_wled_ip")
 async def set_wled_ip(request: WLEDRequest):
+    """Legacy endpoint for backward compatibility - sets WLED as LED provider"""
     state.wled_ip = request.wled_ip
-    state.led_controller = LEDController(request.wled_ip)
-    effect_idle(state.led_controller)
+    state.led_provider = "wled" if request.wled_ip else "none"
+    state.led_controller = LEDInterface("wled", request.wled_ip) if request.wled_ip else None
+    if state.led_controller:
+        state.led_controller.effect_idle()
+        _start_idle_led_timeout()
     state.save()
     logger.info(f"WLED IP updated: {request.wled_ip}")
     return {"success": True, "wled_ip": state.wled_ip}
 
 @app.get("/get_wled_ip")
 async def get_wled_ip():
+    """Legacy endpoint for backward compatibility"""
     if not state.wled_ip:
         raise HTTPException(status_code=404, detail="No WLED IP set")
     return {"success": True, "wled_ip": state.wled_ip}
 
+@app.post("/set_led_config")
+async def set_led_config(request: LEDConfigRequest):
+    """Configure LED provider (WLED, DW LEDs, or none)"""
+    if request.provider not in ["wled", "dw_leds", "none"]:
+        raise HTTPException(status_code=400, detail="Invalid provider. Must be 'wled', 'dw_leds', or 'none'")
+
+    state.led_provider = request.provider
+
+    if request.provider == "wled":
+        if not request.ip_address:
+            raise HTTPException(status_code=400, detail="IP address required for WLED")
+        state.wled_ip = request.ip_address
+        state.led_controller = LEDInterface("wled", request.ip_address)
+        logger.info(f"LED provider set to WLED at {request.ip_address}")
+
+    elif request.provider == "dw_leds":
+        # Check if hardware settings changed (requires restart)
+        old_gpio_pin = state.dw_led_gpio_pin
+        old_pixel_order = state.dw_led_pixel_order
+        hardware_changed = (
+            old_gpio_pin != (request.gpio_pin or 12) or
+            old_pixel_order != (request.pixel_order or "GRB")
+        )
+
+        # Stop existing DW LED controller if hardware settings changed
+        if hardware_changed and state.led_controller and state.led_provider == "dw_leds":
+            logger.info("Hardware settings changed, stopping existing LED controller...")
+            controller = state.led_controller.get_controller()
+            if controller and hasattr(controller, 'stop'):
+                try:
+                    controller.stop()
+                    logger.info("LED controller stopped successfully")
+                except Exception as e:
+                    logger.error(f"Error stopping LED controller: {e}")
+
+        state.dw_led_num_leds = request.num_leds or 60
+        state.dw_led_gpio_pin = request.gpio_pin or 12
+        state.dw_led_pixel_order = request.pixel_order or "GRB"
+        state.dw_led_brightness = request.brightness or 35
+        state.wled_ip = None
+
+        # Create new LED controller with updated settings
+        state.led_controller = LEDInterface(
+            "dw_leds",
+            num_leds=state.dw_led_num_leds,
+            gpio_pin=state.dw_led_gpio_pin,
+            pixel_order=state.dw_led_pixel_order,
+            brightness=state.dw_led_brightness / 100.0,
+            speed=state.dw_led_speed,
+            intensity=state.dw_led_intensity
+        )
+
+        restart_msg = " (restarted)" if hardware_changed else ""
+        logger.info(f"DW LEDs configured{restart_msg}: {state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}, pixel order: {state.dw_led_pixel_order}")
+
+        # Check if initialization succeeded by checking status
+        status = state.led_controller.check_status()
+        if not status.get("connected", False) and status.get("error"):
+            error_msg = status["error"]
+            logger.warning(f"DW LED initialization failed: {error_msg}, but configuration saved for testing")
+            state.led_controller = None
+            # Keep the provider setting for testing purposes
+            # state.led_provider remains "dw_leds" so settings can be saved/tested
+
+            # Save state even with error
+            state.save()
+
+            # Return success with warning instead of error
+            return {
+                "success": True,
+                "warning": error_msg,
+                "hardware_available": False,
+                "provider": state.led_provider,
+                "dw_led_num_leds": state.dw_led_num_leds,
+                "dw_led_gpio_pin": state.dw_led_gpio_pin,
+                "dw_led_pixel_order": state.dw_led_pixel_order,
+                "dw_led_brightness": state.dw_led_brightness
+            }
+
+    else:  # none
+        state.wled_ip = None
+        state.led_controller = None
+        logger.info("LED provider disabled")
+
+    # Show idle effect if controller is configured
+    if state.led_controller:
+        state.led_controller.effect_idle()
+        _start_idle_led_timeout()
+
+    state.save()
+
+    return {
+        "success": True,
+        "provider": state.led_provider,
+        "wled_ip": state.wled_ip,
+        "dw_led_num_leds": state.dw_led_num_leds,
+        "dw_led_gpio_pin": state.dw_led_gpio_pin,
+        "dw_led_brightness": state.dw_led_brightness
+    }
+
+@app.get("/get_led_config")
+async def get_led_config():
+    """Get current LED provider configuration"""
+    # Auto-detect provider for backward compatibility with existing installations
+    provider = state.led_provider
+    if not provider or provider == "none":
+        # If no provider set but we have IPs configured, auto-detect
+        if state.wled_ip:
+            provider = "wled"
+            state.led_provider = "wled"
+            state.save()
+            logger.info("Auto-detected WLED provider from existing configuration")
+        else:
+            provider = "none"
+
+    return {
+        "success": True,
+        "provider": provider,
+        "wled_ip": state.wled_ip,
+        "dw_led_num_leds": state.dw_led_num_leds,
+        "dw_led_gpio_pin": state.dw_led_gpio_pin,
+        "dw_led_pixel_order": state.dw_led_pixel_order,
+        "dw_led_brightness": state.dw_led_brightness,
+        "dw_led_idle_effect": state.dw_led_idle_effect,
+        "dw_led_playing_effect": state.dw_led_playing_effect
+    }
+
 @app.post("/skip_pattern")
 async def skip_pattern():
     if not state.current_playlist:
@@ -1322,9 +1567,337 @@ async def playlists(request: Request):
 async def image2sand(request: Request):
     return templates.TemplateResponse("image2sand.html", {"request": request, "app_name": state.app_name})
 
-@app.get("/wled")
-async def wled(request: Request):
-    return templates.TemplateResponse("wled.html", {"request": request, "app_name": state.app_name})
+@app.get("/led")
+async def led_control_page(request: Request):
+    return templates.TemplateResponse("led.html", {"request": request, "app_name": state.app_name})
+
+# DW LED control endpoints
+@app.get("/api/dw_leds/status")
+async def dw_leds_status():
+    """Get DW LED controller status"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        return {"connected": False, "message": "DW LEDs not configured"}
+
+    try:
+        return state.led_controller.check_status()
+    except Exception as e:
+        logger.error(f"Failed to check DW LED status: {str(e)}")
+        return {"connected": False, "message": str(e)}
+
+@app.post("/api/dw_leds/power")
+async def dw_leds_power(request: dict):
+    """Control DW LED power (0=off, 1=on, 2=toggle)"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    state_value = request.get("state", 1)
+    if state_value not in [0, 1, 2]:
+        raise HTTPException(status_code=400, detail="State must be 0 (off), 1 (on), or 2 (toggle)")
+
+    try:
+        return state.led_controller.set_power(state_value)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED power: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/brightness")
+async def dw_leds_brightness(request: dict):
+    """Set DW LED brightness (0-100)"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("value", 50)
+    if not 0 <= value <= 100:
+        raise HTTPException(status_code=400, detail="Brightness must be between 0 and 100")
+
+    try:
+        controller = state.led_controller.get_controller()
+        result = controller.set_brightness(value)
+        # Update state if successful
+        if result.get("connected"):
+            state.dw_led_brightness = value
+            state.save()
+        return result
+    except Exception as e:
+        logger.error(f"Failed to set DW LED brightness: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/color")
+async def dw_leds_color(request: dict):
+    """Set solid color"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    # Accept both formats: {"r": 255, "g": 0, "b": 0} or {"color": [255, 0, 0]}
+    if "color" in request:
+        color = request["color"]
+        if not isinstance(color, list) or len(color) != 3:
+            raise HTTPException(status_code=400, detail="Color must be [R, G, B] array")
+        r, g, b = color[0], color[1], color[2]
+    elif "r" in request and "g" in request and "b" in request:
+        r = request["r"]
+        g = request["g"]
+        b = request["b"]
+    else:
+        raise HTTPException(status_code=400, detail="Color must include r, g, b fields or color array")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_color(r, g, b)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED color: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/colors")
+async def dw_leds_colors(request: dict):
+    """Set effect colors (color1, color2, color3)"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    # Parse colors from request
+    color1 = None
+    color2 = None
+    color3 = None
+
+    if "color1" in request:
+        c = request["color1"]
+        if isinstance(c, list) and len(c) == 3:
+            color1 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color1 must be [R, G, B] array")
+
+    if "color2" in request:
+        c = request["color2"]
+        if isinstance(c, list) and len(c) == 3:
+            color2 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color2 must be [R, G, B] array")
+
+    if "color3" in request:
+        c = request["color3"]
+        if isinstance(c, list) and len(c) == 3:
+            color3 = tuple(c)
+        else:
+            raise HTTPException(status_code=400, detail="color3 must be [R, G, B] array")
+
+    if not any([color1, color2, color3]):
+        raise HTTPException(status_code=400, detail="Must provide at least one color")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_colors(color1=color1, color2=color2, color3=color3)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED colors: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/dw_leds/effects")
+async def dw_leds_effects():
+    """Get list of available effects"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    try:
+        controller = state.led_controller.get_controller()
+        effects = controller.get_effects()
+        # Convert tuples to lists for JSON serialization
+        effects_list = [[eid, name] for eid, name in effects]
+        return {
+            "success": True,
+            "effects": effects_list
+        }
+    except Exception as e:
+        logger.error(f"Failed to get DW LED effects: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.get("/api/dw_leds/palettes")
+async def dw_leds_palettes():
+    """Get list of available palettes"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    try:
+        controller = state.led_controller.get_controller()
+        palettes = controller.get_palettes()
+        # Convert tuples to lists for JSON serialization
+        palettes_list = [[pid, name] for pid, name in palettes]
+        return {
+            "success": True,
+            "palettes": palettes_list
+        }
+    except Exception as e:
+        logger.error(f"Failed to get DW LED palettes: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/effect")
+async def dw_leds_effect(request: dict):
+    """Set effect by ID"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    effect_id = request.get("effect_id", 0)
+    speed = request.get("speed")
+    intensity = request.get("intensity")
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_effect(effect_id, speed=speed, intensity=intensity)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED effect: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/palette")
+async def dw_leds_palette(request: dict):
+    """Set palette by ID"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    palette_id = request.get("palette_id", 0)
+
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_palette(palette_id)
+    except Exception as e:
+        logger.error(f"Failed to set DW LED palette: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/speed")
+async def dw_leds_speed(request: dict):
+    """Set effect speed (0-255)"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("speed", 128)
+    if not 0 <= value <= 255:
+        raise HTTPException(status_code=400, detail="Speed must be between 0 and 255")
+
+    try:
+        controller = state.led_controller.get_controller()
+        result = controller.set_speed(value)
+        # Save speed to state
+        state.dw_led_speed = value
+        state.save()
+        return result
+    except Exception as e:
+        logger.error(f"Failed to set DW LED speed: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/intensity")
+async def dw_leds_intensity(request: dict):
+    """Set effect intensity (0-255)"""
+    if not state.led_controller or state.led_provider != "dw_leds":
+        raise HTTPException(status_code=400, detail="DW LEDs not configured")
+
+    value = request.get("intensity", 128)
+    if not 0 <= value <= 255:
+        raise HTTPException(status_code=400, detail="Intensity must be between 0 and 255")
+
+    try:
+        controller = state.led_controller.get_controller()
+        result = controller.set_intensity(value)
+        # Save intensity to state
+        state.dw_led_intensity = value
+        state.save()
+        return result
+    except Exception as e:
+        logger.error(f"Failed to set DW LED intensity: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+@app.post("/api/dw_leds/save_effect_settings")
+async def dw_leds_save_effect_settings(request: dict):
+    """Save current LED settings as idle or playing effect"""
+    effect_type = request.get("type")  # 'idle' or 'playing'
+
+    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")
+    }
+
+    if effect_type == "idle":
+        state.dw_led_idle_effect = settings
+    elif effect_type == "playing":
+        state.dw_led_playing_effect = settings
+    else:
+        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
+
+    state.save()
+    logger.info(f"DW LED {effect_type} effect settings saved: {settings}")
+
+    return {"success": True, "type": effect_type, "settings": settings}
+
+@app.post("/api/dw_leds/clear_effect_settings")
+async def dw_leds_clear_effect_settings(request: dict):
+    """Clear idle or playing effect settings"""
+    effect_type = request.get("type")  # 'idle' or 'playing'
+
+    if effect_type == "idle":
+        state.dw_led_idle_effect = None
+    elif effect_type == "playing":
+        state.dw_led_playing_effect = None
+    else:
+        raise HTTPException(status_code=400, detail="Invalid effect type. Must be 'idle' or 'playing'")
+
+    state.save()
+    logger.info(f"DW LED {effect_type} effect settings cleared")
+
+    return {"success": True, "type": effect_type}
+
+@app.get("/api/dw_leds/get_effect_settings")
+async def dw_leds_get_effect_settings():
+    """Get saved idle and playing effect settings"""
+    return {
+        "idle_effect": state.dw_led_idle_effect,
+        "playing_effect": state.dw_led_playing_effect
+    }
+
+@app.post("/api/dw_leds/idle_timeout")
+async def dw_leds_set_idle_timeout(request: dict):
+    """Configure LED idle timeout settings"""
+    enabled = request.get("enabled", False)
+    minutes = request.get("minutes", 30)
+
+    # Validate minutes (between 1 and 1440 - 24 hours)
+    if minutes < 1 or minutes > 1440:
+        raise HTTPException(status_code=400, detail="Timeout must be between 1 and 1440 minutes")
+
+    state.dw_led_idle_timeout_enabled = enabled
+    state.dw_led_idle_timeout_minutes = minutes
+
+    # Reset activity time when settings change
+    import time
+    state.dw_led_last_activity_time = time.time()
+
+    state.save()
+    logger.info(f"DW LED idle timeout configured: enabled={enabled}, minutes={minutes}")
+
+    return {
+        "success": True,
+        "enabled": enabled,
+        "minutes": minutes
+    }
+
+@app.get("/api/dw_leds/idle_timeout")
+async def dw_leds_get_idle_timeout():
+    """Get LED idle timeout settings"""
+    import time
+
+    # Calculate remaining time if timeout is active
+    remaining_minutes = None
+    if state.dw_led_idle_timeout_enabled and state.dw_led_last_activity_time:
+        elapsed_seconds = time.time() - state.dw_led_last_activity_time
+        timeout_seconds = state.dw_led_idle_timeout_minutes * 60
+        remaining_seconds = max(0, timeout_seconds - elapsed_seconds)
+        remaining_minutes = round(remaining_seconds / 60, 1)
+
+    return {
+        "enabled": state.dw_led_idle_timeout_enabled,
+        "minutes": state.dw_led_idle_timeout_minutes,
+        "remaining_minutes": remaining_minutes
+    }
 
 @app.get("/table_control")
 async def table_control(request: Request):
@@ -1421,6 +1994,36 @@ async def trigger_update():
             status_code=500
         )
 
+@app.post("/api/system/shutdown")
+async def shutdown_system():
+    """Shutdown the system"""
+    try:
+        logger.warning("Shutdown initiated via API")
+
+        # Schedule shutdown command after a short delay to allow response to be sent
+        def delayed_shutdown():
+            time.sleep(2)  # Give time for response to be sent
+            try:
+                # Use systemctl to shutdown the host (via mounted systemd socket)
+                subprocess.run(["systemctl", "poweroff"], check=True)
+                logger.info("Host shutdown command executed successfully via systemctl")
+            except FileNotFoundError:
+                logger.error("systemctl command not found - ensure systemd volumes are mounted")
+            except Exception as e:
+                logger.error(f"Error executing host shutdown command: {e}")
+
+        import threading
+        shutdown_thread = threading.Thread(target=delayed_shutdown)
+        shutdown_thread.start()
+
+        return {"success": True, "message": "System shutdown initiated"}
+    except Exception as e:
+        logger.error(f"Error initiating shutdown: {e}")
+        return JSONResponse(
+            content={"success": False, "message": str(e)},
+            status_code=500
+        )
+
 def entrypoint():
     import uvicorn
     logger.info("Starting FastAPI server on port 8080...")

+ 131 - 9
modules/connection/connection_manager.py

@@ -8,13 +8,33 @@ import asyncio
 
 from modules.core import pattern_manager
 from modules.core.state import state
-from modules.led.led_controller import effect_loading, effect_idle, effect_connected, LEDController
+from modules.led.led_interface import LEDInterface
+from modules.led.idle_timeout_manager import idle_timeout_manager
 from modules.connection.reed_switch import ReedSwitchMonitor
 
 logger = logging.getLogger(__name__)
 
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
 
+
+async def _check_table_is_idle() -> bool:
+    """Helper function to check if table is idle."""
+    return not state.current_playing_file or state.pause_requested
+
+
+def _start_idle_led_timeout():
+    """Start idle LED timeout if enabled."""
+    if not state.dw_led_idle_timeout_enabled or state.dw_led_idle_timeout_minutes <= 0:
+        return
+
+    logger.debug(f"Starting idle LED timeout: {state.dw_led_idle_timeout_minutes} minutes")
+    idle_timeout_manager.start_idle_timeout(
+        timeout_minutes=state.dw_led_idle_timeout_minutes,
+        state=state,
+        check_idle_callback=_check_table_is_idle
+    )
+
+
 ###############################################################################
 # Connection Abstraction
 ###############################################################################
@@ -182,10 +202,22 @@ def device_init(homing=True):
 
 
 def connect_device(homing=True):
-    if state.wled_ip:
-        state.led_controller = LEDController(state.wled_ip)
-        effect_loading(state.led_controller)
-        
+    # Initialize LED interface based on configured provider
+    if state.led_provider == "wled" and state.wled_ip:
+        state.led_controller = LEDInterface(provider="wled", ip_address=state.wled_ip)
+    elif state.led_provider == "hyperion" and state.hyperion_ip:
+        state.led_controller = LEDInterface(
+            provider="hyperion",
+            ip_address=state.hyperion_ip,
+            port=state.hyperion_port
+        )
+    else:
+        state.led_controller = None
+
+    # Show loading effect
+    if state.led_controller:
+        state.led_controller.effect_loading()
+
     ports = list_serial_ports()
 
     if state.port and state.port in ports:
@@ -195,11 +227,94 @@ def connect_device(homing=True):
     else:
         logger.error("Auto connect failed.")
         # state.conn = WebSocketConnection('ws://fluidnc.local:81')
+
     if (state.conn.is_connected() if state.conn else False):
+        # Check for alarm state and unlock if needed before initializing
+        if not check_and_unlock_alarm():
+            logger.error("Failed to unlock device from alarm state")
+            # Still proceed with device_init but log the issue
+
         device_init(homing)
-        
+
+    # Show connected effect, then transition to configured idle effect
     if state.led_controller:
-        effect_connected(state.led_controller)
+        logger.info("Showing LED connected effect (green flash)")
+        state.led_controller.effect_connected()
+        # Set the configured idle effect after connection
+        logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
+        state.led_controller.effect_idle(state.dw_led_idle_effect)
+        _start_idle_led_timeout()
+
+def check_and_unlock_alarm():
+    """
+    Check if GRBL is in alarm state and unlock it with $X if needed.
+    Uses $A command to log detailed alarm information before unlocking.
+    Returns True if device is ready (unlocked or no alarm), False on error.
+    """
+    try:
+        logger.info("Checking device status for alarm state...")
+
+        # Send status query
+        state.conn.send('?\n')
+        time.sleep(0.1)
+
+        # Read response with timeout
+        max_attempts = 5
+        response = None
+
+        for attempt in range(max_attempts):
+            if state.conn.in_waiting() > 0:
+                response = state.conn.readline()
+                logger.debug(f"Status response: {response}")
+                break
+            time.sleep(0.1)
+
+        if not response:
+            logger.warning("No status response received, proceeding anyway")
+            return True
+
+        # Check for alarm state
+        if "Alarm" in response:
+            logger.warning(f"Device in ALARM state: {response}")
+
+            # Query alarm details with $A command
+            logger.info("Querying alarm details with $A command...")
+            state.conn.send('$A\n')
+            time.sleep(0.2)
+
+            # Read and log alarm details
+            for attempt in range(max_attempts):
+                if state.conn.in_waiting() > 0:
+                    alarm_details = state.conn.readline()
+                    logger.warning(f"Alarm details: {alarm_details}")
+                    break
+                time.sleep(0.1)
+
+            # Send unlock command
+            logger.info("Sending $X to unlock...")
+            state.conn.send('$X\n')
+            time.sleep(0.5)
+
+            # Verify unlock succeeded
+            state.conn.send('?\n')
+            time.sleep(0.1)
+
+            verify_response = state.conn.readline()
+            logger.debug(f"Verification response: {verify_response}")
+
+            if "Alarm" in verify_response:
+                logger.error("Failed to unlock device from alarm state")
+                return False
+            else:
+                logger.info("Device successfully unlocked")
+                return True
+        else:
+            logger.info("Device not in alarm state, proceeding normally")
+            return True
+
+    except Exception as e:
+        logger.error(f"Error checking/unlocking alarm: {e}")
+        return False
 
 def get_status_response() -> str:
     """
@@ -387,6 +502,8 @@ def get_machine_steps(timeout=10):
             state.table_type = 'dune_weaver_pro'
         elif y_steps_per_mm == 287:
             state.table_type = 'dune_weaver'
+        elif y_steps_per_mm == 164:
+            state.table_type == 'dune_weaver_mini_pro'
         else:
             state.table_type = None
             logger.warning(f"Unknown table type with Y steps/mm: {y_steps_per_mm}")
@@ -408,11 +525,16 @@ def home(timeout=90):
         timeout: Maximum time in seconds to wait for homing to complete (default: 60)
     """
     import threading
-    
+
+    # Check for alarm state before homing and unlock if needed
+    if not check_and_unlock_alarm():
+        logger.error("Failed to unlock device from alarm state, cannot proceed with homing")
+        return False
+
     # Flag to track if homing completed
     homing_complete = threading.Event()
     homing_success = False
-    
+
     def home_internal():
         nonlocal homing_success
         try:

+ 74 - 18
modules/core/pattern_manager.py

@@ -11,7 +11,9 @@ from modules.core.state import state
 from math import pi
 import asyncio
 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
 import queue
 from dataclasses import dataclass
 from typing import Optional, Callable
@@ -135,6 +137,37 @@ def is_in_scheduled_pause_period():
 
     return False
 
+
+async def check_table_is_idle() -> bool:
+    """
+    Check if the table is currently idle (not playing anything).
+    Returns True if idle, False if playing.
+    """
+    return not state.current_playing_file or state.pause_requested
+
+
+def start_idle_led_timeout():
+    """
+    Start the idle LED timeout if enabled.
+    Should be called whenever the idle effect is activated.
+    """
+    if not state.dw_led_idle_timeout_enabled:
+        logger.debug("Idle LED timeout not enabled")
+        return
+
+    timeout_minutes = state.dw_led_idle_timeout_minutes
+    if timeout_minutes <= 0:
+        logger.debug("Idle LED timeout not configured (timeout <= 0)")
+        return
+
+    logger.debug(f"Starting idle LED timeout: {timeout_minutes} minutes")
+    idle_timeout_manager.start_idle_timeout(
+        timeout_minutes=timeout_minutes,
+        state=state,
+        check_idle_callback=check_table_is_idle
+    )
+
+
 # Motion Control Thread Infrastructure
 @dataclass
 class MotionCommand:
@@ -628,14 +661,22 @@ async def run_theta_rho_file(file_path, is_playlist=False):
 
         state.current_playing_file = file_path
         state.stop_requested = False
+
+        # Reset LED idle timeout activity time when pattern starts
+        import time as time_module
+        state.dw_led_last_activity_time = time_module.time()
+
         logger.info(f"Starting pattern execution: {file_path}")
         logger.info(f"t: {state.current_theta}, r: {state.current_rho}")
         await reset_theta()
         
         start_time = time.time()
         if state.led_controller:
-            effect_playing(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)
+            # Cancel idle timeout when playing starts
+            idle_timeout_manager.cancel_timeout()
+
         with tqdm(
             total=total_coordinates,
             unit="coords",
@@ -649,14 +690,16 @@ 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:
-                        effect_idle(state.led_controller)
+                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        start_idle_led_timeout()
                     break
-                
+
                 if state.skip_requested:
                     logger.info("Skipping pattern...")
                     await connection_manager.check_idle_async()
                     if state.led_controller:
-                        effect_idle(state.led_controller)
+                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        start_idle_led_timeout()
                     break
 
                 # Wait for resume if paused (manual or scheduled)
@@ -670,17 +713,18 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                         logger.info("Execution paused (manual)...")
                     else:
                         logger.info("Execution paused (scheduled pause period)...")
-                        # Turn off WLED if scheduled pause and control_wled is enabled
+                        # 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 WLED lights during Still Sands period")
+                            logger.info("Turning off LED lights during Still Sands period")
                             state.led_controller.set_power(0)
 
-                    # Only show idle effect if NOT in scheduled pause with WLED control
+                    # 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):
-                        effect_idle(state.led_controller)
+                        state.led_controller.effect_idle(state.dw_led_idle_effect)
+                        start_idle_led_timeout()
 
-                    # Remember if we turned off WLED for scheduled pause
+                    # Remember if we turned off LED controller for scheduled pause
                     wled_was_off_for_scheduled = scheduled_pause and state.scheduled_pause_control_wled and not manual_pause
 
                     # Wait until both manual pause is released AND we're outside scheduled pause period
@@ -692,11 +736,16 @@ async def run_theta_rho_file(file_path, is_playlist=False):
 
                     logger.info("Execution resumed...")
                     if state.led_controller:
-                        # Turn WLED back on if it was turned off for scheduled pause
+                        # Turn LED controller back on if it was turned off for scheduled pause
                         if wled_was_off_for_scheduled:
-                            logger.info("Turning WLED lights back on as Still Sands period ended")
+                            logger.info("Turning LED lights back on as Still Sands period ended")
                             state.led_controller.set_power(1)
-                        effect_playing(state.led_controller)
+                            # 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)
+                        # Cancel idle timeout when resuming from pause
+                        idle_timeout_manager.cancel_timeout()
 
                 # Dynamically determine the speed for each movement
                 # Use clear_pattern_speed if it's set and this is a clear file, otherwise use state.speed
@@ -730,7 +779,9 @@ 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:
-            effect_idle(state.led_controller)
+            logger.info(f"Setting LED to idle effect: {state.dw_led_idle_effect}")
+            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            start_idle_led_timeout()
             logger.debug("LED effect set to idle after pattern completion")
         
         # Only clear state if not part of a playlist
@@ -754,7 +805,11 @@ async def run_theta_rho_file(file_path, is_playlist=False):
 async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
     """Run multiple .thr files in sequence with options."""
     state.stop_requested = False
-    
+
+    # Reset LED idle timeout activity time when playlist starts
+    import time as time_module
+    state.dw_led_last_activity_time = time_module.time()
+
     # Set initial playlist state
     state.playlist_mode = run_mode
     state.current_playlist_index = 0
@@ -866,10 +921,11 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
         state.current_playlist = None
         state.current_playlist_index = None
         state.playlist_mode = None
-        
+
         if state.led_controller:
-            effect_idle(state.led_controller)
-        
+            state.led_controller.effect_idle(state.dw_led_idle_effect)
+            start_idle_led_timeout()
+
         logger.info("All requested patterns completed (or stopped) and state cleared")
 
 async def stop_actions(clear_playlist = True, wait_for_lock = True):

+ 60 - 0
modules/core/state.py

@@ -49,7 +49,27 @@ class AppState:
         self.conn = None
         self.port = None
         self.wled_ip = None
+        self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
+
+        # DW LED settings
+        self.dw_led_num_leds = 60  # Number of LEDs in strip
+        self.dw_led_gpio_pin = 12  # GPIO pin (12, 13, 18, or 19)
+        self.dw_led_pixel_order = "GRB"  # Pixel color order for WS281x (GRB, RGB, BGR, etc.)
+        self.dw_led_brightness = 35  # Brightness 0-100
+        self.dw_led_speed = 128  # Effect speed 0-255
+        self.dw_led_intensity = 128  # Effect intensity 0-255
+
+        # Idle effect settings (all parameters)
+        self.dw_led_idle_effect = None  # Full effect configuration dict or None
+
+        # Playing effect settings (all parameters)
+        self.dw_led_playing_effect = None  # Full effect configuration dict or None
+
+        # Idle timeout settings
+        self.dw_led_idle_timeout_enabled = False  # Enable automatic LED turn off after idle period
+        self.dw_led_idle_timeout_minutes = 30  # Idle timeout duration in minutes
+        self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.skip_requested = False
         self.table_type = None
         self._playlist_mode = "loop"
@@ -203,6 +223,17 @@ class AppState:
             "custom_clear_from_out": self.custom_clear_from_out,
             "port": self.port,
             "wled_ip": self.wled_ip,
+            "led_provider": self.led_provider,
+            "dw_led_num_leds": self.dw_led_num_leds,
+            "dw_led_gpio_pin": self.dw_led_gpio_pin,
+            "dw_led_pixel_order": self.dw_led_pixel_order,
+            "dw_led_brightness": self.dw_led_brightness,
+            "dw_led_speed": self.dw_led_speed,
+            "dw_led_intensity": self.dw_led_intensity,
+            "dw_led_idle_effect": self.dw_led_idle_effect,
+            "dw_led_playing_effect": self.dw_led_playing_effect,
+            "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,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
@@ -246,6 +277,35 @@ class AppState:
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
         self.port = data.get("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)
+        self.dw_led_gpio_pin = data.get('dw_led_gpio_pin', 12)
+        self.dw_led_pixel_order = data.get('dw_led_pixel_order', "GRB")
+        self.dw_led_brightness = data.get('dw_led_brightness', 35)
+        self.dw_led_speed = data.get('dw_led_speed', 128)
+        self.dw_led_intensity = data.get('dw_led_intensity', 128)
+
+        # Load effect settings (handle both old string format and new dict format)
+        idle_effect_data = data.get('dw_led_idle_effect', None)
+        if isinstance(idle_effect_data, str):
+            # Old format: just effect name
+            self.dw_led_idle_effect = None if idle_effect_data == "off" else {"effect_id": 0}
+        else:
+            # New format: full dict or None
+            self.dw_led_idle_effect = idle_effect_data
+
+        playing_effect_data = data.get('dw_led_playing_effect', None)
+        if isinstance(playing_effect_data, str):
+            # Old format: just effect name
+            self.dw_led_playing_effect = None if playing_effect_data == "off" else {"effect_id": 0}
+        else:
+            # New format: full dict or None
+            self.dw_led_playing_effect = playing_effect_data
+
+        # Load idle timeout settings
+        self.dw_led_idle_timeout_enabled = data.get('dw_led_idle_timeout_enabled', False)
+        self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
+
         self.app_name = data.get("app_name", "Dune Weaver")
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)

+ 632 - 0
modules/led/dw_led_controller.py

@@ -0,0 +1,632 @@
+"""
+Dune Weaver LED Controller - Embedded NeoPixel LED controller for Raspberry Pi
+Provides direct GPIO control of WS2812B LED strips with beautiful effects
+"""
+import threading
+import time
+import logging
+from typing import Optional, Dict, List, Tuple
+from .dw_leds.segment import Segment
+from .dw_leds.effects.basic_effects import get_effect, get_all_effects, FRAMETIME
+from .dw_leds.utils.palettes import get_palette_name, PALETTE_NAMES
+from .dw_leds.utils.colors import rgb_to_color
+
+logger = logging.getLogger(__name__)
+
+
+class DWLEDController:
+    """Dune Weaver LED Controller for NeoPixel LED strips"""
+
+    def __init__(self, num_leds: int = 60, gpio_pin: int = 12, brightness: float = 0.35,
+                 pixel_order: str = "GRB", speed: int = 128, intensity: int = 128):
+        """
+        Initialize Dune Weaver LED controller
+
+        Args:
+            num_leds: Number of LEDs in the strip
+            gpio_pin: GPIO pin number (BCM numbering: 12, 13, 18, or 19)
+            brightness: Global brightness (0.0 - 1.0)
+            pixel_order: Pixel color order (GRB, RGB, RGBW, GRBW)
+            speed: Effect speed 0-255 (default: 128)
+            intensity: Effect intensity 0-255 (default: 128)
+        """
+        self.num_leds = num_leds
+        self.gpio_pin = gpio_pin
+        self.brightness = brightness
+        self.pixel_order = pixel_order
+
+        # State
+        self._powered_on = False
+        self._current_effect_id = 0
+        self._current_palette_id = 0
+        self._speed = speed
+        self._intensity = intensity
+        self._color1 = (255, 0, 0)  # Red (primary)
+        self._color2 = (0, 0, 0)  # Black (background/off)
+        self._color3 = (0, 0, 255)  # Blue (tertiary)
+
+        # Threading
+        self._pixels = None
+        self._segment = None
+        self._effect_thread = None
+        self._stop_thread = threading.Event()
+        self._lock = threading.Lock()
+        self._initialized = False
+        self._init_error = None  # Store initialization error message
+
+    def _initialize_hardware(self):
+        """Lazy initialization of NeoPixel hardware"""
+        if self._initialized:
+            return True
+
+        try:
+            import board
+            import neopixel
+
+            # Map GPIO pin numbers to board pins
+            pin_map = {
+                12: board.D12,
+                13: board.D13,
+                18: board.D18,
+                19: board.D19
+            }
+
+            if self.gpio_pin not in pin_map:
+                error_msg = f"Invalid GPIO pin {self.gpio_pin}. Must be 12, 13, 18, or 19 (PWM-capable pins)"
+                self._init_error = error_msg
+                logger.error(error_msg)
+                return False
+
+            board_pin = pin_map[self.gpio_pin]
+
+            # Initialize NeoPixel strip
+            self._pixels = neopixel.NeoPixel(
+                board_pin,
+                self.num_leds,
+                brightness=self.brightness,
+                auto_write=False,
+                pixel_order=self.pixel_order
+            )
+
+            # Create segment for the entire strip
+            self._segment = Segment(self._pixels, 0, self.num_leds)
+            self._segment.speed = self._speed
+            self._segment.intensity = self._intensity
+            self._segment.palette_id = self._current_palette_id
+
+            # Set colors
+            self._segment.colors[0] = rgb_to_color(*self._color1)
+            self._segment.colors[1] = rgb_to_color(*self._color2)
+            self._segment.colors[2] = rgb_to_color(*self._color3)
+
+            self._initialized = True
+            logger.info(f"DW LEDs initialized: {self.num_leds} LEDs on GPIO {self.gpio_pin}")
+            return True
+
+        except ImportError as e:
+            error_msg = f"Failed to import NeoPixel libraries: {e}. Make sure adafruit-circuitpython-neopixel and Adafruit-Blinka are installed."
+            self._init_error = error_msg
+            logger.error(error_msg)
+            return False
+        except Exception as e:
+            error_msg = f"Failed to initialize NeoPixel hardware: {e}"
+            self._init_error = error_msg
+            logger.error(error_msg)
+            return False
+
+    def _effect_loop(self):
+        """Background thread that runs the current effect"""
+        while not self._stop_thread.is_set():
+            try:
+                with self._lock:
+                    if self._pixels and self._segment and self._powered_on:
+                        # Get current effect function (allows dynamic effect switching)
+                        effect_func = get_effect(self._current_effect_id)
+
+                        # Run effect and get delay
+                        delay_ms = effect_func(self._segment)
+
+                        # Update pixels
+                        self._pixels.show()
+
+                        # Increment call counter
+                        self._segment.call += 1
+                    else:
+                        delay_ms = 100  # Idle delay when off
+
+                # Sleep for the effect's requested delay
+                time.sleep(delay_ms / 1000.0)
+
+            except Exception as e:
+                logger.error(f"Error in effect loop: {e}")
+                time.sleep(0.1)
+
+    def set_power(self, state: int) -> Dict:
+        """
+        Set power state
+
+        Args:
+            state: 0=Off, 1=On, 2=Toggle
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialize_hardware():
+            return {
+                "connected": False,
+                "error": self._init_error or "Failed to initialize LED hardware"
+            }
+
+        with self._lock:
+            if state == 2:  # Toggle
+                self._powered_on = not self._powered_on
+            else:
+                self._powered_on = bool(state)
+
+            # Turn off all pixels immediately when powering off
+            if not self._powered_on and self._pixels:
+                self._pixels.fill((0, 0, 0))
+                self._pixels.show()
+
+            # Start effect thread if not running
+            if self._powered_on and (self._effect_thread is None or not self._effect_thread.is_alive()):
+                self._stop_thread.clear()
+                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
+                self._effect_thread.start()
+
+        return {
+            "connected": True,
+            "power_on": self._powered_on,
+            "message": f"Power {'on' if self._powered_on else 'off'}"
+        }
+
+    def set_brightness(self, value: int) -> Dict:
+        """
+        Set global brightness
+
+        Args:
+            value: Brightness 0-100
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        brightness = max(0.0, min(1.0, value / 100.0))
+
+        with self._lock:
+            self.brightness = brightness
+            if self._pixels:
+                self._pixels.brightness = brightness
+
+        return {
+            "connected": True,
+            "brightness": int(brightness * 100),
+            "message": "Brightness updated"
+        }
+
+    def set_color(self, r: int, g: int, b: int) -> Dict:
+        """
+        Set solid color (sets effect to Static and color1)
+
+        Args:
+            r, g, b: RGB values 0-255
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        with self._lock:
+            self._color1 = (r, g, b)
+            if self._segment:
+                self._segment.colors[0] = rgb_to_color(r, g, b)
+                # Switch to static effect
+                self._current_effect_id = 0
+                self._segment.reset()
+
+            # Auto power on when setting color
+            if not self._powered_on:
+                self._powered_on = True
+
+            # Ensure effect thread is running
+            if self._effect_thread is None or not self._effect_thread.is_alive():
+                self._stop_thread.clear()
+                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
+                self._effect_thread.start()
+
+        return {
+            "connected": True,
+            "color": [r, g, b],
+            "power_on": self._powered_on,
+            "message": "Color set"
+        }
+
+    def set_colors(self, color1: Optional[Tuple[int, int, int]] = None,
+                   color2: Optional[Tuple[int, int, int]] = None,
+                   color3: Optional[Tuple[int, int, int]] = None) -> Dict:
+        """
+        Set effect colors (does not change effect or auto-power on)
+
+        Args:
+            color1: Primary color RGB tuple (0-255)
+            color2: Secondary/background color RGB tuple (0-255)
+            color3: Tertiary color RGB tuple (0-255)
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        colors_set = []
+        with self._lock:
+            if color1 is not None:
+                self._color1 = color1
+                if self._segment:
+                    self._segment.colors[0] = rgb_to_color(*color1)
+                colors_set.append(f"color1={color1}")
+
+            if color2 is not None:
+                self._color2 = color2
+                if self._segment:
+                    self._segment.colors[1] = rgb_to_color(*color2)
+                colors_set.append(f"color2={color2}")
+
+            if color3 is not None:
+                self._color3 = color3
+                if self._segment:
+                    self._segment.colors[2] = rgb_to_color(*color3)
+                colors_set.append(f"color3={color3}")
+
+            # Reset effect to apply new colors
+            if self._segment and colors_set:
+                self._segment.reset()
+
+        return {
+            "connected": True,
+            "colors": {
+                "color1": self._color1,
+                "color2": self._color2,
+                "color3": self._color3
+            },
+            "message": f"Colors updated: {', '.join(colors_set)}"
+        }
+
+    def set_effect(self, effect_id: int, speed: Optional[int] = None,
+                   intensity: Optional[int] = None) -> Dict:
+        """
+        Set active effect
+
+        Args:
+            effect_id: Effect ID (0-15)
+            speed: Optional speed override (0-255)
+            intensity: Optional intensity override (0-255)
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        # Validate effect ID
+        effects = get_all_effects()
+        if not any(eid == effect_id for eid, _ in effects):
+            return {
+                "connected": False,
+                "message": f"Invalid effect ID: {effect_id}"
+            }
+
+        with self._lock:
+            self._current_effect_id = effect_id
+
+            if speed is not None:
+                self._speed = max(0, min(255, speed))
+                if self._segment:
+                    self._segment.speed = self._speed
+
+            if intensity is not None:
+                self._intensity = max(0, min(255, intensity))
+                if self._segment:
+                    self._segment.intensity = self._intensity
+
+            # Reset effect state
+            if self._segment:
+                self._segment.reset()
+
+            # Auto power on when setting effect
+            if not self._powered_on:
+                self._powered_on = True
+
+            # Ensure effect thread is running
+            if self._effect_thread is None or not self._effect_thread.is_alive():
+                self._stop_thread.clear()
+                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
+                self._effect_thread.start()
+
+        effect_name = next(name for eid, name in effects if eid == effect_id)
+        return {
+            "connected": True,
+            "effect_id": effect_id,
+            "effect_name": effect_name,
+            "power_on": self._powered_on,
+            "message": f"Effect set to {effect_name}"
+        }
+
+    def set_palette(self, palette_id: int) -> Dict:
+        """
+        Set color palette
+
+        Args:
+            palette_id: Palette ID (0-58)
+
+        Returns:
+            Dict with status
+        """
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        if palette_id < 0 or palette_id >= len(PALETTE_NAMES):
+            return {
+                "connected": False,
+                "message": f"Invalid palette ID: {palette_id}"
+            }
+
+        with self._lock:
+            self._current_palette_id = palette_id
+            if self._segment:
+                self._segment.palette_id = palette_id
+
+            # Auto power on when setting palette
+            if not self._powered_on:
+                self._powered_on = True
+
+            # Ensure effect thread is running
+            if self._effect_thread is None or not self._effect_thread.is_alive():
+                self._stop_thread.clear()
+                self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
+                self._effect_thread.start()
+
+        palette_name = get_palette_name(palette_id)
+        return {
+            "connected": True,
+            "palette_id": palette_id,
+            "palette_name": palette_name,
+            "power_on": self._powered_on,
+            "message": f"Palette set to {palette_name}"
+        }
+
+    def set_speed(self, speed: int) -> Dict:
+        """Set effect speed (0-255)"""
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        speed = max(0, min(255, speed))
+
+        with self._lock:
+            self._speed = speed
+            if self._segment:
+                self._segment.speed = speed
+                # Reset effect state so speed change takes effect immediately
+                self._segment.reset()
+
+        return {
+            "connected": True,
+            "speed": speed,
+            "message": "Speed updated"
+        }
+
+    def set_intensity(self, intensity: int) -> Dict:
+        """Set effect intensity (0-255)"""
+        if not self._initialized:
+            if not self._initialize_hardware():
+                return {"connected": False, "error": self._init_error or "Hardware not initialized"}
+
+        intensity = max(0, min(255, intensity))
+
+        with self._lock:
+            self._intensity = intensity
+            if self._segment:
+                self._segment.intensity = intensity
+                # Reset effect state so intensity change takes effect immediately
+                self._segment.reset()
+
+        return {
+            "connected": True,
+            "intensity": intensity,
+            "message": "Intensity updated"
+        }
+
+    def get_effects(self) -> List[Tuple[int, str]]:
+        """Get list of all available effects"""
+        return get_all_effects()
+
+    def get_palettes(self) -> List[Tuple[int, str]]:
+        """Get list of all available palettes"""
+        return [(i, name) for i, name in enumerate(PALETTE_NAMES)]
+
+    def check_status(self) -> Dict:
+        """Get current controller status"""
+        # Attempt initialization if not already initialized
+        if not self._initialized:
+            self._initialize_hardware()
+
+        # Get color slots from segment if available
+        colors = []
+        if self._segment and hasattr(self._segment, 'colors'):
+            for color_int in self._segment.colors[:3]:  # Get up to 3 colors
+                # Convert integer color to hex string
+                r = (color_int >> 16) & 0xFF
+                g = (color_int >> 8) & 0xFF
+                b = color_int & 0xFF
+                colors.append(f"#{r:02x}{g:02x}{b:02x}")
+        else:
+            colors = ["#ff0000", "#000000", "#0000ff"]  # Defaults
+
+        status = {
+            "connected": self._initialized,
+            "power_on": self._powered_on,
+            "num_leds": self.num_leds,
+            "gpio_pin": self.gpio_pin,
+            "brightness": int(self.brightness * 100),
+            "current_effect": self._current_effect_id,
+            "current_palette": self._current_palette_id,
+            "speed": self._speed,
+            "intensity": self._intensity,
+            "colors": colors,
+            "effect_running": self._effect_thread is not None and self._effect_thread.is_alive()
+        }
+
+        # Include error message if not initialized
+        if not self._initialized and self._init_error:
+            status["error"] = self._init_error
+
+        return status
+
+    def stop(self):
+        """Stop the effect loop and cleanup"""
+        self._stop_thread.set()
+        if self._effect_thread and self._effect_thread.is_alive():
+            self._effect_thread.join(timeout=1.0)
+
+        with self._lock:
+            if self._pixels:
+                self._pixels.fill((0, 0, 0))
+                self._pixels.show()
+                self._pixels.deinit()
+            self._pixels = None
+            self._segment = None
+            self._initialized = False
+
+
+# Helper functions for pattern manager integration
+def effect_loading(controller: DWLEDController) -> bool:
+    """Show loading effect (Rainbow Cycle)"""
+    try:
+        controller.set_power(1)
+        controller.set_effect(8, speed=100)  # Rainbow Cycle
+        return True
+    except Exception as e:
+        logger.error(f"Error setting loading effect: {e}")
+        return False
+
+
+def effect_idle(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
+    """Show idle effect with full settings"""
+    try:
+        if effect_settings and isinstance(effect_settings, dict):
+            # New format: full settings dict
+            controller.set_power(1)
+
+            # Set effect
+            effect_id = effect_settings.get("effect_id", 0)
+            palette_id = effect_settings.get("palette_id", 0)
+            speed = effect_settings.get("speed", 128)
+            intensity = effect_settings.get("intensity", 128)
+
+            controller.set_effect(effect_id, speed=speed, intensity=intensity)
+            controller.set_palette(palette_id)
+
+            # Set colors if provided
+            color1 = effect_settings.get("color1")
+            if color1:
+                # Convert hex to RGB
+                r1 = int(color1[1:3], 16)
+                g1 = int(color1[3:5], 16)
+                b1 = int(color1[5:7], 16)
+
+                color2 = effect_settings.get("color2", "#000000")
+                r2 = int(color2[1:3], 16)
+                g2 = int(color2[3:5], 16)
+                b2 = int(color2[5:7], 16)
+
+                color3 = effect_settings.get("color3", "#0000ff")
+                r3 = int(color3[1:3], 16)
+                g3 = int(color3[3:5], 16)
+                b3 = int(color3[5:7], 16)
+
+                controller.set_colors(
+                    color1=(r1, g1, b1),
+                    color2=(r2, g2, b2),
+                    color3=(r3, g3, b3)
+                )
+
+            return True
+
+        # Default or old format: turn off
+        controller.set_power(0)
+        return True
+    except Exception as e:
+        logger.error(f"Error setting idle effect: {e}")
+        return False
+
+
+def effect_connected(controller: DWLEDController) -> bool:
+    """Show connected effect (green flash)"""
+    try:
+        controller.set_power(1)
+        controller.set_color(0, 255, 0)  # Green
+        controller.set_effect(1, speed=200, intensity=128)  # Blink effect
+        time.sleep(1.0)
+        return True
+    except Exception as e:
+        logger.error(f"Error setting connected effect: {e}")
+        return False
+
+
+def effect_playing(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
+    """Show playing effect with full settings"""
+    try:
+        if effect_settings and isinstance(effect_settings, dict):
+            # New format: full settings dict
+            controller.set_power(1)
+
+            # Set effect
+            effect_id = effect_settings.get("effect_id", 0)
+            palette_id = effect_settings.get("palette_id", 0)
+            speed = effect_settings.get("speed", 128)
+            intensity = effect_settings.get("intensity", 128)
+
+            controller.set_effect(effect_id, speed=speed, intensity=intensity)
+            controller.set_palette(palette_id)
+
+            # Set colors if provided
+            color1 = effect_settings.get("color1")
+            if color1:
+                # Convert hex to RGB
+                r1 = int(color1[1:3], 16)
+                g1 = int(color1[3:5], 16)
+                b1 = int(color1[5:7], 16)
+
+                color2 = effect_settings.get("color2", "#000000")
+                r2 = int(color2[1:3], 16)
+                g2 = int(color2[3:5], 16)
+                b2 = int(color2[5:7], 16)
+
+                color3 = effect_settings.get("color3", "#0000ff")
+                r3 = int(color3[1:3], 16)
+                g3 = int(color3[3:5], 16)
+                b3 = int(color3[5:7], 16)
+
+                controller.set_colors(
+                    color1=(r1, g1, b1),
+                    color2=(r2, g2, b2),
+                    color3=(r3, g3, b3)
+                )
+
+            return True
+
+        # Default or old format: turn off
+        controller.set_power(0)
+        return True
+    except Exception as e:
+        logger.error(f"Error setting playing effect: {e}")
+        return False

+ 4 - 0
modules/led/dw_leds/__init__.py

@@ -0,0 +1,4 @@
+"""
+WLED RPI - NeoPixel LED controller for Raspberry Pi
+Embedded into Dune Weaver sand table project
+"""

+ 1 - 0
modules/led/dw_leds/effects/__init__.py

@@ -0,0 +1 @@
+"""WLED RPI effects"""

+ 1132 - 0
modules/led/dw_leds/effects/basic_effects.py

@@ -0,0 +1,1132 @@
+#!/usr/bin/env python3
+"""
+WLED Basic Effects for Raspberry Pi
+Effects 0-30: Static, Blink, Rainbow, Scan, etc.
+Ported from WLED FX.cpp
+"""
+import random
+import math
+from ..segment import Segment
+from ..utils.colors import *
+
+# Effect return value is delay in milliseconds
+FRAMETIME = 24  # ~42 FPS
+
+def mode_static(seg: Segment) -> int:
+    """Solid color"""
+    seg.fill(seg.get_color(0))
+    return 350 if seg.call == 0 else FRAMETIME
+
+def mode_blink(seg: Segment) -> int:
+    """Blink between two colors"""
+    cycle_time = (255 - seg.speed) * 20
+    on_time = FRAMETIME + ((cycle_time * seg.intensity) >> 8)
+    cycle_time += FRAMETIME * 2
+
+    now = seg.now()
+    iteration = now // cycle_time
+    rem = now % cycle_time
+
+    on = (iteration != seg.step) or (rem <= on_time)
+    seg.step = iteration
+
+    seg.fill(seg.get_color(0) if on else seg.get_color(1))
+    return FRAMETIME
+
+def mode_strobe(seg: Segment) -> int:
+    """Strobe effect"""
+    cycle_time = (255 - seg.speed) * 20 + FRAMETIME * 2
+    now = seg.now()
+    iteration = now // cycle_time
+    on = (iteration != seg.step)
+    seg.step = iteration
+
+    seg.fill(seg.get_color(0) if on else seg.get_color(1))
+    return FRAMETIME
+
+def mode_breath(seg: Segment) -> int:
+    """Breathing effect"""
+    counter = (seg.now() * ((seg.speed >> 3) + 10)) & 0xFFFF
+    counter = (counter >> 2) + (counter >> 4)
+
+    var = 0
+    if counter < 16384:
+        if counter > 8192:
+            counter = 8192 - (counter - 8192)
+        var = sin16(counter) // 103
+
+    lum = 30 + var
+    for i in range(seg.length):
+        seg.set_pixel_color(i, color_blend(seg.get_color(1),
+                                          seg.color_from_palette(i),
+                                          lum & 0xFF))
+    return FRAMETIME
+
+def mode_fade(seg: Segment) -> int:
+    """Fade between two colors"""
+    counter = seg.now() * ((seg.speed >> 3) + 10)
+    lum = triwave16(counter & 0xFFFF) >> 8
+
+    for i in range(seg.length):
+        seg.set_pixel_color(i, color_blend(seg.get_color(1),
+                                          seg.color_from_palette(i),
+                                          lum))
+    return FRAMETIME
+
+def mode_scan(seg: Segment) -> int:
+    """Scanning pixel"""
+    if seg.length <= 1:
+        return mode_static(seg)
+
+    cycle_time = 750 + (255 - seg.speed) * 150
+    perc = seg.now() % cycle_time
+    prog = (perc * 65535) // cycle_time
+    size = 1 + ((seg.intensity * seg.length) >> 9)
+    led_index = (prog * ((seg.length * 2) - size * 2)) >> 16
+
+    seg.fill(seg.get_color(1))
+
+    led_offset = led_index - (seg.length - size)
+    led_offset = abs(led_offset)
+
+    for j in range(led_offset, min(led_offset + size, seg.length)):
+        seg.set_pixel_color(j, seg.color_from_palette(j))
+
+    return FRAMETIME
+
+def mode_dual_scan(seg: Segment) -> int:
+    """Dual scanning pixels"""
+    if seg.length <= 1:
+        return mode_static(seg)
+
+    cycle_time = 750 + (255 - seg.speed) * 150
+    perc = seg.now() % cycle_time
+    prog = (perc * 65535) // cycle_time
+    size = 1 + ((seg.intensity * seg.length) >> 9)
+    led_index = (prog * ((seg.length * 2) - size * 2)) >> 16
+
+    seg.fill(seg.get_color(1))
+
+    led_offset = led_index - (seg.length - size)
+    led_offset = abs(led_offset)
+
+    # First scanner
+    for j in range(led_offset, min(led_offset + size, seg.length)):
+        seg.set_pixel_color(j, seg.color_from_palette(j))
+
+    # Second scanner (opposite direction)
+    for j in range(led_offset, min(led_offset + size, seg.length)):
+        i2 = seg.length - 1 - j
+        seg.set_pixel_color(i2, seg.color_from_palette(i2))
+
+    return FRAMETIME
+
+def mode_rainbow(seg: Segment) -> int:
+    """Solid rainbow (cycles through hues)"""
+    counter = (seg.now() * ((seg.speed >> 2) + 2)) & 0xFFFF
+    counter = counter >> 8
+
+    if seg.intensity < 128:
+        color = color_blend(color_wheel(counter), WHITE,
+                           128 - seg.intensity)
+    else:
+        color = color_wheel(counter)
+
+    seg.fill(color)
+    return FRAMETIME
+
+def mode_rainbow_cycle(seg: Segment) -> int:
+    """Rainbow distributed across strip"""
+    counter = (seg.now() * ((seg.speed >> 2) + 2)) & 0xFFFF
+    counter = counter >> 8
+
+    for i in range(seg.length):
+        # intensity controls density
+        index = (i * (16 << (seg.intensity // 29)) // seg.length) + counter
+        seg.set_pixel_color(i, color_wheel(index & 0xFF))
+
+    return FRAMETIME
+
+def mode_theater_chase(seg: Segment) -> int:
+    """Theater chase effect"""
+    width = 3 + (seg.intensity >> 4)
+    cycle_time = 50 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    for i in range(seg.length):
+        if (i % width) == seg.aux0:
+            seg.set_pixel_color(i, seg.color_from_palette(i))
+        else:
+            seg.set_pixel_color(i, seg.get_color(1))
+
+    if iteration != seg.step:
+        seg.aux0 = (seg.aux0 + 1) % width
+        seg.step = iteration
+
+    return FRAMETIME
+
+def mode_running_lights(seg: Segment) -> int:
+    """Running lights with sine wave"""
+    x_scale = seg.intensity >> 2
+    counter = (seg.now() * seg.speed) >> 9
+
+    for i in range(seg.length):
+        a = i * x_scale - counter
+        s = sin8(a & 0xFF)
+        color = color_blend(seg.get_color(1),
+                           seg.color_from_palette(i), s)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_color_wipe(seg: Segment) -> int:
+    """Color wipe effect"""
+    if seg.length <= 1:
+        return mode_static(seg)
+
+    cycle_time = 750 + (255 - seg.speed) * 150
+    perc = seg.now() % cycle_time
+    prog = (perc * 65535) // cycle_time
+    back = prog > 32767
+
+    if back:
+        prog -= 32767
+        if seg.step == 0:
+            seg.step = 1
+    else:
+        if seg.step == 2:
+            seg.step = 3
+
+    led_index = (prog * seg.length) >> 15
+    rem = (prog * seg.length) * 2
+    rem //= (seg.intensity + 1)
+    rem = min(255, rem)
+
+    col0 = seg.get_color(0)
+    col1 = seg.get_color(1)
+
+    for i in range(seg.length):
+        if i < led_index:
+            seg.set_pixel_color(i, col1 if back else col0)
+        else:
+            seg.set_pixel_color(i, col0 if back else col1)
+            if i == led_index:
+                blended = color_blend(col1 if back else col0,
+                                     col0 if back else col1,
+                                     rem)
+                seg.set_pixel_color(i, blended)
+
+    return FRAMETIME
+
+def mode_random_color(seg: Segment) -> int:
+    """Random solid colors with fade"""
+    cycle_time = 200 + (255 - seg.speed) * 50
+    iteration = seg.now() // cycle_time
+    rem = seg.now() % cycle_time
+    fade_dur = (cycle_time * seg.intensity) >> 8
+
+    fade = 255
+    if fade_dur:
+        fade = (rem * 255) // fade_dur
+        fade = min(255, fade)
+
+    if seg.call == 0:
+        seg.aux0 = random.randint(0, 255)
+        seg.step = 2
+
+    if iteration != seg.step:
+        seg.aux1 = seg.aux0
+        seg.aux0 = random.randint(0, 255)
+        seg.step = iteration
+
+    color = color_blend(color_wheel(seg.aux1),
+                       color_wheel(seg.aux0), fade)
+    seg.fill(color)
+    return FRAMETIME
+
+def mode_dynamic(seg: Segment) -> int:
+    """Dynamic random colors per pixel"""
+    if seg.call == 0:
+        seg.data = [random.randint(0, 255) for _ in range(seg.length)]
+
+    cycle_time = 50 + (255 - seg.speed) * 15
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step and seg.speed != 0:
+        for i in range(seg.length):
+            if random.randint(0, 255) <= seg.intensity:
+                seg.data[i] = random.randint(0, 255)
+        seg.step = iteration
+
+    for i in range(seg.length):
+        seg.set_pixel_color(i, color_wheel(seg.data[i]))
+
+    return FRAMETIME
+
+def mode_twinkle(seg: Segment) -> int:
+    """Twinkle effect"""
+    seg.fade_out(224)
+
+    cycle_time = 20 + (255 - seg.speed) * 5
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        max_on = max(1, (seg.intensity * seg.length) // 255)
+        if seg.aux0 >= max_on:
+            seg.aux0 = 0
+            seg.aux1 = random.randint(0, 0xFFFF)
+        seg.aux0 += 1
+        seg.step = iteration
+
+    prng = seg.aux1
+    for _ in range(seg.aux0):
+        prng = (prng * 2053 + 13849) & 0xFFFF
+        j = (prng * seg.length) >> 16
+        if j < seg.length:
+            seg.set_pixel_color(j, seg.color_from_palette(j))
+
+    return FRAMETIME
+
+def mode_sparkle(seg: Segment) -> int:
+    """Single sparkle effect"""
+    for i in range(seg.length):
+        seg.set_pixel_color(i, seg.color_from_palette(i))
+
+    cycle_time = 10 + (255 - seg.speed) * 2
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        seg.aux0 = random.randint(0, seg.length - 1)
+        seg.step = iteration
+
+    seg.set_pixel_color(seg.aux0, seg.get_color(0))
+    return FRAMETIME
+
+def mode_fire(seg: Segment) -> int:
+    """Fire/flame effect"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+
+    # Cooling parameter (higher = cooler flames)
+    cooling = ((100 - (seg.intensity >> 1)) * 10) // seg.length + 2
+
+    # Heat decay for all pixels
+    for i in range(seg.length):
+        cool_down = random.randint(0, cooling)
+        seg.data[i] = max(0, seg.data[i] - cool_down)
+
+    # Heat drift upward
+    for i in range(seg.length - 1, 2, -1):
+        seg.data[i] = (seg.data[i - 1] + seg.data[i - 2] + seg.data[i - 2]) // 3
+
+    # Randomly ignite new sparks near bottom
+    if random.randint(0, 255) < seg.intensity:
+        spark_pos = random.randint(0, min(7, seg.length - 1))
+        seg.data[spark_pos] = min(255, seg.data[spark_pos] + random.randint(160, 255))
+
+    # Convert heat to colors
+    for i in range(seg.length):
+        heat = seg.data[i]
+
+        # Black -> Red -> Yellow -> White
+        if heat < 85:
+            color = (heat * 3, 0, 0)
+        elif heat < 170:
+            h = heat - 85
+            color = (255, h * 3, 0)
+        else:
+            h = heat - 170
+            color = (255, 255, h * 3)
+
+        r, g, b = color
+        seg.set_pixel_color(i, (r << 16) | (g << 8) | b)
+
+    return FRAMETIME
+
+def mode_comet(seg: Segment) -> int:
+    """Comet/shooting star effect"""
+    if seg.call == 0:
+        seg.aux0 = 0
+        seg.aux1 = 0
+
+    seg.fade_out(128)
+
+    size = 1 + ((seg.intensity * seg.length) >> 9)
+    cycle_time = 10 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        seg.aux0 = (seg.aux0 + 1) % seg.length
+        seg.step = iteration
+
+    # Draw comet
+    for i in range(size):
+        pos = (seg.aux0 - i) % seg.length
+        brightness = 255 - (i * 255 // max(1, size))
+        color = color_blend(0, seg.color_from_palette(pos), brightness)
+        seg.set_pixel_color(pos, color)
+
+    return FRAMETIME
+
+def mode_chase(seg: Segment) -> int:
+    """Chase effect with colored segments"""
+    if seg.call == 0:
+        seg.aux0 = 0
+
+    size = max(1, seg.length // 4)
+    cycle_time = 10 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        seg.aux0 = (seg.aux0 + 1) % seg.length
+        seg.step = iteration
+
+    seg.fill(seg.get_color(1))
+
+    for i in range(size):
+        pos = (seg.aux0 + i) % seg.length
+        seg.set_pixel_color(pos, seg.color_from_palette(pos))
+
+    return FRAMETIME
+
+def mode_police(seg: Segment) -> int:
+    """Police lights (red/blue alternating)"""
+    cycle_time = 25 + (255 - seg.speed)
+    on_time = cycle_time // 2
+
+    now = seg.now()
+    iteration = now // cycle_time
+    rem = now % cycle_time
+    on = rem < on_time
+
+    half = seg.length // 2
+
+    # Red on left, blue on right
+    red = (255, 0, 0)
+    blue = (0, 0, 255)
+    off_color = (0, 0, 0)
+
+    for i in range(half):
+        if (iteration % 2 == 0 and on) or (iteration % 2 == 1 and not on):
+            seg.set_pixel_color(i, (red[0] << 16) | (red[1] << 8) | red[2])
+        else:
+            seg.set_pixel_color(i, (off_color[0] << 16) | (off_color[1] << 8) | off_color[2])
+
+    for i in range(half, seg.length):
+        if (iteration % 2 == 1 and on) or (iteration % 2 == 0 and not on):
+            seg.set_pixel_color(i, (blue[0] << 16) | (blue[1] << 8) | blue[2])
+        else:
+            seg.set_pixel_color(i, (off_color[0] << 16) | (off_color[1] << 8) | off_color[2])
+
+    return FRAMETIME
+
+def mode_lightning(seg: Segment) -> int:
+    """Lightning flash effect"""
+    if seg.call == 0:
+        seg.aux0 = 0
+        seg.aux1 = 0
+
+    cycle_time = 50 + (255 - seg.speed) * 10
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Random chance of lightning
+        if random.randint(0, 255) < seg.intensity:
+            seg.aux0 = random.randint(3, 8)  # Number of flashes
+            seg.aux1 = seg.now()
+        seg.step = iteration
+
+    # Flash sequence
+    if seg.aux0 > 0:
+        flash_duration = 50
+        time_since = seg.now() - seg.aux1
+
+        if time_since < flash_duration:
+            # Flash on
+            brightness = 255 - (time_since * 255 // flash_duration)
+            for i in range(seg.length):
+                color = color_blend(0, WHITE, brightness)
+                seg.set_pixel_color(i, color)
+        else:
+            # Flash off, wait for next
+            if time_since > flash_duration + random.randint(10, 100):
+                seg.aux0 -= 1
+                seg.aux1 = seg.now()
+            else:
+                seg.fill(seg.get_color(1))
+    else:
+        seg.fill(seg.get_color(1))
+
+    return FRAMETIME
+
+def mode_fireworks(seg: Segment) -> int:
+    """Fireworks effect"""
+    seg.fade_out(64)
+
+    cycle_time = 20 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Launch new firework
+        if random.randint(0, 255) < seg.intensity:
+            pos = random.randint(0, seg.length - 1)
+            color = color_wheel(random.randint(0, 255))
+
+            # Bright center
+            seg.set_pixel_color(pos, color)
+
+            # Dimmer neighbors
+            if pos > 0:
+                seg.set_pixel_color(pos - 1, color_blend(0, color, 128))
+            if pos < seg.length - 1:
+                seg.set_pixel_color(pos + 1, color_blend(0, color, 128))
+
+        seg.step = iteration
+
+    return FRAMETIME
+
+def mode_ripple(seg: Segment) -> int:
+    """Ripple effect"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+        seg.aux0 = seg.length // 2
+
+    seg.fade_out(250)
+
+    cycle_time = 50 + (255 - seg.speed) * 2
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # New ripple
+        if random.randint(0, 255) < seg.intensity:
+            seg.aux0 = random.randint(0, seg.length - 1)
+            seg.data[seg.aux0] = 255
+
+        # Propagate ripple
+        new_data = seg.data.copy()
+        for i in range(1, seg.length - 1):
+            new_data[i] = (seg.data[i - 1] + seg.data[i + 1]) // 2
+        seg.data = new_data
+
+        seg.step = iteration
+
+    for i in range(seg.length):
+        if seg.data[i] > 0:
+            color = color_blend(seg.get_color(1),
+                              seg.color_from_palette(i),
+                              seg.data[i])
+            seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_flow(seg: Segment) -> int:
+    """Smooth flowing color movement"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    for i in range(seg.length):
+        pos = ((i * 256 // seg.length) + counter) & 0xFFFF
+        color = color_wheel((pos >> 8) & 0xFF)
+
+        # Apply intensity as brightness modulation
+        brightness = 128 + ((sin8((pos >> 7) & 0xFF) - 128) * seg.intensity // 255)
+        color = color_blend(0, color, brightness)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_colorloop(seg: Segment) -> int:
+    """Smooth color loop across entire strip"""
+    counter = (seg.now() * ((seg.speed >> 3) + 1)) & 0xFFFF
+
+    for i in range(seg.length):
+        # Create gradient based on position and time
+        hue = ((i * 256 // max(1, seg.length)) + (counter >> 7)) & 0xFF
+        color = color_wheel(hue)
+
+        # Intensity controls saturation
+        if seg.intensity < 255:
+            color = color_blend(color, WHITE, 255 - seg.intensity)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_palette_flow(seg: Segment) -> int:
+    """Flowing palette colors"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    for i in range(seg.length):
+        # Get color from palette based on position and time
+        palette_pos = ((i * 255 // max(1, seg.length)) + (counter >> 7)) & 0xFF
+        color = seg.color_from_palette(palette_pos)
+
+        # Intensity controls brightness modulation
+        if seg.intensity < 255:
+            brightness = 128 + ((sin8(palette_pos) - 128) * seg.intensity // 255)
+            color = color_blend(0, color, brightness)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_gradient(seg: Segment) -> int:
+    """Smooth gradient between colors"""
+    for i in range(seg.length):
+        # Create gradient from color 0 to color 2
+        blend_amount = (i * 255) // max(1, seg.length - 1)
+        color = color_blend(seg.get_color(0), seg.get_color(2), blend_amount)
+
+        # Intensity controls a pulsing brightness
+        if seg.intensity > 0:
+            counter = (seg.now() * ((seg.speed >> 3) + 1)) & 0xFFFF
+            pulse = sin8((counter >> 8) & 0xFF)
+            brightness = 128 + ((pulse - 128) * seg.intensity // 255)
+            color = color_blend(0, color, brightness)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_multi_strobe(seg: Segment) -> int:
+    """Multi-color strobe effect"""
+    cycle_time = 50 + (255 - seg.speed)
+    flash_duration = max(5, cycle_time // 4)
+
+    now = seg.now()
+    iteration = now // cycle_time
+    rem = now % cycle_time
+
+    if rem < flash_duration:
+        # Strobe on with color from palette
+        color_index = (iteration * 85) & 0xFF
+        color = color_wheel(color_index)
+        seg.fill(color)
+    else:
+        # Strobe off
+        seg.fill(seg.get_color(1))
+
+    return FRAMETIME
+
+def mode_waves(seg: Segment) -> int:
+    """Sine wave effect"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    for i in range(seg.length):
+        # Create wave pattern
+        wave_pos = (i * 255 // max(1, seg.length)) + (counter >> 7)
+        brightness = sin8(wave_pos & 0xFF)
+
+        # Intensity controls wave amplitude
+        brightness = 128 + ((brightness - 128) * seg.intensity // 255)
+
+        color = color_blend(seg.get_color(1),
+                          seg.color_from_palette(i),
+                          brightness)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_bpm(seg: Segment) -> int:
+    """BPM (beats per minute) pulse effect"""
+    # Calculate BPM based on speed (60-180 BPM)
+    bpm = 60 + ((seg.speed * 120) >> 8)
+    ms_per_beat = 60000 // bpm
+
+    beat_phase = (seg.now() % ms_per_beat) * 255 // ms_per_beat
+    brightness = sin8(beat_phase)
+
+    for i in range(seg.length):
+        # Create traveling beat
+        offset = (i * 255 // max(1, seg.length))
+        local_brightness = sin8((beat_phase + offset) & 0xFF)
+
+        # Intensity controls brightness range
+        local_brightness = 128 + ((local_brightness - 128) * seg.intensity // 255)
+
+        color = color_blend(seg.get_color(1),
+                          seg.color_from_palette(i),
+                          local_brightness)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_juggle(seg: Segment) -> int:
+    """Juggling colored dots"""
+    if seg.call == 0:
+        seg.data = [0] * 8  # Track 8 dot positions
+
+    seg.fade_out(224)
+
+    cycle_time = 10 + (255 - seg.speed) // 2
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Update dot positions using different sine waves
+        for dot in range(min(8, 1 + seg.intensity // 32)):
+            phase = (seg.now() * (dot + 1)) & 0xFFFF
+            pos = (sin16(phase) + 32768) * seg.length // 65536
+            pos = max(0, min(seg.length - 1, pos))
+
+            hue = (dot * 32) & 0xFF
+            color = color_wheel(hue)
+            seg.set_pixel_color(pos, color)
+
+        seg.step = iteration
+
+    return FRAMETIME
+
+def mode_meteor(seg: Segment) -> int:
+    """Meteor shower effect with trails"""
+    if seg.call == 0:
+        seg.aux0 = 0
+        seg.aux1 = 0
+
+    # Fade all pixels
+    seg.fade_out(200)
+
+    size = 1 + ((seg.intensity * seg.length) >> 8)
+    cycle_time = 10 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        seg.aux0 = (seg.aux0 + 1) % (seg.length + size)
+        seg.step = iteration
+
+    # Draw meteor head and tail
+    if seg.aux0 < seg.length:
+        for i in range(size):
+            pos = seg.aux0 - i
+            if 0 <= pos < seg.length:
+                brightness = 255 - (i * 200 // max(1, size))
+                color = color_blend(0, seg.color_from_palette(pos), brightness)
+                # Add to existing color for brighter effect
+                existing = seg.get_pixel_color(pos)
+                seg.set_pixel_color(pos, color_add(existing, color))
+
+    return FRAMETIME
+
+def mode_pride(seg: Segment) -> int:
+    """Pride flag colors moving effect"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    # Pride flag colors (6 stripes)
+    pride_colors = [
+        (0xE4, 0x00, 0x3A),  # Red
+        (0xFF, 0x8C, 0x00),  # Orange
+        (0xFF, 0xED, 0x00),  # Yellow
+        (0x00, 0x81, 0x1F),  # Green
+        (0x00, 0x4C, 0xFF),  # Blue
+        (0x76, 0x01, 0x89),  # Purple
+    ]
+
+    for i in range(seg.length):
+        # Determine which stripe this pixel belongs to
+        stripe_size = max(1, seg.length // 6)
+        offset = (counter >> 7) & 0xFF
+        stripe_idx = ((i + offset) // stripe_size) % 6
+
+        r, g, b = pride_colors[stripe_idx]
+        color = (r << 16) | (g << 8) | b
+
+        # Intensity controls blending with background
+        if seg.intensity < 255:
+            color = color_blend(seg.get_color(1), color, seg.intensity)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_pacifica(seg: Segment) -> int:
+    """Ocean/water simulation"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+
+    counter = seg.now() * ((seg.speed >> 4) + 1)
+
+    for i in range(seg.length):
+        # Create multiple layered waves
+        wave1 = sin8(((i * 5) + (counter >> 5)) & 0xFF)
+        wave2 = sin8(((i * 3) + (counter >> 3)) & 0xFF)
+        wave3 = sin8(((i * 7) + (counter >> 6)) & 0xFF)
+
+        # Combine waves
+        brightness = (wave1 + wave2 + wave3) // 3
+
+        # Blue-green ocean colors
+        if brightness < 64:
+            # Deep blue
+            r, g, b = 0, 0, 60 + brightness
+        elif brightness < 128:
+            # Blue-cyan
+            val = brightness - 64
+            r, g, b = 0, val * 2, 100 + val
+        else:
+            # Cyan-white (foam)
+            val = brightness - 128
+            r, g, b = val, 128 + val, 180 + (val // 2)
+
+        # Apply intensity
+        brightness = 128 + ((brightness - 128) * seg.intensity // 255)
+        color = (r << 16) | (g << 8) | b
+        color = color_blend(0, color, brightness)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_plasma(seg: Segment) -> int:
+    """Plasma effect"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    for i in range(seg.length):
+        # Create plasma using multiple sine waves
+        phase1 = sin8(((i * 16) + (counter >> 5)) & 0xFF)
+        phase2 = sin8(((i * 8) + (counter >> 6)) & 0xFF)
+        phase3 = sin8((counter >> 4) & 0xFF)
+
+        # Combine phases
+        hue = (phase1 + phase2 + phase3) & 0xFF
+        color = color_wheel(hue)
+
+        # Intensity controls saturation
+        if seg.intensity < 255:
+            color = color_blend(color, WHITE, 255 - seg.intensity)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_dissolve(seg: Segment) -> int:
+    """Random pixel dissolve/fade"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+        for i in range(seg.length):
+            seg.data[i] = random.randint(0, 255)
+
+    cycle_time = 20 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Randomly update some pixels
+        for i in range(seg.length):
+            if random.randint(0, 255) < seg.intensity:
+                seg.data[i] = random.randint(0, 255)
+
+        seg.step = iteration
+
+    for i in range(seg.length):
+        brightness = seg.data[i]
+        color = color_blend(seg.get_color(1),
+                          seg.color_from_palette(i),
+                          brightness)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_glitter(seg: Segment) -> int:
+    """Sparkle glitter overlay"""
+    # Fill with base color
+    for i in range(seg.length):
+        seg.set_pixel_color(i, seg.color_from_palette(i))
+
+    # Add random sparkles based on intensity
+    sparkle_chance = seg.intensity
+    num_sparkles = max(1, (seg.length * sparkle_chance) // 255)
+
+    for _ in range(num_sparkles):
+        if random.randint(0, 255) < seg.speed:
+            pos = random.randint(0, seg.length - 1)
+            seg.set_pixel_color(pos, WHITE)
+
+    return FRAMETIME
+
+def mode_confetti(seg: Segment) -> int:
+    """Random colored pixels"""
+    seg.fade_out(224)
+
+    cycle_time = 10 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Add random confetti
+        density = 1 + (seg.intensity >> 5)
+        for _ in range(density):
+            pos = random.randint(0, seg.length - 1)
+            hue = random.randint(0, 255)
+            color = color_wheel(hue)
+            seg.set_pixel_color(pos, color)
+
+        seg.step = iteration
+
+    return FRAMETIME
+
+def mode_sinelon(seg: Segment) -> int:
+    """Sine wave colored dot"""
+    seg.fade_out(224)
+
+    counter = seg.now() * ((seg.speed >> 2) + 1)
+    phase = (counter >> 8) & 0xFFFF
+
+    # Calculate position using sine
+    pos = (sin16(phase) + 32768) * seg.length // 65536
+    pos = max(0, min(seg.length - 1, pos))
+
+    # Color rotates
+    hue = (counter >> 7) & 0xFF
+    color = color_wheel(hue)
+
+    # Intensity controls trail length (via fade)
+    if seg.intensity < 255:
+        color = color_blend(0, color, seg.intensity)
+
+    seg.set_pixel_color(pos, color)
+
+    return FRAMETIME
+
+def mode_candle(seg: Segment) -> int:
+    """Flickering candle simulation"""
+    if seg.call == 0:
+        seg.data = [128] * seg.length
+
+    # Random flicker for each pixel
+    for i in range(seg.length):
+        # Small random changes
+        change = random.randint(-20, 20)
+        seg.data[i] = max(30, min(255, seg.data[i] + change))
+
+        # Speed affects flicker rate
+        if seg.speed > 128 and random.randint(0, 255) < (seg.speed - 128):
+            seg.data[i] = random.randint(50, 200)
+
+        # Warm orange/yellow color
+        brightness = seg.data[i]
+        r = brightness
+        g = (brightness * 2) // 3
+        b = (brightness * seg.intensity) // 512  # Intensity controls blue
+
+        color = (r << 16) | (g << 8) | b
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_aurora(seg: Segment) -> int:
+    """Northern lights effect"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+
+    counter = seg.now() * ((seg.speed >> 4) + 1)
+
+    for i in range(seg.length):
+        # Multiple slow-moving waves
+        wave1 = sin8(((i * 3) + (counter >> 6)) & 0xFF)
+        wave2 = sin8(((i * 5) + (counter >> 7) + 85) & 0xFF)
+        wave3 = sin8(((i * 2) + (counter >> 5) + 170) & 0xFF)
+
+        # Aurora colors: green, blue, purple
+        brightness = (wave1 + wave2 + wave3) // 3
+
+        if brightness < 85:
+            # Green
+            r, g, b = 0, brightness * 2, brightness // 2
+        elif brightness < 170:
+            # Blue-green
+            val = brightness - 85
+            r, g, b = 0, 100 + val, 100 + val
+        else:
+            # Purple-pink
+            val = brightness - 170
+            r, g, b = val * 2, val, 150 + val
+
+        # Apply intensity for brightness variation
+        brightness_mod = 128 + ((brightness - 128) * seg.intensity // 255)
+        color = (r << 16) | (g << 8) | b
+        color = color_blend(0, color, brightness_mod)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_rain(seg: Segment) -> int:
+    """Rain drops falling"""
+    if seg.call == 0:
+        seg.data = [0] * seg.length
+        seg.aux0 = 0
+
+    seg.fade_out(235)
+
+    cycle_time = 15 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # New rain drops
+        if random.randint(0, 255) < seg.intensity:
+            pos = random.randint(0, min(5, seg.length - 1))  # Start near beginning
+            seg.data[pos] = 255
+
+        # Move drops down
+        new_data = [0] * seg.length
+        for i in range(seg.length - 1):
+            if seg.data[i] > 0:
+                # Drop moves forward
+                if i + 1 < seg.length:
+                    new_data[i + 1] = seg.data[i] - 10
+        seg.data = new_data
+
+        seg.step = iteration
+
+    # Draw rain drops (blue)
+    for i in range(seg.length):
+        if seg.data[i] > 0:
+            brightness = seg.data[i]
+            color = color_blend(0, seg.color_from_palette(i), brightness)
+            seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_halloween(seg: Segment) -> int:
+    """Halloween orange and purple"""
+    counter = seg.now() * ((seg.speed >> 3) + 1)
+
+    # Alternating orange and purple
+    orange = (0xFF << 16) | (0x44 << 8) | 0x00
+    purple = (0x88 << 16) | (0x00 << 8) | 0xFF
+
+    for i in range(seg.length):
+        # Create moving pattern
+        phase = ((i * 255 // max(1, seg.length)) + (counter >> 7)) & 0xFF
+        wave = sin8(phase)
+
+        # Blend between orange and purple
+        if wave < 128:
+            color = orange
+        else:
+            color = purple
+
+        # Intensity controls blending smoothness
+        if seg.intensity > 0:
+            blend_amt = (wave * seg.intensity) // 255
+            color = color_blend(orange, purple, blend_amt)
+
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_noise(seg: Segment) -> int:
+    """Perlin-like noise pattern"""
+    if seg.call == 0:
+        seg.data = [random.randint(0, 255) for _ in range(seg.length)]
+
+    cycle_time = 20 + (255 - seg.speed) // 2
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        # Smooth noise by averaging neighbors
+        new_data = seg.data.copy()
+        for i in range(1, seg.length - 1):
+            avg = (seg.data[i - 1] + seg.data[i] * 2 + seg.data[i + 1]) // 4
+            variation = random.randint(-20, 20)
+            new_data[i] = max(0, min(255, avg + variation))
+
+        # Occasionally inject new random values
+        if random.randint(0, 255) < seg.intensity:
+            pos = random.randint(0, seg.length - 1)
+            new_data[pos] = random.randint(0, 255)
+
+        seg.data = new_data
+        seg.step = iteration
+
+    # Map noise to colors
+    for i in range(seg.length):
+        hue = seg.data[i]
+        color = color_wheel(hue)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+def mode_funky_plank(seg: Segment) -> int:
+    """Multiple colored bars moving"""
+    if seg.call == 0:
+        seg.aux0 = 0
+
+    cycle_time = 10 + (255 - seg.speed)
+    iteration = seg.now() // cycle_time
+
+    if iteration != seg.step:
+        seg.aux0 = (seg.aux0 + 1) % seg.length
+        seg.step = iteration
+
+    num_planks = max(2, 1 + (seg.intensity >> 5))
+    plank_size = max(1, seg.length // num_planks)
+
+    for i in range(seg.length):
+        plank_idx = ((i + seg.aux0) // plank_size) % num_planks
+        hue = (plank_idx * 256 // num_planks) & 0xFF
+        color = color_wheel(hue)
+        seg.set_pixel_color(i, color)
+
+    return FRAMETIME
+
+# Effect registry
+EFFECTS = {
+    0: ("Static", mode_static),
+    1: ("Blink", mode_blink),
+    2: ("Breathe", mode_breath),
+    3: ("Wipe", mode_color_wipe),
+    4: ("Fade", mode_fade),
+    5: ("Scan", mode_scan),
+    6: ("Dual Scan", mode_dual_scan),
+    7: ("Rainbow Cycle", mode_rainbow),
+    8: ("Rainbow", mode_rainbow_cycle),
+    9: ("Theater Chase", mode_theater_chase),
+    10: ("Running Lights", mode_running_lights),
+    11: ("Random Color", mode_random_color),
+    12: ("Dynamic", mode_dynamic),
+    13: ("Twinkle", mode_twinkle),
+    14: ("Sparkle", mode_sparkle),
+    15: ("Strobe", mode_strobe),
+    16: ("Fire", mode_fire),
+    17: ("Comet", mode_comet),
+    18: ("Chase", mode_chase),
+    19: ("Police", mode_police),
+    20: ("Lightning", mode_lightning),
+    21: ("Fireworks", mode_fireworks),
+    22: ("Ripple", mode_ripple),
+    23: ("Flow", mode_flow),
+    24: ("Colorloop", mode_colorloop),
+    25: ("Palette Flow", mode_palette_flow),
+    26: ("Gradient", mode_gradient),
+    27: ("Multi Strobe", mode_multi_strobe),
+    28: ("Waves", mode_waves),
+    29: ("BPM", mode_bpm),
+    30: ("Juggle", mode_juggle),
+    31: ("Meteor", mode_meteor),
+    32: ("Pride", mode_pride),
+    33: ("Pacifica", mode_pacifica),
+    34: ("Plasma", mode_plasma),
+    35: ("Dissolve", mode_dissolve),
+    36: ("Glitter", mode_glitter),
+    37: ("Confetti", mode_confetti),
+    38: ("Sinelon", mode_sinelon),
+    39: ("Candle", mode_candle),
+    40: ("Aurora", mode_aurora),
+    41: ("Rain", mode_rain),
+    42: ("Halloween", mode_halloween),
+    43: ("Noise", mode_noise),
+    44: ("Funky Plank", mode_funky_plank),
+}
+
+def get_effect(effect_id: int):
+    """Get effect function by ID"""
+    if effect_id in EFFECTS:
+        return EFFECTS[effect_id][1]
+    return mode_static
+
+def get_effect_name(effect_id: int) -> str:
+    """Get effect name by ID"""
+    if effect_id in EFFECTS:
+        return EFFECTS[effect_id][0]
+    return "Unknown"
+
+def get_all_effects():
+    """Get list of all effects"""
+    return [(k, v[0]) for k, v in sorted(EFFECTS.items())]

+ 141 - 0
modules/led/dw_leds/segment.py

@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""
+WLED Segment class for Raspberry Pi
+Manages LED strip segments and their effects
+"""
+import time
+from typing import List, Callable, Optional
+from .utils.colors import *
+from .utils.palettes import color_from_palette, get_palette
+
+class Segment:
+    """LED segment with effect support"""
+
+    def __init__(self, pixels, start: int, stop: int):
+        """
+        Initialize a segment
+        pixels: neopixel.NeoPixel object
+        start: starting LED index
+        stop: ending LED index (exclusive)
+        """
+        self.pixels = pixels
+        self.start = start
+        self.stop = stop
+        self.length = stop - start
+
+        # Colors (up to 3 colors like WLED)
+        self.colors = [0x00FF0000, 0x000000FF, 0x0000FF00]  # Red, Blue, Green defaults
+
+        # Effect parameters
+        self.speed = 128        # 0-255
+        self.intensity = 128    # 0-255
+        self.palette_id = 0     # Palette ID
+        self.custom1 = 0        # Custom parameter 1
+        self.custom2 = 0        # Custom parameter 2
+        self.custom3 = 0        # Custom parameter 3
+
+        # Runtime state (like SEGENV in WLED)
+        self.call = 0           # Number of times effect has been called
+        self.step = 0           # Effect step counter
+        self.aux0 = 0           # Auxiliary variable 0
+        self.aux1 = 0           # Auxiliary variable 1
+        self.next_time = 0      # Next time to run effect (ms)
+        self.data = []          # Effect data storage
+
+        # Timing
+        self._start_time = time.time() * 1000  # Convert to milliseconds
+
+    def get_pixel_color(self, i: int) -> int:
+        """Get color of pixel at index i (segment-relative)"""
+        if i < 0 or i >= self.length:
+            return 0
+        actual_idx = self.start + i
+        color = self.pixels[actual_idx]
+        # Convert from neopixel (R,G,B) or (G,R,B) to 32-bit
+        if isinstance(color, tuple):
+            if len(color) == 3:
+                return (color[0] << 16) | (color[1] << 8) | color[2]
+            elif len(color) == 4:
+                return (color[3] << 24) | (color[0] << 16) | (color[1] << 8) | color[2]
+        return 0
+
+    def set_pixel_color(self, i: int, color: int):
+        """Set color of pixel at index i (segment-relative)"""
+        if i < 0 or i >= self.length:
+            return
+        actual_idx = self.start + i
+        r = get_r(color)
+        g = get_g(color)
+        b = get_b(color)
+        self.pixels[actual_idx] = (r, g, b)
+
+    def fill(self, color: int):
+        """Fill entire segment with color"""
+        for i in range(self.length):
+            self.set_pixel_color(i, color)
+
+    def fade_out(self, amount: int):
+        """Fade all pixels in segment toward black"""
+        for i in range(self.length):
+            current = self.get_pixel_color(i)
+            faded = color_fade(current, amount)
+            self.set_pixel_color(i, faded)
+
+    def blur(self, amount: int):
+        """Blur/blend pixels with neighbors"""
+        if amount == 0 or self.length < 3:
+            return
+
+        # Simple blur: blend each pixel with neighbors
+        temp = [self.get_pixel_color(i) for i in range(self.length)]
+
+        for i in range(self.length):
+            if i == 0:
+                # First pixel: blend with next
+                blended = color_blend(temp[i], temp[i + 1], amount)
+            elif i == self.length - 1:
+                # Last pixel: blend with previous
+                blended = color_blend(temp[i], temp[i - 1], amount)
+            else:
+                # Middle pixels: blend with both neighbors
+                left = color_blend(temp[i], temp[i - 1], amount // 2)
+                blended = color_blend(left, temp[i + 1], amount // 2)
+
+            self.set_pixel_color(i, blended)
+
+    def now(self) -> int:
+        """Get current time in milliseconds since segment start"""
+        return int((time.time() * 1000) - self._start_time)
+
+    def color_from_palette(self, index: int, use_index: bool = True,
+                          brightness: int = 255) -> int:
+        """
+        Get color from current palette
+        index: LED index or palette position
+        use_index: if True, scale index across palette
+        brightness: 0-255
+        """
+        palette = get_palette(self.palette_id)
+
+        if use_index and self.length > 1:
+            # Map LED position to palette position
+            palette_pos = (index * 255) // (self.length - 1)
+        else:
+            palette_pos = index & 0xFF
+
+        return color_from_palette(palette, palette_pos, brightness)
+
+    def get_color(self, index: int) -> int:
+        """Get segment color by index (0-2)"""
+        if 0 <= index < len(self.colors):
+            return self.colors[index]
+        return 0
+
+    def reset(self):
+        """Reset segment state"""
+        self.call = 0
+        self.step = 0
+        self.aux0 = 0
+        self.aux1 = 0
+        self.next_time = 0
+        self.data = []

+ 1 - 0
modules/led/dw_leds/utils/__init__.py

@@ -0,0 +1 @@
+"""WLED RPI utilities"""

+ 234 - 0
modules/led/dw_leds/utils/colors.py

@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+"""
+WLED Color Utilities for Raspberry Pi
+Ported from WLED colors.cpp
+"""
+import math
+from typing import Tuple
+
+# Color manipulation functions
+
+def color_blend(color1: int, color2: int, blend: int) -> int:
+    """
+    Blend two colors together (32-bit WRGB format)
+    blend: 0-255 (0 = full color1, 255 = full color2)
+    """
+    if blend == 0:
+        return color1
+    if blend == 255:
+        return color2
+
+    w1 = (color1 >> 24) & 0xFF
+    r1 = (color1 >> 16) & 0xFF
+    g1 = (color1 >> 8) & 0xFF
+    b1 = color1 & 0xFF
+
+    w2 = (color2 >> 24) & 0xFF
+    r2 = (color2 >> 16) & 0xFF
+    g2 = (color2 >> 8) & 0xFF
+    b2 = color2 & 0xFF
+
+    w3 = ((w1 * (255 - blend)) + (w2 * blend)) // 255
+    r3 = ((r1 * (255 - blend)) + (r2 * blend)) // 255
+    g3 = ((g1 * (255 - blend)) + (g2 * blend)) // 255
+    b3 = ((b1 * (255 - blend)) + (b2 * blend)) // 255
+
+    return (w3 << 24) | (r3 << 16) | (g3 << 8) | b3
+
+def color_add(c1: int, c2: int, preserve_ratio: bool = False) -> int:
+    """Add two colors together with optional ratio preservation"""
+    if c1 == 0:
+        return c2
+    if c2 == 0:
+        return c1
+
+    w1 = (c1 >> 24) & 0xFF
+    r1 = (c1 >> 16) & 0xFF
+    g1 = (c1 >> 8) & 0xFF
+    b1 = c1 & 0xFF
+
+    w2 = (c2 >> 24) & 0xFF
+    r2 = (c2 >> 16) & 0xFF
+    g2 = (c2 >> 8) & 0xFF
+    b2 = c2 & 0xFF
+
+    r = min(255, r1 + r2)
+    g = min(255, g1 + g2)
+    b = min(255, b1 + b2)
+    w = min(255, w1 + w2)
+
+    if preserve_ratio:
+        max_val = max(r, g, b, w)
+        if max_val > 255:
+            scale = 255.0 / max_val
+            r = int(r * scale)
+            g = int(g * scale)
+            b = int(b * scale)
+            w = int(w * scale)
+
+    return (w << 24) | (r << 16) | (g << 8) | b
+
+def color_fade(color: int, amount: int, video: bool = False) -> int:
+    """
+    Fade color toward black
+    amount: 0 (black) to 255 (no fade)
+    video: if True, uses "video" scaling (never goes to pure black)
+    """
+    if color == 0 or amount == 0:
+        return 0
+    if amount == 255:
+        return color
+
+    w = (color >> 24) & 0xFF
+    r = (color >> 16) & 0xFF
+    g = (color >> 8) & 0xFF
+    b = color & 0xFF
+
+    if not video:
+        amount += 1
+        w = (w * amount) >> 8
+        r = (r * amount) >> 8
+        g = (g * amount) >> 8
+        b = (b * amount) >> 8
+    else:
+        # Video scaling - ensure colors don't go to zero if they started non-zero
+        w = max(1 if w else 0, (w * amount) >> 8)
+        r = max(1 if r else 0, (r * amount) >> 8)
+        g = max(1 if g else 0, (g * amount) >> 8)
+        b = max(1 if b else 0, (b * amount) >> 8)
+
+    return (w << 24) | (r << 16) | (g << 8) | b
+
+def wheel(pos: int) -> Tuple[int, int, int]:
+    """
+    Color wheel function (0-255) -> RGB
+    Used for rainbow effects
+    """
+    pos &= 0xFF
+    if pos < 85:
+        return (pos * 3, 255 - pos * 3, 0)
+    elif pos < 170:
+        pos -= 85
+        return (255 - pos * 3, 0, pos * 3)
+    else:
+        pos -= 170
+        return (0, pos * 3, 255 - pos * 3)
+
+def color_wheel(pos: int) -> int:
+    """Color wheel returning 32-bit color (WLED format)"""
+    r, g, b = wheel(pos)
+    return (r << 16) | (g << 8) | b
+
+def hsv_to_rgb(h: int, s: int, v: int) -> Tuple[int, int, int]:
+    """
+    Convert HSV to RGB
+    h: 0-65535 (16-bit hue)
+    s: 0-255 (saturation)
+    v: 0-255 (value/brightness)
+    Returns: (r, g, b) tuple, each 0-255
+    """
+    if s == 0:
+        return (v, v, v)
+
+    region = h // 10923  # 65536 / 6
+    remainder = (h - (region * 10923)) * 6
+
+    p = (v * (255 - s)) >> 8
+    q = (v * (255 - ((s * remainder) >> 16))) >> 8
+    t = (v * (255 - ((s * (65535 - remainder)) >> 16))) >> 8
+
+    if region == 0:
+        return (v, t, p)
+    elif region == 1:
+        return (q, v, p)
+    elif region == 2:
+        return (p, v, t)
+    elif region == 3:
+        return (p, q, v)
+    elif region == 4:
+        return (t, p, v)
+    else:
+        return (v, p, q)
+
+def rgb_to_hsv(r: int, g: int, b: int) -> Tuple[int, int, int]:
+    """
+    Convert RGB to HSV
+    Returns: (h, s, v) where h is 0-65535, s and v are 0-255
+    """
+    min_val = min(r, g, b)
+    max_val = max(r, g, b)
+
+    if max_val == 0:
+        return (0, 0, 0)
+
+    v = max_val
+    delta = max_val - min_val
+    s = (255 * delta) // max_val
+
+    if s == 0:
+        return (0, 0, v)
+
+    if max_val == r:
+        h = (10923 * (g - b)) // delta
+    elif max_val == g:
+        h = 21845 + (10923 * (b - r)) // delta
+    else:
+        h = 43690 + (10923 * (r - g)) // delta
+
+    h &= 0xFFFF  # Ensure 16-bit
+    return (h, s, v)
+
+def sin8(x: int) -> int:
+    """Fast 8-bit sine approximation (0-255 input, 0-255 output)"""
+    # Simple sine approximation
+    x &= 0xFF
+    if x < 128:
+        # Rising half
+        return int(128 + 127 * math.sin(x * math.pi / 128))
+    else:
+        # Falling half
+        return int(128 + 127 * math.sin((x - 128) * math.pi / 128))
+
+def sin16(x: int) -> int:
+    """16-bit sine (-32768 to 32767 output)"""
+    angle = (x & 0xFFFF) / 65536.0 * 2 * math.pi
+    return int(32767 * math.sin(angle))
+
+def triwave16(x: int) -> int:
+    """Triangle wave 0-65535"""
+    x &= 0xFFFF
+    if x < 0x8000:
+        return x * 2
+    return 0xFFFF - (x - 0x8000) * 2
+
+# Helper functions to extract color components
+def get_r(color: int) -> int:
+    """Extract red component from 32-bit color"""
+    return (color >> 16) & 0xFF
+
+def get_g(color: int) -> int:
+    """Extract green component from 32-bit color"""
+    return (color >> 8) & 0xFF
+
+def get_b(color: int) -> int:
+    """Extract blue component from 32-bit color"""
+    return color & 0xFF
+
+def get_w(color: int) -> int:
+    """Extract white component from 32-bit color"""
+    return (color >> 24) & 0xFF
+
+def rgb_to_color(r: int, g: int, b: int, w: int = 0) -> int:
+    """Create 32-bit color from RGBW components"""
+    return (w << 24) | (r << 16) | (g << 8) | b
+
+def color_from_tuple(rgb: Tuple[int, int, int]) -> int:
+    """Convert RGB tuple to 32-bit color"""
+    return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
+
+# Constants
+BLACK = 0x00000000
+WHITE = 0x00FFFFFF
+RED = 0x00FF0000
+GREEN = 0x0000FF00
+BLUE = 0x000000FF

+ 766 - 0
modules/led/dw_leds/utils/palettes.py

@@ -0,0 +1,766 @@
+#!/usr/bin/env python3
+"""
+WLED Color Palettes for Raspberry Pi
+Ported from WLED palettes.cpp
+All 59 gradient palettes from WLED
+"""
+from typing import List, Tuple
+from .colors import color_blend, rgb_to_color
+
+# Gradient palette format: [(index, r, g, b), ...]
+# Index is 0-255 position in the palette
+
+# Gradient palettes (indices 13-71 in WLED, 0-58 here)
+
+SUNSET_REAL = [
+    (0, 181, 0, 0),
+    (22, 218, 85, 0),
+    (51, 255, 170, 0),
+    (85, 211, 85, 77),
+    (135, 167, 0, 169),
+    (198, 73, 0, 188),
+    (255, 0, 0, 207)
+]
+
+RIVENDELL = [
+    (0, 24, 69, 44),
+    (101, 73, 105, 70),
+    (165, 129, 140, 97),
+    (242, 200, 204, 166),
+    (255, 200, 204, 166)
+]
+
+BREEZE = [
+    (0, 16, 48, 51),
+    (89, 27, 166, 175),
+    (153, 197, 233, 255),
+    (255, 0, 145, 152)
+]
+
+RED_BLUE = [
+    (0, 41, 14, 99),
+    (31, 128, 24, 74),
+    (63, 227, 34, 50),
+    (95, 132, 31, 76),
+    (127, 47, 29, 102),
+    (159, 109, 47, 101),
+    (191, 176, 66, 100),
+    (223, 129, 57, 104),
+    (255, 84, 48, 108)
+]
+
+YELLOWOUT = [
+    (0, 222, 191, 8),
+    (255, 117, 52, 1)
+]
+
+ANALOGOUS = [
+    (0, 38, 0, 255),
+    (63, 86, 0, 255),
+    (127, 139, 0, 255),
+    (191, 196, 0, 117),
+    (255, 255, 0, 0)
+]
+
+SPLASH = [
+    (0, 186, 63, 255),
+    (127, 227, 9, 85),
+    (175, 234, 205, 213),
+    (221, 205, 38, 176),
+    (255, 205, 38, 176)
+]
+
+PASTEL = [
+    (0, 61, 135, 184),
+    (36, 129, 188, 169),
+    (87, 203, 241, 155),
+    (100, 228, 237, 141),
+    (107, 255, 232, 127),
+    (115, 251, 202, 130),
+    (120, 248, 172, 133),
+    (128, 251, 202, 130),
+    (180, 255, 232, 127),
+    (223, 255, 242, 120),
+    (255, 255, 252, 113)
+]
+
+SUNSET2 = [
+    (0, 175, 121, 62),
+    (29, 128, 103, 60),
+    (68, 84, 84, 58),
+    (68, 248, 184, 55),
+    (97, 239, 204, 93),
+    (124, 230, 225, 133),
+    (178, 102, 125, 129),
+    (255, 0, 26, 125)
+]
+
+BEECH = [
+    (0, 255, 254, 236),
+    (12, 255, 254, 236),
+    (22, 255, 254, 236),
+    (26, 223, 224, 178),
+    (28, 192, 195, 124),
+    (28, 176, 255, 231),
+    (50, 123, 251, 236),
+    (71, 74, 246, 241),
+    (93, 33, 225, 228),
+    (120, 0, 204, 215),
+    (133, 4, 168, 178),
+    (136, 10, 132, 143),
+    (136, 51, 189, 212),
+    (208, 23, 159, 201),
+    (255, 0, 129, 190)
+]
+
+VINTAGE = [
+    (0, 41, 18, 24),
+    (51, 73, 0, 22),
+    (76, 165, 170, 38),
+    (101, 255, 189, 80),
+    (127, 139, 56, 40),
+    (153, 73, 0, 22),
+    (229, 41, 18, 24),
+    (255, 41, 18, 24)
+]
+
+DEPARTURE = [
+    (0, 53, 34, 0),
+    (42, 86, 51, 0),
+    (63, 147, 108, 49),
+    (84, 212, 166, 108),
+    (106, 235, 212, 180),
+    (116, 255, 255, 255),
+    (138, 191, 255, 193),
+    (148, 84, 255, 88),
+    (170, 0, 255, 0),
+    (191, 0, 192, 0),
+    (212, 0, 128, 0),
+    (255, 0, 128, 0)
+]
+
+LANDSCAPE = [
+    (0, 0, 0, 0),
+    (37, 31, 89, 19),
+    (76, 72, 178, 43),
+    (127, 150, 235, 5),
+    (128, 186, 234, 119),
+    (130, 222, 233, 252),
+    (153, 197, 219, 231),
+    (204, 132, 179, 253),
+    (255, 28, 107, 225)
+]
+
+BEACH = [
+    (0, 12, 45, 0),
+    (19, 101, 86, 2),
+    (38, 207, 128, 4),
+    (63, 243, 197, 18),
+    (66, 109, 196, 146),
+    (255, 5, 39, 7)
+]
+
+SHERBET = [
+    (0, 255, 102, 41),
+    (43, 255, 140, 90),
+    (86, 255, 51, 90),
+    (127, 255, 153, 169),
+    (170, 255, 255, 249),
+    (209, 113, 255, 85),
+    (255, 157, 255, 137)
+]
+
+HULT = [
+    (0, 251, 216, 252),
+    (48, 255, 192, 255),
+    (89, 239, 95, 241),
+    (160, 51, 153, 217),
+    (216, 24, 184, 174),
+    (255, 24, 184, 174)
+]
+
+HULT64 = [
+    (0, 24, 184, 174),
+    (66, 8, 162, 150),
+    (104, 124, 137, 7),
+    (130, 178, 186, 22),
+    (150, 124, 137, 7),
+    (201, 6, 156, 144),
+    (239, 0, 128, 117),
+    (255, 0, 128, 117)
+]
+
+DRYWET = [
+    (0, 119, 97, 33),
+    (42, 235, 199, 88),
+    (84, 169, 238, 124),
+    (127, 37, 238, 232),
+    (170, 7, 120, 236),
+    (212, 27, 1, 175),
+    (255, 4, 51, 101)
+]
+
+JUL = [
+    (0, 226, 6, 12),
+    (94, 26, 96, 78),
+    (132, 130, 189, 94),
+    (255, 177, 3, 9)
+]
+
+GRINTAGE = [
+    (0, 29, 8, 3),
+    (53, 76, 1, 0),
+    (104, 142, 96, 28),
+    (153, 211, 191, 61),
+    (255, 117, 129, 42)
+]
+
+REWHI = [
+    (0, 177, 160, 199),
+    (72, 205, 158, 149),
+    (89, 233, 155, 101),
+    (107, 255, 95, 63),
+    (141, 192, 98, 109),
+    (255, 132, 101, 159)
+]
+
+TERTIARY = [
+    (0, 0, 25, 255),
+    (63, 38, 140, 117),
+    (127, 86, 255, 0),
+    (191, 167, 140, 19),
+    (255, 255, 25, 41)
+]
+
+FIRE = [
+    (0, 0, 0, 0),
+    (46, 77, 0, 0),
+    (96, 177, 0, 0),
+    (108, 196, 38, 9),
+    (119, 215, 76, 19),
+    (146, 235, 115, 29),
+    (174, 255, 153, 41),
+    (188, 255, 178, 41),
+    (202, 255, 204, 41),
+    (218, 255, 230, 41),
+    (234, 255, 255, 41),
+    (244, 255, 255, 143),
+    (255, 255, 255, 255)
+]
+
+ICEFIRE = [
+    (0, 0, 0, 0),
+    (59, 0, 51, 117),
+    (119, 0, 102, 255),
+    (149, 38, 153, 255),
+    (180, 86, 204, 255),
+    (217, 167, 230, 255),
+    (255, 255, 255, 255)
+]
+
+CYANE = [
+    (0, 61, 155, 44),
+    (25, 95, 174, 77),
+    (60, 132, 193, 113),
+    (93, 154, 166, 125),
+    (106, 175, 138, 136),
+    (109, 183, 121, 137),
+    (113, 194, 104, 138),
+    (116, 225, 179, 165),
+    (124, 255, 255, 192),
+    (168, 167, 218, 203),
+    (255, 84, 182, 215)
+]
+
+LIGHT_PINK = [
+    (0, 79, 32, 109),
+    (25, 90, 40, 117),
+    (51, 102, 48, 124),
+    (76, 141, 135, 185),
+    (102, 180, 222, 248),
+    (109, 208, 236, 252),
+    (114, 237, 250, 255),
+    (122, 206, 200, 239),
+    (149, 177, 149, 222),
+    (183, 187, 130, 203),
+    (255, 198, 111, 184)
+]
+
+AUTUMN = [
+    (0, 90, 14, 5),
+    (51, 139, 41, 13),
+    (84, 180, 70, 17),
+    (104, 192, 202, 125),
+    (112, 177, 137, 3),
+    (122, 190, 200, 131),
+    (124, 192, 202, 124),
+    (135, 177, 137, 3),
+    (142, 194, 203, 118),
+    (163, 177, 68, 17),
+    (204, 128, 35, 12),
+    (249, 74, 5, 2),
+    (255, 74, 5, 2)
+]
+
+MAGENTA = [
+    (0, 0, 0, 0),
+    (42, 0, 0, 117),
+    (84, 0, 0, 255),
+    (127, 113, 0, 255),
+    (170, 255, 0, 255),
+    (212, 255, 128, 255),
+    (255, 255, 255, 255)
+]
+
+MAGRED = [
+    (0, 0, 0, 0),
+    (63, 113, 0, 117),
+    (127, 255, 0, 255),
+    (191, 255, 0, 117),
+    (255, 255, 0, 0)
+]
+
+YELMAG = [
+    (0, 0, 0, 0),
+    (42, 113, 0, 0),
+    (84, 255, 0, 0),
+    (127, 255, 0, 117),
+    (170, 255, 0, 255),
+    (212, 255, 128, 117),
+    (255, 255, 255, 0)
+]
+
+YELBLU = [
+    (0, 0, 0, 255),
+    (63, 0, 128, 255),
+    (127, 0, 255, 255),
+    (191, 113, 255, 117),
+    (255, 255, 255, 0)
+]
+
+ORANGE_TEAL = [
+    (0, 0, 150, 92),
+    (55, 0, 150, 92),
+    (200, 255, 72, 0),
+    (255, 255, 72, 0)
+]
+
+TIAMAT = [
+    (0, 1, 2, 14),
+    (33, 2, 5, 35),
+    (100, 13, 135, 92),
+    (120, 43, 255, 193),
+    (140, 247, 7, 249),
+    (160, 193, 17, 208),
+    (180, 39, 255, 154),
+    (200, 4, 213, 236),
+    (220, 39, 252, 135),
+    (240, 193, 213, 253),
+    (255, 255, 249, 255)
+]
+
+APRIL_NIGHT = [
+    (0, 1, 5, 45),
+    (10, 1, 5, 45),
+    (25, 5, 169, 175),
+    (40, 1, 5, 45),
+    (61, 1, 5, 45),
+    (76, 45, 175, 31),
+    (91, 1, 5, 45),
+    (112, 1, 5, 45),
+    (127, 249, 150, 5),
+    (143, 1, 5, 45),
+    (162, 1, 5, 45),
+    (178, 255, 92, 0),
+    (193, 1, 5, 45),
+    (214, 1, 5, 45),
+    (229, 223, 45, 72),
+    (244, 1, 5, 45),
+    (255, 1, 5, 45)
+]
+
+ORANGERY = [
+    (0, 255, 95, 23),
+    (30, 255, 82, 0),
+    (60, 223, 13, 8),
+    (90, 144, 44, 2),
+    (120, 255, 110, 17),
+    (150, 255, 69, 0),
+    (180, 158, 13, 11),
+    (210, 241, 82, 17),
+    (255, 213, 37, 4)
+]
+
+C9 = [
+    (0, 184, 4, 0),
+    (60, 184, 4, 0),
+    (65, 144, 44, 2),
+    (125, 144, 44, 2),
+    (130, 4, 96, 2),
+    (190, 4, 96, 2),
+    (195, 7, 7, 88),
+    (255, 7, 7, 88)
+]
+
+SAKURA = [
+    (0, 196, 19, 10),
+    (65, 255, 69, 45),
+    (130, 223, 45, 72),
+    (195, 255, 82, 103),
+    (255, 223, 13, 17)
+]
+
+AURORA = [
+    (0, 1, 5, 45),
+    (64, 0, 200, 23),
+    (128, 0, 255, 0),
+    (170, 0, 243, 45),
+    (200, 0, 135, 7),
+    (255, 1, 5, 45)
+]
+
+ATLANTICA = [
+    (0, 0, 28, 112),
+    (50, 32, 96, 255),
+    (100, 0, 243, 45),
+    (150, 12, 95, 82),
+    (200, 25, 190, 95),
+    (255, 40, 170, 80)
+]
+
+C9_2 = [
+    (0, 6, 126, 2),
+    (45, 6, 126, 2),
+    (46, 4, 30, 114),
+    (90, 4, 30, 114),
+    (91, 255, 5, 0),
+    (135, 255, 5, 0),
+    (136, 196, 57, 2),
+    (180, 196, 57, 2),
+    (181, 137, 85, 2),
+    (255, 137, 85, 2)
+]
+
+C9_NEW = [
+    (0, 255, 5, 0),
+    (60, 255, 5, 0),
+    (61, 196, 57, 2),
+    (120, 196, 57, 2),
+    (121, 6, 126, 2),
+    (180, 6, 126, 2),
+    (181, 4, 30, 114),
+    (255, 4, 30, 114)
+]
+
+TEMPERATURE = [
+    (0, 20, 92, 171),
+    (14, 15, 111, 186),
+    (28, 6, 142, 211),
+    (42, 2, 161, 227),
+    (56, 16, 181, 239),
+    (70, 38, 188, 201),
+    (84, 86, 204, 200),
+    (99, 139, 219, 176),
+    (113, 182, 229, 125),
+    (127, 196, 230, 63),
+    (141, 241, 240, 22),
+    (155, 254, 222, 30),
+    (170, 251, 199, 4),
+    (184, 247, 157, 9),
+    (198, 243, 114, 15),
+    (226, 213, 30, 29),
+    (240, 151, 38, 35),
+    (255, 151, 38, 35)
+]
+
+AURORA2 = [
+    (0, 17, 177, 13),
+    (64, 121, 242, 5),
+    (128, 25, 173, 121),
+    (192, 250, 77, 127),
+    (255, 171, 101, 221)
+]
+
+RETRO_CLOWN = [
+    (0, 242, 168, 38),
+    (117, 226, 78, 80),
+    (255, 161, 54, 225)
+]
+
+CANDY = [
+    (0, 243, 242, 23),
+    (15, 242, 168, 38),
+    (142, 111, 21, 151),
+    (198, 74, 22, 150),
+    (255, 0, 0, 117)
+]
+
+TOXY_REAF = [
+    (0, 2, 239, 126),
+    (255, 145, 35, 217)
+]
+
+FAIRY_REAF = [
+    (0, 220, 19, 187),
+    (160, 12, 225, 219),
+    (219, 203, 242, 223),
+    (255, 255, 255, 255)
+]
+
+SEMI_BLUE = [
+    (0, 0, 0, 0),
+    (12, 24, 4, 38),
+    (53, 55, 8, 84),
+    (80, 43, 48, 159),
+    (119, 31, 89, 237),
+    (145, 50, 59, 166),
+    (186, 71, 30, 98),
+    (233, 31, 15, 45),
+    (255, 0, 0, 0)
+]
+
+PINK_CANDY = [
+    (0, 255, 255, 255),
+    (45, 50, 64, 255),
+    (112, 242, 16, 186),
+    (140, 255, 255, 255),
+    (155, 242, 16, 186),
+    (196, 116, 13, 166),
+    (255, 255, 255, 255)
+]
+
+RED_REAF = [
+    (0, 36, 68, 114),
+    (104, 149, 195, 248),
+    (188, 255, 0, 0),
+    (255, 94, 14, 9)
+]
+
+AQUA_FLASH = [
+    (0, 0, 0, 0),
+    (66, 130, 242, 245),
+    (96, 255, 255, 53),
+    (124, 255, 255, 255),
+    (153, 255, 255, 53),
+    (188, 130, 242, 245),
+    (255, 0, 0, 0)
+]
+
+YELBLU_HOT = [
+    (0, 43, 30, 57),
+    (58, 73, 0, 119),
+    (122, 87, 0, 74),
+    (158, 197, 57, 22),
+    (183, 218, 117, 27),
+    (219, 239, 177, 32),
+    (255, 246, 247, 27)
+]
+
+LITE_LIGHT = [
+    (0, 0, 0, 0),
+    (9, 20, 21, 22),
+    (40, 46, 43, 49),
+    (66, 46, 43, 49),
+    (101, 61, 16, 65),
+    (255, 0, 0, 0)
+]
+
+RED_FLASH = [
+    (0, 0, 0, 0),
+    (99, 242, 12, 8),
+    (130, 253, 228, 163),
+    (155, 242, 12, 8),
+    (255, 0, 0, 0)
+]
+
+BLINK_RED = [
+    (0, 4, 7, 4),
+    (43, 40, 25, 62),
+    (76, 61, 15, 36),
+    (109, 207, 39, 96),
+    (127, 255, 156, 184),
+    (165, 185, 73, 207),
+    (204, 105, 66, 240),
+    (255, 77, 29, 78)
+]
+
+RED_SHIFT = [
+    (0, 98, 22, 93),
+    (45, 103, 22, 73),
+    (99, 192, 45, 56),
+    (132, 235, 187, 59),
+    (175, 228, 85, 26),
+    (201, 228, 56, 48),
+    (255, 2, 0, 2)
+]
+
+RED_TIDE = [
+    (0, 251, 46, 0),
+    (28, 255, 139, 25),
+    (43, 246, 158, 63),
+    (58, 246, 216, 123),
+    (84, 243, 94, 10),
+    (114, 177, 65, 11),
+    (140, 255, 241, 115),
+    (168, 177, 65, 11),
+    (196, 250, 233, 158),
+    (216, 255, 94, 6),
+    (255, 126, 8, 4)
+]
+
+CANDY2 = [
+    (0, 109, 102, 102),
+    (25, 42, 49, 71),
+    (48, 121, 96, 84),
+    (73, 241, 214, 26),
+    (89, 216, 104, 44),
+    (130, 42, 49, 71),
+    (163, 255, 177, 47),
+    (186, 241, 214, 26),
+    (211, 109, 102, 102),
+    (255, 20, 19, 13)
+]
+
+TRAFFIC_LIGHT = [
+    (0, 0, 0, 0),
+    (85, 0, 255, 0),
+    (170, 255, 255, 0),
+    (255, 255, 0, 0)
+]
+
+# All palettes in order (matching WLED indices 13-71, stored as 0-58 here)
+ALL_PALETTES = [
+    SUNSET_REAL,      # 0 (WLED 13)
+    RIVENDELL,        # 1
+    BREEZE,           # 2
+    RED_BLUE,         # 3
+    YELLOWOUT,        # 4
+    ANALOGOUS,        # 5
+    SPLASH,           # 6
+    PASTEL,           # 7
+    SUNSET2,          # 8
+    BEECH,            # 9
+    VINTAGE,          # 10
+    DEPARTURE,        # 11
+    LANDSCAPE,        # 12
+    BEACH,            # 13
+    SHERBET,          # 14
+    HULT,             # 15
+    HULT64,           # 16
+    DRYWET,           # 17
+    JUL,              # 18
+    GRINTAGE,         # 19
+    REWHI,            # 20
+    TERTIARY,         # 21
+    FIRE,             # 22
+    ICEFIRE,          # 23
+    CYANE,            # 24
+    LIGHT_PINK,       # 25
+    AUTUMN,           # 26
+    MAGENTA,          # 27
+    MAGRED,           # 28
+    YELMAG,           # 29
+    YELBLU,           # 30
+    ORANGE_TEAL,      # 31
+    TIAMAT,           # 32
+    APRIL_NIGHT,      # 33
+    ORANGERY,         # 34
+    C9,               # 35
+    SAKURA,           # 36
+    AURORA,           # 37
+    ATLANTICA,        # 38
+    C9_2,             # 39
+    C9_NEW,           # 40
+    TEMPERATURE,      # 41
+    AURORA2,          # 42
+    RETRO_CLOWN,      # 43
+    CANDY,            # 44
+    TOXY_REAF,        # 45
+    FAIRY_REAF,       # 46
+    SEMI_BLUE,        # 47
+    PINK_CANDY,       # 48
+    RED_REAF,         # 49
+    AQUA_FLASH,       # 50
+    YELBLU_HOT,       # 51
+    LITE_LIGHT,       # 52
+    RED_FLASH,        # 53
+    BLINK_RED,        # 54
+    RED_SHIFT,        # 55
+    RED_TIDE,         # 56
+    CANDY2,           # 57
+    TRAFFIC_LIGHT     # 58
+]
+
+PALETTE_NAMES = [
+    "Sunset", "Rivendell", "Breeze", "Red & Blue", "Yellowout",
+    "Analogous", "Splash", "Pastel", "Sunset2", "Beech",
+    "Vintage", "Departure", "Landscape", "Beach", "Sherbet",
+    "Hult", "Hult64", "Drywet", "Jul", "Grintage",
+    "Rewhi", "Tertiary", "Fire", "Icefire", "Cyane",
+    "Light Pink", "Autumn", "Magenta", "Magred", "Yelmag",
+    "Yelblu", "Orange & Teal", "Tiamat", "April Night", "Orangery",
+    "C9", "Sakura", "Aurora", "Atlantica", "C9 2",
+    "C9 New", "Temperature", "Aurora 2", "Retro Clown", "Candy",
+    "Toxy Reaf", "Fairy Reaf", "Semi Blue", "Pink Candy", "Red Reaf",
+    "Aqua Flash", "Yelblu Hot", "Lite Light", "Red Flash", "Blink Red",
+    "Red Shift", "Red Tide", "Candy2", "Traffic Light"
+]
+
+
+def color_from_palette(palette: List[Tuple[int, int, int, int]],
+                       index: int,
+                       brightness: int = 255) -> int:
+    """
+    Get color from gradient palette at given index
+    index: 0-255 position in palette
+    brightness: 0-255 brightness scaling
+    Returns: 32-bit RGB color
+    """
+    index = index & 0xFF
+
+    # Find the two palette entries to blend between
+    for i in range(len(palette) - 1):
+        if index <= palette[i + 1][0]:
+            idx1, r1, g1, b1 = palette[i]
+            idx2, r2, g2, b2 = palette[i + 1]
+
+            # Calculate blend amount
+            if idx2 == idx1:
+                blend = 0
+            else:
+                blend = ((index - idx1) * 255) // (idx2 - idx1)
+
+            # Blend colors
+            r = ((r1 * (255 - blend)) + (r2 * blend)) // 255
+            g = ((g1 * (255 - blend)) + (g2 * blend)) // 255
+            b = ((b1 * (255 - blend)) + (b2 * blend)) // 255
+
+            # Apply brightness
+            if brightness < 255:
+                r = (r * brightness) // 255
+                g = (g * brightness) // 255
+                b = (b * brightness) // 255
+
+            return rgb_to_color(r, g, b)
+
+    # If we're past the last entry, use the last color
+    idx, r, g, b = palette[-1]
+    if brightness < 255:
+        r = (r * brightness) // 255
+        g = (g * brightness) // 255
+        b = (b * brightness) // 255
+    return rgb_to_color(r, g, b)
+
+
+def get_palette(palette_id: int) -> List[Tuple[int, int, int, int]]:
+    """Get palette by ID (0-58)"""
+    if 0 <= palette_id < len(ALL_PALETTES):
+        return ALL_PALETTES[palette_id]
+    return ALL_PALETTES[0]  # Default to Sunset
+
+
+def get_palette_name(palette_id: int) -> str:
+    """Get palette name by ID"""
+    if 0 <= palette_id < len(PALETTE_NAMES):
+        return PALETTE_NAMES[palette_id]
+    return "Unknown"

+ 95 - 0
modules/led/idle_timeout_manager.py

@@ -0,0 +1,95 @@
+"""
+Idle LED Timeout Manager
+Handles automatic LED turn-off after a period of inactivity.
+"""
+import asyncio
+import logging
+from datetime import datetime
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class IdleTimeoutManager:
+    """
+    Manages idle timeout for LED effects.
+    When idle effect is played, starts a timer. When timer expires,
+    checks if table is still idle and turns off LEDs if so.
+    """
+
+    def __init__(self):
+        self._timeout_task: Optional[asyncio.Task] = None
+        self._last_idle_time: Optional[datetime] = None
+
+    def start_idle_timeout(self, timeout_minutes: float, state, check_idle_callback):
+        """
+        Start or restart the idle timeout timer.
+
+        Args:
+            timeout_minutes: Minutes to wait before turning off LEDs
+            state: Application state object
+            check_idle_callback: Async callback to check if table is still idle
+        """
+        # Cancel any existing timeout
+        self.cancel_timeout()
+
+        if timeout_minutes <= 0:
+            logger.debug("Idle timeout disabled (timeout <= 0)")
+            return
+
+        # Record when idle effect was started
+        self._last_idle_time = datetime.now()
+        logger.info(f"Starting idle LED timeout: {timeout_minutes} minutes")
+
+        # Create background task to handle timeout
+        self._timeout_task = asyncio.create_task(
+            self._timeout_handler(timeout_minutes, state, check_idle_callback)
+        )
+
+    async def _timeout_handler(self, timeout_minutes: float, state, check_idle_callback):
+        """
+        Background task that waits for timeout and turns off LEDs if still idle.
+        """
+        try:
+            # Wait for the specified timeout
+            timeout_seconds = timeout_minutes * 60
+            await asyncio.sleep(timeout_seconds)
+
+            # Check if we should turn off the LEDs
+            logger.debug("Idle timeout expired, checking table state...")
+
+            # Check if table is still idle (not playing anything)
+            is_idle = await check_idle_callback()
+
+            if is_idle:
+                logger.info("Table is still idle after timeout - turning off LEDs")
+                if state.led_controller:
+                    try:
+                        state.led_controller.set_power(0)  # Turn off LEDs
+                        logger.info("LEDs turned off successfully")
+                    except Exception as e:
+                        logger.error(f"Failed to turn off LEDs: {e}")
+                else:
+                    logger.warning("LED controller not configured")
+            else:
+                logger.debug("Table is not idle - skipping LED turn-off")
+
+        except asyncio.CancelledError:
+            logger.debug("Idle timeout cancelled")
+        except Exception as e:
+            logger.error(f"Error in idle timeout handler: {e}")
+
+    def cancel_timeout(self):
+        """Cancel any running timeout task."""
+        if self._timeout_task and not self._timeout_task.done():
+            logger.debug("Cancelling existing idle timeout")
+            self._timeout_task.cancel()
+            self._timeout_task = None
+
+    def is_timeout_active(self) -> bool:
+        """Check if a timeout is currently active."""
+        return self._timeout_task is not None and not self._timeout_task.done()
+
+
+# Singleton instance
+idle_timeout_manager = IdleTimeoutManager()

+ 149 - 0
modules/led/led_interface.py

@@ -0,0 +1,149 @@
+"""
+Unified LED interface for different LED control systems
+Provides a common abstraction layer for pattern manager integration.
+"""
+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
+
+# Try to import DW LED controller - it requires RPi-specific dependencies
+try:
+    from modules.led.dw_led_controller import DWLEDController, effect_loading as dw_led_loading, effect_idle as dw_led_idle, effect_connected as dw_led_connected, effect_playing as dw_led_playing
+    DW_LEDS_AVAILABLE = True
+except ImportError:
+    # Running on non-RPi platform - DW LEDs not available
+    DWLEDController = None
+    dw_led_loading = None
+    dw_led_idle = None
+    dw_led_connected = None
+    dw_led_playing = None
+    DW_LEDS_AVAILABLE = False
+
+
+LEDProviderType = Literal["wled", "dw_leds", "none"]
+
+
+class LEDInterface:
+    """
+    Unified interface for LED control that works with multiple backends.
+    Automatically delegates to the appropriate controller based on configuration.
+    """
+
+    def __init__(self, provider: LEDProviderType = "none", ip_address: Optional[str] = None,
+                 num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
+                 brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
+        self.provider = provider
+        self._controller = None
+
+        if provider == "wled" and ip_address:
+            self._controller = LEDController(ip_address)
+        elif provider == "dw_leds":
+            if not DW_LEDS_AVAILABLE:
+                raise ImportError("DW LED controller requires Raspberry Pi GPIO libraries. Install with: pip install -r requirements.txt")
+            # DW LEDs uses local GPIO, no IP needed
+            num_leds = num_leds or 60
+            gpio_pin = gpio_pin or 12
+            pixel_order = pixel_order or "GRB"
+            brightness = brightness if brightness is not None else 0.35
+            speed = speed if speed is not None else 128
+            intensity = intensity if intensity is not None else 128
+            self._controller = DWLEDController(num_leds, gpio_pin, brightness, pixel_order=pixel_order, speed=speed, intensity=intensity)
+
+    @property
+    def is_configured(self) -> bool:
+        """Check if LED controller is configured"""
+        return self._controller is not None
+
+    def update_config(self, provider: LEDProviderType, ip_address: Optional[str] = None,
+                     num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, pixel_order: Optional[str] = None,
+                     brightness: Optional[float] = None, speed: Optional[int] = None, intensity: Optional[int] = None):
+        """Update LED provider configuration"""
+        self.provider = provider
+
+        # Stop existing controller if switching providers
+        if self._controller and hasattr(self._controller, 'stop'):
+            try:
+                self._controller.stop()
+            except:
+                pass
+
+        if provider == "wled" and ip_address:
+            self._controller = LEDController(ip_address)
+        elif provider == "dw_leds":
+            if not DW_LEDS_AVAILABLE:
+                raise ImportError("DW LED controller requires Raspberry Pi GPIO libraries. Install with: pip install -r requirements.txt")
+            num_leds = num_leds or 60
+            gpio_pin = gpio_pin or 12
+            pixel_order = pixel_order or "GRB"
+            brightness = brightness if brightness is not None else 0.35
+            speed = speed if speed is not None else 128
+            intensity = intensity if intensity is not None else 128
+            self._controller = DWLEDController(num_leds, gpio_pin, brightness, pixel_order=pixel_order, speed=speed, intensity=intensity)
+        else:
+            self._controller = None
+
+    def effect_loading(self) -> bool:
+        """Show loading effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_loading(self._controller)
+        elif self.provider == "dw_leds":
+            return dw_led_loading(self._controller)
+        return False
+
+    def effect_idle(self, effect_name: Optional[str] = None) -> bool:
+        """Show idle effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_idle(self._controller)
+        elif self.provider == "dw_leds":
+            return dw_led_idle(self._controller, effect_name)
+        return False
+
+    def effect_connected(self) -> bool:
+        """Show connected effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_connected(self._controller)
+        elif self.provider == "dw_leds":
+            return dw_led_connected(self._controller)
+        return False
+
+    def effect_playing(self, effect_name: Optional[str] = None) -> bool:
+        """Show playing effect"""
+        if not self.is_configured:
+            return False
+
+        if self.provider == "wled":
+            return wled_playing(self._controller)
+        elif self.provider == "dw_leds":
+            return dw_led_playing(self._controller, effect_name)
+        return False
+
+    def set_power(self, state: int) -> dict:
+        """Set power state (0=Off, 1=On, 2=Toggle)"""
+        if not self.is_configured:
+            return {"connected": False, "message": "No LED controller configured"}
+
+        return self._controller.set_power(state)
+
+    def check_status(self) -> dict:
+        """Check controller status"""
+        if not self.is_configured:
+            return {"connected": False, "message": "No LED controller configured"}
+
+        if self.provider == "wled":
+            return self._controller.check_wled_status()
+        elif self.provider == "dw_leds":
+            return self._controller.check_status()
+
+        return {"connected": False, "message": "Unknown provider"}
+
+    def get_controller(self):
+        """Get the underlying controller instance (for advanced usage)"""
+        return self._controller

+ 243 - 2
modules/mqtt/handler.py

@@ -51,6 +51,14 @@ class MQTTHandler(BaseMQTTHandler):
         self.completion_topic = f"{self.device_id}/state/completion"
         self.time_remaining_topic = f"{self.device_id}/state/time_remaining"
 
+        # LED control topics
+        self.led_power_topic = f"{self.device_id}/led/power/set"
+        self.led_brightness_topic = f"{self.device_id}/led/brightness/set"
+        self.led_effect_topic = f"{self.device_id}/led/effect/set"
+        self.led_speed_topic = f"{self.device_id}/led/speed/set"
+        self.led_intensity_topic = f"{self.device_id}/led/intensity/set"
+        self.led_color_topic = f"{self.device_id}/led/color/set"
+
         # Store current state
         self.current_file = ""
         self.is_running_state = False
@@ -262,6 +270,101 @@ class MQTTHandler(BaseMQTTHandler):
         }
         self._publish_discovery("sensor", "time_remaining", time_remaining_config)
 
+        # LED Control Entities (only for DW LEDs - WLED has its own MQTT integration)
+        if state.led_provider == "dw_leds":
+            # LED Power Switch
+            led_power_config = {
+                "name": f"{self.device_name} LED Power",
+                "unique_id": f"{self.device_id}_led_power",
+                "command_topic": self.led_power_topic,
+                "state_topic": f"{self.device_id}/led/power/state",
+                "payload_on": "ON",
+                "payload_off": "OFF",
+                "device": base_device,
+                "icon": "mdi:lightbulb",
+                "optimistic": False
+            }
+            self._publish_discovery("switch", "led_power", led_power_config)
+
+            # LED Brightness Control
+            led_brightness_config = {
+                "name": f"{self.device_name} LED Brightness",
+                "unique_id": f"{self.device_id}_led_brightness",
+                "command_topic": self.led_brightness_topic,
+                "state_topic": f"{self.device_id}/led/brightness/state",
+                "device": base_device,
+                "icon": "mdi:brightness-6",
+                "min": 0,
+                "max": 100,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_brightness", led_brightness_config)
+
+            # LED Effect Selector
+            led_effect_options = [
+                "Static", "Blink", "Breathe", "Wipe", "Fade", "Scan", "Dual Scan",
+                "Rainbow Cycle", "Rainbow", "Theater Chase", "Running Lights",
+                "Random Color", "Dynamic", "Twinkle", "Sparkle", "Strobe", "Fire",
+                "Comet", "Chase", "Police", "Lightning", "Fireworks", "Ripple", "Flow",
+                "Colorloop", "Palette Flow", "Gradient", "Multi Strobe", "Waves", "BPM",
+                "Juggle", "Meteor", "Pride", "Pacifica", "Plasma", "Dissolve", "Glitter",
+                "Confetti", "Sinelon", "Candle", "Aurora", "Rain", "Halloween", "Noise",
+                "Funky Plank"
+            ]
+            led_effect_config = {
+                "name": f"{self.device_name} LED Effect",
+                "unique_id": f"{self.device_id}_led_effect",
+                "command_topic": self.led_effect_topic,
+                "state_topic": f"{self.device_id}/led/effect/state",
+                "options": led_effect_options,
+                "device": base_device,
+                "icon": "mdi:palette"
+            }
+            self._publish_discovery("select", "led_effect", led_effect_config)
+
+            # LED Speed Control
+            led_speed_config = {
+                "name": f"{self.device_name} LED Speed",
+                "unique_id": f"{self.device_id}_led_speed",
+                "command_topic": self.led_speed_topic,
+                "state_topic": f"{self.device_id}/led/speed/state",
+                "device": base_device,
+                "icon": "mdi:speedometer",
+                "min": 0,
+                "max": 255,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_speed", led_speed_config)
+
+            # LED Intensity Control
+            led_intensity_config = {
+                "name": f"{self.device_name} LED Intensity",
+                "unique_id": f"{self.device_id}_led_intensity",
+                "command_topic": self.led_intensity_topic,
+                "state_topic": f"{self.device_id}/led/intensity/state",
+                "device": base_device,
+                "icon": "mdi:brightness-7",
+                "min": 0,
+                "max": 255,
+                "mode": "slider"
+            }
+            self._publish_discovery("number", "led_intensity", led_intensity_config)
+
+            # LED RGB Color Control
+            led_color_config = {
+                "name": f"{self.device_name} LED Color",
+                "unique_id": f"{self.device_id}_led_color",
+                "command_topic": self.led_color_topic,
+                "state_topic": f"{self.device_id}/led/color/state",
+                "rgb_command_topic": self.led_color_topic,
+                "rgb_state_topic": f"{self.device_id}/led/color/state",
+                "device": base_device,
+                "icon": "mdi:palette-swatch",
+                "schema": "json",
+                "rgb": True
+            }
+            self._publish_discovery("light", "led_color", led_color_config)
+
     def _publish_discovery(self, component: str, config_type: str, config: dict):
         """Helper method to publish HA discovery configs."""
         if not self.is_enabled:
@@ -340,6 +443,66 @@ class MQTTHandler(BaseMQTTHandler):
             self.client.publish(self.completion_topic, 0, retain=True)
             self.client.publish(self.time_remaining_topic, 0, retain=True)
 
+    def _publish_led_state(self):
+        """Helper to publish LED state to MQTT (DW LEDs only - WLED has its own MQTT)."""
+        if not state.led_controller or state.led_provider != "dw_leds":
+            return
+
+        try:
+            status = state.led_controller.check_status()
+            if not status.get("connected", False):
+                return
+
+            # Publish power state
+            power_state = "ON" if status.get("power", False) else "OFF"
+            self.client.publish(f"{self.device_id}/led/power/state", power_state, retain=True)
+
+            # Publish brightness (convert from 0-1 to 0-100)
+            if "brightness" in status:
+                brightness = int(status["brightness"] * 100)
+                self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
+
+            # Publish effect
+            if "effect_id" in status:
+                effect_map = {
+                    0: "Static", 1: "Blink", 2: "Breathe", 3: "Wipe", 4: "Fade",
+                    5: "Scan", 6: "Dual Scan", 7: "Rainbow Cycle", 8: "Rainbow",
+                    9: "Theater Chase", 10: "Running Lights", 11: "Random Color",
+                    12: "Dynamic", 13: "Twinkle", 14: "Sparkle", 15: "Strobe",
+                    16: "Fire", 17: "Comet", 18: "Chase", 19: "Police", 20: "Lightning",
+                    21: "Fireworks", 22: "Ripple", 23: "Flow", 24: "Colorloop",
+                    25: "Palette Flow", 26: "Gradient", 27: "Multi Strobe", 28: "Waves",
+                    29: "BPM", 30: "Juggle", 31: "Meteor", 32: "Pride", 33: "Pacifica",
+                    34: "Plasma", 35: "Dissolve", 36: "Glitter", 37: "Confetti",
+                    38: "Sinelon", 39: "Candle", 40: "Aurora", 41: "Rain",
+                    42: "Halloween", 43: "Noise", 44: "Funky Plank"
+                }
+                effect_name = effect_map.get(status["effect_id"], "Static")
+                self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
+
+            # Publish speed
+            if "speed" in status:
+                self.client.publish(f"{self.device_id}/led/speed/state", status["speed"], retain=True)
+
+            # Publish intensity
+            if "intensity" in status:
+                self.client.publish(f"{self.device_id}/led/intensity/state", status["intensity"], retain=True)
+
+            # Publish color (RGB)
+            if "colors" in status and len(status["colors"]) > 0:
+                # colors is array of hex strings like ["#ff0000", "#00ff00", "#0000ff"]
+                # Convert first color to RGB dict
+                color_hex = status["colors"][0]
+                if color_hex and color_hex.startswith('#') and len(color_hex) == 7:
+                    r = int(color_hex[1:3], 16)
+                    g = int(color_hex[3:5], 16)
+                    b = int(color_hex[5:7], 16)
+                    self.client.publish(f"{self.device_id}/led/color/state",
+                                      json.dumps({"r": r, "g": g, "b": b}), retain=True)
+
+        except Exception as e:
+            logger.error(f"Error publishing LED state: {e}")
+
     def update_state(self, current_file=None, is_running=None, playlist=None, playlist_name=None):
         """Update state in Home Assistant. Only publishes the attributes that are explicitly passed."""
         if not self.is_enabled:
@@ -374,6 +537,12 @@ class MQTTHandler(BaseMQTTHandler):
                 (f"{self.device_id}/playlist/mode/set", 0),
                 (f"{self.device_id}/playlist/pause_time/set", 0),
                 (f"{self.device_id}/playlist/clear_pattern/set", 0),
+                (self.led_power_topic, 0),
+                (self.led_brightness_topic, 0),
+                (self.led_effect_topic, 0),
+                (self.led_speed_topic, 0),
+                (self.led_intensity_topic, 0),
+                (self.led_color_topic, 0),
             ])
             # Publish discovery configurations
             self.setup_ha_discovery()
@@ -469,6 +638,74 @@ class MQTTHandler(BaseMQTTHandler):
                 if clear_pattern in ["none", "random", "adaptive", "clear_from_in", "clear_from_out", "clear_sideway"]:
                     state.clear_pattern = clear_pattern
                     self.client.publish(f"{self.device_id}/playlist/clear_pattern/state", clear_pattern, retain=True)
+            elif msg.topic == self.led_power_topic:
+                # Handle LED power command (DW LEDs only)
+                payload = msg.payload.decode()
+                if state.led_controller and state.led_provider == "dw_leds":
+                    power_state = 1 if payload == "ON" else 0
+                    state.led_controller.set_power(power_state)
+                    self.client.publish(f"{self.device_id}/led/power/state", payload, retain=True)
+            elif msg.topic == self.led_brightness_topic:
+                # Handle LED brightness command (DW LEDs only)
+                brightness = int(msg.payload.decode())
+                if 0 <= brightness <= 100 and state.led_controller and state.led_provider == "dw_leds":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_brightness'):
+                        controller.set_brightness(brightness / 100.0)
+                        self.client.publish(f"{self.device_id}/led/brightness/state", brightness, retain=True)
+            elif msg.topic == self.led_effect_topic:
+                # Handle LED effect command (DW LEDs only)
+                effect_name = msg.payload.decode()
+                if state.led_controller and state.led_provider == "dw_leds":
+                    # Map effect name to ID
+                    effect_map = {
+                        "Static": 0, "Blink": 1, "Breathe": 2, "Wipe": 3, "Fade": 4,
+                        "Scan": 5, "Dual Scan": 6, "Rainbow Cycle": 7, "Rainbow": 8,
+                        "Theater Chase": 9, "Running Lights": 10, "Random Color": 11,
+                        "Dynamic": 12, "Twinkle": 13, "Sparkle": 14, "Strobe": 15,
+                        "Fire": 16, "Comet": 17, "Chase": 18, "Police": 19, "Lightning": 20,
+                        "Fireworks": 21, "Ripple": 22, "Flow": 23, "Colorloop": 24,
+                        "Palette Flow": 25, "Gradient": 26, "Multi Strobe": 27, "Waves": 28,
+                        "BPM": 29, "Juggle": 30, "Meteor": 31, "Pride": 32, "Pacifica": 33,
+                        "Plasma": 34, "Dissolve": 35, "Glitter": 36, "Confetti": 37,
+                        "Sinelon": 38, "Candle": 39, "Aurora": 40, "Rain": 41,
+                        "Halloween": 42, "Noise": 43, "Funky Plank": 44
+                    }
+                    effect_id = effect_map.get(effect_name)
+                    if effect_id is not None:
+                        controller = state.led_controller.get_controller()
+                        if controller and hasattr(controller, 'set_effect'):
+                            controller.set_effect(effect_id)
+                            self.client.publish(f"{self.device_id}/led/effect/state", effect_name, retain=True)
+            elif msg.topic == self.led_speed_topic:
+                # Handle LED speed command (DW LEDs only)
+                speed = int(msg.payload.decode())
+                if 0 <= speed <= 255 and state.led_controller and state.led_provider == "dw_leds":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_speed'):
+                        controller.set_speed(speed)
+                        self.client.publish(f"{self.device_id}/led/speed/state", speed, retain=True)
+            elif msg.topic == self.led_intensity_topic:
+                # Handle LED intensity command (DW LEDs only)
+                intensity = int(msg.payload.decode())
+                if 0 <= intensity <= 255 and state.led_controller and state.led_provider == "dw_leds":
+                    controller = state.led_controller.get_controller()
+                    if controller and hasattr(controller, 'set_intensity'):
+                        controller.set_intensity(intensity)
+                        self.client.publish(f"{self.device_id}/led/intensity/state", intensity, retain=True)
+            elif msg.topic == self.led_color_topic:
+                # Handle LED color command (RGB) (DW LEDs only)
+                try:
+                    color_data = json.loads(msg.payload.decode())
+                    if state.led_controller and state.led_provider == "dw_leds" and 'r' in color_data and 'g' in color_data and 'b' in color_data:
+                        controller = state.led_controller.get_controller()
+                        if controller and hasattr(controller, 'set_color'):
+                            r, g, b = color_data['r'], color_data['g'], color_data['b']
+                            controller.set_color(r, g, b)
+                            self.client.publish(f"{self.device_id}/led/color/state",
+                                              json.dumps({"r": r, "g": g, "b": b}), retain=True)
+                except json.JSONDecodeError:
+                    logger.error(f"Invalid JSON for color command: {msg.payload}")
             else:
                 # Handle other commands
                 payload = json.loads(msg.payload.decode())
@@ -498,7 +735,10 @@ class MQTTHandler(BaseMQTTHandler):
                 
                 # Update speed state
                 self.client.publish(f"{self.speed_topic}/state", self.state.speed, retain=True)
-                
+
+                # Update LED state
+                self._publish_led_state()
+
                 # Publish keepalive status
                 status = {
                     "timestamp": time.time(),
@@ -539,7 +779,8 @@ class MQTTHandler(BaseMQTTHandler):
             self._publish_playlist_state()
             self._publish_serial_state()
             self._publish_progress_state()
-            
+            self._publish_led_state()
+
             # Setup Home Assistant discovery
             self.setup_ha_discovery()
             

+ 24 - 0
requirements-nonrpi.txt

@@ -0,0 +1,24 @@
+# Development dependencies for non-Raspberry Pi platforms (Windows, Linux, macOS)
+# Use this for development, testing, and running the web interface without hardware
+#
+# Install with: pip install -r requirements-nonrpi.txt
+#
+# NOTE: This excludes RPi-specific GPIO/LED libraries. The application will run
+# but DW LED features will be disabled (graceful degradation).
+# For full hardware support on Raspberry Pi, use: pip install -r requirements.txt
+
+pyserial>=3.5
+tqdm>=4.65.0
+paho-mqtt>=1.6.1
+python-dotenv>=1.0.0
+websocket-client>=1.6.1
+fastapi>=0.100.0
+uvicorn>=0.23.0
+pydantic>=2.0.0
+jinja2>=3.1.2
+aiofiles>=23.1.0
+python-multipart>=0.0.6
+websockets>=11.0.3  # Required for FastAPI WebSocket support
+requests>=2.31.0
+Pillow
+aiohttp

+ 5 - 1
requirements.txt

@@ -13,4 +13,8 @@ websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 Pillow
 aiohttp
-RPi.GPIO>=0.7.1; platform_machine == "aarch64" or platform_machine == "armv7l"  # Raspberry Pi GPIO support
+# GPIO/NeoPixel support for DW LEDs and Desert Compass
+RPi.GPIO>=0.7.1  # Required by Adafruit Blinka on Raspberry Pi and for reed switch
+rpi-ws281x>=5.0.0  # Low-level NeoPixel/WS281x driver
+adafruit-circuitpython-neopixel>=6.3.0
+Adafruit-Blinka>=8.0.0

+ 5 - 0
setup_hyperion.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+curl -sSL https://releases.hyperion-project.org/install | bash
+sudo updateHyperionUser -u root
+sudo raspi-config nonint do_spi 0
+sudo reboot

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/css/coloris.min.css


+ 2 - 2
static/css/material-icons.css

@@ -6,7 +6,7 @@
   font-weight: 400;
   src: url('/static/fonts/material-icons/MaterialIcons-Regular.woff2') format('woff2'),
        url('/static/fonts/material-icons/MaterialIcons-Regular.woff') format('woff');
-  font-display: swap;
+  font-display: block;
 }
 
 @font-face {
@@ -15,7 +15,7 @@
   font-weight: 400;
   src: url('/static/fonts/material-icons/MaterialIconsOutlined-Regular.woff2') format('woff2'),
        url('/static/fonts/material-icons/MaterialIconsOutlined-Regular.woff') format('woff');
-  font-display: swap;
+  font-display: block;
 }
 
 .material-icons {

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/css/tailwind.css


+ 46 - 3
static/js/base.js

@@ -1,5 +1,30 @@
 // Player status bar functionality - Updated to fix logMessage errors
 
+// Update LED nav label based on provider
+async function updateLedNavLabel() {
+    try {
+        const response = await fetch('/get_led_config');
+        if (response.ok) {
+            const data = await response.json();
+            const navLabel = document.getElementById('led-nav-label');
+            if (navLabel) {
+                if (data.provider === 'wled') {
+                    navLabel.textContent = 'WLED';
+                } else if (data.provider === 'dw_leds') {
+                    navLabel.textContent = 'DW LEDs';
+                } else {
+                    navLabel.textContent = 'LED';
+                }
+            }
+        }
+    } catch (error) {
+        console.error('Error updating LED nav label:', error);
+    }
+}
+
+// Call on page load
+document.addEventListener('DOMContentLoaded', updateLedNavLabel);
+
 // Pattern files cache for improved performance with localStorage persistence
 const PATTERN_CACHE_KEY = 'dune_weaver_pattern_files_cache';
 const PATTERN_CACHE_EXPIRY = 30 * 60 * 1000; // 30 minutes cache (longer since it persists)
@@ -603,15 +628,21 @@ function setupPlayerPreviewModalEvents() {
 async function togglePauseResume() {
     const pauseButton = document.getElementById('modal-pause-button');
     if (!pauseButton) return;
-    
+
     try {
         const pauseIcon = pauseButton.querySelector('.material-icons');
         const isCurrentlyPaused = pauseIcon.textContent === 'play_arrow';
-        
+
+        // Show immediate feedback
+        showStatusMessage(isCurrentlyPaused ? 'Resuming...' : 'Pausing...', 'info');
+
         const endpoint = isCurrentlyPaused ? '/resume_execution' : '/pause_execution';
         const response = await fetch(endpoint, { method: 'POST' });
-        
+
         if (!response.ok) throw new Error(`Failed to ${isCurrentlyPaused ? 'resume' : 'pause'}`);
+
+        // Show success message
+        showStatusMessage(isCurrentlyPaused ? 'Pattern resumed' : 'Pattern paused', 'success');
     } catch (error) {
         console.error('Error toggling pause:', error);
         showStatusMessage('Failed to pause/resume pattern', 'error');
@@ -634,8 +665,14 @@ function setupModalControls() {
     // Skip button click handler
     skipButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Skipping to next pattern...', 'info');
+
             const response = await fetch('/skip_pattern', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to skip pattern');
+
+            // Show success message
+            showStatusMessage('Skipped to next pattern', 'success');
         } catch (error) {
             console.error('Error skipping pattern:', error);
             showStatusMessage('Failed to skip pattern', 'error');
@@ -645,9 +682,15 @@ function setupModalControls() {
     // Stop button click handler
     stopButton.addEventListener('click', async () => {
         try {
+            // Show immediate feedback
+            showStatusMessage('Stopping...', 'info');
+
             const response = await fetch('/stop_execution', { method: 'POST' });
             if (!response.ok) throw new Error('Failed to stop pattern');
             else {
+                // Show success message
+                showStatusMessage('Pattern stopped', 'success');
+
                 // Hide modal when stopping
                 const modal = document.getElementById('playerPreviewModal');
                 if (modal) modal.classList.add('hidden');

Разница между файлами не показана из-за своего большого размера
+ 5 - 0
static/js/coloris.min.js


+ 820 - 0
static/js/led-control.js

@@ -0,0 +1,820 @@
+// LED Control Page - Unified interface for WLED and DW LEDs
+
+let ledConfig = null;
+
+// Utility function to show status messages
+function showStatus(message, type = 'info') {
+    const statusDiv = document.getElementById('dw-leds-status');
+    if (!statusDiv) return;
+
+    const iconMap = {
+        'success': 'check_circle',
+        'error': 'error',
+        'warning': 'warning',
+        'info': 'info'
+    };
+
+    const colorMap = {
+        'success': 'text-green-700 bg-green-50 border-green-200',
+        'error': 'text-red-700 bg-red-50 border-red-200',
+        'warning': 'text-amber-700 bg-amber-50 border-amber-200',
+        'info': 'text-gray-700 bg-gray-100 border-slate-200'
+    };
+
+    const icon = iconMap[type] || 'info';
+    const colorClass = colorMap[type] || colorMap.info;
+
+    statusDiv.className = `p-4 rounded-lg border ${colorClass}`;
+    statusDiv.innerHTML = `
+        <div class="flex items-center gap-2">
+            <span class="material-icons">${icon}</span>
+            <span class="text-sm">${message}</span>
+        </div>
+    `;
+}
+
+// Initialize the page based on LED configuration
+async function initializeLedPage() {
+    try {
+        const response = await fetch('/get_led_config');
+        if (!response.ok) throw new Error('Failed to fetch LED config');
+
+        ledConfig = await response.json();
+
+        const notConfigured = document.getElementById('led-not-configured');
+        const wledContainer = document.getElementById('wled-container');
+        const dwLedsContainer = document.getElementById('dw-leds-container');
+
+        // Hide all containers first
+        notConfigured.classList.add('hidden');
+        wledContainer.classList.add('hidden');
+        dwLedsContainer.classList.add('hidden');
+
+        if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
+            // Show WLED iframe
+            wledContainer.classList.remove('hidden');
+            const wledFrame = document.getElementById('wled-frame');
+            if (wledFrame) {
+                wledFrame.src = `http://${ledConfig.wled_ip}`;
+            }
+        } else if (ledConfig.provider === 'dw_leds') {
+            // Show DW LEDs controls
+            dwLedsContainer.classList.remove('hidden');
+            await initializeDWLedsControls();
+        } else {
+            // Show not configured message
+            notConfigured.classList.remove('hidden');
+        }
+    } catch (error) {
+        console.error('Error initializing LED page:', error);
+        document.getElementById('led-not-configured').classList.remove('hidden');
+    }
+}
+
+// Initialize DW LEDs controls
+async function initializeDWLedsControls() {
+    // Check status and load available effects/palettes
+    await checkDWLedsStatus();
+    await loadEffectsAndPalettes();
+
+    // Power toggle button
+    document.getElementById('dw-leds-power-toggle')?.addEventListener('click', async () => {
+        try {
+            const response = await fetch('/api/dw_leds/power', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ state: 2 })  // Toggle
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Power ${data.power_on ? 'ON' : 'OFF'}`, 'success');
+                await checkDWLedsStatus();
+            } else {
+                showStatus(data.error || 'Failed to toggle power', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to toggle power: ${error.message}`, 'error');
+        }
+    });
+
+    // Brightness slider
+    const brightnessSlider = document.getElementById('dw-leds-brightness');
+    const brightnessValue = document.getElementById('dw-leds-brightness-value');
+
+    brightnessSlider?.addEventListener('input', (e) => {
+        brightnessValue.textContent = `${e.target.value}%`;
+    });
+
+    brightnessSlider?.addEventListener('change', async (e) => {
+        try {
+            const response = await fetch('/api/dw_leds/brightness', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ value: parseInt(e.target.value) })
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Brightness set to ${e.target.value}%`, 'success');
+            } else {
+                showStatus(data.error || 'Failed to set brightness', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to set brightness: ${error.message}`, 'error');
+        }
+    });
+
+    // Effect color pickers - apply immediately on change
+    document.querySelectorAll('.effect-color-picker').forEach(picker => {
+        picker.addEventListener('change', async () => {
+            const color1 = document.getElementById('dw-leds-color1')?.value;
+            const color2 = document.getElementById('dw-leds-color2')?.value;
+            const color3 = document.getElementById('dw-leds-color3')?.value;
+
+            if (color1 && color2 && color3) {
+                await applyAllColors(color1, color2, color3);
+            }
+        });
+    });
+
+    // Effect selector
+    document.getElementById('dw-leds-effect-select')?.addEventListener('change', async (e) => {
+        const effectId = parseInt(e.target.value);
+        if (isNaN(effectId)) return;
+
+        try {
+            const response = await fetch('/api/dw_leds/effect', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ effect_id: effectId })
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Effect changed`, 'success');
+                // Update power button state if backend auto-powered on
+                if (data.power_on !== undefined) {
+                    updatePowerButtonUI(data.power_on);
+                }
+            } else {
+                showStatus(data.error || 'Failed to set effect', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to set effect: ${error.message}`, 'error');
+        }
+    });
+
+    // Palette selector
+    document.getElementById('dw-leds-palette-select')?.addEventListener('change', async (e) => {
+        const paletteId = parseInt(e.target.value);
+        if (isNaN(paletteId)) return;
+
+        try {
+            const response = await fetch('/api/dw_leds/palette', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ palette_id: paletteId })
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Palette changed`, 'success');
+                // Update power button state if backend auto-powered on
+                if (data.power_on !== undefined) {
+                    updatePowerButtonUI(data.power_on);
+                }
+            } else {
+                showStatus(data.error || 'Failed to set palette', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to set palette: ${error.message}`, 'error');
+        }
+    });
+
+    // Speed slider
+    const speedSlider = document.getElementById('dw-leds-speed');
+    const speedValue = document.getElementById('dw-leds-speed-value');
+
+    speedSlider?.addEventListener('input', (e) => {
+        speedValue.textContent = e.target.value;
+    });
+
+    speedSlider?.addEventListener('change', async (e) => {
+        try {
+            const response = await fetch('/api/dw_leds/speed', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ speed: parseInt(e.target.value) })
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Speed updated`, 'success');
+            } else {
+                showStatus(data.error || 'Failed to set speed', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to set speed: ${error.message}`, 'error');
+        }
+    });
+
+    // Intensity slider
+    const intensitySlider = document.getElementById('dw-leds-intensity');
+    const intensityValue = document.getElementById('dw-leds-intensity-value');
+
+    intensitySlider?.addEventListener('input', (e) => {
+        intensityValue.textContent = e.target.value;
+    });
+
+    intensitySlider?.addEventListener('change', async (e) => {
+        try {
+            const response = await fetch('/api/dw_leds/intensity', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ intensity: parseInt(e.target.value) })
+            });
+
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Intensity updated`, 'success');
+            } else {
+                showStatus(data.error || 'Failed to set intensity', 'error');
+            }
+        } catch (error) {
+            showStatus(`Failed to set intensity: ${error.message}`, 'error');
+        }
+    });
+
+    // Save Current Idle Effect
+    document.getElementById('dw-leds-save-current-idle')?.addEventListener('click', async () => {
+        await saveCurrentEffectSettings('idle');
+    });
+
+    // Clear Idle Effect
+    document.getElementById('dw-leds-clear-idle')?.addEventListener('click', async () => {
+        await clearEffectSettings('idle');
+    });
+
+    // Save Current Playing Effect
+    document.getElementById('dw-leds-save-current-playing')?.addEventListener('click', async () => {
+        await saveCurrentEffectSettings('playing');
+    });
+
+    // Clear Playing Effect
+    document.getElementById('dw-leds-clear-playing')?.addEventListener('click', async () => {
+        await clearEffectSettings('playing');
+    });
+
+    // Load and display saved effect settings
+    await loadEffectSettings();
+
+    // Idle timeout controls
+    await loadIdleTimeout();
+
+    const idleTimeoutEnabled = document.getElementById('dw-leds-idle-timeout-enabled');
+    const idleTimeoutSettings = document.getElementById('idle-timeout-settings');
+    const idleTimeoutDisabledHelp = document.getElementById('idle-timeout-disabled-help');
+
+    // Toggle idle timeout settings visibility and help text
+    idleTimeoutEnabled?.addEventListener('change', (e) => {
+        const isEnabled = e.target.checked;
+
+        if (isEnabled) {
+            idleTimeoutSettings?.classList.remove('opacity-50', 'pointer-events-none');
+            idleTimeoutDisabledHelp?.classList.add('hidden');
+        } else {
+            idleTimeoutSettings?.classList.add('opacity-50', 'pointer-events-none');
+            idleTimeoutDisabledHelp?.classList.remove('hidden');
+        }
+
+        // Auto-save when toggle changes for better UX
+        saveIdleTimeout();
+    });
+
+    // Save idle timeout settings
+    document.getElementById('dw-leds-save-idle-timeout')?.addEventListener('click', async () => {
+        await saveIdleTimeout();
+    });
+
+    // Update remaining time periodically
+    setInterval(updateIdleTimeoutRemaining, 60000); // Update every minute
+
+    // Initialize Coloris color picker for effect colors
+    initializeColoris();
+}
+
+// Save current LED settings as idle or playing effect
+async function saveCurrentEffectSettings(type) {
+    try {
+        const effectId = parseInt(document.getElementById('dw-leds-effect-select')?.value) || 0;
+        const paletteId = parseInt(document.getElementById('dw-leds-palette-select')?.value) || 0;
+        const speed = parseInt(document.getElementById('dw-leds-speed')?.value) || 128;
+        const intensity = parseInt(document.getElementById('dw-leds-intensity')?.value) || 128;
+
+        // Get effect colors
+        const color1 = document.getElementById('dw-leds-color1')?.value || '#ff0000';
+        const color2 = document.getElementById('dw-leds-color2')?.value || '#000000';
+        const color3 = document.getElementById('dw-leds-color3')?.value || '#0000ff';
+
+        const settings = {
+            type: type,  // 'idle' or 'playing'
+            effect_id: effectId,
+            palette_id: paletteId,
+            speed: speed,
+            intensity: intensity,
+            color1: color1,
+            color2: color2,
+            color3: color3
+        };
+
+        const response = await fetch('/api/dw_leds/save_effect_settings', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(settings)
+        });
+
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+        await response.json();
+        showStatus(`${type.charAt(0).toUpperCase() + type.slice(1)} effect settings saved successfully`, 'success');
+
+        // Refresh display
+        await loadEffectSettings();
+    } catch (error) {
+        showStatus(`Failed to save ${type} effect settings: ${error.message}`, 'error');
+    }
+}
+
+// Clear effect settings
+async function clearEffectSettings(type) {
+    try {
+        const response = await fetch('/api/dw_leds/clear_effect_settings', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ type: type })
+        });
+
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+        showStatus(`${type.charAt(0).toUpperCase() + type.slice(1)} effect cleared`, 'success');
+
+        // Refresh display
+        await loadEffectSettings();
+    } catch (error) {
+        showStatus(`Failed to clear ${type} effect: ${error.message}`, 'error');
+    }
+}
+
+// Load and display saved effect settings
+async function loadEffectSettings() {
+    try {
+        const response = await fetch('/api/dw_leds/get_effect_settings');
+        if (!response.ok) return;
+
+        const data = await response.json();
+
+        // Display idle settings
+        const idleDisplay = document.getElementById('idle-settings-display');
+        if (idleDisplay) {
+            idleDisplay.textContent = formatEffectSettings(data.idle_effect);
+        }
+
+        // Display playing settings
+        const playingDisplay = document.getElementById('playing-settings-display');
+        if (playingDisplay) {
+            playingDisplay.textContent = formatEffectSettings(data.playing_effect);
+        }
+    } catch (error) {
+        console.error('Failed to load effect settings:', error);
+    }
+}
+
+// Format effect settings for display
+function formatEffectSettings(settings) {
+    if (!settings) {
+        return 'Not configured (LEDs will turn off)';
+    }
+
+    const parts = [];
+
+    // Get effect name from select (if available)
+    const effectSelect = document.getElementById('dw-leds-effect-select');
+    if (effectSelect && settings.effect_id !== undefined) {
+        const effectOption = effectSelect.querySelector(`option[value="${settings.effect_id}"]`);
+        parts.push(`Effect: ${effectOption ? effectOption.textContent : settings.effect_id}`);
+    }
+
+    // Get palette name from select (if available)
+    const paletteSelect = document.getElementById('dw-leds-palette-select');
+    if (paletteSelect && settings.palette_id !== undefined) {
+        const paletteOption = paletteSelect.querySelector(`option[value="${settings.palette_id}"]`);
+        parts.push(`Palette: ${paletteOption ? paletteOption.textContent : settings.palette_id}`);
+    }
+
+    if (settings.speed !== undefined) {
+        parts.push(`Speed: ${settings.speed}`);
+    }
+
+    if (settings.intensity !== undefined) {
+        parts.push(`Intensity: ${settings.intensity}`);
+    }
+
+    if (settings.color1) {
+        parts.push(`Colors: ${settings.color1}, ${settings.color2 || '#000000'}, ${settings.color3 || '#0000ff'}`);
+    }
+
+    return parts.join(' | ');
+}
+
+// Load idle timeout settings
+async function loadIdleTimeout() {
+    try {
+        const response = await fetch('/api/dw_leds/idle_timeout');
+        if (!response.ok) return;
+
+        const data = await response.json();
+
+        const enabledCheckbox = document.getElementById('dw-leds-idle-timeout-enabled');
+        const minutesInput = document.getElementById('dw-leds-idle-timeout-minutes');
+        const idleTimeoutSettings = document.getElementById('idle-timeout-settings');
+        const idleTimeoutDisabledHelp = document.getElementById('idle-timeout-disabled-help');
+
+        if (enabledCheckbox) {
+            enabledCheckbox.checked = data.enabled;
+        }
+
+        if (minutesInput) {
+            minutesInput.value = data.minutes;
+        }
+
+        // Set initial state of settings panel and help text
+        if (data.enabled) {
+            idleTimeoutSettings?.classList.remove('opacity-50', 'pointer-events-none');
+            idleTimeoutDisabledHelp?.classList.add('hidden');
+        } else {
+            idleTimeoutSettings?.classList.add('opacity-50', 'pointer-events-none');
+            idleTimeoutDisabledHelp?.classList.remove('hidden');
+        }
+
+        // Update remaining time display
+        updateIdleTimeoutRemainingDisplay(data.remaining_minutes);
+    } catch (error) {
+        console.error('Failed to load idle timeout settings:', error);
+    }
+}
+
+// Save idle timeout settings
+async function saveIdleTimeout() {
+    try {
+        const enabled = document.getElementById('dw-leds-idle-timeout-enabled')?.checked || false;
+        const minutes = parseInt(document.getElementById('dw-leds-idle-timeout-minutes')?.value) || 30;
+
+        const response = await fetch('/api/dw_leds/idle_timeout', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ enabled, minutes })
+        });
+
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const data = await response.json();
+
+        if (data.success) {
+            showStatus(`Idle timeout ${enabled ? 'enabled' : 'disabled'} (${minutes} minutes)`, 'success');
+            await loadIdleTimeout(); // Reload to get updated remaining time
+        } else {
+            showStatus('Failed to save idle timeout settings', 'error');
+        }
+    } catch (error) {
+        showStatus(`Failed to save idle timeout: ${error.message}`, 'error');
+    }
+}
+
+// Update idle timeout remaining time
+async function updateIdleTimeoutRemaining() {
+    try {
+        const response = await fetch('/api/dw_leds/idle_timeout');
+        if (!response.ok) return;
+
+        const data = await response.json();
+        updateIdleTimeoutRemainingDisplay(data.remaining_minutes);
+    } catch (error) {
+        console.error('Failed to update idle timeout remaining:', error);
+    }
+}
+
+// Update idle timeout remaining time display
+function updateIdleTimeoutRemainingDisplay(remainingMinutes) {
+    const remainingDiv = document.getElementById('idle-timeout-remaining');
+    const remainingDisplay = document.getElementById('idle-timeout-remaining-display');
+
+    if (!remainingDiv || !remainingDisplay) return;
+
+    if (remainingMinutes !== null && remainingMinutes !== undefined) {
+        remainingDiv.classList.remove('hidden');
+        if (remainingMinutes <= 0) {
+            remainingDisplay.textContent = 'Timeout expired - LEDs will turn off';
+        } else if (remainingMinutes < 1) {
+            remainingDisplay.textContent = 'Less than 1 minute';
+        } else {
+            const hours = Math.floor(remainingMinutes / 60);
+            const mins = Math.round(remainingMinutes % 60);
+            if (hours > 0) {
+                remainingDisplay.textContent = `${hours}h ${mins}m`;
+            } else {
+                remainingDisplay.textContent = `${mins} minutes`;
+            }
+        }
+    } else {
+        remainingDiv.classList.add('hidden');
+    }
+}
+
+// Helper function to apply all effect colors
+async function applyAllColors(hexColor1, hexColor2, hexColor3) {
+    try {
+        const payload = {};
+
+        if (hexColor1) {
+            const r = parseInt(hexColor1.slice(1, 3), 16);
+            const g = parseInt(hexColor1.slice(3, 5), 16);
+            const b = parseInt(hexColor1.slice(5, 7), 16);
+            payload.color1 = [r, g, b];
+        }
+
+        if (hexColor2) {
+            const r = parseInt(hexColor2.slice(1, 3), 16);
+            const g = parseInt(hexColor2.slice(3, 5), 16);
+            const b = parseInt(hexColor2.slice(5, 7), 16);
+            payload.color2 = [r, g, b];
+        }
+
+        if (hexColor3) {
+            const r = parseInt(hexColor3.slice(1, 3), 16);
+            const g = parseInt(hexColor3.slice(3, 5), 16);
+            const b = parseInt(hexColor3.slice(5, 7), 16);
+            payload.color3 = [r, g, b];
+        }
+
+        const response = await fetch('/api/dw_leds/colors', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(payload)
+        });
+
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        const data = await response.json();
+
+        if (data.connected) {
+            showStatus(`Effect colors updated`, 'success');
+        } else {
+            showStatus(data.error || 'Failed to set colors', 'error');
+        }
+    } catch (error) {
+        showStatus(`Failed to set colors: ${error.message}`, 'error');
+    }
+}
+
+// Load available effects and palettes
+async function loadEffectsAndPalettes() {
+    try {
+        // Load effects
+        const effectsResponse = await fetch('/api/dw_leds/effects');
+        if (effectsResponse.ok) {
+            const effectsData = await effectsResponse.json();
+            const effectSelect = document.getElementById('dw-leds-effect-select');
+            const idleEffectSelect = document.getElementById('dw-leds-idle-effect');
+            const playingEffectSelect = document.getElementById('dw-leds-playing-effect');
+
+            if (effectSelect && effectsData.effects) {
+                effectSelect.innerHTML = '';
+                // Sort effects alphabetically by name
+                const sortedEffects = [...effectsData.effects].sort((a, b) =>
+                    a[1].localeCompare(b[1])
+                );
+                sortedEffects.forEach(([id, name]) => {
+                    const option = document.createElement('option');
+                    option.value = id;
+                    option.textContent = name;
+                    effectSelect.appendChild(option);
+                });
+            }
+
+            // Add effects to automation selectors
+            if (idleEffectSelect && effectsData.effects) {
+                idleEffectSelect.innerHTML = '<option value="off">Off</option>';
+                // Sort effects alphabetically by name
+                const sortedEffects = [...effectsData.effects].sort((a, b) =>
+                    a[1].localeCompare(b[1])
+                );
+                sortedEffects.forEach(([, name]) => {
+                    const option = document.createElement('option');
+                    option.value = name.toLowerCase();
+                    option.textContent = name;
+                    idleEffectSelect.appendChild(option);
+                });
+            }
+
+            if (playingEffectSelect && effectsData.effects) {
+                playingEffectSelect.innerHTML = '<option value="off">Off</option>';
+                // Sort effects alphabetically by name
+                const sortedEffects = [...effectsData.effects].sort((a, b) =>
+                    a[1].localeCompare(b[1])
+                );
+                sortedEffects.forEach(([, name]) => {
+                    const option = document.createElement('option');
+                    option.value = name.toLowerCase();
+                    option.textContent = name;
+                    playingEffectSelect.appendChild(option);
+                });
+            }
+
+            // Load saved automation settings
+            const configResponse = await fetch('/get_led_config');
+            if (configResponse.ok) {
+                const config = await configResponse.json();
+                if (idleEffectSelect && config.dw_led_idle_effect) {
+                    idleEffectSelect.value = config.dw_led_idle_effect;
+                }
+                if (playingEffectSelect && config.dw_led_playing_effect) {
+                    playingEffectSelect.value = config.dw_led_playing_effect;
+                }
+            }
+        }
+
+        // Load palettes
+        const palettesResponse = await fetch('/api/dw_leds/palettes');
+        if (palettesResponse.ok) {
+            const palettesData = await palettesResponse.json();
+            const paletteSelect = document.getElementById('dw-leds-palette-select');
+
+            if (paletteSelect && palettesData.palettes) {
+                paletteSelect.innerHTML = '';
+                // Sort palettes alphabetically by name
+                const sortedPalettes = [...palettesData.palettes].sort((a, b) =>
+                    a[1].localeCompare(b[1])
+                );
+                sortedPalettes.forEach(([id, name]) => {
+                    const option = document.createElement('option');
+                    option.value = id;
+                    option.textContent = name;
+                    paletteSelect.appendChild(option);
+                });
+            }
+        }
+    } catch (error) {
+        console.error('Failed to load effects and palettes:', error);
+        showStatus('Failed to load effects and palettes', 'error');
+    }
+}
+
+// Helper function to update power button UI based on power state
+function updatePowerButtonUI(powerOn) {
+    const powerButton = document.getElementById('dw-leds-power-toggle');
+    const powerButtonText = document.getElementById('dw-leds-power-text');
+
+    if (powerButton && powerButtonText) {
+        if (powerOn) {
+            powerButton.className = 'flex items-center justify-center gap-2 rounded-lg bg-red-600 px-4 py-3 text-sm font-semibold text-white shadow-md hover:bg-red-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-offset-2';
+            powerButtonText.textContent = 'Turn OFF';
+        } else {
+            powerButton.className = 'flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-3 text-sm font-semibold text-white shadow-md hover:bg-green-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2';
+            powerButtonText.textContent = 'Turn ON';
+        }
+    }
+}
+
+// Check DW LEDs connection status
+async function checkDWLedsStatus() {
+    try {
+        const response = await fetch('/api/dw_leds/status');
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+
+        const data = await response.json();
+
+        if (data.connected) {
+            const powerState = data.power_on ? 'ON' : 'OFF';
+            showStatus(`Connected: ${data.num_leds} LEDs on GPIO ${data.gpio_pin} - Power: ${powerState}`, 'success');
+
+            // Update power button appearance
+            updatePowerButtonUI(data.power_on);
+
+            // Update slider values
+            const brightnessSlider = document.getElementById('dw-leds-brightness');
+            const brightnessValue = document.getElementById('dw-leds-brightness-value');
+            if (brightnessSlider && data.brightness !== undefined) {
+                brightnessSlider.value = data.brightness;
+                if (brightnessValue) brightnessValue.textContent = `${data.brightness}%`;
+            }
+
+            const speedSlider = document.getElementById('dw-leds-speed');
+            const speedValue = document.getElementById('dw-leds-speed-value');
+            if (speedSlider && data.speed !== undefined) {
+                speedSlider.value = data.speed;
+                if (speedValue) speedValue.textContent = data.speed;
+            }
+
+            const intensitySlider = document.getElementById('dw-leds-intensity');
+            const intensityValue = document.getElementById('dw-leds-intensity-value');
+            if (intensitySlider && data.intensity !== undefined) {
+                intensitySlider.value = data.intensity;
+                if (intensityValue) intensityValue.textContent = data.intensity;
+            }
+
+            // Update effect and palette selectors
+            const effectSelect = document.getElementById('dw-leds-effect-select');
+            if (effectSelect && data.current_effect !== undefined) {
+                effectSelect.value = data.current_effect;
+            }
+
+            const paletteSelect = document.getElementById('dw-leds-palette-select');
+            if (paletteSelect && data.current_palette !== undefined) {
+                paletteSelect.value = data.current_palette;
+            }
+
+            // Update color pickers if colors are provided
+            if (data.colors && Array.isArray(data.colors)) {
+                const color1 = document.getElementById('dw-leds-color1');
+                const color2 = document.getElementById('dw-leds-color2');
+                const color3 = document.getElementById('dw-leds-color3');
+
+                if (color1 && data.colors[0]) {
+                    color1.value = data.colors[0];
+                    updateColorPickerStyle(color1, data.colors[0]);
+                }
+                if (color2 && data.colors[1]) {
+                    color2.value = data.colors[1];
+                    updateColorPickerStyle(color2, data.colors[1]);
+                }
+                if (color3 && data.colors[2]) {
+                    color3.value = data.colors[2];
+                    updateColorPickerStyle(color3, data.colors[2]);
+                }
+            }
+        } else {
+            // Show error message from controller
+            const errorMsg = data.error || 'Connection failed';
+            showStatus(errorMsg, 'error');
+        }
+    } catch (error) {
+        showStatus(`Cannot connect to DW LEDs: ${error.message}`, 'error');
+    }
+}
+
+// Helper function to update color picker background
+function updateColorPickerStyle(input, color) {
+    if (!input || !color) return;
+    input.style.backgroundColor = color;
+}
+
+// Initialize Coloris color picker
+function initializeColoris() {
+    // Initialize Coloris with custom configuration
+    Coloris({
+        theme: 'polaroid',
+        themeMode: 'auto',
+        formatToggle: true,
+        alpha: false,  // No transparency for LED colors
+        swatches: [
+            '#ff0000',  // Red
+            '#00ff00',  // Green
+            '#0000ff',  // Blue
+            '#ffff00',  // Yellow
+            '#ff00ff',  // Magenta
+            '#00ffff',  // Cyan
+            '#ff8000',  // Orange
+            '#ffffff',  // White
+            '#2a9d8f',  // Teal
+            '#e9c46a',  // Sand
+            'coral',    // Coral
+            'Crimson'   // Crimson
+        ],
+        onChange: (color, input) => {
+            // Update the input background to show the selected color
+            updateColorPickerStyle(input, color);
+        }
+    });
+
+    // Apply Coloris to all effect color pickers and set initial background colors
+    const colorPickers = document.querySelectorAll('.effect-color-picker');
+    colorPickers.forEach(picker => {
+        picker.setAttribute('data-coloris', '');
+        // Set initial background color and text color
+        updateColorPickerStyle(picker, picker.value);
+    });
+}
+
+// Initialize on page load
+document.addEventListener('DOMContentLoaded', initializeLedPage);

+ 176 - 51
static/js/settings.js

@@ -7,6 +7,14 @@ const LOG_TYPE = {
     DEBUG: 'debug'
 };
 
+// Helper function to convert provider name to camelCase for ID lookup
+// e.g., "dw_leds" -> "DwLeds", "wled" -> "Wled", "none" -> "None"
+function providerToCamelCase(provider) {
+    return provider.split('_').map(word =>
+        word.charAt(0).toUpperCase() + word.slice(1)
+    ).join('');
+}
+
 // Constants for cache
 const CACHE_KEYS = {
     CONNECTION_STATUS: 'connection_status',
@@ -151,6 +159,77 @@ function setWledButtonState(isSet) {
     }
 }
 
+// Handle LED provider selection and show/hide appropriate config sections
+function updateLedProviderUI() {
+    const provider = document.querySelector('input[name="ledProvider"]:checked')?.value || 'none';
+    const wledConfig = document.getElementById('wledConfig');
+    const dwLedsConfig = document.getElementById('dwLedsConfig');
+
+    if (wledConfig && dwLedsConfig) {
+        if (provider === 'wled') {
+            wledConfig.classList.remove('hidden');
+            dwLedsConfig.classList.add('hidden');
+        } else if (provider === 'dw_leds') {
+            wledConfig.classList.add('hidden');
+            dwLedsConfig.classList.remove('hidden');
+        } else {
+            wledConfig.classList.add('hidden');
+            dwLedsConfig.classList.add('hidden');
+        }
+    }
+}
+
+// Load LED configuration from server
+async function loadLedConfig() {
+    try {
+        const response = await fetch('/get_led_config');
+        if (response.ok) {
+            const data = await response.json();
+
+            // Set provider radio button
+            const providerRadio = document.getElementById(`ledProvider${providerToCamelCase(data.provider)}`);
+            if (providerRadio) {
+                providerRadio.checked = true;
+            } else {
+                document.getElementById('ledProviderNone').checked = true;
+            }
+
+            // Set WLED IP if configured
+            if (data.wled_ip) {
+                const wledIpInput = document.getElementById('wledIpInput');
+                if (wledIpInput) {
+                    wledIpInput.value = data.wled_ip;
+                }
+            }
+
+            // Set DW LED configuration if configured
+            if (data.dw_led_num_leds) {
+                const numLedsInput = document.getElementById('dwLedNumLeds');
+                if (numLedsInput) {
+                    numLedsInput.value = data.dw_led_num_leds;
+                }
+            }
+            if (data.dw_led_gpio_pin) {
+                const gpioPinInput = document.getElementById('dwLedGpioPin');
+                if (gpioPinInput) {
+                    gpioPinInput.value = data.dw_led_gpio_pin;
+                }
+            }
+            if (data.dw_led_pixel_order) {
+                const pixelOrderInput = document.getElementById('dwLedPixelOrder');
+                if (pixelOrderInput) {
+                    pixelOrderInput.value = data.dw_led_pixel_order;
+                }
+            }
+
+            // Update UI to show correct config section
+            updateLedProviderUI();
+        }
+    } catch (error) {
+        logMessage(`Error loading LED config: ${error.message}`, LOG_TYPE.ERROR);
+    }
+}
+
 // Initialize settings page
 document.addEventListener('DOMContentLoaded', async () => {
     // Initialize UI with default disconnected state
@@ -160,9 +239,9 @@ document.addEventListener('DOMContentLoaded', async () => {
     Promise.all([
         // Check connection status
         fetch('/serial_status').then(response => response.json()).catch(() => ({ connected: false })),
-        
-        // Load current WLED IP
-        fetch('/get_wled_ip').then(response => response.json()).catch(() => ({ wled_ip: null })),
+
+        // 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
         fetch('/api/version').then(response => response.json()).catch(() => ({ current: '1.0.0', latest: '1.0.0', update_available: false })),
@@ -184,18 +263,39 @@ document.addEventListener('DOMContentLoaded', async () => {
 
         // Load Still Sands settings
         fetch('/api/scheduled-pause').then(response => response.json()).catch(() => ({ enabled: false, time_slots: [] }))
-    ]).then(([statusData, wledData, updateData, ports, patterns, clearPatterns, clearSpeedData, appNameData, scheduledPauseData]) => {
+    ]).then(([statusData, ledConfigData, updateData, ports, patterns, clearPatterns, clearSpeedData, appNameData, scheduledPauseData]) => {
         // Update connection status
         setCachedConnectionStatus(statusData);
         updateConnectionUI(statusData);
-        
-        // Update WLED IP
-        if (wledData.wled_ip) {
-            document.getElementById('wledIpInput').value = wledData.wled_ip;
-            setWledButtonState(true);
+
+        // Update LED configuration
+        const providerRadio = document.getElementById(`ledProvider${providerToCamelCase(ledConfigData.provider)}`);
+        if (providerRadio) {
+            providerRadio.checked = true;
         } else {
-            setWledButtonState(false);
+            document.getElementById('ledProviderNone').checked = true;
         }
+
+        if (ledConfigData.wled_ip) {
+            const wledIpInput = document.getElementById('wledIpInput');
+            if (wledIpInput) wledIpInput.value = ledConfigData.wled_ip;
+        }
+
+        // Load DW LED settings
+        if (ledConfigData.dw_led_num_leds) {
+            const numLedsInput = document.getElementById('dwLedNumLeds');
+            if (numLedsInput) numLedsInput.value = ledConfigData.dw_led_num_leds;
+        }
+        if (ledConfigData.dw_led_gpio_pin) {
+            const gpioPinInput = document.getElementById('dwLedGpioPin');
+            if (gpioPinInput) gpioPinInput.value = ledConfigData.dw_led_gpio_pin;
+        }
+        if (ledConfigData.dw_led_pixel_order) {
+            const pixelOrderInput = document.getElementById('dwLedPixelOrder');
+            if (pixelOrderInput) pixelOrderInput.value = ledConfigData.dw_led_pixel_order;
+        }
+
+        updateLedProviderUI()
         
         // Update version display
         const currentVersionText = document.getElementById('currentVersionText');
@@ -359,50 +459,75 @@ function setupEventListeners() {
         });
     }
     
-    // Save/Clear WLED configuration
-    const saveWledConfig = document.getElementById('saveWledConfig');
-    const wledIpInput = document.getElementById('wledIpInput');
-    if (saveWledConfig && wledIpInput) {
-        saveWledConfig.addEventListener('click', async () => {
-            if (saveWledConfig.textContent.includes('Clear')) {
-                // Clear WLED IP
-                wledIpInput.value = '';
-                try {
-                    const response = await fetch('/set_wled_ip', {
-                        method: 'POST',
-                        headers: { 'Content-Type': 'application/json' },
-                        body: JSON.stringify({ wled_ip: '' })
-                    });
-                    if (response.ok) {
-                        setWledButtonState(false);
-                        localStorage.removeItem('wled_ip');
-                        showStatusMessage('WLED IP cleared successfully', 'success');
-                    } else {
-                        throw new Error('Failed to clear WLED IP');
-                    }
-                } catch (error) {
-                    showStatusMessage(`Failed to clear WLED IP: ${error.message}`, 'error');
+    // LED provider selection change handlers
+    const ledProviderRadios = document.querySelectorAll('input[name="ledProvider"]');
+    ledProviderRadios.forEach(radio => {
+        radio.addEventListener('change', updateLedProviderUI);
+    });
+
+    // Save LED configuration
+    const saveLedConfig = document.getElementById('saveLedConfig');
+    if (saveLedConfig) {
+        saveLedConfig.addEventListener('click', async () => {
+            const provider = document.querySelector('input[name="ledProvider"]:checked')?.value || 'none';
+
+            let requestBody = { provider };
+
+            if (provider === 'wled') {
+                const wledIp = document.getElementById('wledIpInput')?.value;
+                if (!wledIp) {
+                    showStatusMessage('Please enter a WLED IP address', 'error');
+                    return;
                 }
-            } else {
-                // Save WLED IP
-                const wledIp = wledIpInput.value;
-                try {
-                    const response = await fetch('/set_wled_ip', {
-                        method: 'POST',
-                        headers: { 'Content-Type': 'application/json' },
-                        body: JSON.stringify({ wled_ip: wledIp })
-                    });
-                    if (response.ok && wledIp) {
-                        setWledButtonState(true);
-                        localStorage.setItem('wled_ip', wledIp);
-                        showStatusMessage('WLED IP configured successfully', 'success');
-                    } else {
-                        setWledButtonState(false);
-                        throw new Error('Failed to save WLED configuration');
+                requestBody.ip_address = wledIp;
+            } else if (provider === 'dw_leds') {
+                const numLeds = parseInt(document.getElementById('dwLedNumLeds')?.value) || 60;
+                const gpioPin = parseInt(document.getElementById('dwLedGpioPin')?.value) || 12;
+                const pixelOrder = document.getElementById('dwLedPixelOrder')?.value || 'GRB';
+
+                requestBody.num_leds = numLeds;
+                requestBody.gpio_pin = gpioPin;
+                requestBody.pixel_order = pixelOrder;
+            }
+
+            try {
+                const response = await fetch('/set_led_config', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify(requestBody)
+                });
+
+                if (response.ok) {
+                    const data = await response.json();
+
+                    if (provider === 'wled' && data.wled_ip) {
+                        localStorage.setItem('wled_ip', data.wled_ip);
+                        showStatusMessage('WLED configured successfully', 'success');
+                    } else if (provider === 'dw_leds') {
+                        // Check if there's a warning (hardware not available but settings saved)
+                        if (data.warning) {
+                            showStatusMessage(
+                                `Settings saved for testing. Hardware issue: ${data.warning}`,
+                                'warning'
+                            );
+                        } else {
+                            showStatusMessage(
+                                `DW LEDs configured: ${data.dw_led_num_leds} LEDs on GPIO${data.dw_led_gpio_pin}`,
+                                'success'
+                            );
+                        }
+                    } else if (provider === 'none') {
+                        localStorage.removeItem('wled_ip');
+                        showStatusMessage('LED controller disabled', 'success');
                     }
-                } catch (error) {
-                    showStatusMessage(`Failed to save WLED IP: ${error.message}`, 'error');
+                } else {
+                    // Extract error detail from response
+                    const errorData = await response.json().catch(() => ({}));
+                    const errorMessage = errorData.detail || 'Failed to save LED configuration';
+                    showStatusMessage(errorMessage, 'error');
                 }
+            } catch (error) {
+                showStatusMessage(`Failed to save LED configuration: ${error.message}`, 'error');
             }
         });
     }

+ 48 - 4
templates/base.html

@@ -18,6 +18,9 @@
       onload="this.rel='stylesheet'"
       rel="stylesheet"
     />
+    <!-- Preload Material Icons fonts for faster loading -->
+    <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>
     <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">
@@ -110,6 +113,12 @@
       .dark .bg-white {
         background-color: #262626;
       }
+      .dark #shutdown-button:hover {
+        background-color: #404040;
+      }
+      .dark #theme-toggle:hover {
+        background-color: #404040;
+      }
       .dark .bg-gray-50 {
         background-color: #1a1a1a;
       }
@@ -262,7 +271,7 @@
             </h1>
             </a>
           </div>
-          <div class="flex items-center gap-4">
+          <div class="flex items-center gap-2">
             <button
               id="theme-toggle"
               class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -270,6 +279,14 @@
             >
               <span class="material-icons" id="theme-toggle-icon">dark_mode</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"
+              aria-label="Shutdown system"
+              title="Shutdown System (Raspberry Pi only)"
+            >
+              <span class="material-icons text-red-600">power_settings_new</span>
+            </button>
           </div>
         </header>
         <main class="flex flex-1 justify-center px-4 sm:px-6 lg:px-8 pt-16 sm:pt-20">
@@ -308,10 +325,10 @@
               <span class="material-icons">table_chart</span> Table Control
             </a>
             <a
-            class="{% if request.url.path == '/wled' %}active-tab{% else %}inactive-tab{% endif %} flex flex-1 flex-col items-center justify-center gap-1 border-b-[3px] py-3 text-xs font-medium sm:text-sm"
-            href="/wled"
+            class="{% if request.url.path == '/led' %}active-tab{% else %}inactive-tab{% endif %} flex flex-1 flex-col items-center justify-center gap-1 border-b-[3px] py-3 text-xs font-medium sm:text-sm"
+            href="/led"
           >
-            <span class="material-icons">lightbulb</span> WLED
+            <span class="material-icons">lightbulb</span> <span id="led-nav-label">LED</span>
           </a>
             <a
               class="{% if request.url.path == '/settings' %}active-tab{% else %}inactive-tab{% endif %} flex flex-1 flex-col items-center justify-center gap-1 border-b-[3px] py-3 text-xs font-medium sm:text-sm"
@@ -678,6 +695,33 @@
           updateThemeIcon();
         });
       });
+
+      // Shutdown button functionality
+      document.addEventListener('DOMContentLoaded', function() {
+        const shutdownButton = document.getElementById('shutdown-button');
+
+        // Shutdown button click handler
+        shutdownButton.addEventListener('click', async () => {
+          const confirmed = confirm('Are you sure you want to shutdown the system?\n\nNote: This only works on Raspberry Pi.\n\nThis will:\n1. Stop Docker containers\n2. Shut down the system\n\nYou will need physical access to restart it.');
+
+          if (!confirmed) return;
+
+          try {
+            showStatusMessage('Initiating shutdown...', 'warning');
+
+            const response = await fetch('/api/system/shutdown', { method: 'POST' });
+            const data = await response.json();
+
+            if (data.success) {
+              showStatusMessage('System is shutting down...', 'success');
+            } else {
+              showStatusMessage('Shutdown failed: ' + data.message, 'error');
+            }
+          } catch (error) {
+            showStatusMessage('Failed to shutdown: ' + error.message, 'error');
+          }
+        });
+      });
     </script>
 
     <!-- Cache All Previews Prompt Modal -->

+ 387 - 0
templates/led.html

@@ -0,0 +1,387 @@
+{% extends "base.html" %} {% block title %}LED Control - {{ app_name or 'Dune Weaver' }}{% endblock %}
+
+{% block additional_head %}
+<link rel="stylesheet" href="/static/css/coloris.min.css">
+{% endblock %}
+
+{% block additional_styles %}
+/* Dark mode styles for LED page */
+.dark .bg-white {
+  background-color: #262626;
+}
+.dark .bg-gray-100 {
+  background-color: #1f1f1f;
+}
+.dark .border-slate-200 {
+  border-color: #404040;
+}
+.dark .border-slate-300 {
+  border-color: #404040;
+}
+.dark .text-gray-500 {
+  color: #9ca3af;
+}
+.dark .text-gray-700 {
+  color: #d1d5db;
+}
+.dark .text-slate-500 {
+  color: #e2e8f0;
+}
+.dark .text-slate-600 {
+  color: #f1f5f9;
+}
+.dark .text-slate-700 {
+  color: #f8fafc;
+}
+.dark .text-slate-800 {
+  color: #ffffff;
+}
+.dark .text-slate-900 {
+  color: #ffffff;
+}
+.dark .bg-slate-50 {
+  background-color: #262626;
+}
+
+/* Specific label overrides for better visibility */
+.dark label {
+  color: #f1f5f9;
+}
+
+/* Form elements */
+.dark input[type="range"] {
+  background-color: #404040;
+}
+.dark input[type="number"],
+.dark input[type="text"]:not(.effect-color-picker) {
+  background-color: #1f1f1f;
+  border-color: #404040;
+  color: #e5e5e5;
+}
+.dark select,
+.dark .form-select {
+  background-color: #1f1f1f;
+  border-color: #404040;
+  color: #e5e5e5;
+}
+.dark select option {
+  background-color: #262626;
+  color: #e5e5e5;
+}
+
+/* Status messages - keep backgrounds but adjust borders */
+.dark .bg-green-50 {
+  background-color: #14532d;
+}
+.dark .border-green-200 {
+  border-color: #166534;
+}
+.dark .text-green-700 {
+  color: #86efac;
+}
+.dark .bg-red-50 {
+  background-color: #450a0a;
+}
+.dark .border-red-200 {
+  border-color: #991b1b;
+}
+.dark .text-red-700 {
+  color: #fca5a5;
+}
+.dark .bg-amber-50 {
+  background-color: #451a03;
+}
+.dark .border-amber-200 {
+  border-color: #92400e;
+}
+.dark .text-amber-700 {
+  color: #fcd34d;
+}
+.dark .bg-blue-50 {
+  background-color: #1f1f1f;
+}
+.dark .border-blue-200 {
+  border-color: #404040;
+}
+.dark .text-blue-700 {
+  color: #e2e8f0;
+}
+.dark .text-blue-800 {
+  color: #f1f5f9;
+}
+
+/* Iframe border */
+.dark iframe {
+  border-color: #404040;
+}
+
+/* Hide hex input in Coloris picker */
+.clr-field input {
+  display: none !important;
+}
+.clr-field button {
+  display: none !important;
+}
+
+/* Hide text inside the circular color pickers */
+.effect-color-picker {
+  color: transparent !important;
+  text-indent: -9999px;
+  font-size: 0;
+  caret-color: transparent;
+}
+{% endblock %}
+
+{% block content %}
+<div class="layout-content-container flex flex-col w-full max-w-4xl gap-0 pt-2 pb-[75px]">
+  <!-- Not Configured State -->
+  <section id="led-not-configured" class="bg-white rounded-xl shadow-sm overflow-hidden pt-4 sm:pt-0 h-full hidden">
+    <div class="flex flex-col items-center px-0 py-0 h-full">
+      <div class="w-full h-full max-w-5xl flex flex-col overflow-hidden">
+        <div class="w-full p-8 text-center">
+          <div class="flex flex-col items-center gap-4">
+            <span class="material-icons text-6xl text-gray-500">lightbulb</span>
+            <h2 class="text-2xl font-semibold text-gray-700">LED Controller Not Configured</h2>
+            <p class="text-gray-500 max-w-md">Please configure your LED controller (WLED or DW LEDs) in the Settings page.</p>
+            <a href="/settings" class="mt-4 flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-md hover:bg-blue-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2">
+              <span class="material-icons">settings</span>
+              Go to Settings
+            </a>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+
+  <!-- WLED iframe -->
+  <section id="wled-container" class="bg-white rounded-xl shadow-sm overflow-hidden pt-4 sm:pt-0 h-full hidden">
+    <div class="flex flex-col items-center px-0 py-0 h-full">
+      <div class="w-full h-full max-w-5xl flex flex-col overflow-hidden">
+        <iframe id="wled-frame"
+          src=""
+          class="h-full w-full rounded-lg border border-slate-200"
+          frameborder="0"
+          allowfullscreen
+        ></iframe>
+      </div>
+    </div>
+  </section>
+
+  <!-- DW LEDs Controls -->
+  <section id="dw-leds-container" class="bg-white rounded-xl shadow-sm overflow-hidden hidden">
+    <div class="px-6 py-5 space-y-6">
+      <h2 class="text-slate-800 text-xl sm:text-2xl font-semibold leading-tight tracking-[-0.01em]">
+        DW LEDs Control
+      </h2>
+
+      <!-- Connection Status -->
+      <div id="dw-leds-status" class="p-4 rounded-lg bg-gray-100 border border-slate-200">
+        <div class="flex items-center gap-2">
+          <span class="material-icons text-gray-500">info</span>
+          <span class="text-sm text-gray-700">Checking connection...</span>
+        </div>
+      </div>
+
+      <!-- Power and Brightness Grid -->
+      <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
+        <!-- Power Control -->
+        <div class="flex flex-col gap-3">
+          <h3 class="text-slate-800 text-base font-semibold">Power</h3>
+          <button id="dw-leds-power-toggle" class="flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-3 text-sm font-semibold text-white shadow-md hover:bg-green-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-green-400 focus:ring-offset-2">
+            <span class="material-icons">power_settings_new</span>
+            <span id="dw-leds-power-text">Turn ON</span>
+          </button>
+        </div>
+
+        <!-- Brightness Control -->
+        <div class="flex flex-col gap-3">
+          <div class="flex items-center justify-between">
+            <h3 class="text-slate-800 text-base font-semibold">Brightness</h3>
+            <span id="dw-leds-brightness-value" class="text-sm font-medium text-slate-600">35%</span>
+          </div>
+          <input type="range" id="dw-leds-brightness" min="0" max="100" value="35" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
+        </div>
+      </div>
+
+      <!-- Effects and Palettes -->
+      <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+        <div class="flex flex-col gap-3">
+          <h3 class="text-slate-800 text-base font-semibold">Effect</h3>
+          <select id="dw-leds-effect-select" class="form-select w-full rounded-lg border border-slate-300 bg-white px-4 py-3 text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
+            <option value="">Loading effects...</option>
+          </select>
+        </div>
+        <div class="flex flex-col gap-3">
+          <h3 class="text-slate-800 text-base font-semibold">Palette</h3>
+          <select id="dw-leds-palette-select" class="form-select w-full rounded-lg border border-slate-300 bg-white px-4 py-3 text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
+            <option value="">Loading palettes...</option>
+          </select>
+        </div>
+      </div>
+
+      <!-- Speed and Intensity -->
+      <div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
+        <!-- Speed Control -->
+        <div class="flex flex-col gap-3">
+          <div class="flex items-center justify-between">
+            <h3 class="text-slate-800 text-base font-semibold">Speed</h3>
+            <span id="dw-leds-speed-value" class="text-sm font-medium text-slate-600">128</span>
+          </div>
+          <input type="range" id="dw-leds-speed" min="0" max="255" value="128" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
+        </div>
+
+        <!-- Intensity Control -->
+        <div class="flex flex-col gap-3">
+          <div class="flex items-center justify-between">
+            <h3 class="text-slate-800 text-base font-semibold">Intensity</h3>
+            <span id="dw-leds-intensity-value" class="text-sm font-medium text-slate-600">128</span>
+          </div>
+          <input type="range" id="dw-leds-intensity" min="0" max="255" value="128" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
+        </div>
+      </div>
+
+      <!-- Effect Color Slots -->
+      <div class="flex items-center gap-4">
+        <h3 class="text-slate-800 text-base font-semibold">Effect Colors:</h3>
+        <div class="flex items-center gap-2">
+          <label class="text-xs font-medium text-slate-600">Slot 1</label>
+          <input type="text" id="dw-leds-color1" value="#ff0000" class="w-8 h-8 rounded-full border-2 border-slate-300 cursor-pointer effect-color-picker text-transparent" readonly>
+        </div>
+        <div class="flex items-center gap-2">
+          <label class="text-xs font-medium text-slate-600">Slot 2</label>
+          <input type="text" id="dw-leds-color2" value="#000000" class="w-8 h-8 rounded-full border-2 border-slate-300 cursor-pointer effect-color-picker text-transparent" readonly>
+        </div>
+        <div class="flex items-center gap-2">
+          <label class="text-xs font-medium text-slate-600">Slot 3</label>
+          <input type="text" id="dw-leds-color3" value="#0000ff" class="w-8 h-8 rounded-full border-2 border-slate-300 cursor-pointer effect-color-picker text-transparent" readonly>
+        </div>
+      </div>
+
+      <!-- Effect Settings -->
+      <div class="flex flex-col gap-6 pt-4 border-t border-slate-200">
+        <div>
+          <h3 class="text-slate-800 text-base font-semibold">Automation Settings</h3>
+          <p class="text-xs text-slate-500 mt-1">Configure LED effects to automatically activate when idle or playing patterns</p>
+        </div>
+
+        <!-- Playing Effect Configuration -->
+        <div class="bg-slate-50 rounded-lg p-4 border border-slate-200">
+          <div class="flex items-center justify-between mb-3">
+            <h4 class="text-slate-700 text-sm font-semibold flex items-center gap-2">
+              <span class="material-icons text-green-600 text-lg">play_circle</span>
+              Playing Effect
+            </h4>
+            <div class="flex gap-2">
+              <button id="dw-leds-save-current-playing" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
+                <span class="material-icons text-sm">save</span>
+                Save Current
+              </button>
+              <button id="dw-leds-clear-playing" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
+                <span class="material-icons text-sm">clear</span>
+                Clear
+              </button>
+            </div>
+          </div>
+
+          <div id="playing-current-settings" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200">
+            <span class="font-medium">Current:</span> <span id="playing-settings-display">Not configured</span>
+          </div>
+        </div>
+
+        <!-- Idle Configuration (Effect + Timeout) -->
+        <div class="bg-slate-50 rounded-lg p-4 border border-slate-200">
+          <div class="flex items-center justify-between mb-4">
+            <h4 class="text-slate-700 text-sm font-semibold flex items-center gap-2">
+              <span class="material-icons text-blue-600 text-lg">bedtime</span>
+              Idle Configuration
+            </h4>
+          </div>
+
+          <!-- Idle Effect -->
+          <div class="mb-4 pb-4 border-b border-slate-200">
+            <div class="flex items-center justify-between mb-3">
+              <label class="text-xs font-medium text-slate-600">Idle Effect</label>
+              <div class="flex gap-2">
+                <button id="dw-leds-save-current-idle" class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-700 transition-colors focus:outline-none focus:ring-2 focus:ring-green-400">
+                  <span class="material-icons text-sm">save</span>
+                  Save Current
+                </button>
+                <button id="dw-leds-clear-idle" class="flex items-center gap-1.5 rounded-lg bg-gray-500 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-gray-600 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400">
+                  <span class="material-icons text-sm">clear</span>
+                  Clear
+                </button>
+              </div>
+            </div>
+            <div id="idle-current-settings" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200">
+              <span class="font-medium">Current:</span> <span id="idle-settings-display">Not configured</span>
+            </div>
+          </div>
+
+          <!-- Idle Timeout -->
+          <div>
+            <div class="flex items-center justify-between mb-3">
+              <label class="text-xs font-medium text-slate-600 flex items-center gap-2">
+                <span class="material-icons text-blue-600 text-base">schedule</span>
+                Auto Turn Off
+              </label>
+              <label class="relative inline-flex items-center cursor-pointer" title="Enable automatic LED turn off after idle period">
+                <input type="checkbox" id="dw-leds-idle-timeout-enabled" class="sr-only peer">
+                <div class="w-11 h-6 bg-slate-300 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
+              </label>
+            </div>
+
+            <!-- Help text when disabled -->
+            <div id="idle-timeout-disabled-help" class="hidden mb-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
+              <div class="flex items-start gap-2">
+                <span class="material-icons text-blue-600 text-sm">info</span>
+                <p class="text-xs text-blue-700">
+                  <span class="font-semibold">Enable the toggle above</span> to automatically turn off LEDs after a period of inactivity.
+                </p>
+              </div>
+            </div>
+
+            <div id="idle-timeout-settings" class="space-y-3 transition-opacity duration-200">
+              <div>
+                <label class="block text-xs text-slate-500 mb-2">Turn off LEDs after</label>
+                <div class="flex items-center gap-3">
+                  <input type="number" id="dw-leds-idle-timeout-minutes" min="1" max="1440" value="30"
+                         class="flex-1 rounded-lg border-slate-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
+                  <span class="text-sm text-slate-600 whitespace-nowrap">minutes idle</span>
+                </div>
+              </div>
+
+              <div id="idle-timeout-remaining" class="text-xs text-slate-600 p-2 bg-white rounded border border-slate-200 hidden">
+                <span class="font-medium">Time remaining:</span> <span id="idle-timeout-remaining-display">--</span>
+              </div>
+
+              <button id="dw-leds-save-idle-timeout" class="w-full flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400">
+                <span class="material-icons text-base">save</span>
+                Save Timeout Settings
+              </button>
+            </div>
+          </div>
+        </div>
+
+        <div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
+          <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">How to use:</p>
+              <ul class="mt-1 space-y-1 list-disc list-inside">
+                <li>Adjust LED settings above (effect, palette, speed, intensity, colors)</li>
+                <li>Click "Save Current" to capture current settings for automation</li>
+                <li>Click "Clear" to turn off automation for that state</li>
+                <li>Settings are applied automatically when table changes state</li>
+              </ul>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+
+</div>
+
+<script src="/static/js/coloris.min.js"></script>
+<script src="/static/js/led-control.js"></script>
+{% endblock %}

+ 135 - 31
templates/settings.html

@@ -7,19 +7,24 @@ endblock %}
   background-color: #262626;
 }
 .dark .text-slate-900 {
-  color: #e5e5e5;
+  color: #ffffff;
 }
 .dark .text-slate-800 {
-  color: #e5e5e5;
+  color: #f8fafc;
 }
 .dark .text-slate-700 {
-  color: #d1d5db;
+  color: #f1f5f9;
 }
 .dark .text-slate-600 {
-  color: #9ca3af;
+  color: #e2e8f0;
 }
 .dark .text-slate-500 {
-  color: #9ca3af;
+  color: #e2e8f0;
+}
+
+/* Label overrides for better visibility */
+.dark label {
+  color: #f1f5f9;
 }
 .dark .border-slate-200 {
   border-color: #404040;
@@ -39,8 +44,10 @@ endblock %}
 .dark .bg-slate-100 {
   background-color: #404040;
 }
-.dark .form-input {
-  background-color: #262626;
+.dark .form-input,
+.dark input[type="number"],
+.dark input[type="text"] {
+  background-color: #1f1f1f;
   border-color: #404040;
   color: #e5e5e5;
 }
@@ -52,7 +59,7 @@ endblock %}
   ring-color: #0c7ff2;
 }
 .dark .form-select {
-  background-color: #262626;
+  background-color: #1f1f1f;
   border-color: #404040;
   color: #e5e5e5;
 }
@@ -214,25 +221,38 @@ input:checked + .slider:before {
   border-color: #64748b;
 }
 
-/* Info box dark mode */
+/* Info box dark mode - grey theme */
 .dark .bg-blue-50 {
-  background-color: #1e3a8a;
+  background-color: #1f1f1f;
 }
 
 .dark .border-blue-200 {
-  border-color: #1e40af;
+  border-color: #404040;
 }
 
 .dark .text-blue-600 {
-  color: #60a5fa;
+  color: #e2e8f0;
 }
 
 .dark .text-blue-800 {
-  color: #dbeafe;
+  color: #f1f5f9;
 }
 
 .dark .text-blue-700 {
-  color: #bfdbfe;
+  color: #e2e8f0;
+}
+
+/* Amber box dark mode - grey theme */
+.dark .bg-amber-50 {
+  background-color: #1f1f1f;
+}
+
+.dark .border-amber-200 {
+  border-color: #404040;
+}
+
+.dark .text-amber-600 {
+  color: #f1f5f9;
 }
 {% endblock %}
 
@@ -585,14 +605,35 @@ input:checked + .slider:before {
     <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"
     >
-      WLED Configuration
+      LED Controller Configuration
     </h2>
     <div class="px-6 py-5 space-y-6">
-      <label class="flex flex-col gap-1.5">
-        <span class="text-slate-700 text-sm font-medium leading-normal"
-          >IP Address</span
-        >
-        <div class="flex gap-3 items-center">
+      <!-- LED Provider Selection -->
+      <div class="flex flex-col gap-2">
+        <span class="text-slate-700 text-sm font-medium leading-normal">LED Provider</span>
+        <div class="flex gap-3">
+          <label class="flex items-center gap-2 cursor-pointer">
+            <input type="radio" name="ledProvider" value="none" id="ledProviderNone" class="w-4 h-4 text-sky-600 border-slate-300 focus:ring-sky-500">
+            <span class="text-sm text-slate-700">None</span>
+          </label>
+          <label class="flex items-center gap-2 cursor-pointer">
+            <input type="radio" name="ledProvider" value="wled" id="ledProviderWled" class="w-4 h-4 text-sky-600 border-slate-300 focus:ring-sky-500">
+            <span class="text-sm text-slate-700">WLED</span>
+          </label>
+          <label class="flex items-center gap-2 cursor-pointer">
+            <input type="radio" name="ledProvider" value="dw_leds" id="ledProviderDwLeds" class="w-4 h-4 text-sky-600 border-slate-300 focus:ring-sky-500">
+            <span class="text-sm text-slate-700">DW LEDs (Local GPIO)</span>
+          </label>
+        </div>
+        <p class="text-xs text-slate-500">
+          Select your LED control system (settings are mutually exclusive)
+        </p>
+      </div>
+
+      <!-- WLED Configuration (shown when WLED is selected) -->
+      <div id="wledConfig" class="flex flex-col gap-4 hidden">
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">WLED IP Address</span>
           <div class="relative flex-1">
             <input
               id="wledIpInput"
@@ -609,18 +650,81 @@ input:checked + .slider:before {
               <span class="material-icons">close</span>
             </button>
           </div>
-          <button
-            id="saveWledConfig"
-            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 flex-shrink-0"
-          >
-            <span class="material-icons text-lg">save</span>
-            <span class="truncate">Save Configuration</span>
-          </button>
+          <p class="text-xs text-slate-500">
+            Enter the IP address of your WLED controller
+          </p>
+        </label>
+      </div>
+
+      <!-- DW LEDs Configuration (shown when DW LEDs is selected) -->
+      <div id="dwLedsConfig" class="flex flex-col gap-4 hidden">
+        <div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
+          <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">Supported LED Strips: WS281x Only</p>
+              <p class="mt-1">Compatible with WS2811, WS2812, WS2812B, WS2813, WS2815 (12V), and other WS281x RGB LED strips. RGBW strips (SK6812, SK6812W) are not supported.</p>
+            </div>
+          </div>
         </div>
-        <p class="text-xs text-slate-500 mt-2">
-          Enter the IP address of your WLED controller.
-        </p>
-      </label>
+
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Number of LEDs</span>
+          <input
+            id="dwLedNumLeds"
+            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="60"
+            value="60"
+            min="1"
+            max="1000"
+          />
+          <p class="text-xs text-slate-500">
+            Total number of LEDs in your WS281x strip
+          </p>
+        </label>
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">GPIO Pin</span>
+          <select
+            id="dwLedGpioPin"
+            class="form-select 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 px-4 text-base font-normal leading-normal transition-colors"
+          >
+            <option value="12">GPIO 12 (PWM0)</option>
+            <option value="13">GPIO 13 (PWM1)</option>
+            <option value="18">GPIO 18 (PWM0)</option>
+            <option value="19">GPIO 19 (PWM1)</option>
+          </select>
+          <p class="text-xs text-slate-500">
+            Select a PWM-capable GPIO pin for WS281x timing
+          </p>
+        </label>
+        <label class="flex flex-col gap-1.5">
+          <span class="text-slate-700 text-sm font-medium leading-normal">Pixel Color Order (WS281x)</span>
+          <select
+            id="dwLedPixelOrder"
+            class="form-select 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 px-4 text-base font-normal leading-normal transition-colors"
+          >
+            <option value="GRB" selected>GRB - WS2812/WS2812B (most common)</option>
+            <option value="RGB">RGB - WS2815/WS2811 and some variants</option>
+            <option value="BGR">BGR - Some WS2811 variants</option>
+            <option value="RBG">RBG - Rare variant</option>
+            <option value="GBR">GBR - Rare variant</option>
+            <option value="BRG">BRG - Rare variant</option>
+          </select>
+          <p class="text-xs text-slate-500">
+            Most WS2812B and WS2815 strips use GRB. WS2811 often uses RGB. If colors appear wrong, try different orders.
+          </p>
+        </label>
+      </div>
+
+      <!-- Save Button -->
+      <button
+        id="saveLedConfig"
+        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 LED Configuration</span>
+      </button>
     </div>
   </section>
   <section class="bg-white rounded-xl shadow-sm overflow-hidden">

+ 0 - 76
templates/wled.html

@@ -1,76 +0,0 @@
-{% extends "base.html" %} {% block title %}WLED - {{ app_name or 'Dune Weaver' }}{% endblock %}
-
-{% block additional_styles %}
-/* Dark mode styles for WLED page */
-.dark .bg-white {
-  background-color: #262626;
-}
-.dark .border-slate-200 {
-  border-color: #404040;
-}
-.dark .text-gray-500 {
-  color: #9ca3af;
-}
-.dark .text-gray-700 {
-  color: #d1d5db;
-}
-{% endblock %}
-
-{% block content %}
-<div class="layout-content-container flex flex-col w-full max-w-4xl gap-0 pt-2 pb-[75px]">
-  <section class="bg-white rounded-xl shadow-sm overflow-hidden pt-4 sm:pt-0 h-full">
-    <div class="flex flex-col items-center px-0 py-0 h-full">
-      <div class="w-full h-full max-w-5xl flex flex-col overflow-hidden">
-        <div id="wled-status" class="w-full p-8 text-center">
-          <div class="flex flex-col items-center gap-4">
-            <span class="material-icons text-6xl text-gray-500">lightbulb</span>
-            <h2 class="text-2xl font-semibold text-gray-700">WLED Not Configured</h2>
-            <p class="text-gray-500 max-w-md">Please set up your WLED IP address in the Settings page to control your LED lights.</p>
-            <a href="/settings" class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
-              Go to Settings
-            </a>
-          </div>
-        </div>
-        <iframe id="wled-frame"
-          src=""
-          class="h-full w-full rounded-lg border border-slate-200 hidden"
-          frameborder="0"
-          allowfullscreen
-        ></iframe>
-      </div>
-    </div>
-  </section>
-</div>
-<script>
-  async function loadWledIframe() {
-    let wledIp = localStorage.getItem('wled_ip');
-    const status = document.getElementById('wled-status');
-    const frame = document.getElementById('wled-frame');
-    if (!wledIp) {
-      // Try to fetch from server if not in localStorage
-      try {
-        const resp = await fetch('/get_wled_ip');
-        if (resp.ok) {
-          const data = await resp.json();
-          wledIp = data.wled_ip;
-          localStorage.setItem('wled_ip', wledIp);
-        }
-      } catch (e) {}
-    }
-    if (wledIp && frame) {
-      frame.src = `http://${wledIp}`;
-      frame.classList.remove('hidden');
-      status.classList.add('hidden');
-    } else {
-      if (frame) {
-        frame.src = '';
-        frame.classList.add('hidden');
-      }
-      if (status) {
-        status.classList.remove('hidden');
-      }
-    }
-  }
-  document.addEventListener('DOMContentLoaded', loadWledIframe);
-</script>
-{% endblock %}

Некоторые файлы не были показаны из-за большого количества измененных файлов