Explorar o código

Fix Safari mobile WebSocket issues and improve request handling

- Fix WebSocket race condition causing "closed before established" error
  on Safari mobile by assigning wsRef immediately after creation
- Bypass Vite WebSocket proxy in dev mode for Safari mobile compatibility
  (connect directly to port 8080 instead of proxying through 5173)
- Add AbortController to cancel in-flight preview requests on page
  navigation (BrowsePage, PlaylistsPage)
- Fix cache manager race condition with asyncio.Lock for metadata writes
- Add debouncing (300ms) to LED color picker to reduce API spam
- Remove 30s motion thread timeout while preserving force stop capability
- Add Reset button (Ctrl+X) to serial terminal section
- Remove table discovery refresh button from TableSelector
- Fix TableContext reload loop on initial table selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris hai 6 meses
pai
achega
85df4d8b25

+ 14 - 9
frontend/src/components/NowPlayingBar.tsx

@@ -165,8 +165,21 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     const connectWebSocket = () => {
     const connectWebSocket = () => {
       if (!shouldReconnect) return
       if (!shouldReconnect) return
 
 
+      // Don't interrupt an existing connection that's still connecting
+      if (wsRef.current) {
+        if (wsRef.current.readyState === WebSocket.CONNECTING) {
+          return // Already connecting, wait for it
+        }
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+        }
+        wsRef.current = null
+      }
+
       const wsUrl = apiClient.getWebSocketUrl('/ws/status')
       const wsUrl = apiClient.getWebSocketUrl('/ws/status')
       const ws = new WebSocket(wsUrl)
       const ws = new WebSocket(wsUrl)
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      wsRef.current = ws
 
 
       ws.onmessage = (event) => {
       ws.onmessage = (event) => {
         if (!shouldReconnect) return
         if (!shouldReconnect) return
@@ -184,25 +197,17 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
         if (!shouldReconnect) return
         if (!shouldReconnect) return
         reconnectTimeout = setTimeout(connectWebSocket, 3000)
         reconnectTimeout = setTimeout(connectWebSocket, 3000)
       }
       }
-
-      wsRef.current = ws
     }
     }
 
 
     connectWebSocket()
     connectWebSocket()
 
 
     // Reconnect when base URL changes (table switch)
     // Reconnect when base URL changes (table switch)
     const unsubscribe = apiClient.onBaseUrlChange(() => {
     const unsubscribe = apiClient.onBaseUrlChange(() => {
-      // Disable reconnect for old connection
-      shouldReconnect = false
       if (reconnectTimeout) {
       if (reconnectTimeout) {
         clearTimeout(reconnectTimeout)
         clearTimeout(reconnectTimeout)
         reconnectTimeout = null
         reconnectTimeout = null
       }
       }
-      if (wsRef.current) {
-        wsRef.current.close()
-      }
-      // Re-enable and connect to new URL
-      shouldReconnect = true
+      // connectWebSocket handles closing existing connection safely
       connectWebSocket()
       connectWebSocket()
     })
     })
 
 

+ 1 - 18
frontend/src/components/TableSelector.tsx

