Bladeren bron

add dw led support

tuanchris 3 maanden geleden
bovenliggende
commit
d99e84d7fa

+ 3 - 0
Dockerfile

@@ -11,6 +11,9 @@ WORKDIR /app
 COPY requirements.txt ./
 RUN apt-get update && apt-get install -y --no-install-recommends \
         gcc libjpeg-dev zlib1g-dev git \
+        # GPIO/NeoPixel support for DW LEDs
+        python3-dev python3-pip \
+        libgpiod2 libgpiod-dev \
     && pip install --upgrade pip \
     && pip install --no-cache-dir -r requirements.txt \
     && apt-get purge -y gcc \

+ 7 - 3
docker-compose.yml

@@ -5,7 +5,7 @@ services:
     restart: always
     # 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 to access Hyperion on localhost
+    network_mode: "host" # Use host network for device access
     volumes:
       - .:/app
       # Mount timezone file from host for Still Sands scheduling
@@ -15,7 +15,11 @@ services:
       - /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 (NeoPixel control)
+      - /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

+ 160 - 132
main.py

@@ -95,23 +95,25 @@ async def lifespan(app: FastAPI):
             if state.wled_ip:
                 state.led_provider = "wled"
                 logger.info("Auto-detected WLED provider from existing configuration")
-            elif state.hyperion_ip:
-                state.led_provider = "hyperion"
-                logger.info("Auto-detected Hyperion 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 == "hyperion" and state.hyperion_ip:
-            state.led_controller = LEDInterface("hyperion", state.hyperion_ip, state.hyperion_port)
-            logger.info(f"LED controller initialized: Hyperion at {state.hyperion_ip}:{state.hyperion_port}")
+        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,
+                brightness=state.dw_led_brightness / 100.0
+            )
+            logger.info(f"LED controller initialized: DW LEDs ({state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin})")
         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 or state.hyperion_ip):
+        if state.led_provider and state.wled_ip:
             state.save()
     except Exception as e:
         logger.warning(f"Failed to initialize LED controller: {str(e)}")
@@ -225,9 +227,12 @@ class WLEDRequest(BaseModel):
     wled_ip: Optional[str] = None
 
 class LEDConfigRequest(BaseModel):
-    provider: str  # "wled", "hyperion", or "none"
-    ip_address: Optional[str] = None
-    port: Optional[int] = None
+    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
+    brightness: Optional[int] = None
 
 class DeletePlaylistRequest(BaseModel):
     playlist_name: str
@@ -1133,9 +1138,9 @@ async def get_wled_ip():
 
 @app.post("/set_led_config")
 async def set_led_config(request: LEDConfigRequest):
-    """Configure LED provider (WLED, Hyperion, or none)"""
-    if request.provider not in ["wled", "hyperion", "none"]:
-        raise HTTPException(status_code=400, detail="Invalid provider. Must be 'wled', 'hyperion', or 'none'")
+    """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
 
@@ -1143,22 +1148,24 @@ async def set_led_config(request: LEDConfigRequest):
         if not request.ip_address:
             raise HTTPException(status_code=400, detail="IP address required for WLED")
         state.wled_ip = request.ip_address
-        state.hyperion_ip = None  # Clear other provider
         state.led_controller = LEDInterface("wled", request.ip_address)
         logger.info(f"LED provider set to WLED at {request.ip_address}")
 
-    elif request.provider == "hyperion":
-        if not request.ip_address:
-            raise HTTPException(status_code=400, detail="IP address required for Hyperion")
-        state.hyperion_ip = request.ip_address
-        state.hyperion_port = request.port or 8090
-        state.wled_ip = None  # Clear other provider
-        state.led_controller = LEDInterface("hyperion", request.ip_address, request.port or 8090)
-        logger.info(f"LED provider set to Hyperion at {request.ip_address}:{request.port or 8090}")
+    elif request.provider == "dw_leds":
+        state.dw_led_num_leds = request.num_leds or 60
+        state.dw_led_gpio_pin = request.gpio_pin or 12
+        state.dw_led_brightness = request.brightness or 35
+        state.wled_ip = None
+        state.led_controller = LEDInterface(
+            "dw_leds",
+            num_leds=state.dw_led_num_leds,
+            gpio_pin=state.dw_led_gpio_pin,
+            brightness=state.dw_led_brightness / 100.0
+        )
+        logger.info(f"DW LEDs configured: {state.dw_led_num_leds} LEDs on GPIO{state.dw_led_gpio_pin}")
 
     else:  # none
         state.wled_ip = None
-        state.hyperion_ip = None
         state.led_controller = None
         logger.info("LED provider disabled")
 
@@ -1172,8 +1179,9 @@ async def set_led_config(request: LEDConfigRequest):
         "success": True,
         "provider": state.led_provider,
         "wled_ip": state.wled_ip,
-        "hyperion_ip": state.hyperion_ip,
-        "hyperion_port": state.hyperion_port
+        "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")
@@ -1188,11 +1196,6 @@ async def get_led_config():
             state.led_provider = "wled"
             state.save()
             logger.info("Auto-detected WLED provider from existing configuration")
-        elif state.hyperion_ip:
-            provider = "hyperion"
-            state.led_provider = "hyperion"
-            state.save()
-            logger.info("Auto-detected Hyperion provider from existing configuration")
         else:
             provider = "none"
 
@@ -1200,10 +1203,11 @@ async def get_led_config():
         "success": True,
         "provider": provider,
         "wled_ip": state.wled_ip,
-        "hyperion_ip": state.hyperion_ip,
-        "hyperion_port": state.hyperion_port,
-        "hyperion_idle_effect": state.hyperion_idle_effect,
-        "hyperion_playing_effect": state.hyperion_playing_effect
+        "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,
+        "dw_led_idle_effect": state.dw_led_idle_effect,
+        "dw_led_playing_effect": state.dw_led_playing_effect
     }
 
 @app.post("/skip_pattern")
@@ -1404,166 +1408,190 @@ async def image2sand(request: Request):
 async def wled(request: Request):
     return templates.TemplateResponse("wled.html", {"request": request, "app_name": state.app_name})
 
-# Hyperion control endpoints
-@app.get("/api/hyperion/status")
-async def hyperion_status():
-    """Get Hyperion connection status"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+# 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:
-        status = state.led_controller.check_status()
-        return status
+        return state.led_controller.check_status()
     except Exception as e:
-        logger.error(f"Failed to check Hyperion status: {str(e)}")
+        logger.error(f"Failed to check DW LED status: {str(e)}")
         return {"connected": False, "message": str(e)}
 
