소스 검색

Add force stop endpoint and improve pattern execution control

- Add /force_stop endpoint to clear all pattern state when normal stop times out
- Improve soft_reset to send Ctrl+X directly via serial for reliability
- Add timeout parameter to check_idle_async with stop request handling
- Fix homing to skip zeroing only when X homed but Y failed (avoids Y crash)
- Return success/failure from stop_actions for better error handling
- Change default LED speed from 128 to 50
- Remove "(common)" labels from LED pixel order options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 주 전
부모
커밋
1c7a6798bb
5개의 변경된 파일111개의 추가작업 그리고 40개의 파일을 삭제
  1. 2 2
      frontend/src/pages/SettingsPage.tsx
  2. 54 5
      main.py
  3. 46 31
      modules/connection/connection_manager.py
  4. 7 0
      modules/core/pattern_manager.py
  5. 2 2
      modules/core/state.py

+ 2 - 2
frontend/src/pages/SettingsPage.tsx

@@ -1464,7 +1464,7 @@ export function SettingsPage() {
                       <SelectGroup>
                         <SelectLabel>RGB Strips (3-channel)</SelectLabel>
                         <SelectItem value="RGB">RGB - WS2815/WS2811</SelectItem>
-                        <SelectItem value="GRB">GRB - WS2812/WS2812B (common)</SelectItem>
+                        <SelectItem value="GRB">GRB - WS2812/WS2812B</SelectItem>
                         <SelectItem value="BGR">BGR - Some WS2811 variants</SelectItem>
                         <SelectItem value="RBG">RBG - Rare variant</SelectItem>
                         <SelectItem value="GBR">GBR - Rare variant</SelectItem>
@@ -1472,7 +1472,7 @@ export function SettingsPage() {
                       </SelectGroup>
                       <SelectGroup>
                         <SelectLabel>RGBW Strips (4-channel)</SelectLabel>
-                        <SelectItem value="GRBW">GRBW - SK6812 RGBW (common)</SelectItem>
+                        <SelectItem value="GRBW">GRBW - SK6812 RGBW</SelectItem>
                         <SelectItem value="RGBW">RGBW - SK6812 variant</SelectItem>
                       </SelectGroup>
                     </SelectContent>

+ 54 - 5
main.py

@@ -1620,13 +1620,53 @@ async def stop_execution():
     if not (state.conn.is_connected() if state.conn else False):
         logger.warning("Attempted to stop without a connection")
         raise HTTPException(status_code=400, detail="Connection not established")
-    await pattern_manager.stop_actions()
+    success = await pattern_manager.stop_actions()
+    if not success:
+        raise HTTPException(status_code=500, detail="Stop timed out - use force_stop")
     return {"success": True}
 
+@app.post("/force_stop")
+async def force_stop():
+    """Force stop all pattern execution and clear all state. Use when normal stop doesn't work."""
+    logger.info("Force stop requested - clearing all pattern state")
+
+    # Set stop flag first
+    state.stop_requested = True
+    state.pause_requested = False
+
+    # Clear all pattern-related state
+    state.current_playing_file = None
+    state.execution_progress = None
+    state.is_running = False
+    state.is_clearing = False
+    state.is_homing = False
+    state.current_playlist = None
+    state.current_playlist_index = None
+    state.playlist_mode = None
+    state.pause_time_remaining = 0
+
+    # Wake up any waiting tasks
+    try:
+        pattern_manager.get_pause_event().set()
+    except:
+        pass
+
+    # Stop motion controller and clear its queue
+    if pattern_manager.motion_controller.running:
+        pattern_manager.motion_controller.command_queue.put(
+            pattern_manager.MotionCommand('stop')
+        )
+
+    # Force release pattern lock by recreating it
+    pattern_manager.pattern_lock = None  # Will be recreated on next use
+
+    logger.info("Force stop completed - all pattern state cleared")
+    return {"success": True, "message": "Force stop completed"}
+
 @app.post("/soft_reset")
 async def soft_reset():
     """Send Ctrl+X soft reset to the controller (DLC32/ESP32). Requires re-homing after."""
-    if not (state.conn.is_connected() if state.conn else False):
+    if not (state.conn and state.conn.is_connected()):
         logger.warning("Attempted to soft reset without a connection")
         raise HTTPException(status_code=400, detail="Connection not established")
 
@@ -1634,9 +1674,18 @@ async def soft_reset():
         # Stop any running patterns first
         await pattern_manager.stop_actions()
 
-        # Send Ctrl+X (0x18) - GRBL/FluidNC soft reset command
-        state.conn.send('\x18')
-        logger.info("Soft reset command (Ctrl+X) sent to controller")
+        # Access the underlying serial object directly for more reliable reset
+        # This bypasses the connection abstraction which may have buffering issues
+        from modules.connection.connection_manager import SerialConnection
+        if isinstance(state.conn, SerialConnection) and state.conn.ser:
+            state.conn.ser.reset_input_buffer()  # Clear any pending data
+            state.conn.ser.write(b'\x18')  # Ctrl+X as bytes
+            state.conn.ser.flush()
+            logger.info(f"Soft reset command (Ctrl+X) sent directly via serial to {state.port}")
+        else:
+            # Fallback for WebSocket or other connection types
+            state.conn.send('\x18')
+            logger.info("Soft reset command (Ctrl+X) sent via connection abstraction")
 
         # Mark as needing homing since position is now unknown
         state.is_homed = False

+ 46 - 31
modules/connection/connection_manager.py

@@ -998,42 +998,38 @@ def home(timeout=90):
                     homing_complete.set()
                     return
 
-                # Only zero positions if BOTH axes were homed successfully
-                # Otherwise, moving to x0 y0 would crash the unhomed axis
-                if not (state.homed_x and state.homed_y):
-                    logger.error(f"Skipping position zeroing - not all axes homed (X:{state.homed_x}, Y:{state.homed_y})")
-                    logger.error("Homing failed - please check sensors and try again")
-                    homing_complete.set()
-                    return
+                # Skip zeroing if X homed but Y failed - moving Y to 0 would crash it
+                # (Y controls rho/radial position which is unknown if Y didn't home)
+                if state.homed_x and not state.homed_y:
+                    logger.warning("Skipping position zeroing - X homed but Y failed (would crash Y axis)")
+                else:
+                    # Send x0 y0 to zero both positions using send_grbl_coordinates
+                    logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
 
-                # Send x0 y0 to zero both positions using send_grbl_coordinates
-                logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
+                    # Run async function in new event loop
+                    loop = asyncio.new_event_loop()
+                    asyncio.set_event_loop(loop)
+                    try:
+                        # Send G1 X0 Y0 F{homing_speed}
+                        result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
+                        if result == False:
+                            logger.error("Position zeroing failed - send_grbl_coordinates returned False")
+                            homing_complete.set()
+                            return
+                        logger.info("Position zeroing completed successfully")
+                    finally:
+                        loop.close()
 
-                # Run async function in new event loop
-                loop = asyncio.new_event_loop()
-                asyncio.set_event_loop(loop)
-                try:
-                    # Send G1 X0 Y0 F{homing_speed}
-                    result = loop.run_until_complete(send_grbl_coordinates(0, 0, homing_speed))
-                    if result == False:
-                        logger.error("Position zeroing failed - send_grbl_coordinates returned False")
+                    # Wait for device to reach idle state after zeroing movement
+                    logger.info("Waiting for device to reach idle state after zeroing...")
+                    idle_reached = check_idle()
+
+                    if not idle_reached:
+                        logger.error("Device did not reach idle state after zeroing")
                         homing_complete.set()
                         return
-                    logger.info("Position zeroing completed successfully")
-                finally:
-                    loop.close()
-
-                # Wait for device to reach idle state after zeroing movement
-                logger.info("Waiting for device to reach idle state after zeroing...")
-                idle_reached = check_idle()
-
-                if not idle_reached:
-                    logger.error("Device did not reach idle state after zeroing")
-                    homing_complete.set()
-                    return
 
                 # Set current position based on compass reference point (sensor mode only)
-                # Only set AFTER x0 y0 is confirmed and device is idle
                 offset_radians = math.radians(state.angular_homing_offset_degrees)
                 state.current_theta = offset_radians
                 state.current_rho = 0
@@ -1149,12 +1145,31 @@ def check_idle():
             return True
         time.sleep(1)
 
-async def check_idle_async():
+async def check_idle_async(timeout: float = 30.0):
     """
     Continuously check if the device is idle (async version).
+
+    Args:
+        timeout: Maximum seconds to wait for idle state (default 30s)
+
+    Returns:
+        True if device became idle, False if timeout or stop requested
     """
     logger.info("Checking idle (async)")
+    start_time = asyncio.get_event_loop().time()
+
     while True:
+        # Check if stop was requested - exit early
+        if state.stop_requested:
+            logger.info("Stop requested during idle check, exiting early")
+            return False
+
+        # Check timeout
+        elapsed = asyncio.get_event_loop().time() - start_time
+        if elapsed > timeout:
+            logger.warning(f"Timeout ({timeout}s) waiting for device idle state")
+            return False
+
         response = await asyncio.to_thread(get_status_response)
         if response and "Idle" in response:
             logger.info("Device is idle")

+ 7 - 0
modules/core/pattern_manager.py

@@ -1210,7 +1210,11 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
         clear_playlist: Whether to clear playlist state
         wait_for_lock: Whether to wait for pattern_lock to be released. Set to False when
                       called from within pattern execution to avoid deadlock.
+
+    Returns:
+        True if stopped cleanly, False if timed out waiting for pattern lock
     """
+    timed_out = False
     try:
         with state.pause_condition:
             state.pause_requested = False
@@ -1254,6 +1258,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
                         logger.info("Pattern lock acquired - pattern has fully stopped")
             except asyncio.TimeoutError:
                 logger.warning("Timeout waiting for pattern to stop - forcing cleanup")
+                timed_out = True
                 # Force cleanup of state even if pattern didn't release lock gracefully
                 state.current_playing_file = None
                 state.execution_progress = None
@@ -1265,6 +1270,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
 
         # Call async function directly since we're in async context
         await connection_manager.update_machine_position()
+        return not timed_out
     except Exception as e:
         logger.error(f"Error during stop_actions: {e}")
         # Force cleanup state on error
@@ -1276,6 +1282,7 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
             await connection_manager.update_machine_position()
         except Exception as update_err:
             logger.error(f"Error updating machine position on error: {update_err}")
+        return False
 
 async def move_polar(theta, rho, speed=None):
     """

+ 2 - 2
modules/core/state.py

@@ -80,7 +80,7 @@ class AppState:
         self.dw_led_gpio_pin = 18  # GPIO pin (12, 13, 18, or 19)
         self.dw_led_pixel_order = "RGB"  # Pixel color order for WS281x (RGB for WS2815, GRB for WS2812)
         self.dw_led_brightness = 35  # Brightness 0-100
-        self.dw_led_speed = 128  # Effect speed 0-255
+        self.dw_led_speed = 50  # Effect speed 0-255
         self.dw_led_intensity = 128  # Effect intensity 0-255
 
         # Idle effect settings (all parameters)
@@ -358,7 +358,7 @@ class AppState:
         self.dw_led_gpio_pin = data.get('dw_led_gpio_pin', 18)
         self.dw_led_pixel_order = data.get('dw_led_pixel_order', "RGB")
         self.dw_led_brightness = data.get('dw_led_brightness', 35)
-        self.dw_led_speed = data.get('dw_led_speed', 128)
+        self.dw_led_speed = data.get('dw_led_speed', 50)
         self.dw_led_intensity = data.get('dw_led_intensity', 128)
 
         # Load effect settings (handle both old string format and new dict format)