@@ -25,7 +25,6 @@ import {
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import {
 import {
   Layers,
   Layers,
-  RefreshCw,
   Plus,
   Plus,
   Check,
   Check,
   Wifi,
   Wifi,
@@ -39,9 +38,7 @@ export function TableSelector() {
   const {
   const {
     tables,
     tables,
     activeTable,
     activeTable,
-    isDiscovering,
     setActiveTable,
     setActiveTable,
-    discoverTables,
     addTable,
     addTable,
     removeTable,
     removeTable,
     updateTableName,
     updateTableName,
@@ -64,11 +61,6 @@ export function TableSelector() {
     setIsOpen(false)
     setIsOpen(false)
   }
   }
 
 
-  const handleDiscover = async () => {
-    await discoverTables()
-    toast.success('Table discovery complete')
-  }
-
   const handleAddTable = async () => {
   const handleAddTable = async () => {
     if (!newTableUrl.trim()) {
     if (!newTableUrl.trim()) {
       toast.error('Please enter a URL')
       toast.error('Please enter a URL')
@@ -144,17 +136,8 @@ export function TableSelector() {
         <PopoverContent className="w-72 p-2" align="end">
         <PopoverContent className="w-72 p-2" align="end">
           <div className="space-y-2">
           <div className="space-y-2">
             {/* Header */}
             {/* Header */}
-            <div className="flex items-center justify-between px-2 py-1">
+            <div className="px-2 py-1">
               <span className="text-sm font-medium">Sand Tables</span>
               <span className="text-sm font-medium">Sand Tables</span>
-              <Button
-                variant="ghost"
-                size="sm"
-                onClick={handleDiscover}
-                disabled={isDiscovering}
-                className="h-7 px-2"
-              >
-                <RefreshCw className={`h-3.5 w-3.5 ${isDiscovering ? 'animate-spin' : ''}`} />
-              </Button>
             </div>
             </div>
 
 
             {/* Table list */}
             {/* Table list */}

+ 30 - 5
frontend/src/components/layout/Layout.tsx

@@ -160,6 +160,7 @@ export function Layout() {
   const logsContainerRef = useRef<HTMLDivElement>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
 
 
   // Check device connection status via WebSocket
   // Check device connection status via WebSocket
+  // This effect runs once on mount and manages its own reconnection logic
   useEffect(() => {
   useEffect(() => {
     let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
     let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
     let isMounted = true
     let isMounted = true
@@ -167,7 +168,21 @@ export function Layout() {
     const connectWebSocket = () => {
     const connectWebSocket = () => {
       if (!isMounted) return
       if (!isMounted) return
 
 
+      // Only close existing connection if it's open (not still connecting)
+      // This prevents "WebSocket closed before connection established" errors
+      if (wsRef.current) {
+        if (wsRef.current.readyState === WebSocket.OPEN) {
+          wsRef.current.close()
+          wsRef.current = null
+        } else if (wsRef.current.readyState === WebSocket.CONNECTING) {
+          // Already connecting, don't interrupt
+          return
+        }
+      }
+
       const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
       const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
+      // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
+      wsRef.current = ws
 
 
       ws.onopen = () => {
       ws.onopen = () => {
         if (!isMounted) return
         if (!isMounted) return
@@ -227,6 +242,7 @@ export function Layout() {
 
 
       ws.onclose = () => {
       ws.onclose = () => {
         if (!isMounted) return
         if (!isMounted) return
+        wsRef.current = null
         setIsBackendConnected(false)
         setIsBackendConnected(false)
         setConnectionAttempts((prev) => prev + 1)
         setConnectionAttempts((prev) => prev + 1)
         // Reconnect after 3 seconds (don't change device status on WS disconnect)
         // Reconnect after 3 seconds (don't change device status on WS disconnect)
@@ -237,26 +253,35 @@ export function Layout() {
         if (!isMounted) return
         if (!isMounted) return
         setIsBackendConnected(false)
         setIsBackendConnected(false)
       }
       }
-
-      wsRef.current = ws
     }
     }
 
 
-    // Reset playing state when table changes to avoid false transitions
+    // Reset playing state on mount
     wasPlayingRef.current = null
     wasPlayingRef.current = null
 
 
+    // Connect on mount
     connectWebSocket()
     connectWebSocket()
 
 
+    // Subscribe to base URL changes (when user switches tables)
+    // This triggers reconnection to the new backend
+    const unsubscribe = apiClient.onBaseUrlChange(() => {
+      if (isMounted) {
+        wasPlayingRef.current = null // Reset playing state for new table
+        connectWebSocket()
+      }
+    })
+
     return () => {
     return () => {
       isMounted = false
       isMounted = false
+      unsubscribe()
       if (reconnectTimeout) {
       if (reconnectTimeout) {
         clearTimeout(reconnectTimeout)
         clearTimeout(reconnectTimeout)
       }
       }
       if (wsRef.current) {
       if (wsRef.current) {
         wsRef.current.close()
         wsRef.current.close()
+        wsRef.current = null
       }
       }
     }
     }
-    // Reconnect when active table changes
-  }, [activeTable?.id])
+  }, []) // Empty deps - runs once on mount, reconnects via apiClient listener
 
 
   // Connect to logs WebSocket when drawer opens
   // Connect to logs WebSocket when drawer opens
   useEffect(() => {
   useEffect(() => {

+ 15 - 2
frontend/src/contexts/TableContext.tsx

@@ -174,7 +174,20 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
       // If no active table AND no restored selection, select the current one
       // If no active table AND no restored selection, select the current one
       // Use ref to check restored selection because activeTable state may not be updated yet
       // Use ref to check restored selection because activeTable state may not be updated yet
       if (!activeTable && !restoredActiveIdRef.current) {
       if (!activeTable && !restoredActiveIdRef.current) {
-        setActiveTable(currentTable)
+        // For initial selection of current table, just update state without reload
+        // Reload is only needed when switching between DIFFERENT tables
+        setActiveTableState(currentTable)
+        // Save to localStorage so it persists
+        try {
+          const data: StoredTableData = {
+            tables: [currentTable],
+            activeTableId: currentTable.id,
+          }
+          localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
+          localStorage.setItem(ACTIVE_TABLE_KEY, currentTable.id)
+        } catch (e) {
+          console.error('Failed to save initial table selection:', e)
+        }
       } else if (activeTable?.isCurrent) {
       } else if (activeTable?.isCurrent) {
         // Update active table name if it changed on the backend
         // Update active table name if it changed on the backend
         setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
         setActiveTableState(prev => prev ? { ...prev, name: currentTable.name } : null)
@@ -188,7 +201,7 @@ export function TableProvider({ children }: { children: React.ReactNode }) {
     } finally {
     } finally {
       setIsDiscovering(false)
       setIsDiscovering(false)
     }
     }
-  }, [activeTable, setActiveTable])
+  }, [activeTable]) // Only depends on activeTable for checking if we need to update name
 
 
   // Add a table manually by URL
   // Add a table manually by URL
   const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {
   const addTable = useCallback(async (url: string, name?: string): Promise<Table | null> => {

+ 6 - 1
frontend/src/lib/apiClient.ts

@@ -136,7 +136,12 @@ class ApiClient {
     } else {
     } else {
       // Use current page's host for relative URLs
       // Use current page's host for relative URLs
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-      return `${protocol}//${window.location.host}${path}`
+      // In development mode (Vite on port 5173), connect directly to backend (port 8080)
+      // This bypasses Vite's WebSocket proxy which has issues with Safari mobile
+      const host = window.location.hostname
+      const port = import.meta.env.DEV ? '8080' : window.location.port
+      const portSuffix = port ? `:${port}` : ''
+      return `${protocol}//${host}${portSuffix}${path}`
     }
     }
   }
   }
 
 

+ 25 - 3
frontend/src/pages/BrowsePage.tsx

@@ -94,6 +94,7 @@ export function BrowsePage() {
   // Lazy loading queue for previews
   // Lazy loading queue for previews
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const abortControllerRef = useRef<AbortController | null>(null)
 
 
   // Cache all previews state
   // Cache all previews state
   const [isCaching, setIsCaching] = useState(false)
   const [isCaching, setIsCaching] = useState(false)
@@ -125,6 +126,17 @@ export function BrowsePage() {
       fetchPatterns()
       fetchPatterns()
     })
     })
     loadFavorites()
     loadFavorites()
+
+    // Cleanup on unmount: abort in-flight requests and clear pending queue
+    return () => {
+      if (batchTimeoutRef.current) {
+        clearTimeout(batchTimeoutRef.current)
+      }
+      if (abortControllerRef.current) {
+        abortControllerRef.current.abort()
+      }
+      pendingPreviewsRef.current.clear()
+    }
   }, [])
   }, [])
 
 
   // Refetch when backend reconnects
   // Refetch when backend reconnects
@@ -223,6 +235,10 @@ export function BrowsePage() {
   const fetchPreviewsBatch = async (filePaths: string[]) => {
   const fetchPreviewsBatch = async (filePaths: string[]) => {
     const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
     const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
 
 
+    // Create new AbortController for this batch of requests
+    abortControllerRef.current = new AbortController()
+    const signal = abortControllerRef.current.signal
+
     try {
     try {
       // First check IndexedDB cache for all patterns
       // First check IndexedDB cache for all patterns
       const cachedPreviews = await getPreviewsFromCache(filePaths)
       const cachedPreviews = await getPreviewsFromCache(filePaths)
@@ -242,10 +258,13 @@ export function BrowsePage() {
       // Fetch uncached patterns in batches to avoid overwhelming the backend
       // Fetch uncached patterns in batches to avoid overwhelming the backend
       if (uncachedPaths.length > 0) {
       if (uncachedPaths.length > 0) {
         for (let i = 0; i < uncachedPaths.length; i += BATCH_SIZE) {
         for (let i = 0; i < uncachedPaths.length; i += BATCH_SIZE) {
+          // Check if aborted before each batch
+          if (signal.aborted) break
+
           const batch = uncachedPaths.slice(i, i + BATCH_SIZE)
           const batch = uncachedPaths.slice(i, i + BATCH_SIZE)
 
 
           try {
           try {
-            const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch })
+            const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
 
 
             // Save fetched previews to IndexedDB cache
             // Save fetched previews to IndexedDB cache
             for (const [path, previewData] of Object.entries(data)) {
             for (const [path, previewData] of Object.entries(data)) {
@@ -255,8 +274,9 @@ export function BrowsePage() {
             }
             }
 
 
             setPreviews((prev) => ({ ...prev, ...data }))
             setPreviews((prev) => ({ ...prev, ...data }))
-          } catch {
-            // Continue with next batch even if one fails
+          } catch (err) {
+            // Stop processing if aborted, otherwise continue with next batch
+            if (err instanceof Error && err.name === 'AbortError') break
           }
           }
 
 
           // Small delay between batches to reduce backend load
           // Small delay between batches to reduce backend load
@@ -266,6 +286,8 @@ export function BrowsePage() {
         }
         }
       }
       }
     } catch (error) {
     } catch (error) {
+      // Silently ignore abort errors
+      if (error instanceof Error && error.name === 'AbortError') return
       console.error('Error fetching previews:', error)
       console.error('Error fetching previews:', error)
     }
     }
   }
   }

+ 27 - 16
frontend/src/pages/LEDPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback } from 'react'
+import { useState, useEffect, useCallback, useRef } from 'react'
 import { Link } from 'react-router-dom'
 import { Link } from 'react-router-dom'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { apiClient } from '@/lib/apiClient'
@@ -73,6 +73,9 @@ export function LEDPage() {
   const [color2, setColor2] = useState('#000000')
   const [color2, setColor2] = useState('#000000')
   const [color3, setColor3] = useState('#0000ff')
   const [color3, setColor3] = useState('#0000ff')
 
 
+  // Ref for debouncing color picker API calls
+  const colorDebounceRef = useRef<NodeJS.Timeout | null>(null)
+
   // Effect automation state
   // Effect automation state
   const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
   const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
   const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
@@ -254,27 +257,35 @@ export function LEDPage() {
     }
     }
   }
   }
 
 
-  const handleColorChange = async (slot: 1 | 2 | 3, value: string) => {
+  const handleColorChange = (slot: 1 | 2 | 3, value: string) => {
+    // Update UI immediately for responsive feedback
     if (slot === 1) setColor1(value)
     if (slot === 1) setColor1(value)
     else if (slot === 2) setColor2(value)
     else if (slot === 2) setColor2(value)
     else setColor3(value)
     else setColor3(value)
 
 
-    // Debounce color changes
-    try {
-      const hexToRgb = (hex: string) => {
-        const r = parseInt(hex.slice(1, 3), 16)
-        const g = parseInt(hex.slice(3, 5), 16)
-        const b = parseInt(hex.slice(5, 7), 16)
-        return [r, g, b]
-      }
+    // Clear any pending debounce timer
+    if (colorDebounceRef.current) {
+      clearTimeout(colorDebounceRef.current)
+    }
 
 
-      const payload: Record<string, number[]> = {}
-      payload[`color${slot}`] = hexToRgb(value)
+    // Debounce API call by 300ms to prevent overwhelming the backend
+    colorDebounceRef.current = setTimeout(async () => {
+      try {
+        const hexToRgb = (hex: string) => {
+          const r = parseInt(hex.slice(1, 3), 16)
+          const g = parseInt(hex.slice(3, 5), 16)
+          const b = parseInt(hex.slice(5, 7), 16)
+          return [r, g, b]
+        }
 
 
-      await apiClient.post('/api/dw_leds/colors', payload)
-    } catch (error) {
-      console.error('Failed to set color:', error)
-    }
+        const payload: Record<string, number[]> = {}
+        payload[`color${slot}`] = hexToRgb(value)
+
+        await apiClient.post('/api/dw_leds/colors', payload)
+      } catch (error) {
+        console.error('Failed to set color:', error)
+      }
+    }, 300)
   }
   }
 
 
   const saveCurrentEffectSettings = async (type: 'idle' | 'playing') => {
   const saveCurrentEffectSettings = async (type: 'idle' | 'playing') => {

+ 22 - 2
frontend/src/pages/PlaylistsPage.tsx

@@ -150,12 +150,24 @@ export function PlaylistsPage() {
   // Preview loading
   // Preview loading
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const pendingPreviewsRef = useRef<Set<string>>(new Set())
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const abortControllerRef = useRef<AbortController | null>(null)
 
 
   // Initialize and fetch data
   // Initialize and fetch data
   useEffect(() => {
   useEffect(() => {
     initPreviewCacheDB().catch(() => {})
     initPreviewCacheDB().catch(() => {})
     fetchPlaylists()
     fetchPlaylists()
     fetchAllPatterns()
     fetchAllPatterns()
+
+    // Cleanup on unmount: abort in-flight requests and clear pending queue
+    return () => {
+      if (batchTimeoutRef.current) {
+        clearTimeout(batchTimeoutRef.current)
+      }
+      if (abortControllerRef.current) {
+        abortControllerRef.current.abort()
+      }
+      pendingPreviewsRef.current.clear()
+    }
   }, [])
   }, [])
 
 
   // Refetch when backend reconnects
   // Refetch when backend reconnects
@@ -224,12 +236,19 @@ export function PlaylistsPage() {
   const fetchPreviewsBatch = async (paths: string[]) => {
   const fetchPreviewsBatch = async (paths: string[]) => {
     const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
     const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
 
 
+    // Create new AbortController for this batch of requests
+    abortControllerRef.current = new AbortController()
+    const signal = abortControllerRef.current.signal
+
     // Process in batches
     // Process in batches
     for (let i = 0; i < paths.length; i += BATCH_SIZE) {
     for (let i = 0; i < paths.length; i += BATCH_SIZE) {
+      // Check if aborted before each batch
+      if (signal.aborted) break
+
       const batch = paths.slice(i, i + BATCH_SIZE)
       const batch = paths.slice(i, i + BATCH_SIZE)
 
 
       try {
       try {
-        const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch })
+        const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
 
 
         const newPreviews: Record<string, PreviewData> = {}
         const newPreviews: Record<string, PreviewData> = {}
         for (const [path, previewData] of Object.entries(data)) {
         for (const [path, previewData] of Object.entries(data)) {
@@ -241,8 +260,9 @@ export function PlaylistsPage() {
         }
         }
         setPreviews(prev => ({ ...prev, ...newPreviews }))
         setPreviews(prev => ({ ...prev, ...newPreviews }))
       } catch (error) {
       } catch (error) {
+        // Stop processing if aborted, otherwise continue with next batch
+        if (error instanceof Error && error.name === 'AbortError') break
         console.error('Error fetching previews batch:', error)
         console.error('Error fetching previews batch:', error)
-        // Continue with next batch even if one fails
       }
       }
 
 
       // Small delay between batches to reduce backend load
       // Small delay between batches to reduce backend load

+ 45 - 10
frontend/src/pages/TableControlPage.tsx

@@ -296,6 +296,29 @@ export function TableControlPage() {
     }
     }
   }
   }
 
 
+  const handleSerialReset = async () => {
+    if (!serialConnected || serialLoading) return
+
+    setSerialLoading(true)
+    addSerialHistory('cmd', '[Ctrl+X] Soft Reset')
+
+    try {
+      // Send Ctrl+X (0x18) - GRBL soft reset command
+      const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
+      if (data.responses && data.responses.length > 0) {
+        data.responses.forEach((line: string) => addSerialHistory('resp', line))
+      } else {
+        addSerialHistory('resp', 'Reset sent')
+      }
+      toast.success('Reset command sent')
+    } catch (error) {
+      addSerialHistory('error', `Reset failed: ${error}`)
+      toast.error('Failed to send reset')
+    } finally {
+      setSerialLoading(false)
+    }
+  }
+
   const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
   const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
     if (e.key === 'Enter' && !e.shiftKey) {
     if (e.key === 'Enter' && !e.shiftKey) {
       e.preventDefault()
       e.preventDefault()
@@ -689,16 +712,28 @@ export function TableControlPage() {
                   <span className="hidden sm:inline">Connect</span>
                   <span className="hidden sm:inline">Connect</span>
                 </Button>
                 </Button>
               ) : (
               ) : (
-                <Button
-                  size="sm"
-                  variant="destructive"
-                  onClick={handleSerialDisconnect}
-                  disabled={serialLoading}
-                  title="Disconnect"
-                >
-                  <span className="material-icons-outlined sm:mr-1">power_off</span>
-                  <span className="hidden sm:inline">Disconnect</span>
-                </Button>
+                <>
+                  <Button
+                    size="sm"
+                    variant="destructive"
+                    onClick={handleSerialDisconnect}
+                    disabled={serialLoading}
+                    title="Disconnect"
+                  >
+                    <span className="material-icons-outlined sm:mr-1">power_off</span>
+                    <span className="hidden sm:inline">Disconnect</span>
+                  </Button>
+                  <Button
+                    size="sm"
+                    variant="outline"
+                    onClick={handleSerialReset}
+                    disabled={serialLoading}
+                    title="Send Ctrl+X soft reset"
+                  >
+                    <span className="material-icons-outlined sm:mr-1">restart_alt</span>
+                    <span className="hidden sm:inline">Reset</span>
+                  </Button>
+                </>
               )}
               )}
               {/* Clear button - show on mobile in controls row */}
               {/* Clear button - show on mobile in controls row */}
               {serialHistory.length > 0 && (
               {serialHistory.length > 0 && (

+ 1 - 0
frontend/vite.config.ts

@@ -12,6 +12,7 @@ export default defineConfig({
   },
   },
   server: {
   server: {
     port: parseInt(process.env.PORT || '5173'),
     port: parseInt(process.env.PORT || '5173'),
+    host: true, // Listen on all interfaces (0.0.0.0) for mobile/network access
     allowedHosts: true, // Allow all hosts for local network development
     allowedHosts: true, // Allow all hosts for local network development
     proxy: {
     proxy: {
       // WebSocket endpoints
       // WebSocket endpoints

+ 22 - 7
main.py

@@ -301,7 +301,15 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
 
 
 # Global semaphore to limit concurrent preview processing
 # Global semaphore to limit concurrent preview processing
 # Prevents resource exhaustion when loading many previews simultaneously
 # Prevents resource exhaustion when loading many previews simultaneously
-preview_semaphore = asyncio.Semaphore(5)
+# Lazily initialized to avoid "attached to a different loop" errors
+_preview_semaphore: Optional[asyncio.Semaphore] = None
+
+def get_preview_semaphore() -> asyncio.Semaphore:
+    """Get or create the preview semaphore in the current event loop."""
+    global _preview_semaphore
+    if _preview_semaphore is None:
+        _preview_semaphore = asyncio.Semaphore(5)
+    return _preview_semaphore
 
 
 # Pydantic models for request/response validation
 # Pydantic models for request/response validation
 class ConnectRequest(BaseModel):
 class ConnectRequest(BaseModel):
@@ -1156,7 +1164,14 @@ async def restart(request: ConnectRequest):
 
 
 # Store for debug serial connections (separate from main connection)
 # Store for debug serial connections (separate from main connection)
 _debug_serial_connections: dict = {}
 _debug_serial_connections: dict = {}
-_debug_serial_lock = asyncio.Lock()
+_debug_serial_lock: Optional[asyncio.Lock] = None
+
+def get_debug_serial_lock() -> asyncio.Lock:
+    """Get or create the debug serial lock in the current event loop."""
+    global _debug_serial_lock
+    if _debug_serial_lock is None:
+        _debug_serial_lock = asyncio.Lock()
+    return _debug_serial_lock
 
 
 class DebugSerialRequest(BaseModel):
 class DebugSerialRequest(BaseModel):
     port: str
     port: str
@@ -1173,7 +1188,7 @@ async def debug_serial_open(request: DebugSerialRequest):
     """Open a debug serial connection (independent of main connection)."""
     """Open a debug serial connection (independent of main connection)."""
     import serial
     import serial
 
 
-    async with _debug_serial_lock:
+    async with get_debug_serial_lock():
         # Close existing connection on this port if any
         # Close existing connection on this port if any
         if request.port in _debug_serial_connections:
         if request.port in _debug_serial_connections:
             try:
             try:
@@ -1198,7 +1213,7 @@ async def debug_serial_open(request: DebugSerialRequest):
 @app.post("/api/debug-serial/close", tags=["debug-serial"])
 @app.post("/api/debug-serial/close", tags=["debug-serial"])
 async def debug_serial_close(request: ConnectRequest):
 async def debug_serial_close(request: ConnectRequest):
     """Close a debug serial connection."""
     """Close a debug serial connection."""
-    async with _debug_serial_lock:
+    async with get_debug_serial_lock():
         if request.port not in _debug_serial_connections:
         if request.port not in _debug_serial_connections:
             return {"success": True, "message": "Port not open"}
             return {"success": True, "message": "Port not open"}
 
 
@@ -1216,7 +1231,7 @@ async def debug_serial_send(request: DebugSerialCommand):
     """Send a command and receive response on debug serial connection."""
     """Send a command and receive response on debug serial connection."""
     import serial
     import serial
 
 
-    async with _debug_serial_lock:
+    async with get_debug_serial_lock():
         if request.port not in _debug_serial_connections:
         if request.port not in _debug_serial_connections:
             raise HTTPException(status_code=400, detail="Port not open. Open it first.")
             raise HTTPException(status_code=400, detail="Port not open. Open it first.")
 
 
@@ -1271,7 +1286,7 @@ async def debug_serial_send(request: DebugSerialCommand):
 @app.get("/api/debug-serial/status", tags=["debug-serial"])
 @app.get("/api/debug-serial/status", tags=["debug-serial"])
 async def debug_serial_status():
 async def debug_serial_status():
     """Get status of all debug serial connections."""
     """Get status of all debug serial connections."""
-    async with _debug_serial_lock:
+    async with get_debug_serial_lock():
         status = {}
         status = {}
         for port, ser in _debug_serial_connections.items():
         for port, ser in _debug_serial_connections.items():
             try:
             try:
@@ -2586,7 +2601,7 @@ async def preview_thr_batch(request: dict):
     async def process_single_file(file_name):
     async def process_single_file(file_name):
         """Process a single file and return its preview data."""
         """Process a single file and return its preview data."""
         # Acquire semaphore to limit concurrent processing
         # Acquire semaphore to limit concurrent processing
-        async with preview_semaphore:
+        async with get_preview_semaphore():
             t1 = time.time()
             t1 = time.time()
             try:
             try:
                 # Normalize file path for cross-platform compatibility
                 # Normalize file path for cross-platform compatibility

+ 40 - 23
modules/core/cache_manager.py

@@ -19,6 +19,18 @@ cache_progress = {
     "error": None
     "error": None
 }
 }
 
 
+# Lock to prevent race conditions when writing to metadata cache
+# Multiple concurrent tasks (from asyncio.gather) can try to read-modify-write simultaneously
+# Lazily initialized to avoid "attached to a different loop" errors
+_metadata_cache_lock: "asyncio.Lock | None" = None
+
+def _get_metadata_cache_lock() -> asyncio.Lock:
+    """Get or create the metadata cache lock in the current event loop."""
+    global _metadata_cache_lock
+    if _metadata_cache_lock is None:
+        _metadata_cache_lock = asyncio.Lock()
+    return _metadata_cache_lock
+
 # Constants
 # Constants
 CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
 CACHE_DIR = os.path.join(THETA_RHO_DIR, "cached_images")
 METADATA_CACHE_FILE = "metadata_cache.json"  # Now in root directory
 METADATA_CACHE_FILE = "metadata_cache.json"  # Now in root directory
@@ -375,28 +387,33 @@ async def get_pattern_metadata_async(pattern_file):
     
     
     return None
     return None
 
 
-def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
-    """Cache metadata for a pattern file."""
-    try:
-        cache_data = load_metadata_cache()
-        data_section = cache_data.get('data', {})
-        pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
-        file_mtime = os.path.getmtime(pattern_path)
-        
-        data_section[pattern_file] = {
-            'mtime': file_mtime,
-            'metadata': {
-                'first_coordinate': first_coord,
-                'last_coordinate': last_coord,
-                'total_coordinates': total_coords
+async def cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords):
+    """Cache metadata for a pattern file.
+
+    Uses asyncio.Lock to prevent race conditions when multiple concurrent tasks
+    (from asyncio.gather) try to read-modify-write the cache file simultaneously.
+    """
+    async with _get_metadata_cache_lock():
+        try:
+            cache_data = await asyncio.to_thread(load_metadata_cache)
+            data_section = cache_data.get('data', {})
+            pattern_path = os.path.join(THETA_RHO_DIR, pattern_file)
+            file_mtime = await asyncio.to_thread(os.path.getmtime, pattern_path)
+
+            data_section[pattern_file] = {
+                'mtime': file_mtime,
+                'metadata': {
+                    'first_coordinate': first_coord,
+                    'last_coordinate': last_coord,
+                    'total_coordinates': total_coords
+                }
             }
             }
-        }
-        
-        cache_data['data'] = data_section
-        save_metadata_cache(cache_data)
-        logger.debug(f"Cached metadata for {pattern_file}")
-    except Exception as e:
-        logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
+
+            cache_data['data'] = data_section
+            await asyncio.to_thread(save_metadata_cache, cache_data)
+            logger.debug(f"Cached metadata for {pattern_file}")
+        except Exception as e:
+            logger.warning(f"Failed to cache metadata for {pattern_file}: {str(e)}")
 
 
 def needs_cache(pattern_file):
 def needs_cache(pattern_file):
     """Check if a pattern file needs its cache generated."""
     """Check if a pattern file needs its cache generated."""
@@ -469,7 +486,7 @@ async def generate_image_preview(pattern_file):
                     total_coords = len(coordinates)
                     total_coords = len(coordinates)
 
 
                     # Cache the metadata for future use
                     # Cache the metadata for future use
-                    cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords)
+                    await cache_pattern_metadata(pattern_file, first_coord, last_coord, total_coords)
                     logger.debug(f"Metadata cached for {pattern_file}: {total_coords} coordinates")
                     logger.debug(f"Metadata cached for {pattern_file}: {total_coords} coordinates")
                 else:
                 else:
                     logger.warning(f"No coordinates found in {pattern_file}")
                     logger.warning(f"No coordinates found in {pattern_file}")
@@ -655,7 +672,7 @@ async def generate_metadata_cache():
                         total_coords = len(coordinates)
                         total_coords = len(coordinates)
 
 
                         # Cache the metadata
                         # Cache the metadata
-                        cache_pattern_metadata(file_name, first_coord, last_coord, total_coords)
+                        await cache_pattern_metadata(file_name, first_coord, last_coord, total_coords)
                         successful += 1
                         successful += 1
                         logger.debug(f"Generated metadata for {file_name}")
                         logger.debug(f"Generated metadata for {file_name}")
 
 

+ 5 - 10
modules/core/pattern_manager.py

@@ -393,12 +393,12 @@ class MotionControlThread:
         state.machine_y = new_y_abs
         state.machine_y = new_y_abs
 
 
     def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
     def _send_grbl_coordinates_sync(self, x: float, y: float, speed: int = 600, timeout: int = 2, home: bool = False):
-        """Synchronous version of send_grbl_coordinates for motion thread."""
-        logger.debug(f"Motion thread sending G-code: X{x} Y{y} at F{speed}")
+        """Synchronous version of send_grbl_coordinates for motion thread.
 
 
-        # Track overall attempt time
-        overall_start_time = time.time()
-        max_total_timeout = 30  # Maximum total time to retry before giving up
+        Retries indefinitely until success or stop_requested is set.
+        Use force stop to abort if the hardware is unresponsive.
+        """
+        logger.debug(f"Motion thread sending G-code: X{x} Y{y} at F{speed}")
 
 
         while True:
         while True:
             # Check for stop request before each attempt
             # Check for stop request before each attempt
@@ -406,11 +406,6 @@ class MotionControlThread:
                 logger.info("Motion thread: Stop requested, aborting command")
                 logger.info("Motion thread: Stop requested, aborting command")
                 return False
                 return False
 
 
-            # Check for overall timeout
-            if time.time() - overall_start_time > max_total_timeout:
-                logger.error(f"Motion thread: Timeout after {max_total_timeout}s, aborting command")
-                return False
-
             try:
             try:
                 gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 G53 X{x} Y{y} F{speed}"
                 gcode = f"$J=G91 G21 Y{y} F{speed}" if home else f"G1 G53 X{x} Y{y} F{speed}"
                 state.conn.send(gcode + "\n")
                 state.conn.send(gcode + "\n")