-@app.post("/api/hyperion/power")
-async def hyperion_power(request: dict):
-    """Control Hyperion power state"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+@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:
-        result = state.led_controller.set_power(state_value)
-        return result
+        return state.led_controller.set_power(state_value)
     except Exception as e:
-        logger.error(f"Failed to set Hyperion power: {str(e)}")
+        logger.error(f"Failed to set DW LED power: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.post("/api/hyperion/brightness")
-async def hyperion_brightness(request: dict):
-    """Set Hyperion brightness"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+@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", 100)
+    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 Hyperion brightness: {str(e)}")
+        logger.error(f"Failed to set DW LED brightness: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.post("/api/hyperion/color")
-async def hyperion_color(request: dict):
-    """Set Hyperion color"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
-
-    hex_color = request.get("hex")
-    r = request.get("r")
-    g = request.get("g")
-    b = request.get("b")
+@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")
 
-    if not hex_color and (r is None or g is None or b is None):
-        raise HTTPException(status_code=400, detail="Either hex or RGB values required")
+    color = request.get("color", [255, 0, 0])
+    if not isinstance(color, list) or len(color) != 3:
+        raise HTTPException(status_code=400, detail="Color must be [R, G, B] array")
 
     try:
         controller = state.led_controller.get_controller()
+        return controller.set_color(color[0], color[1], color[2])
+    except Exception as e:
+        logger.error(f"Failed to set DW LED color: {str(e)}")
+        raise HTTPException(status_code=500, detail=str(e))
 
-        # Convert hex to RGB if hex was provided
-        if hex_color:
-            hex_color = hex_color.lstrip('#')
-            if len(hex_color) != 6:
-                raise HTTPException(status_code=400, detail="Hex color must be 6 characters")
-            r = int(hex_color[0:2], 16)
-            g = int(hex_color[2:4], 16)
-            b = int(hex_color[4:6], 16)
+@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")
 
-        result = controller.set_color(r=r, g=g, b=b)
-        return result
-    except ValueError as e:
-        logger.error(f"Failed to parse hex color: {str(e)}")
-        raise HTTPException(status_code=400, detail="Invalid hex color format")
+    try:
+        controller = state.led_controller.get_controller()
+        effects = controller.get_effects()
+        return {
+            "success": True,
+            "effects": [{"id": eid, "name": name} for eid, name in effects]
+        }
     except Exception as e:
-        logger.error(f"Failed to set Hyperion color: {str(e)}")
+        logger.error(f"Failed to get DW LED effects: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.post("/api/hyperion/clear")
-async def hyperion_clear(request: dict):
-    """Clear Hyperion priority"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+@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()
-        result = controller.clear_priority()
-        return result
+        palettes = controller.get_palettes()
+        return {
+            "success": True,
+            "palettes": [{"id": pid, "name": name} for pid, name in palettes]
+        }
     except Exception as e:
-        logger.error(f"Failed to clear Hyperion priority: {str(e)}")
+        logger.error(f"Failed to get DW LED palettes: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.get("/api/hyperion/effects")
-async def hyperion_effects():
-    """Get list of available Hyperion effects"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+@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:
-        import requests as req
-        response = req.post(
-            f"http://{state.hyperion_ip}:{state.hyperion_port}/json-rpc",
-            json={"command": "serverinfo"},
-            timeout=2
-        )
-        data = response.json()
-        effects = data.get('info', {}).get('effects', [])
+        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")
 
-        # Return sorted list of effects
-        effects_list = [{"name": e.get("name"), "args": e.get("args", {})} for e in effects]
-        effects_list.sort(key=lambda x: x["name"])
+    palette_id = request.get("palette_id", 0)
 
-        return {"success": True, "effects": effects_list}
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_palette(palette_id)
     except Exception as e:
-        logger.error(f"Failed to get Hyperion effects: {str(e)}")
+        logger.error(f"Failed to set DW LED palette: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.post("/api/hyperion/effect")
-async def hyperion_effect(request: dict):
-    """Set Hyperion effect"""
-    if not state.led_controller or state.led_provider != "hyperion":
-        raise HTTPException(status_code=400, detail="Hyperion not configured")
+@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")
 
-    effect_name = request.get("effect_name")
-    effect_args = request.get("args", {})
+    value = request.get("value", 128)
+    if not 0 <= value <= 255:
+        raise HTTPException(status_code=400, detail="Speed must be between 0 and 255")
 
-    if not effect_name:
-        raise HTTPException(status_code=400, detail="effect_name required")
+    try:
+        controller = state.led_controller.get_controller()
+        return controller.set_speed(value)
+    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("value", 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_effect(effect_name, effect_args)
-        return result
+        return controller.set_intensity(value)
     except Exception as e:
-        logger.error(f"Failed to set Hyperion effect: {str(e)}")
+        logger.error(f"Failed to set DW LED intensity: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
 
-@app.post("/api/hyperion/set_effects")
-async def hyperion_set_effects(request: dict):
-    """Configure idle and playing effects for Hyperion"""
+@app.post("/api/dw_leds/set_effects")
+async def dw_leds_set_effects(request: dict):
+    """Configure idle and playing effects"""
     idle_effect = request.get("idle_effect")
     playing_effect = request.get("playing_effect")
 
-    # Save effect settings - "off"/None/empty string all mean clear priority
-    state.hyperion_idle_effect = idle_effect if idle_effect else "off"
-    state.hyperion_playing_effect = playing_effect if playing_effect else "off"
-
+    state.dw_led_idle_effect = idle_effect if idle_effect else "off"
+    state.dw_led_playing_effect = playing_effect if playing_effect else "off"
     state.save()
-    logger.info(f"Hyperion effects configured - Idle: {state.hyperion_idle_effect}, Playing: {state.hyperion_playing_effect}")
+
+    logger.info(f"DW LED effects configured - Idle: {state.dw_led_idle_effect}, Playing: {state.dw_led_playing_effect}")
 
     return {
         "success": True,
-        "idle_effect": state.hyperion_idle_effect,
-        "playing_effect": state.hyperion_playing_effect
+        "idle_effect": state.dw_led_idle_effect,
+        "playing_effect": state.dw_led_playing_effect
     }
 
 @app.get("/table_control")

+ 18 - 13
modules/core/state.py

@@ -40,12 +40,15 @@ class AppState:
         self.conn = None
         self.port = None
         self.wled_ip = None
-        self.hyperion_ip = "127.0.0.1"
-        self.hyperion_port = 8090
-        self.led_provider = "none"  # "wled", "hyperion", or "none"
+        self.led_provider = "none"  # "wled", "dw_leds", or "none"
         self.led_controller = None
-        self.hyperion_idle_effect = "off"  # Effect to show when idle ("off" = clear priority)
-        self.hyperion_playing_effect = "off"  # Effect to show when playing ("off" = clear priority)
+
+        # 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_brightness = 35  # Brightness 0-100
+        self.dw_led_idle_effect = "off"  # Effect to show when idle
+        self.dw_led_playing_effect = "off"  # Effect to show when playing
         self.skip_requested = False
         self.table_type = None
         self._playlist_mode = "loop"
@@ -195,11 +198,12 @@ class AppState:
             "custom_clear_from_out": self.custom_clear_from_out,
             "port": self.port,
             "wled_ip": self.wled_ip,
-            "hyperion_ip": self.hyperion_ip,
-            "hyperion_port": self.hyperion_port,
             "led_provider": self.led_provider,
-            "hyperion_idle_effect": self.hyperion_idle_effect,
-            "hyperion_playing_effect": self.hyperion_playing_effect,
+            "dw_led_num_leds": self.dw_led_num_leds,
+            "dw_led_gpio_pin": self.dw_led_gpio_pin,
+            "dw_led_brightness": self.dw_led_brightness,
+            "dw_led_idle_effect": self.dw_led_idle_effect,
+            "dw_led_playing_effect": self.dw_led_playing_effect,
             "app_name": self.app_name,
             "auto_play_enabled": self.auto_play_enabled,
             "auto_play_playlist": self.auto_play_playlist,
@@ -239,11 +243,12 @@ 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.hyperion_ip = data.get('hyperion_ip', "127.0.0.1")
-        self.hyperion_port = data.get('hyperion_port', 8090)
         self.led_provider = data.get('led_provider', "none")
-        self.hyperion_idle_effect = data.get('hyperion_idle_effect', "off")
-        self.hyperion_playing_effect = data.get('hyperion_playing_effect', "off")
+        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_brightness = data.get('dw_led_brightness', 35)
+        self.dw_led_idle_effect = data.get('dw_led_idle_effect', "off")
+        self.dw_led_playing_effect = data.get('dw_led_playing_effect', "off")
         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)

+ 463 - 0
modules/led/dw_led_controller.py

@@ -0,0 +1,463 @@
+"""
+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"):
+        """
+        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)
+        """
+        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 = 128
+        self._intensity = 128
+        self._color1 = (255, 0, 0)  # Red
+        self._color2 = (0, 0, 255)  # Blue
+        self._color3 = (0, 255, 0)  # Green
+
+        # 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"""
+        effect_func = get_effect(self._current_effect_id)
+
+        while not self._stop_thread.is_set():
+            try:
+                with self._lock:
+                    if self._pixels and self._segment and self._powered_on:
+                        # 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()
+
+        return {
+            "connected": True,
+            "color": [r, g, b],
+            "message": "Color 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()
+
+        effect_name = next(name for eid, name in effects if eid == effect_id)
+        return {
+            "connected": True,
+            "effect_id": effect_id,
+            "effect_name": effect_name,
+            "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
+
+        palette_name = get_palette_name(palette_id)
+        return {
+            "connected": True,
+            "palette_id": palette_id,
+            "palette_name": palette_name,
+            "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
+
+        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
+
+        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"""
+        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,
+            "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_name: Optional[str] = None) -> bool:
+    """Show idle effect"""
+    try:
+        if effect_name and effect_name.lower() != "off":
+            # Try to find effect by name
+            effects = controller.get_effects()
+            for eid, name in effects:
+                if name.lower() == effect_name.lower():
+                    controller.set_power(1)
+                    controller.set_effect(eid)
+                    return True
+
+        # Default: 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_name: Optional[str] = None) -> bool:
+    """Show playing effect"""
+    try:
+        if effect_name and effect_name.lower() != "off":
+            # Try to find effect by name
+            effects = controller.get_effects()
+            for eid, name in effects:
+                if name.lower() == effect_name.lower():
+                    controller.set_power(1)
+                    controller.set_effect(eid)
+                    return True
+        else:
+            # Default: 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"""

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

@@ -0,0 +1,339 @@
+#!/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
+
+# 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", mode_rainbow),
+    8: ("Rainbow Cycle", 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),
+}
+
+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"

+ 0 - 349
modules/led/hyperion_controller.py

@@ -1,349 +0,0 @@
-import requests
-import json
-from typing import Dict, Optional
-import time
-import logging
-
-logger = logging.getLogger(__name__)
-
-
-class HyperionController:
-    """Controller for Hyperion LED system using JSON-RPC API"""
-
-    def __init__(self, ip_address: Optional[str] = None, port: int = 8090):
-        self.ip_address = ip_address
-        self.port = port
-        # Priority for Dune Weaver effects (lower = higher priority)
-        # Using 100 to allow user to override with lower priorities if needed
-        self.priority = 100
-
-    def _get_base_url(self) -> str:
-        """Get base URL for Hyperion JSON-RPC API"""
-        if not self.ip_address:
-            raise ValueError("No Hyperion IP configured")
-        return f"http://{self.ip_address}:{self.port}/json-rpc"
-
-    def set_ip(self, ip_address: str, port: int = 8090) -> None:
-        """Update the Hyperion IP address and port"""
-        self.ip_address = ip_address
-        self.port = port
-
-    def _send_command(self, command: str, **params) -> Dict:
-        """Send JSON-RPC command to Hyperion and return response"""
-        try:
-            url = self._get_base_url()
-
-            payload = {
-                "command": command,
-                **params
-            }
-
-            # Reduced timeout from 2s to 1s - Hyperion should respond quickly
-            # This prevents hanging when Hyperion is under load
-            response = requests.post(url, json=payload, timeout=1)
-            response.raise_for_status()
-            result = response.json()
-
-            if not result.get("success", False):
-                error_msg = result.get("error", "Unknown error")
-                return {
-                    "connected": False,
-                    "message": f"Hyperion command failed: {error_msg}"
-                }
-
-            return {
-                "connected": True,
-                "message": "Command successful",
-                "response": result
-            }
-
-        except ValueError as e:
-            return {"connected": False, "message": str(e)}
-        except requests.RequestException as e:
-            return {"connected": False, "message": f"Cannot connect to Hyperion: {str(e)}"}
-        except json.JSONDecodeError as e:
-            return {"connected": False, "message": f"Error parsing Hyperion response: {str(e)}"}
-
-    def check_hyperion_status(self) -> Dict:
-        """Check Hyperion connection status, component state, and active priorities"""
-        result = self._send_command("serverinfo")
-
-        if result.get("connected"):
-            response = result.get("response", {})
-            info = response.get("info", {})
-            components = {c["name"]: c["enabled"] for c in info.get("components", [])}
-
-            # Get active priorities information
-            priorities = info.get("priorities", [])
-            active_priority = None
-            active_effect = None
-            active_color = None
-
-            # Find the highest priority (lowest number) active source
-            if priorities:
-                # Filter for visible priorities only
-                visible = [p for p in priorities if p.get("visible", True)]
-                if visible:
-                    # Sort by priority (lowest first)
-                    visible.sort(key=lambda x: x.get("priority", 999))
-                    active_priority = visible[0].get("priority")
-
-                    # Check if it's our priority
-                    if active_priority == self.priority:
-                        component_id = visible[0].get("componentId", "")
-                        if component_id == "EFFECT":
-                            active_effect = visible[0].get("owner", "")
-                        elif component_id == "COLOR":
-                            active_color = visible[0].get("value", {}).get("RGB")
-
-            return {
-                "connected": True,
-                "is_on": components.get("ALL", False),
-                "ledstream_on": components.get("LEDDEVICE", False),
-                "hostname": info.get("hostname", "unknown"),
-                "version": info.get("version", "unknown"),
-                "message": "Hyperion is ON" if components.get("ALL", False) else "Hyperion is OFF",
-                "active_priority": active_priority,
-                "active_effect": active_effect,
-                "active_color": active_color,
-                "our_priority_active": active_priority == self.priority if active_priority else False
-            }
-
-        return result
-
-    def set_power(self, state: int, check_current: bool = True) -> Dict:
-        """
-        Set Hyperion power state (component control)
-        Args:
-            state: 0=Off, 1=On, 2=Toggle
-            check_current: If True, check current state and skip if already in desired state
-        """
-        if state not in [0, 1, 2]:
-            return {"connected": False, "message": "Power state must be 0 (Off), 1 (On), or 2 (Toggle)"}
-
-        # Always check current state for toggle or when check_current is enabled
-        if state == 2 or check_current:
-            status = self.check_hyperion_status()
-            if not status.get("connected"):
-                return status
-
-            current_state = status.get("is_on", False)
-
-            if state == 2:
-                # Toggle: flip the current state
-                state = 0 if current_state else 1
-            elif check_current:
-                # Check if already in desired state
-                desired_state = bool(state)
-                if current_state == desired_state:
-                    logger.debug(f"Hyperion already {'ON' if desired_state else 'OFF'}, skipping power command")
-                    return {
-                        "connected": True,
-                        "message": f"Already in desired state ({'ON' if desired_state else 'OFF'})",
-                        "skipped": True
-                    }
-
-        result = self._send_command(
-            "componentstate",
-            componentstate={
-                "component": "ALL",
-                "state": bool(state)
-            }
-        )
-
-        return result
-
-    def set_color(self, r: int = 0, g: int = 0, b: int = 0, duration: int = 86400000) -> Dict:
-        """
-        Set solid color on Hyperion
-        Args:
-            r, g, b: RGB values (0-255)
-            duration: Duration in milliseconds (default = 86400000ms = 24 hours)
-                     Note: Some Hyperion instances don't support duration=0 for infinite
-        """
-        if not all(0 <= val <= 255 for val in [r, g, b]):
-            return {"connected": False, "message": "RGB values must be between 0 and 255"}
-
-        # Turn on Hyperion first
-        self.set_power(1)
-        # Clear priority before setting new color
-        self.clear_priority()
-
-        result = self._send_command(
-            "color",
-            priority=self.priority,
-            color=[r, g, b],
-            duration=duration
-        )
-
-        return result
-
-    def set_effect(self, effect_name: str, args: Optional[Dict] = None, duration: int = 86400000, check_current: bool = True) -> Dict:
-        """
-        Set Hyperion effect
-        Args:
-            effect_name: Name of the effect (e.g., 'Rainbow swirl', 'Warm mood blobs')
-            args: Optional effect arguments
-            duration: Duration in milliseconds (default = 86400000ms = 24 hours)
-            check_current: If True, check if effect is already active and skip if so
-        """
-        # Check current state if requested
-        if check_current:
-            status = self.check_hyperion_status()
-            if not status.get("connected"):
-                return status
-
-            # Check if the same effect is already active at our priority
-            if status.get("our_priority_active") and status.get("active_effect") == effect_name:
-                logger.debug(f"Effect '{effect_name}' already active at our priority, skipping")
-                return {
-                    "connected": True,
-                    "message": f"Effect '{effect_name}' already active",
-                    "skipped": True
-                }
-
-            # Ensure Hyperion is on (with state check)
-            self.set_power(1, check_current=True)
-        else:
-            # Turn on without checking
-            self.set_power(1, check_current=False)
-
-        # Clear priority before setting new effect
-        self.clear_priority()
-
-        params = {
-            "priority": self.priority,
-            "effect": {"name": effect_name},
-            "duration": duration
-        }
-
-        if args:
-            params["effect"]["args"] = args
-
-        result = self._send_command("effect", **params)
-        return result
-
-    def clear_priority(self, priority: Optional[int] = None, check_current: bool = True) -> Dict:
-        """
-        Clear a specific priority or Dune Weaver's priority
-        Args:
-            priority: Priority to clear (defaults to self.priority)
-            check_current: If True, check if priority is active before clearing
-        """
-        if priority is None:
-            priority = self.priority
-
-        # Check if the priority is actually active
-        if check_current:
-            status = self.check_hyperion_status()
-            if not status.get("connected"):
-                return status
-
-            # If our priority isn't active, no need to clear
-            if priority == self.priority and not status.get("our_priority_active"):
-                logger.debug(f"Priority {priority} not active, skipping clear")
-                return {
-                    "connected": True,
-                    "message": f"Priority {priority} not active",
-                    "skipped": True
-                }
-
-        result = self._send_command("clear", priority=priority)
-        return result
-
-    def clear_all(self) -> Dict:
-        """Clear all priorities (return to default state)"""
-        result = self._send_command("clear", priority=-1)
-        return result
-
-    def set_brightness(self, value: int) -> Dict:
-        """
-        Set Hyperion brightness
-        Args:
-            value: Brightness (0-100)
-        """
-        if not 0 <= value <= 100:
-            return {"connected": False, "message": "Brightness must be between 0 and 100"}
-
-        result = self._send_command(
-            "adjustment",
-            adjustment={
-                "brightness": value
-            }
-        )
-
-        return result
-
-
-def effect_loading(hyperion_controller: HyperionController) -> bool:
-    """Show loading effect - Atomic swirl effect"""
-    try:
-        # Set effect with smart checking (will check power state and current effect)
-        res = hyperion_controller.set_effect("Atomic swirl", check_current=True)
-        return res.get('connected', False)
-    except Exception as e:
-        logger.error(f"Error in effect_loading: {e}")
-        return False
-
-
-def effect_idle(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
-    """Show idle effect - use configured effect or clear priority to return to default
-
-    Args:
-        effect_name: Effect name to show, "off" to clear priority (default), or None for off
-    """
-    try:
-        if effect_name and effect_name != "off":
-            # Set effect with smart checking (will check power state and current effect)
-            res = hyperion_controller.set_effect(effect_name, check_current=True)
-        else:
-            # Clear priority with smart checking (only if our priority is active)
-            res = hyperion_controller.clear_priority(check_current=True)
-
-        return res.get('connected', False)
-    except Exception as e:
-        logger.error(f"Error in effect_idle: {e}")
-        return False
-
-
-def effect_connected(hyperion_controller: HyperionController) -> bool:
-    """Show connected effect - green flash
-
-    Note: This function only shows the connection flash. The calling code
-    should explicitly set the idle effect afterwards to ensure the user's
-    configured idle effect is used.
-    """
-    try:
-        # Turn on Hyperion and clear in one go
-        hyperion_controller.set_power(1)
-        time.sleep(0.1)  # Reduced blocking time
-        hyperion_controller.clear_priority()
-
-        # Single green flash instead of double - reduces load
-        res = hyperion_controller.set_color(r=8, g=255, b=0, duration=1000)
-        time.sleep(1.0)  # Wait for flash to complete
-        # Don't call effect_idle here - let the caller set the configured idle effect
-        return res.get('connected', False)
-    except Exception as e:
-        logger.error(f"Error in effect_connected: {e}")
-        return False
-
-
-def effect_playing(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
-    """Show playing effect - use configured effect or clear to show default
-
-    Args:
-        effect_name: Effect name to show, "off" to clear priority (default), or None for off
-    """
-    try:
-        if effect_name and effect_name != "off":
-            # Set effect with smart checking (will check power state and current effect)
-            res = hyperion_controller.set_effect(effect_name, check_current=True)
-        else:
-            # Clear priority with smart checking (only if our priority is active)
-            res = hyperion_controller.clear_priority(check_current=True)
-
-        return res.get('connected', False)
-    except Exception as e:
-        logger.error(f"Error in effect_playing: {e}")
-        return False

+ 35 - 21
modules/led/led_interface.py

@@ -1,13 +1,13 @@
 """
-Unified LED interface for different LED control systems (WLED, Hyperion, etc.)
+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
-from modules.led.hyperion_controller import HyperionController, effect_loading as hyperion_loading, effect_idle as hyperion_idle, effect_connected as hyperion_connected, effect_playing as hyperion_playing
+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
 
 
-LEDProviderType = Literal["wled", "hyperion", "none"]
+LEDProviderType = Literal["wled", "dw_leds", "none"]
 
 
 class LEDInterface:
@@ -16,30 +16,44 @@ class LEDInterface:
     Automatically delegates to the appropriate controller based on configuration.
     """
 
-    def __init__(self, provider: LEDProviderType = "none", ip_address: Optional[str] = None, port: Optional[int] = None):
+    def __init__(self, provider: LEDProviderType = "none", ip_address: Optional[str] = None,
+                 num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, brightness: Optional[float] = None):
         self.provider = provider
         self._controller = None
 
         if provider == "wled" and ip_address:
             self._controller = LEDController(ip_address)
-        elif provider == "hyperion" and ip_address:
-            port = port or 8090  # Default Hyperion port
-            self._controller = HyperionController(ip_address, port)
+        elif provider == "dw_leds":
+            # DW LEDs uses local GPIO, no IP needed
+            num_leds = num_leds or 60
+            gpio_pin = gpio_pin or 12
+            brightness = brightness if brightness is not None else 0.35
+            self._controller = DWLEDController(num_leds, gpio_pin, brightness)
 
     @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, port: Optional[int] = None):
+    def update_config(self, provider: LEDProviderType, ip_address: Optional[str] = None,
+                     num_leds: Optional[int] = None, gpio_pin: Optional[int] = None, brightness: Optional[float] = 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 == "hyperion" and ip_address:
-            port = port or 8090
-            self._controller = HyperionController(ip_address, port)
+        elif provider == "dw_leds":
+            num_leds = num_leds or 60
+            gpio_pin = gpio_pin or 12
+            brightness = brightness if brightness is not None else 0.35
+            self._controller = DWLEDController(num_leds, gpio_pin, brightness)
         else:
             self._controller = None
 
@@ -50,8 +64,8 @@ class LEDInterface:
 
         if self.provider == "wled":
             return wled_loading(self._controller)
-        elif self.provider == "hyperion":
-            return hyperion_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:
@@ -61,8 +75,8 @@ class LEDInterface:
 
         if self.provider == "wled":
             return wled_idle(self._controller)
-        elif self.provider == "hyperion":
-            return hyperion_idle(self._controller, effect_name)
+        elif self.provider == "dw_leds":
+            return dw_led_idle(self._controller, effect_name)
         return False
 
     def effect_connected(self) -> bool:
@@ -72,8 +86,8 @@ class LEDInterface:
 
         if self.provider == "wled":
             return wled_connected(self._controller)
-        elif self.provider == "hyperion":
-            return hyperion_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:
@@ -83,8 +97,8 @@ class LEDInterface:
 
         if self.provider == "wled":
             return wled_playing(self._controller)
-        elif self.provider == "hyperion":
-            return hyperion_playing(self._controller, effect_name)
+        elif self.provider == "dw_leds":
+            return dw_led_playing(self._controller, effect_name)
         return False
 
     def set_power(self, state: int) -> dict:
@@ -101,8 +115,8 @@ class LEDInterface:
 
         if self.provider == "wled":
             return self._controller.check_wled_status()
-        elif self.provider == "hyperion":
-            return self._controller.check_hyperion_status()
+        elif self.provider == "dw_leds":
+            return self._controller.check_status()
 
         return {"connected": False, "message": "Unknown provider"}
 

+ 3 - 1
requirements.txt

@@ -12,4 +12,6 @@ python-multipart>=0.0.6
 websockets>=11.0.3  # Required for FastAPI WebSocket support
 requests>=2.31.0
 Pillow
-aiohttp
+aiohttp
+adafruit-circuitpython-neopixel>=6.3.0
+Adafruit-Blinka>=8.0.0

+ 2 - 2
static/js/base.js

@@ -10,8 +10,8 @@ async function updateLedNavLabel() {
             if (navLabel) {
                 if (data.provider === 'wled') {
                     navLabel.textContent = 'WLED';
-                } else if (data.provider === 'hyperion') {
-                    navLabel.textContent = 'Hyperion';
+                } else if (data.provider === 'dw_leds') {
+                    navLabel.textContent = 'DW LEDs';
                 } else {
                     navLabel.textContent = 'LED';
                 }

+ 288 - 147
static/js/led-control.js

@@ -1,11 +1,10 @@
-// LED Control Page - Unified interface for WLED and Hyperion
+// LED Control Page - Unified interface for WLED and DW LEDs
 
 let ledConfig = null;
-let hyperionController = null;
 
 // Utility function to show status messages
 function showStatus(message, type = 'info') {
-    const statusDiv = document.getElementById('hyperion-status');
+    const statusDiv = document.getElementById('dw-leds-status');
     if (!statusDiv) return;
 
     const iconMap = {
@@ -44,12 +43,12 @@ async function initializeLedPage() {
 
         const notConfigured = document.getElementById('led-not-configured');
         const wledContainer = document.getElementById('wled-container');
-        const hyperionContainer = document.getElementById('hyperion-container');
+        const dwLedsContainer = document.getElementById('dw-leds-container');
 
         // Hide all containers first
         notConfigured.classList.add('hidden');
         wledContainer.classList.add('hidden');
-        hyperionContainer.classList.add('hidden');
+        dwLedsContainer.classList.add('hidden');
 
         if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
             // Show WLED iframe
@@ -58,10 +57,10 @@ async function initializeLedPage() {
             if (wledFrame) {
                 wledFrame.src = `http://${ledConfig.wled_ip}`;
             }
-        } else if (ledConfig.provider === 'hyperion' && ledConfig.hyperion_ip) {
-            // Show Hyperion controls
-            hyperionContainer.classList.remove('hidden');
-            await initializeHyperionControls();
+        } 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');
@@ -72,45 +71,38 @@ async function initializeLedPage() {
     }
 }
 
-// Initialize Hyperion controls
-async function initializeHyperionControls() {
-    // Create API helper
-    hyperionController = {
-        async sendCommand(endpoint, data) {
-            try {
-                const response = await fetch(`/api/hyperion/${endpoint}`, {
-                    method: 'POST',
-                    headers: { 'Content-Type': 'application/json' },
-                    body: JSON.stringify(data)
-                });
-
-                if (!response.ok) throw new Error(`HTTP ${response.status}`);
-                return await response.json();
-            } catch (error) {
-                throw error;
-            }
-        }
-    };
-
-    // Check connection status and load effects
-    await checkHyperionStatus();
-    await loadEffectsList();
+// Initialize DW LEDs controls
+async function initializeDWLedsControls() {
+    // Check status and load available effects/palettes
+    await checkDWLedsStatus();
+    await loadEffectsAndPalettes();
 
     // Power toggle button
-    document.getElementById('hyperion-power-toggle')?.addEventListener('click', async () => {
+    document.getElementById('dw-leds-power-toggle')?.addEventListener('click', async () => {
         try {
-            // Toggle using state 2
-            await hyperionController.sendCommand('power', { state: 2 });
-            showStatus('Power toggled', 'success');
-            await checkHyperionStatus();
+            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('hyperion-brightness');
-    const brightnessValue = document.getElementById('brightness-value');
+    const brightnessSlider = document.getElementById('dw-leds-brightness');
+    const brightnessValue = document.getElementById('dw-leds-brightness-value');
 
     brightnessSlider?.addEventListener('input', (e) => {
         brightnessValue.textContent = `${e.target.value}%`;
@@ -118,16 +110,28 @@ async function initializeHyperionControls() {
 
     brightnessSlider?.addEventListener('change', async (e) => {
         try {
-            await hyperionController.sendCommand('brightness', { value: parseInt(e.target.value) });
-            showStatus(`Brightness set to ${e.target.value}%`, 'success');
+            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');
         }
     });
 
     // Color picker - update display when color changes
-    const colorPicker = document.getElementById('hyperion-color');
-    const colorHexDisplay = document.getElementById('color-hex-display');
+    const colorPicker = document.getElementById('dw-leds-color');
+    const colorHexDisplay = document.getElementById('dw-leds-color-hex');
 
     colorPicker?.addEventListener('input', (e) => {
         if (colorHexDisplay) {
@@ -136,62 +140,137 @@ async function initializeHyperionControls() {
     });
 
     // Color picker - apply button
-    document.getElementById('hyperion-set-color')?.addEventListener('click', async () => {
-        const hexColor = colorPicker.value;
+    document.getElementById('dw-leds-set-color')?.addEventListener('click', async () => {
+        await applyColor(colorPicker.value);
+    });
+
+    // Quick color buttons
+    document.querySelectorAll('.dw-leds-quick-color').forEach(button => {
+        button.addEventListener('click', async () => {
+            const hexColor = button.getAttribute('data-color');
+            await applyColor(hexColor);
+
+            // Update color picker and hex display to match
+            if (colorPicker) colorPicker.value = hexColor;
+            if (colorHexDisplay) colorHexDisplay.textContent = hexColor.toUpperCase();
+        });
+    });
+
+    // Effect selector
+    document.getElementById('dw-leds-effect-select')?.addEventListener('change', async (e) => {
+        const effectId = parseInt(e.target.value);
+        if (isNaN(effectId)) return;
 
         try {
-            await hyperionController.sendCommand('color', { hex: hexColor });
-            showStatus(`Color set to ${hexColor.toUpperCase()}`, 'success');
+            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');
+            } else {
+                showStatus(data.error || 'Failed to set effect', 'error');
+            }
         } catch (error) {
-            showStatus(`Failed to set color: ${error.message}`, 'error');
+            showStatus(`Failed to set effect: ${error.message}`, 'error');
         }
     });
 
-    // Quick color buttons
-    document.querySelectorAll('.quick-color').forEach(button => {
-        button.addEventListener('click', async () => {
-            const hexColor = button.getAttribute('data-color');
+    // 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 })
+            });
 
-            try {
-                await hyperionController.sendCommand('color', { hex: hexColor });
-                showStatus(`Color set to ${hexColor.toUpperCase()}`, 'success');
-
-                // Update color picker and hex display to match
-                const colorPicker = document.getElementById('hyperion-color');
-                const colorHexDisplay = document.getElementById('color-hex-display');
-                if (colorPicker) colorPicker.value = hexColor;
-                if (colorHexDisplay) colorHexDisplay.textContent = hexColor.toUpperCase();
-            } catch (error) {
-                showStatus(`Failed to set color: ${error.message}`, 'error');
+            if (!response.ok) throw new Error(`HTTP ${response.status}`);
+            const data = await response.json();
+
+            if (data.connected) {
+                showStatus(`Palette changed`, 'success');
+            } else {
+                showStatus(data.error || 'Failed to set palette', 'error');
             }
-        });
+        } catch (error) {
+            showStatus(`Failed to set palette: ${error.message}`, 'error');
+        }
     });
 
-    // Effects selection
-    document.getElementById('hyperion-set-effect')?.addEventListener('click', async () => {
-        const effectSelect = document.getElementById('hyperion-effect-select');
-        const effectName = effectSelect.value;
+    // 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 (!effectName) {
-            showStatus('Please select an effect', 'warning');
-            return;
+            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 {
-            await hyperionController.sendCommand('effect', { effect_name: effectName });
-            showStatus(`Effect '${effectName}' activated`, 'success');
+            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 effect: ${error.message}`, 'error');
+            showStatus(`Failed to set intensity: ${error.message}`, 'error');
         }
     });
 
-    // Save effect settings button
-    document.getElementById('save-hyperion-effects')?.addEventListener('click', async () => {
+    // Save automation effects button
+    document.getElementById('dw-leds-save-effects')?.addEventListener('click', async () => {
         try {
-            const idleEffect = document.getElementById('hyperion-idle-effect')?.value || '';
-            const playingEffect = document.getElementById('hyperion-playing-effect')?.value || '';
+            const idleEffect = document.getElementById('dw-leds-idle-effect')?.value || 'off';
+            const playingEffect = document.getElementById('dw-leds-playing-effect')?.value || 'off';
 
-            const response = await fetch('/api/hyperion/set_effects', {
+            const response = await fetch('/api/dw_leds/set_effects', {
                 method: 'POST',
                 headers: { 'Content-Type': 'application/json' },
                 body: JSON.stringify({
@@ -210,99 +289,128 @@ async function initializeHyperionControls() {
     });
 }
 
-// Load available Hyperion effects
-async function loadEffectsList() {
+// Helper function to apply color
+async function applyColor(hexColor) {
     try {
-        const response = await fetch('/api/hyperion/effects');
-        if (!response.ok) throw new Error(`HTTP ${response.status}`);
+        // Convert hex to RGB
+        const r = parseInt(hexColor.slice(1, 3), 16);
+        const g = parseInt(hexColor.slice(3, 5), 16);
+        const b = parseInt(hexColor.slice(5, 7), 16);
+
+        const response = await fetch('/api/dw_leds/color', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ r, g, b })
+        });
 
+        if (!response.ok) throw new Error(`HTTP ${response.status}`);
         const data = await response.json();
-        const effectSelect = document.getElementById('hyperion-effect-select');
-        const idleEffectSelect = document.getElementById('hyperion-idle-effect');
-        const playingEffectSelect = document.getElementById('hyperion-playing-effect');
 
-        if (!effectSelect) return;
-
-        // Clear loading option
-        effectSelect.innerHTML = '<option value="">-- Select an effect --</option>';
-
-        // Add "Default (Off)" option to idle and playing effect selectors
-        if (idleEffectSelect) {
-            idleEffectSelect.innerHTML = '<option value="off">Default (Off)</option>';
-        }
-        if (playingEffectSelect) {
-            playingEffectSelect.innerHTML = '<option value="off">Default (Off)</option>';
+        if (data.connected) {
+            showStatus(`Color set to ${hexColor.toUpperCase()}`, 'success');
+        } else {
+            showStatus(data.error || 'Failed to set color', 'error');
         }
+    } catch (error) {
+        showStatus(`Failed to set color: ${error.message}`, 'error');
+    }
+}
 
-        // Add effects to all dropdowns
-        if (data.effects && data.effects.length > 0) {
-            data.effects.forEach(effect => {
-                // Main effect selector
-                const option = document.createElement('option');
-                option.value = effect.name;
-                option.textContent = effect.name;
-                effectSelect.appendChild(option);
-
-                // Idle effect selector
-                if (idleEffectSelect) {
-                    const idleOption = document.createElement('option');
-                    idleOption.value = effect.name;
-                    idleOption.textContent = effect.name;
-                    idleEffectSelect.appendChild(idleOption);
-                }
+// 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 = '';
+                effectsData.effects.forEach(([id, name]) => {
+                    const option = document.createElement('option');
+                    option.value = id;
+                    option.textContent = name;
+                    effectSelect.appendChild(option);
+                });
+            }
 
-                // Playing effect selector
-                if (playingEffectSelect) {
-                    const playingOption = document.createElement('option');
-                    playingOption.value = effect.name;
-                    playingOption.textContent = effect.name;
-                    playingEffectSelect.appendChild(playingOption);
-                }
-            });
+            // Add effects to automation selectors
+            if (idleEffectSelect && effectsData.effects) {
+                idleEffectSelect.innerHTML = '<option value="off">Off</option>';
+                effectsData.effects.forEach(([id, name]) => {
+                    const option = document.createElement('option');
+                    option.value = name.toLowerCase();
+                    option.textContent = name;
+                    idleEffectSelect.appendChild(option);
+                });
+            }
 
-            // Load saved settings from config
+            if (playingEffectSelect && effectsData.effects) {
+                playingEffectSelect.innerHTML = '<option value="off">Off</option>';
+                effectsData.effects.forEach(([id, 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.hyperion_idle_effect) {
-                    idleEffectSelect.value = config.hyperion_idle_effect;
+                if (idleEffectSelect && config.dw_led_idle_effect) {
+                    idleEffectSelect.value = config.dw_led_idle_effect;
                 }
-                if (playingEffectSelect && config.hyperion_playing_effect) {
-                    playingEffectSelect.value = config.hyperion_playing_effect;
+                if (playingEffectSelect && config.dw_led_playing_effect) {
+                    playingEffectSelect.value = config.dw_led_playing_effect;
                 }
             }
-        } else {
-            effectSelect.innerHTML = '<option value="">No effects available</option>';
         }
-    } catch (error) {
-        console.error('Failed to load effects:', error);
-        const effectSelect = document.getElementById('hyperion-effect-select');
-        if (effectSelect) {
-            effectSelect.innerHTML = '<option value="">Failed to load effects</option>';
+
+        // 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 = '';
+                palettesData.palettes.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');
     }
 }
 
-// Check Hyperion connection status
-async function checkHyperionStatus() {
+// Check DW LEDs connection status
+async function checkDWLedsStatus() {
     try {
-        const response = await fetch('/api/hyperion/status');
+        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 version = data.version || 'unknown';
-            const hostname = data.hostname || 'unknown';
-            const isOn = data.is_on;
-            const state = isOn ? 'ON' : 'OFF';
+            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 - shows current state with appropriate action
-            const powerButton = document.getElementById('hyperion-power-toggle');
-            const powerButtonText = document.getElementById('power-button-text');
+            // Update power button appearance
+            const powerButton = document.getElementById('dw-leds-power-toggle');
+            const powerButtonText = document.getElementById('dw-leds-power-text');
 
             if (powerButton && powerButtonText) {
-                if (isOn) {
+                if (data.power_on) {
                     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 {
@@ -311,12 +419,45 @@ async function checkHyperionStatus() {
                 }
             }
 
-            showStatus(`Connected to ${hostname} (${version}) - Power: ${state}`, 'success');
+            // 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;
+            }
         } else {
-            showStatus(`Connection failed: ${data.message}`, 'error');
+            // Show error message from controller
+            const errorMsg = data.error || 'Connection failed';
+            showStatus(errorMsg, 'error');
         }
     } catch (error) {
-        showStatus(`Cannot connect to Hyperion: ${error.message}`, 'error');
+        showStatus(`Cannot connect to DW LEDs: ${error.message}`, 'error');
     }
 }
 

+ 28 - 38
static/js/settings.js

@@ -155,18 +155,18 @@ function setWledButtonState(isSet) {
 function updateLedProviderUI() {
     const provider = document.querySelector('input[name="ledProvider"]:checked')?.value || 'none';
     const wledConfig = document.getElementById('wledConfig');
-    const hyperionConfig = document.getElementById('hyperionConfig');
+    const dwLedsConfig = document.getElementById('dwLedsConfig');
 
-    if (wledConfig && hyperionConfig) {
+    if (wledConfig && dwLedsConfig) {
         if (provider === 'wled') {
             wledConfig.classList.remove('hidden');
-            hyperionConfig.classList.add('hidden');
-        } else if (provider === 'hyperion') {
+            dwLedsConfig.classList.add('hidden');
+        } else if (provider === 'dw_leds') {
             wledConfig.classList.add('hidden');
-            hyperionConfig.classList.remove('hidden');
+            dwLedsConfig.classList.remove('hidden');
         } else {
             wledConfig.classList.add('hidden');
-            hyperionConfig.classList.add('hidden');
+            dwLedsConfig.classList.add('hidden');
         }
     }
 }
@@ -194,17 +194,17 @@ async function loadLedConfig() {
                 }
             }
 
-            // Set Hyperion IP and port if configured
-            if (data.hyperion_ip) {
-                const hyperionIpInput = document.getElementById('hyperionIpInput');
-                if (hyperionIpInput) {
-                    hyperionIpInput.value = data.hyperion_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.hyperion_port) {
-                const hyperionPortInput = document.getElementById('hyperionPortInput');
-                if (hyperionPortInput) {
-                    hyperionPortInput.value = data.hyperion_port;
+            if (data.dw_led_gpio_pin) {
+                const gpioPinInput = document.getElementById('dwLedGpioPin');
+                if (gpioPinInput) {
+                    gpioPinInput.value = data.dw_led_gpio_pin;
                 }
             }
 
@@ -227,7 +227,7 @@ document.addEventListener('DOMContentLoaded', async () => {
         fetch('/serial_status').then(response => response.json()).catch(() => ({ connected: false })),
 
         // Load LED configuration (replaces old WLED-only loading)
-        fetch('/get_led_config').then(response => response.json()).catch(() => ({ provider: 'none', wled_ip: null, hyperion_ip: null, hyperion_port: 8090 })),
+        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 })),
@@ -267,16 +267,6 @@ document.addEventListener('DOMContentLoaded', async () => {
             if (wledIpInput) wledIpInput.value = ledConfigData.wled_ip;
         }
 
-        if (ledConfigData.hyperion_ip) {
-            const hyperionIpInput = document.getElementById('hyperionIpInput');
-            if (hyperionIpInput) hyperionIpInput.value = ledConfigData.hyperion_ip;
-        }
-
-        if (ledConfigData.hyperion_port) {
-            const hyperionPortInput = document.getElementById('hyperionPortInput');
-            if (hyperionPortInput) hyperionPortInput.value = ledConfigData.hyperion_port;
-        }
-
         updateLedProviderUI()
         
         // Update version display
@@ -462,15 +452,12 @@ function setupEventListeners() {
                     return;
                 }
                 requestBody.ip_address = wledIp;
-            } else if (provider === 'hyperion') {
-                const hyperionIp = document.getElementById('hyperionIpInput')?.value;
-                const hyperionPort = parseInt(document.getElementById('hyperionPortInput')?.value) || 8090;
-                if (!hyperionIp) {
-                    showStatusMessage('Please enter a Hyperion IP address', 'error');
-                    return;
-                }
-                requestBody.ip_address = hyperionIp;
-                requestBody.port = hyperionPort;
+            } else if (provider === 'dw_leds') {
+                const numLeds = parseInt(document.getElementById('dwLedNumLeds')?.value) || 60;
+                const gpioPin = parseInt(document.getElementById('dwLedGpioPin')?.value) || 12;
+
+                requestBody.num_leds = numLeds;
+                requestBody.gpio_pin = gpioPin;
             }
 
             try {
@@ -486,14 +473,17 @@ function setupEventListeners() {
                     if (provider === 'wled' && data.wled_ip) {
                         localStorage.setItem('wled_ip', data.wled_ip);
                         showStatusMessage('WLED configured successfully', 'success');
-                    } else if (provider === 'hyperion' && data.hyperion_ip) {
-                        showStatusMessage('Hyperion configured successfully', 'success');
+                    } else if (provider === 'dw_leds') {
+                        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');
                     }
                 } else {
-                    throw new Error('Failed to save LED configuration');
+                    // 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');

+ 25 - 29
templates/settings.html

@@ -488,8 +488,8 @@ input:checked + .slider:before {
             <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="hyperion" id="ledProviderHyperion" class="w-4 h-4 text-sky-600 border-slate-300 focus:ring-sky-500">
-            <span class="text-sm text-slate-700">Hyperion</span>
+            <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">
@@ -523,40 +523,36 @@ input:checked + .slider:before {
         </label>
       </div>
 
-      <!-- Hyperion Configuration (shown when Hyperion is selected) -->
-      <div id="hyperionConfig" class="flex flex-col gap-4 hidden">
+      <!-- DW LEDs Configuration (shown when DW LEDs is selected) -->
+      <div id="dwLedsConfig" 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">Hyperion IP Address</span>
-          <div class="relative flex-1">
-            <input
-              id="hyperionIpInput"
-              class="form-input flex w-full min-w-0 resize-none overflow-hidden rounded-lg text-slate-900 focus:outline-0 focus:ring-2 focus:ring-sky-500 border border-slate-300 bg-white focus:border-sky-500 h-10 placeholder:text-slate-400 px-4 pr-10 text-base font-normal leading-normal transition-colors"
-              placeholder="e.g., 192.168.1.200"
-              value=""
-            />
-            <button
-              type="button"
-              onclick="document.getElementById('hyperionIpInput').value='';"
-              class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-700"
-              aria-label="Clear Hyperion IP"
-            >
-              <span class="material-icons">close</span>
-            </button>
-          </div>
-        </label>
-        <label class="flex flex-col gap-1.5">
-          <span class="text-slate-700 text-sm font-medium leading-normal">Hyperion Port</span>
+          <span class="text-slate-700 text-sm font-medium leading-normal">Number of LEDs</span>
           <input
-            id="hyperionPortInput"
+            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="8090"
-            value="8090"
+            placeholder="60"
+            value="60"
             min="1"
-            max="65535"
+            max="1000"
           />
           <p class="text-xs text-slate-500">
-            Enter the JSON-RPC port (default: 8090)
+            Total number of LEDs in your 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 best performance
           </p>
         </label>
       </div>

+ 53 - 33
templates/wled.html

@@ -105,7 +105,7 @@
           <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 Hyperion) in the Settings page.</p>
+            <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
@@ -130,15 +130,15 @@
     </div>
   </section>
 
-  <!-- Hyperion Controls -->
-  <section id="hyperion-container" class="bg-white rounded-xl shadow-sm overflow-hidden hidden">
+  <!-- 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]">
-        Hyperion Control
+        DW LEDs Control
       </h2>
 
       <!-- Connection Status -->
-      <div id="hyperion-status" class="p-4 rounded-lg bg-gray-100 border border-slate-200">
+      <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>
@@ -150,9 +150,9 @@
         <!-- Power Control -->
         <div class="flex flex-col gap-3">
           <h3 class="text-slate-800 text-base font-semibold">Power</h3>
-          <button id="hyperion-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">
+          <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="power-button-text">Turn ON</span>
+            <span id="dw-leds-power-text">Turn ON</span>
           </button>
         </div>
 
@@ -160,9 +160,9 @@
         <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="brightness-value" class="text-sm font-medium text-slate-600">100%</span>
+            <span id="dw-leds-brightness-value" class="text-sm font-medium text-slate-600">35%</span>
           </div>
-          <input type="range" id="hyperion-brightness" min="0" max="100" value="100" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
+          <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>
 
@@ -171,10 +171,10 @@
         <h3 class="text-slate-800 text-base font-semibold">Color Picker</h3>
         <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
           <div class="flex items-center gap-3">
-            <input type="color" id="hyperion-color" value="#ff0000" class="w-20 h-20 rounded-lg border-2 border-slate-300 cursor-pointer">
+            <input type="color" id="dw-leds-color" value="#ff0000" class="w-20 h-20 rounded-lg border-2 border-slate-300 cursor-pointer">
             <div class="flex flex-col gap-1">
-              <span id="color-hex-display" class="text-sm font-mono font-semibold text-slate-700">#FF0000</span>
-              <button id="hyperion-set-color" class="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-3 py-2 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 id="dw-leds-color-hex" class="text-sm font-mono font-semibold text-slate-700">#FF0000</span>
+              <button id="dw-leds-set-color" class="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-3 py-2 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 text-base">palette</span>
                 <span>Apply</span>
               </button>
@@ -185,33 +185,53 @@
           <div class="flex flex-col gap-2">
             <span class="text-sm font-medium text-slate-600">Quick Colors</span>
             <div class="grid grid-cols-4 gap-2">
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff0000" style="background-color: #ff0000;" title="Red"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#00ff00" style="background-color: #00ff00;" title="Green"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#0000ff" style="background-color: #0000ff;" title="Blue"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ffff00" style="background-color: #ffff00;" title="Yellow"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff00ff" style="background-color: #ff00ff;" title="Magenta"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#00ffff" style="background-color: #00ffff;" title="Cyan"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff8000" style="background-color: #ff8000;" title="Orange"></button>
-              <button class="quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ffffff" style="background-color: #ffffff;" title="White"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff0000" style="background-color: #ff0000;" title="Red"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#00ff00" style="background-color: #00ff00;" title="Green"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#0000ff" style="background-color: #0000ff;" title="Blue"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ffff00" style="background-color: #ffff00;" title="Yellow"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff00ff" style="background-color: #ff00ff;" title="Magenta"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#00ffff" style="background-color: #00ffff;" title="Cyan"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ff8000" style="background-color: #ff8000;" title="Orange"></button>
+              <button class="dw-leds-quick-color h-10 rounded-lg border-2 border-slate-300 hover:scale-105 transition-transform" data-color="#ffffff" style="background-color: #ffffff;" title="White"></button>
             </div>
           </div>
         </div>
       </div>
 
-      <!-- Effects -->
+      <!-- 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">Effects</h3>
-          <select id="hyperion-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">
+          <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 opacity-0 pointer-events-none">Spacer</h3>
-          <button id="hyperion-set-effect" class="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">auto_awesome</span>
-            <span>Apply Effect</span>
-          </button>
+          <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>
 
@@ -224,21 +244,21 @@
           <!-- Idle Effect -->
           <div class="flex flex-col gap-2">
             <label class="text-sm font-medium text-slate-700">Idle Effect</label>
-            <select id="hyperion-idle-effect" class="form-select w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
-              <option value="">Default (Off)</option>
+            <select id="dw-leds-idle-effect" class="form-select w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
+              <option value="off">Off</option>
             </select>
           </div>
 
           <!-- Playing Effect -->
           <div class="flex flex-col gap-2">
             <label class="text-sm font-medium text-slate-700">Playing Effect</label>
-            <select id="hyperion-playing-effect" class="form-select w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
-              <option value="">Default (Off)</option>
+            <select id="dw-leds-playing-effect" class="form-select w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:outline-0 focus:ring-2 focus:ring-blue-500">
+              <option value="off">Off</option>
             </select>
           </div>
         </div>
 
-        <button id="save-hyperion-effects" class="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 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">
+        <button id="dw-leds-save-effects" class="flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 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 text-base">save</span>
           <span>Save Effect Settings</span>
         </button>

+ 0 - 63
test_hyperion_effects.py

@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-"""
-Test script for Hyperion loading and connected effects
-This simulates the startup sequence when LEDs are off
-"""
-
-import time
-import sys
-from modules.led.led_interface import LEDInterface
-
-# Configuration
-HYPERION_IP = "192.168.2.183"
-HYPERION_PORT = 8090
-
-def test_effects():
-    """Test Hyperion effects with LEDs off"""
-    print(f"Testing Hyperion effects at {HYPERION_IP}:{HYPERION_PORT}")
-    print("=" * 60)
-
-    # Create LED interface
-    led = LEDInterface(
-        provider="hyperion",
-        ip_address=HYPERION_IP,
-        port=HYPERION_PORT
-    )
-
-    # Test 1: Loading effect
-    print("\n1. Testing LOADING effect (orange color)...")
-    print("   - This should turn on the LEDs")
-    print("   - Clear any previous effects")
-    print("   - Show orange color")
-    result = led.effect_loading()
-    print(f"   Result: {'SUCCESS' if result else 'FAILED'}")
-
-    # Wait 5 seconds so you can see the loading effect
-    print("\n   Waiting 5 seconds...")
-    time.sleep(5)
-
-    # Test 2: Connected effect
-    print("\n2. Testing CONNECTED effect (green flash)...")
-    print("   - This should flash green twice")
-    print("   - Then return to idle state (cleared)")
-    result = led.effect_connected()
-    print(f"   Result: {'SUCCESS' if result else 'FAILED'}")
-
-    print("\n" + "=" * 60)
-    print("Test complete!")
-    print("\nDid you see:")
-    print("  1. Orange color for ~5 seconds?")
-    print("  2. Two green flashes?")
-    print("  3. LEDs return to default state after?")
-
-if __name__ == "__main__":
-    try:
-        test_effects()
-    except KeyboardInterrupt:
-        print("\n\nTest interrupted by user")
-        sys.exit(0)
-    except Exception as e:
-        print(f"\n\nERROR: {e}")
-        import traceback
-        traceback.print_exc()
-        sys.exit(1)