import { useState, useEffect, useRef } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Separator } from '@/components/ui/separator' import { Badge } from '@/components/ui/badge' import { Alert, AlertDescription } from '@/components/ui/alert' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { apiClient } from '@/lib/apiClient' export function TableControlPage() { const [speedInput, setSpeedInput] = useState('') const [currentSpeed, setCurrentSpeed] = useState(null) const [currentTheta, setCurrentTheta] = useState(0) const [isLoading, setIsLoading] = useState(null) const [isPatternRunning, setIsPatternRunning] = useState(false) // Serial terminal state const [serialPorts, setSerialPorts] = useState([]) const [selectedSerialPort, setSelectedSerialPort] = useState('') const [serialConnected, setSerialConnected] = useState(false) const [serialCommand, setSerialCommand] = useState('') const [serialHistory, setSerialHistory] = useState>([]) const [serialLoading, setSerialLoading] = useState(false) const serialOutputRef = useRef(null) const serialInputRef = useRef(null) // Connect to status WebSocket to get current speed and playback status useEffect(() => { let ws: WebSocket | null = null let shouldReconnect = true const connect = () => { if (!shouldReconnect) return // Don't interrupt an existing connection that's still connecting if (ws) { if (ws.readyState === WebSocket.CONNECTING) { return // Already connecting, wait for it } if (ws.readyState === WebSocket.OPEN) { ws.close() } ws = null } ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status')) ws.onopen = () => { if (!shouldReconnect) { // Component unmounted while connecting - close the WebSocket now ws?.close() } } ws.onmessage = (event) => { if (!shouldReconnect) return try { const message = JSON.parse(event.data) if (message.type === 'status_update' && message.data) { if (message.data.speed !== null && message.data.speed !== undefined) { setCurrentSpeed(message.data.speed) } // Track if a pattern is running or paused setIsPatternRunning(message.data.is_running || message.data.is_paused) } } catch (error) { console.error('Failed to parse status:', error) } } } connect() // Reconnect when table changes const unsubscribe = apiClient.onBaseUrlChange(() => { connect() }) return () => { shouldReconnect = false unsubscribe() if (ws) { // Only close if already OPEN - CONNECTING WebSockets will close in onopen if (ws.readyState === WebSocket.OPEN) { ws.close() } ws = null } } }, []) const handleAction = async ( action: string, endpoint: string, body?: object ) => { setIsLoading(action) try { const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body) if (data.success !== false) { return { success: true, data } } throw new Error(data.detail || 'Action failed') } catch (error) { console.error(`Error with ${action}:`, error) throw error } finally { setIsLoading(null) } } // Helper to check if pattern is running and show warning const checkPatternRunning = (actionName: string): boolean => { if (isPatternRunning) { toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, { action: { label: 'Stop', onClick: () => handleStop(), }, }) return true } return false } const handleHome = async () => { try { await handleAction('home', '/send_home') toast.success('Moving to home position...') } catch { toast.error('Failed to move to home position') } } const handleStop = async () => { try { await handleAction('stop', '/stop_execution') toast.success('Execution stopped') } catch { // Normal stop failed, try force stop try { await handleAction('stop', '/force_stop') toast.success('Force stopped') } catch { toast.error('Failed to stop execution') } } } const handleReset = async () => { try { await handleAction('reset', '/soft_reset') toast.success('Reset sent. Please home the table.') } catch { toast.error('Failed to send reset command') } } const handleMoveToCenter = async () => { if (checkPatternRunning('move to center')) return try { await handleAction('center', '/move_to_center') toast.success('Moving to center...') } catch { toast.error('Failed to move to center') } } const handleMoveToPerimeter = async () => { if (checkPatternRunning('move to perimeter')) return try { await handleAction('perimeter', '/move_to_perimeter') toast.success('Moving to perimeter...') } catch { toast.error('Failed to move to perimeter') } } const handleSetSpeed = async () => { const speed = parseFloat(speedInput) if (isNaN(speed) || speed <= 0) { toast.error('Please enter a valid speed value') return } try { await handleAction('speed', '/set_speed', { speed }) setCurrentSpeed(speed) toast.success(`Speed set to ${speed} mm/s`) setSpeedInput('') } catch { toast.error('Failed to set speed') } } const handleClearPattern = async (patternFile: string, label: string) => { try { await handleAction(patternFile, '/run_theta_rho', { file_name: patternFile, pre_execution: 'none', }) toast.success(`Running ${label}...`) } catch (error) { if (error instanceof Error && error.message.includes('409')) { toast.error('Another pattern is already running') } else { toast.error(`Failed to run ${label}`) } } } const handleRotate = async (degrees: number) => { if (checkPatternRunning('align')) return try { const radians = degrees * (Math.PI / 180) const newTheta = currentTheta + radians await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 }) setCurrentTheta(newTheta) toast.info(`Rotated ${degrees}°`) } catch { toast.error('Failed to rotate') } } // Serial terminal functions const fetchSerialPorts = async () => { try { const data = await apiClient.get('/list_serial_ports') setSerialPorts(Array.isArray(data) ? data : []) } catch { toast.error('Failed to fetch serial ports') } } const fetchMainConnectionStatus = async () => { try { // Fetch available ports first to validate against const portsData = await apiClient.get('/list_serial_ports') const availablePorts = Array.isArray(portsData) ? portsData : [] const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status') if (data.connected && data.port) { // Only set port if it exists in available ports // This prevents race conditions where stale port data from a different // backend (e.g., Mac port on a Pi) could be set and auto-connected if (availablePorts.includes(data.port)) { setSelectedSerialPort(data.port) } else { console.warn(`Port ${data.port} from status not in available ports, ignoring`) } } } catch { // Ignore errors } } const handleSerialConnect = async (silent = false) => { if (!selectedSerialPort) { if (!silent) toast.error('Please select a serial port') return } setSerialLoading(true) try { await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort }) setSerialConnected(true) addSerialHistory('resp', `Connected to ${selectedSerialPort}`) if (!silent) toast.success(`Connected to ${selectedSerialPort}`) } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error' addSerialHistory('error', `Failed to connect: ${errorMsg}`) if (!silent) toast.error('Failed to connect to serial port') } finally { setSerialLoading(false) } } const handleSerialDisconnect = async () => { setSerialLoading(true) try { await apiClient.post('/api/debug-serial/close', { port: selectedSerialPort }) setSerialConnected(false) addSerialHistory('resp', 'Disconnected') toast.success('Disconnected from serial port') } catch { toast.error('Failed to disconnect') } finally { setSerialLoading(false) } } const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => { const time = new Date().toLocaleTimeString() setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }]) setTimeout(() => { if (serialOutputRef.current) { serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight } }, 10) } const handleSerialSend = async () => { if (!serialCommand.trim() || !serialConnected || serialLoading) return const cmd = serialCommand.trim() setSerialCommand('') setSerialLoading(true) addSerialHistory('cmd', cmd) try { const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: cmd }) if (data.responses) { if (data.responses.length > 0) { data.responses.forEach((line: string) => addSerialHistory('resp', line)) } else { addSerialHistory('resp', '(no response)') } } else if (data.detail) { addSerialHistory('error', data.detail || 'Command failed') } } catch (error) { addSerialHistory('error', `Error: ${error}`) } finally { setSerialLoading(false) setTimeout(() => serialInputRef.current?.focus(), 0) } } const handleSerialReset = async () => { if (!serialConnected || serialLoading) return setSerialLoading(true) addSerialHistory('cmd', '[Soft Reset]') try { // Send soft reset command (backend auto-detects: $Bye for FluidNC, Ctrl+X for GRBL) 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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (!serialLoading) { handleSerialSend() } } } // Fetch serial ports and main connection status on mount useEffect(() => { fetchSerialPorts() fetchMainConnectionStatus() }, []) return (
{/* Page Header */}

Table Control

Manual controls for your sand table

{/* Main Controls Grid - 2x2 */}
{/* Primary Actions */} Primary Actions Calibrate or stop the table
Return to home position Gracefully stop Send soft reset to controller Reset Controller? This will send a soft reset to the controller. warning Homing is required after resetting. The table will lose its position reference.
{/* Speed Control */}
Speed Ball movement speed
{currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
setSpeedInput(e.target.value)} placeholder="mm/s" min="1" step="1" className="flex-1" onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()} />
{/* Position */} Position Move ball to a specific location
Move ball to center Move ball to edge Align pattern orientation Pattern Orientation Alignment Follow these steps to align your patterns with their previews
    {[ 'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.', 'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.', 'Click the "Home" button to establish this as the reference position.', 'All patterns will now be oriented according to their previews!', ].map((step, i) => (
  1. {i + 1} {step}
  2. ))}
