Răsfoiți Sursa

add wled integration

Tuan Nguyen 11 luni în urmă
părinte
comite
1dce5124bd

+ 6 - 37
app.py

@@ -18,7 +18,7 @@ import signal
 import sys
 import sys
 import asyncio
 import asyncio
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
-
+from modules.led.led_controller import LEDController, effect_idle
 # Configure logging
 # Configure logging
 logging.basicConfig(
 logging.basicConfig(
     level=logging.INFO,
     level=logging.INFO,
@@ -50,8 +50,6 @@ async def lifespan(app: FastAPI):
 
 
     yield  # This separates startup from shutdown code
     yield  # This separates startup from shutdown code
 
 
-    # Shutdown
-    await on_exit()
 
 
 app = FastAPI(lifespan=lifespan)
 app = FastAPI(lifespan=lifespan)
 templates = Jinja2Templates(directory="templates")
 templates = Jinja2Templates(directory="templates")
@@ -519,6 +517,8 @@ async def update_software():
 @app.post("/set_wled_ip")
 @app.post("/set_wled_ip")
 async def set_wled_ip(request: WLEDRequest):
 async def set_wled_ip(request: WLEDRequest):
     state.wled_ip = request.wled_ip
     state.wled_ip = request.wled_ip
+    state.led_controller = LEDController(request.wled_ip)
+    effect_idle(state.led_controller)
     state.save()
     state.save()
     logger.info(f"WLED IP updated: {request.wled_ip}")
     logger.info(f"WLED IP updated: {request.wled_ip}")
     return {"success": True, "wled_ip": state.wled_ip}
     return {"success": True, "wled_ip": state.wled_ip}
@@ -529,57 +529,26 @@ async def get_wled_ip():
         raise HTTPException(status_code=404, detail="No WLED IP set")
         raise HTTPException(status_code=404, detail="No WLED IP set")
     return {"success": True, "wled_ip": state.wled_ip}
     return {"success": True, "wled_ip": state.wled_ip}
 
 
-async def on_exit():
-    """Function to execute on application shutdown."""
-    logger.info("Shutting down gracefully, please wait for execution to complete")
-    
-    # Stop any running patterns and save state
-    pattern_manager.stop_actions()
-    state.save()
-    
-    # Clean up pattern manager resources
-    await pattern_manager.cleanup_pattern_manager()
-    
-    # Clean up MQTT resources
-    mqtt.cleanup_mqtt()
-    
-    # Clean up state resources
-    state.cleanup()
-    
-    logger.info("Cleanup completed")
 
 
 def signal_handler(signum, frame):
 def signal_handler(signum, frame):
     """Handle shutdown signals gracefully but forcefully."""
     """Handle shutdown signals gracefully but forcefully."""
     logger.info("Received shutdown signal, cleaning up...")
     logger.info("Received shutdown signal, cleaning up...")
     try:
     try:
+        if state.led_controller:
+            state.led_controller.set_power(0)
         # Run cleanup operations synchronously to ensure completion
         # Run cleanup operations synchronously to ensure completion
         pattern_manager.stop_actions()
         pattern_manager.stop_actions()
         state.save()
         state.save()
         
         
-        # Create an event loop for async cleanup
-        loop = asyncio.new_event_loop()
-        asyncio.set_event_loop(loop)
-        
-        # Run async cleanup operations
-        loop.run_until_complete(pattern_manager.cleanup_pattern_manager())
-        
-        # Clean up MQTT and state
-        mqtt.cleanup_mqtt()
-        state.cleanup()
-        
-        # Close the event loop
-        loop.close()
-        
         logger.info("Cleanup completed")
         logger.info("Cleanup completed")
     except Exception as e:
     except Exception as e:
         logger.error(f"Error during cleanup: {str(e)}")
         logger.error(f"Error during cleanup: {str(e)}")
     finally:
     finally:
-        logger.info("Forcing exit...")
+        logger.info("Exiting application...")
         os._exit(0)  # Force exit regardless of other threads
         os._exit(0)  # Force exit regardless of other threads
 
 
 def entrypoint():
 def entrypoint():
     import uvicorn
     import uvicorn
-    atexit.register(on_exit)
     logger.info("Starting FastAPI server on port 8080...")
     logger.info("Starting FastAPI server on port 8080...")
     uvicorn.run(app, host="0.0.0.0", port=8080, workers=1)  # Set workers to 1 to avoid multiple signal handlers
     uvicorn.run(app, host="0.0.0.0", port=8080, workers=1)  # Set workers to 1 to avoid multiple signal handlers
 
 

+ 8 - 1
modules/connection/connection_manager.py

@@ -7,7 +7,7 @@ import websocket
 
 
 from modules.core.state import state
 from modules.core.state import state
 from modules.core.pattern_manager import move_polar, reset_theta
 from modules.core.pattern_manager import move_polar, reset_theta
-
+from modules.led.led_controller import effect_loading, effect_idle, effect_connected, LEDController
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
 IGNORE_PORTS = ['/dev/cu.debug-console', '/dev/cu.Bluetooth-Incoming-Port']
@@ -167,6 +167,10 @@ def device_init(homing=True):
         return False
         return False
 
 
 def connect_device(homing=True):
 def connect_device(homing=True):
+    if state.wled_ip:
+        state.led_controller = LEDController(state.wled_ip)
+        effect_loading(state.led_controller)
+        
     ports = list_serial_ports()
     ports = list_serial_ports()
 
 
     if state.port and state.port in ports:
     if state.port and state.port in ports:
@@ -179,6 +183,9 @@ def connect_device(homing=True):
         return
         return
     if (state.conn.is_connected() if state.conn else False):
     if (state.conn.is_connected() if state.conn else False):
         device_init(homing)
         device_init(homing)
+        
+    if state.led_controller:
+        effect_connected(state.led_controller)
 
 
 def get_status_response() -> str:
 def get_status_response() -> str:
     """
     """

+ 37 - 33
modules/core/pattern_manager.py

@@ -10,6 +10,7 @@ from modules.core.state import state
 from math import pi
 from math import pi
 import asyncio
 import asyncio
 import json
 import json
+from modules.led.led_controller import effect_playing, effect_idle
 
 
 # Configure logging
 # Configure logging
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -34,76 +35,68 @@ pattern_lock = asyncio.Lock()
 progress_update_task = None
 progress_update_task = None
 
 
 async def cleanup_pattern_manager():
 async def cleanup_pattern_manager():
-    """Clean up pattern manager resources."""
+    """Clean up pattern manager resources"""
     global progress_update_task, pattern_lock, pause_event
     global progress_update_task, pattern_lock, pause_event
     
     
     try:
     try:
         # Cancel progress update task if running
         # Cancel progress update task if running
         if progress_update_task and not progress_update_task.done():
         if progress_update_task and not progress_update_task.done():
-            progress_update_task.cancel()
             try:
             try:
-                # Use shield to prevent cancellation of the cleanup itself
-                await asyncio.shield(progress_update_task)
-            except asyncio.CancelledError:
-                pass
+                progress_update_task.cancel()
+                # Wait for task to actually cancel
+                try:
+                    await progress_update_task
+                except asyncio.CancelledError:
+                    pass
             except Exception as e:
             except Exception as e:
                 logger.error(f"Error cancelling progress update task: {e}")
                 logger.error(f"Error cancelling progress update task: {e}")
-            finally:
-                progress_update_task = None
-
+        
         # Clean up pattern lock
         # Clean up pattern lock
         if pattern_lock:
         if pattern_lock:
             try:
             try:
                 if pattern_lock.locked():
                 if pattern_lock.locked():
-                    # Release the lock directly instead of manipulating internal state
-                    pattern_lock._locked = False
-                    for waiter in pattern_lock._waiters:
-                        if not waiter.done():
-                            waiter.set_result(True)
+                    pattern_lock.release()
+                pattern_lock = None
             except Exception as e:
             except Exception as e:
                 logger.error(f"Error cleaning up pattern lock: {e}")
                 logger.error(f"Error cleaning up pattern lock: {e}")
-            pattern_lock = None
-
+        
         # Clean up pause event
         # Clean up pause event
         if pause_event:
         if pause_event:
             try:
             try:
-                # Set the event and wake up any waiters
-                pause_event.set()
-                for waiter in pause_event._waiters:
-                    if not waiter.done():
-                        waiter.set()
+                pause_event.set()  # Wake up any waiting tasks
+                pause_event = None
             except Exception as e:
             except Exception as e:
                 logger.error(f"Error cleaning up pause event: {e}")
                 logger.error(f"Error cleaning up pause event: {e}")
-            pause_event = None
-
+        
         # Clean up pause condition from state
         # Clean up pause condition from state
         if state.pause_condition:
         if state.pause_condition:
             try:
             try:
                 with state.pause_condition:
                 with state.pause_condition:
-                    # Wake up all waiting threads
                     state.pause_condition.notify_all()
                     state.pause_condition.notify_all()
-                # Create a new condition to ensure clean state
                 state.pause_condition = threading.Condition()
                 state.pause_condition = threading.Condition()
             except Exception as e:
             except Exception as e:
                 logger.error(f"Error cleaning up pause condition: {e}")
                 logger.error(f"Error cleaning up pause condition: {e}")
 
 
         # Clear all state variables
         # Clear all state variables
         state.current_playing_file = None
         state.current_playing_file = None
-        state.execution_progress = None
-        state.current_playlist = None
-        state.current_playlist_index = None
-        state.playlist_mode = None
+        state.execution_progress = 0
+        state.is_running = False
         state.pause_requested = False
         state.pause_requested = False
         state.stop_requested = True
         state.stop_requested = True
         state.is_clearing = False
         state.is_clearing = False
         
         
-        # Reset machine state
-        connection_manager.update_machine_position()
+        # Reset machine position
+        await connection_manager.update_machine_position()
         
         
         logger.info("Pattern manager resources cleaned up")
         logger.info("Pattern manager resources cleaned up")
+        
     except Exception as e:
     except Exception as e:
         logger.error(f"Error during pattern manager cleanup: {e}")
         logger.error(f"Error during pattern manager cleanup: {e}")
-        raise
+    finally:
+        # Ensure we always reset these
+        progress_update_task = None
+        pattern_lock = None
+        pause_event = None
 
 
 def list_theta_rho_files():
 def list_theta_rho_files():
     files = []
     files = []
@@ -206,7 +199,9 @@ async def run_theta_rho_file(file_path, is_playlist=False):
         reset_theta()
         reset_theta()
         
         
         start_time = time.time()
         start_time = time.time()
-        
+        if state.led_controller:
+            effect_playing(state.led_controller)
+            
         with tqdm(
         with tqdm(
             total=total_coordinates,
             total=total_coordinates,
             unit="coords",
             unit="coords",
@@ -219,13 +214,19 @@ async def run_theta_rho_file(file_path, is_playlist=False):
                 theta, rho = coordinate
                 theta, rho = coordinate
                 if state.stop_requested:
                 if state.stop_requested:
                     logger.info("Execution stopped by user")
                     logger.info("Execution stopped by user")
+                    if state.led_controller:
+                        effect_idle(state.led_controller)
                     break
                     break
 
 
                 # Wait for resume if paused
                 # Wait for resume if paused
                 if state.pause_requested:
                 if state.pause_requested:
                     logger.info("Execution paused...")
                     logger.info("Execution paused...")
+                    if state.led_controller:
+                        effect_idle(state.led_controller)
                     await pause_event.wait()
                     await pause_event.wait()
                     logger.info("Execution resumed...")
                     logger.info("Execution resumed...")
+                    if state.led_controller:
+                        effect_playing(state.led_controller)
 
 
                 move_polar(theta, rho)
                 move_polar(theta, rho)
                 
                 
@@ -262,6 +263,9 @@ async def run_theta_rho_file(file_path, is_playlist=False):
             except asyncio.CancelledError:
             except asyncio.CancelledError:
                 pass
                 pass
             progress_update_task = None
             progress_update_task = None
+            
+        if state.led_controller:
+            effect_idle(state.led_controller)
 
 
 async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
 async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
     """Run multiple .thr files in sequence with options."""
     """Run multiple .thr files in sequence with options."""

+ 1 - 30
modules/core/state.py

@@ -39,6 +39,7 @@ class AppState:
         self.conn = None
         self.conn = None
         self.port = None
         self.port = None
         self.wled_ip = None
         self.wled_ip = None
+        self.led_controller = None
         self._playlist_mode = "loop"
         self._playlist_mode = "loop"
         self._pause_time = 0
         self._pause_time = 0
         self._clear_pattern = "none"
         self._clear_pattern = "none"
@@ -214,36 +215,6 @@ class AppState:
         self.__init__()  # Reinitialize the state
         self.__init__()  # Reinitialize the state
         self.save()
         self.save()
 
 
-    def cleanup(self):
-        """Clean up AppState resources."""
-        try:
-            # Notify all waiting threads and clean up the condition
-            if self.pause_condition:
-                try:
-                    with self.pause_condition:
-                        self.pause_condition.notify_all()
-                    # Release the underlying lock resources
-                    self.pause_condition._lock._release_save()
-                    self.pause_condition._lock = None
-                except Exception as e:
-                    logger.error(f"Error cleaning up pause condition: {e}")
-                finally:
-                    self.pause_condition = None
-            
-            # Clean up other resources
-            if self.conn:
-                try:
-                    self.conn.close()
-                except Exception as e:
-                    logger.error(f"Error closing connection: {e}")
-                finally:
-                    self.conn = None
-                    
-            self.mqtt_handler = None
-            logger.info("AppState resources cleaned up")
-        except Exception as e:
-            logger.error(f"Error during AppState cleanup: {e}")
-            raise
 
 
 # Create a singleton instance that you can import elsewhere:
 # Create a singleton instance that you can import elsewhere:
 state = AppState()
 state = AppState()

+ 250 - 0
modules/led/led_controller.py

@@ -0,0 +1,250 @@
+import requests
+import json
+from typing import Dict, Optional
+import time
+import logging
+logger = logging.getLogger(__name__)
+
+
+class LEDController:
+    def __init__(self, ip_address: Optional[str] = None):
+        self.ip_address = ip_address
+
+    def _get_base_url(self) -> str:
+        """Get base URL for WLED JSON API"""
+        if not self.ip_address:
+            raise ValueError("No WLED IP configured")
+        return f"http://{self.ip_address}/json"
+
+    def set_ip(self, ip_address: str) -> None:
+        """Update the WLED IP address"""
+        self.ip_address = ip_address
+
+    def _send_command(self, state_params: Dict = None) -> Dict:
+        """Send command to WLED and return status"""
+        try:
+            url = self._get_base_url()
+            
+            # First check current state
+            response = requests.get(f"{url}/state", timeout=2)
+            response.raise_for_status()
+            current_state = response.json()
+            
+            preset_id = current_state.get('ps', -1)
+            # If WLED is off and we're trying to set something, turn it on first
+            if not current_state.get('on', False) and state_params and 'on' not in state_params:
+                # Turn on power first
+                requests.post(f"{url}/state", json={"on": True}, timeout=2)
+            
+            # Now send the actual command if there are parameters
+            if state_params:
+                response = requests.post(f"{url}/state", json=state_params, timeout=2)
+                response.raise_for_status()
+                # Only update current_state if we got a non-empty response
+                if response.text:
+                    current_state = response.json()
+
+            # Use True as default since WLED is typically on when responding
+            is_on = current_state.get('on', True)
+            
+            return {
+                "connected": True,
+                "is_on": is_on,
+                "preset_id": preset_id,
+                "brightness": current_state.get('bri', 0),
+                "message": "WLED is ON" if is_on else "WLED is OFF"
+            }
+
+        except ValueError as e:
+            return {"connected": False, "message": str(e)}
+        except requests.RequestException as e:
+            return {"connected": False, "message": f"Cannot connect to WLED: {str(e)}"}
+        except json.JSONDecodeError as e:
+            return {"connected": False, "message": f"Error parsing WLED response: {str(e)}"}
+
+    def check_wled_status(self) -> Dict:
+        """Check WLED connection status and brightness"""
+        return self._send_command()
+
+    def set_brightness(self, value: int) -> Dict:
+        """Set WLED brightness (0-255)"""
+        if not 0 <= value <= 255:
+            return {"connected": False, "message": "Brightness must be between 0 and 255"}
+        return self._send_command({"bri": value})
+
+    def set_power(self, state: int) -> Dict:
+        """Set WLED power state (0=Off, 1=On, 2=Toggle)"""
+        if state not in [0, 1, 2]:
+            return {"connected": False, "message": "Power state must be 0 (Off), 1 (On), or 2 (Toggle)"}
+        if state == 2:
+            return self._send_command({"on": "t"})  # Toggle
+        return self._send_command({"on": bool(state)})
+
+    def _hex_to_rgb(self, hex_color: str) -> tuple:
+        """Convert hex color string to RGB tuple"""
+        hex_color = hex_color.lstrip('#')
+        if len(hex_color) != 6:
+            raise ValueError("Hex color must be 6 characters long (without #)")
+        return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
+
+    def set_color(self, r: int = None, g: int = None, b: int = None, w: int = None, hex: str = None) -> Dict:
+        """Set WLED color using RGB(W) values or hex color code"""
+        if hex is not None:
+            try:
+                r, g, b = self._hex_to_rgb(hex)
+            except ValueError as e:
+                return {"connected": False, "message": str(e)}
+
+        # Prepare segment with color
+        seg = {"col": [[r or 0, g or 0, b or 0]]}
+        if w is not None:
+            if not 0 <= w <= 255:
+                return {"connected": False, "message": "White value must be between 0 and 255"}
+            seg["col"][0].append(w)
+
+        return self._send_command({"seg": [seg]})
+
+    def set_effect(self, effect_index: int, speed: int = None, intensity: int = None, 
+                   brightness: int = None, palette: int = None,
+                   # Primary color
+                   r: int = None, g: int = None, b: int = None, w: int = None, hex: str = None,
+                   # Secondary color
+                   r2: int = None, g2: int = None, b2: int = None, w2: int = None, hex2: str = None,
+                   # Transition
+                   transition: int = 0) -> Dict:
+        """
+        Set WLED effect with optional parameters
+        Args:
+            effect_index: Effect index (0-101)
+            speed: Effect speed (0-255)
+            intensity: Effect intensity (0-255)
+            brightness: LED brightness (0-255)
+            palette: FastLED palette index (0-46)
+            r, g, b: Primary RGB color values (0-255)
+            w: Primary White value for RGBW (0-255)
+            hex: Primary hex color code (e.g., '#ff0000' or 'ff0000')
+            r2, g2, b2: Secondary RGB color values (0-255)
+            w2: Secondary White value for RGBW (0-255)
+            hex2: Secondary hex color code
+            transition: Duration of crossfade in 100ms units (e.g. 7 = 700ms). Default 0 for instant change.
+        """
+        try:
+            effect_index = int(effect_index)
+        except (ValueError, TypeError):
+            return {"connected": False, "message": "Effect index must be a valid integer between 0 and 101"}
+
+        if not 0 <= effect_index <= 101:
+            return {"connected": False, "message": "Effect index must be between 0 and 101"}
+
+        # Convert primary hex to RGB if provided
+        if hex is not None:
+            try:
+                r, g, b = self._hex_to_rgb(hex)
+            except ValueError as e:
+                return {"connected": False, "message": f"Primary color: {str(e)}"}
+
+        # Convert secondary hex to RGB if provided
+        if hex2 is not None:
+            try:
+                r2, g2, b2 = self._hex_to_rgb(hex2)
+            except ValueError as e:
+                return {"connected": False, "message": f"Secondary color: {str(e)}"}
+
+        # Build segment parameters
+        seg = {"fx": effect_index}
+        
+        if speed is not None:
+            if not 0 <= speed <= 255:
+                return {"connected": False, "message": "Speed must be between 0 and 255"}
+            seg["sx"] = speed
+        
+        if intensity is not None:
+            if not 0 <= intensity <= 255:
+                return {"connected": False, "message": "Intensity must be between 0 and 255"}
+            seg["ix"] = intensity
+
+        # Prepare colors array
+        colors = []
+        
+        # Add primary color
+        primary = [r or 0, g or 0, b or 0]
+        if w is not None:
+            if not 0 <= w <= 255:
+                return {"connected": False, "message": "Primary white value must be between 0 and 255"}
+            primary.append(w)
+        colors.append(primary)
+        
+        # Add secondary color if any secondary color parameter is provided
+        if any(x is not None for x in [r2, g2, b2, w2, hex2]):
+            secondary = [r2 or 0, g2 or 0, b2 or 0]
+            if w2 is not None:
+                if not 0 <= w2 <= 255:
+                    return {"connected": False, "message": "Secondary white value must be between 0 and 255"}
+                secondary.append(w2)
+            colors.append(secondary)
+
+        if colors:
+            seg["col"] = colors
+
+        if palette is not None:
+            if not 0 <= palette <= 46:
+                return {"connected": False, "message": "Palette index must be between 0 and 46"}
+            seg["pal"] = palette
+
+        # Combine with global parameters
+        state = {"seg": [seg], "transition": transition}
+        if brightness is not None:
+            if not 0 <= brightness <= 255:
+                return {"connected": False, "message": "Brightness must be between 0 and 255"}
+            state["bri"] = brightness
+
+        return self._send_command(state)
+
+    def set_preset(self, preset_id: int) -> bool:
+        preset_id = int(preset_id)
+        # Send the command and get response
+        response = self._send_command({"ps": preset_id})
+        # if response.get('preset_id', -1) != preset_id:
+        #     return False
+        # else:
+        #     return True
+
+
+
+def effect_loading(led_controller: LEDController):
+    res = led_controller.set_effect(47, hex='#ffa000', hex2='#000000', speed=150, intensity=150)
+    if res.get('is_on', False):
+        return True
+    else:
+        return False
+
+def effect_idle(led_controller: LEDController):
+    led_controller.set_preset(1)
+
+    # res = led_controller.set_effect(0, hex='#ffe0a0', brightness=255)
+    # if res.get('is_on', False):
+    #     return True
+    # else:
+    #     return False
+
+def effect_connected(led_controller: LEDController):
+    res = led_controller.set_effect(0, hex='#08ff00', brightness=100)
+    time.sleep(1)
+    led_controller.set_effect(0, brightness=0)  # Turn off
+    time.sleep(0.5)
+    res = led_controller.set_effect(0, hex='#08ff00', brightness=100)
+    time.sleep(1)
+    effect_idle(led_controller)
+    if res.get('is_on', False):
+        return True
+    else:
+        return False
+
+def effect_playing(led_controller: LEDController):
+    led_controller.set_preset(2)
+    # res = led_controller.set_effect(9, speed=40, intensity=125, brightness=255)
+    # if res.get('is_on', False):
+    #     return True
+    # else:
+    #     return False
+        

+ 1 - 0
requirements.txt

@@ -10,3 +10,4 @@ jinja2>=3.1.2
 aiofiles>=23.1.0
 aiofiles>=23.1.0
 python-multipart>=0.0.6
 python-multipart>=0.0.6
 websockets>=11.0.3  # Required for FastAPI WebSocket support
 websockets>=11.0.3  # Required for FastAPI WebSocket support
+requests>=2.31.0

+ 1 - 1
static/css/style.css

@@ -4,7 +4,7 @@
     --background-secondary-light: #fff;
     --background-secondary-light: #fff;
     --background-tertiary-light: #ddd;
     --background-tertiary-light: #ddd;
     --background-accent-light: #4e453fbf;
     --background-accent-light: #4e453fbf;
-    --background-translucent-light: #FFFFFF80;
+    --background-translucent-light: #ffffffcc;
     --text-primary-light: #333;
     --text-primary-light: #333;
     --text-secondary-light: #fff;
     --text-secondary-light: #fff;
     --border-primary-light: #ddd;
     --border-primary-light: #ddd;