Selaa lähdekoodia

Fix stuck pattern state and improve stop/reset reliability

- Add timeout (30s) to check_idle_async() to prevent infinite loops
- check_idle_async() now respects stop_requested flag for early exit
- Add /force_stop endpoint for nuclear cleanup when normal stop fails
- /stop_execution now returns error on timeout, triggering force_stop fallback
- /soft_reset uses direct serial write for more reliable reset
- Reset button calls force_stop first to clear stuck state
- Stop buttons in UI auto-retry with force_stop on failure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 viikkoa sitten
vanhempi
sitoutus
6290fc3b25

+ 7 - 1
frontend/src/components/NowPlayingBar.tsx

@@ -664,7 +664,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
       await apiClient.post('/stop_execution')
       toast.success('Stopped')
     } catch {
-      toast.error('Failed to stop')
+      // Normal stop failed, try force stop
+      try {
+        await apiClient.post('/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop')
+      }
     }
   }
 

+ 9 - 1
frontend/src/pages/TableControlPage.tsx

@@ -161,12 +161,20 @@ export function TableControlPage() {
       await handleAction('stop', '/stop_execution')
       toast.success('Execution stopped')
     } catch {
-      toast.error('Failed to stop execution')
+      // Normal stop failed, try force stop
+      try {
+        await handleAction('stop', '/force_stop')
+        toast.success('Force stopped')
+      } catch {
+        toast.error('Failed to stop execution')
+      }
     }
   }
 
   const handleSoftReset = async () => {
     try {
+      // Force stop first to clear any stuck state
+      await apiClient.post('/force_stop')
       await handleAction('reset', '/soft_reset')
       toast.success('Reset sent. Homing required.')
     } catch {

+ 1 - 0
frontend/vite.config.ts

@@ -67,6 +67,7 @@ export default defineConfig({
       '/send_home': 'http://localhost:8080',
       '/send_coordinate': 'http://localhost:8080',
       '/stop_execution': 'http://localhost:8080',
+      '/force_stop': 'http://localhost:8080',
       '/soft_reset': 'http://localhost:8080',
       '/pause_execution': 'http://localhost:8080',
       '/resume_execution': 'http://localhost:8080',

+ 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

+ 20 - 1
modules/connection/connection_manager.py

@@ -1141,12 +1141,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):
     """