warning Only perform this when you want to change the orientation reference.

Fine Adjustment

Each click rotates 10 degrees

{/* Clear Patterns */} Clear Sand Erase current pattern from the table
Spiral outward from center Spiral inward from edge Clear with side-to-side motion
{/* Serial Terminal */}
terminal Serial Terminal Send raw commands to the table controller {/* Warning about pattern interference */} warning Do not use while a pattern is running. This will interfere with the main connection.
{/* Clear button - only show on desktop in header */}
{serialHistory.length > 0 && ( )}
{/* Controls row - stacks better on mobile */}
{/* Port selector - auto-refreshes on open */} {!serialConnected ? ( ) : ( <> )} {/* Clear button - show on mobile in controls row */} {serialHistory.length > 0 && ( )}
{/* Output area */}
{serialHistory.length > 0 ? ( serialHistory.map((entry, i) => (
{entry.time} {entry.type === 'cmd' ? '> ' : ''} {entry.text}
)) ) : (
{serialConnected ? 'Ready. Enter a command below (e.g., $, $$, ?, $H)' : 'Connect to a serial port to send commands'}
)}
{/* Input area */}
setSerialCommand(e.target.value)} onKeyDown={handleSerialKeyDown} disabled={!serialConnected} readOnly={serialLoading} placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'} className="font-mono text-base h-11" />
) }