TableControlPage.tsx 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. import { useState, useEffect, useRef } from 'react'
  2. import { toast } from 'sonner'
  3. import { Button } from '@/components/ui/button'
  4. import {
  5. Card,
  6. CardContent,
  7. CardDescription,
  8. CardHeader,
  9. CardTitle,
  10. } from '@/components/ui/card'
  11. import { Input } from '@/components/ui/input'
  12. import { Separator } from '@/components/ui/separator'
  13. import { Badge } from '@/components/ui/badge'
  14. import { Alert, AlertDescription } from '@/components/ui/alert'
  15. import {
  16. Dialog,
  17. DialogContent,
  18. DialogDescription,
  19. DialogHeader,
  20. DialogTitle,
  21. DialogTrigger,
  22. DialogFooter,
  23. } from '@/components/ui/dialog'
  24. import {
  25. Tooltip,
  26. TooltipContent,
  27. TooltipProvider,
  28. TooltipTrigger,
  29. } from '@/components/ui/tooltip'
  30. import { apiClient } from '@/lib/apiClient'
  31. export function TableControlPage() {
  32. const [speedInput, setSpeedInput] = useState('')
  33. const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
  34. const [currentTheta, setCurrentTheta] = useState(0)
  35. const [isLoading, setIsLoading] = useState<string | null>(null)
  36. const [isPatternRunning, setIsPatternRunning] = useState(false)
  37. // Serial terminal state
  38. const [serialPorts, setSerialPorts] = useState<string[]>([])
  39. const [selectedSerialPort, setSelectedSerialPort] = useState('')
  40. const [serialConnected, setSerialConnected] = useState(false)
  41. const [serialCommand, setSerialCommand] = useState('')
  42. const [serialHistory, setSerialHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
  43. const [serialLoading, setSerialLoading] = useState(false)
  44. const [mainConnectionPort, setMainConnectionPort] = useState<string | null>(null)
  45. const serialOutputRef = useRef<HTMLDivElement>(null)
  46. const serialInputRef = useRef<HTMLInputElement>(null)
  47. // Connect to status WebSocket to get current speed and playback status
  48. useEffect(() => {
  49. let ws: WebSocket | null = null
  50. let shouldReconnect = true
  51. const connect = () => {
  52. if (!shouldReconnect) return
  53. // Don't interrupt an existing connection that's still connecting
  54. if (ws) {
  55. if (ws.readyState === WebSocket.CONNECTING) {
  56. return // Already connecting, wait for it
  57. }
  58. if (ws.readyState === WebSocket.OPEN) {
  59. ws.close()
  60. }
  61. ws = null
  62. }
  63. ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
  64. ws.onopen = () => {
  65. if (!shouldReconnect) {
  66. // Component unmounted while connecting - close the WebSocket now
  67. ws?.close()
  68. }
  69. }
  70. ws.onmessage = (event) => {
  71. if (!shouldReconnect) return
  72. try {
  73. const message = JSON.parse(event.data)
  74. if (message.type === 'status_update' && message.data) {
  75. if (message.data.speed !== null && message.data.speed !== undefined) {
  76. setCurrentSpeed(message.data.speed)
  77. }
  78. // Track if a pattern is running or paused
  79. setIsPatternRunning(message.data.is_running || message.data.is_paused)
  80. }
  81. } catch (error) {
  82. console.error('Failed to parse status:', error)
  83. }
  84. }
  85. }
  86. connect()
  87. // Reconnect when table changes
  88. const unsubscribe = apiClient.onBaseUrlChange(() => {
  89. connect()
  90. })
  91. return () => {
  92. shouldReconnect = false
  93. unsubscribe()
  94. if (ws) {
  95. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  96. if (ws.readyState === WebSocket.OPEN) {
  97. ws.close()
  98. }
  99. ws = null
  100. }
  101. }
  102. }, [])
  103. const handleAction = async (
  104. action: string,
  105. endpoint: string,
  106. body?: object
  107. ) => {
  108. setIsLoading(action)
  109. try {
  110. const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
  111. if (data.success !== false) {
  112. return { success: true, data }
  113. }
  114. throw new Error(data.detail || 'Action failed')
  115. } catch (error) {
  116. console.error(`Error with ${action}:`, error)
  117. throw error
  118. } finally {
  119. setIsLoading(null)
  120. }
  121. }
  122. // Helper to check if pattern is running and show warning
  123. const checkPatternRunning = (actionName: string): boolean => {
  124. if (isPatternRunning) {
  125. toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, {
  126. action: {
  127. label: 'Stop',
  128. onClick: () => handleStop(),
  129. },
  130. })
  131. return true
  132. }
  133. return false
  134. }
  135. const handleHome = async () => {
  136. if (checkPatternRunning('home')) return
  137. try {
  138. await handleAction('home', '/send_home')
  139. toast.success('Moving to home position...')
  140. } catch {
  141. toast.error('Failed to move to home position')
  142. }
  143. }
  144. const handleStop = async () => {
  145. try {
  146. await handleAction('stop', '/stop_execution')
  147. toast.success('Execution stopped')
  148. } catch {
  149. toast.error('Failed to stop execution')
  150. }
  151. }
  152. const handleSoftReset = async () => {
  153. try {
  154. await handleAction('reset', '/soft_reset')
  155. toast.success('Reset sent. Homing required.')
  156. } catch {
  157. toast.error('Failed to send reset')
  158. }
  159. }
  160. const handleMoveToCenter = async () => {
  161. if (checkPatternRunning('move to center')) return
  162. try {
  163. await handleAction('center', '/move_to_center')
  164. toast.success('Moving to center...')
  165. } catch {
  166. toast.error('Failed to move to center')
  167. }
  168. }
  169. const handleMoveToPerimeter = async () => {
  170. if (checkPatternRunning('move to perimeter')) return
  171. try {
  172. await handleAction('perimeter', '/move_to_perimeter')
  173. toast.success('Moving to perimeter...')
  174. } catch {
  175. toast.error('Failed to move to perimeter')
  176. }
  177. }
  178. const handleSetSpeed = async () => {
  179. const speed = parseFloat(speedInput)
  180. if (isNaN(speed) || speed <= 0) {
  181. toast.error('Please enter a valid speed value')
  182. return
  183. }
  184. try {
  185. await handleAction('speed', '/set_speed', { speed })
  186. setCurrentSpeed(speed)
  187. toast.success(`Speed set to ${speed} mm/s`)
  188. setSpeedInput('')
  189. } catch {
  190. toast.error('Failed to set speed')
  191. }
  192. }
  193. const handleClearPattern = async (patternFile: string, label: string) => {
  194. try {
  195. await handleAction(patternFile, '/run_theta_rho', {
  196. file_name: patternFile,
  197. pre_execution: 'none',
  198. })
  199. toast.success(`Running ${label}...`)
  200. } catch (error) {
  201. if (error instanceof Error && error.message.includes('409')) {
  202. toast.error('Another pattern is already running')
  203. } else {
  204. toast.error(`Failed to run ${label}`)
  205. }
  206. }
  207. }
  208. const handleRotate = async (degrees: number) => {
  209. if (checkPatternRunning('align')) return
  210. try {
  211. const radians = degrees * (Math.PI / 180)
  212. const newTheta = currentTheta + radians
  213. await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 })
  214. setCurrentTheta(newTheta)
  215. toast.info(`Rotated ${degrees}°`)
  216. } catch {
  217. toast.error('Failed to rotate')
  218. }
  219. }
  220. // Serial terminal functions
  221. const fetchSerialPorts = async () => {
  222. try {
  223. const data = await apiClient.get<string[]>('/list_serial_ports')
  224. setSerialPorts(Array.isArray(data) ? data : [])
  225. } catch {
  226. toast.error('Failed to fetch serial ports')
  227. }
  228. }
  229. const fetchMainConnectionStatus = async () => {
  230. try {
  231. const data = await apiClient.get<{ connected: boolean; port?: string }>('/serial_status')
  232. if (data.connected && data.port) {
  233. setMainConnectionPort(data.port)
  234. // Auto-select the connected port
  235. setSelectedSerialPort(data.port)
  236. }
  237. } catch {
  238. // Ignore errors
  239. }
  240. }
  241. const handleSerialConnect = async (silent = false) => {
  242. if (!selectedSerialPort) {
  243. if (!silent) toast.error('Please select a serial port')
  244. return
  245. }
  246. setSerialLoading(true)
  247. try {
  248. await apiClient.post('/api/debug-serial/open', { port: selectedSerialPort })
  249. setSerialConnected(true)
  250. addSerialHistory('resp', `Connected to ${selectedSerialPort}`)
  251. if (!silent) toast.success(`Connected to ${selectedSerialPort}`)
  252. } catch (error) {
  253. const errorMsg = error instanceof Error ? error.message : 'Unknown error'
  254. addSerialHistory('error', `Failed to connect: ${errorMsg}`)
  255. if (!silent) toast.error('Failed to connect to serial port')
  256. } finally {
  257. setSerialLoading(false)
  258. }
  259. }
  260. const handleSerialDisconnect = async () => {
  261. setSerialLoading(true)
  262. try {
  263. await apiClient.post('/api/debug-serial/close', { port: selectedSerialPort })
  264. setSerialConnected(false)
  265. addSerialHistory('resp', 'Disconnected')
  266. toast.success('Disconnected from serial port')
  267. } catch {
  268. toast.error('Failed to disconnect')
  269. } finally {
  270. setSerialLoading(false)
  271. }
  272. }
  273. const addSerialHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
  274. const time = new Date().toLocaleTimeString()
  275. setSerialHistory((prev) => [...prev.slice(-200), { type, text, time }])
  276. setTimeout(() => {
  277. if (serialOutputRef.current) {
  278. serialOutputRef.current.scrollTop = serialOutputRef.current.scrollHeight
  279. }
  280. }, 10)
  281. }
  282. const handleSerialSend = async () => {
  283. if (!serialCommand.trim() || !serialConnected || serialLoading) return
  284. const cmd = serialCommand.trim()
  285. setSerialCommand('')
  286. setSerialLoading(true)
  287. addSerialHistory('cmd', cmd)
  288. try {
  289. const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: cmd })
  290. if (data.responses) {
  291. if (data.responses.length > 0) {
  292. data.responses.forEach((line: string) => addSerialHistory('resp', line))
  293. } else {
  294. addSerialHistory('resp', '(no response)')
  295. }
  296. } else if (data.detail) {
  297. addSerialHistory('error', data.detail || 'Command failed')
  298. }
  299. } catch (error) {
  300. addSerialHistory('error', `Error: ${error}`)
  301. } finally {
  302. setSerialLoading(false)
  303. setTimeout(() => serialInputRef.current?.focus(), 0)
  304. }
  305. }
  306. const handleSerialReset = async () => {
  307. if (!serialConnected || serialLoading) return
  308. setSerialLoading(true)
  309. addSerialHistory('cmd', '[Ctrl+X] Soft Reset')
  310. try {
  311. // Send Ctrl+X (0x18) - GRBL soft reset command
  312. const data = await apiClient.post<{ responses?: string[]; detail?: string }>('/api/debug-serial/send', { port: selectedSerialPort, command: '\x18' })
  313. if (data.responses && data.responses.length > 0) {
  314. data.responses.forEach((line: string) => addSerialHistory('resp', line))
  315. } else {
  316. addSerialHistory('resp', 'Reset sent')
  317. }
  318. toast.success('Reset command sent')
  319. } catch (error) {
  320. addSerialHistory('error', `Reset failed: ${error}`)
  321. toast.error('Failed to send reset')
  322. } finally {
  323. setSerialLoading(false)
  324. }
  325. }
  326. const handleSerialKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  327. if (e.key === 'Enter' && !e.shiftKey) {
  328. e.preventDefault()
  329. if (!serialLoading) {
  330. handleSerialSend()
  331. }
  332. }
  333. }
  334. // Fetch serial ports and main connection status on mount
  335. useEffect(() => {
  336. fetchSerialPorts()
  337. fetchMainConnectionStatus()
  338. }, [])
  339. // Auto-connect to the main connection port (silent - no toast)
  340. useEffect(() => {
  341. if (mainConnectionPort && selectedSerialPort === mainConnectionPort && !serialConnected && !serialLoading) {
  342. handleSerialConnect(true)
  343. }
  344. }, [mainConnectionPort, selectedSerialPort])
  345. return (
  346. <TooltipProvider>
  347. <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
  348. {/* Page Header */}
  349. <div className="space-y-0.5 sm:space-y-1 pl-1">
  350. <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
  351. <p className="text-xs text-muted-foreground">
  352. Manual controls for your sand table
  353. </p>
  354. </div>
  355. <Separator />
  356. {/* Main Controls Grid - 2x2 */}
  357. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  358. {/* Primary Actions */}
  359. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  360. <CardHeader className="pb-3">
  361. <CardTitle className="text-lg">Primary Actions</CardTitle>
  362. <CardDescription>Calibrate or stop the table</CardDescription>
  363. </CardHeader>
  364. <CardContent>
  365. <div className="grid grid-cols-3 gap-3">
  366. <Tooltip>
  367. <TooltipTrigger asChild>
  368. <Button
  369. onClick={handleHome}
  370. disabled={isLoading === 'home'}
  371. variant="primary"
  372. className="h-16 gap-1 flex-col items-center justify-center"
  373. >
  374. {isLoading === 'home' ? (
  375. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  376. ) : (
  377. <span className="material-icons-outlined text-2xl">home</span>
  378. )}
  379. <span className="text-xs">Home</span>
  380. </Button>
  381. </TooltipTrigger>
  382. <TooltipContent>Return to home position</TooltipContent>
  383. </Tooltip>
  384. <Tooltip>
  385. <TooltipTrigger asChild>
  386. <Button
  387. onClick={handleStop}
  388. disabled={isLoading === 'stop'}
  389. variant="destructive"
  390. className="h-16 gap-1 flex-col items-center justify-center"
  391. >
  392. {isLoading === 'stop' ? (
  393. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  394. ) : (
  395. <span className="material-icons-outlined text-2xl">stop_circle</span>
  396. )}
  397. <span className="text-xs">Stop</span>
  398. </Button>
  399. </TooltipTrigger>
  400. <TooltipContent>Gracefully stop</TooltipContent>
  401. </Tooltip>
  402. <Tooltip>
  403. <TooltipTrigger asChild>
  404. <Button
  405. onClick={handleSoftReset}
  406. disabled={isLoading === 'reset'}
  407. variant="secondary"
  408. className="h-16 gap-1 flex-col items-center justify-center"
  409. >
  410. {isLoading === 'reset' ? (
  411. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  412. ) : (
  413. <span className="material-icons-outlined text-2xl">restart_alt</span>
  414. )}
  415. <span className="text-xs">Reset</span>
  416. </Button>
  417. </TooltipTrigger>
  418. <TooltipContent>Reset DLC32/ESP32, requires homing</TooltipContent>
  419. </Tooltip>
  420. </div>
  421. </CardContent>
  422. </Card>
  423. {/* Speed Control */}
  424. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  425. <CardHeader className="pb-3">
  426. <div className="flex items-center justify-between">
  427. <div>
  428. <CardTitle className="text-lg">Speed</CardTitle>
  429. <CardDescription>Ball movement speed</CardDescription>
  430. </div>
  431. <Badge variant="secondary" className="font-mono">
  432. {currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
  433. </Badge>
  434. </div>
  435. </CardHeader>
  436. <CardContent>
  437. <div className="flex gap-2">
  438. <Input
  439. type="number"
  440. value={speedInput}
  441. onChange={(e) => setSpeedInput(e.target.value)}
  442. placeholder="mm/s"
  443. min="1"
  444. step="1"
  445. className="flex-1"
  446. onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
  447. />
  448. <Button
  449. onClick={handleSetSpeed}
  450. disabled={isLoading === 'speed' || !speedInput}
  451. className="gap-2"
  452. >
  453. {isLoading === 'speed' ? (
  454. <span className="material-icons-outlined animate-spin">sync</span>
  455. ) : (
  456. <span className="material-icons-outlined">check</span>
  457. )}
  458. Set
  459. </Button>
  460. </div>
  461. </CardContent>
  462. </Card>
  463. {/* Position */}
  464. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  465. <CardHeader className="pb-3">
  466. <CardTitle className="text-lg">Position</CardTitle>
  467. <CardDescription>Move ball to a specific location</CardDescription>
  468. </CardHeader>
  469. <CardContent>
  470. <div className="grid grid-cols-3 gap-3">
  471. <Tooltip>
  472. <TooltipTrigger asChild>
  473. <Button
  474. onClick={handleMoveToCenter}
  475. disabled={isLoading === 'center'}
  476. variant="secondary"
  477. className="h-16 gap-1 flex-col items-center justify-center"
  478. >
  479. {isLoading === 'center' ? (
  480. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  481. ) : (
  482. <span className="material-icons-outlined text-2xl">center_focus_strong</span>
  483. )}
  484. <span className="text-xs">Center</span>
  485. </Button>
  486. </TooltipTrigger>
  487. <TooltipContent>Move ball to center</TooltipContent>
  488. </Tooltip>
  489. <Tooltip>
  490. <TooltipTrigger asChild>
  491. <Button
  492. onClick={handleMoveToPerimeter}
  493. disabled={isLoading === 'perimeter'}
  494. variant="secondary"
  495. className="h-16 gap-1 flex-col items-center justify-center"
  496. >
  497. {isLoading === 'perimeter' ? (
  498. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  499. ) : (
  500. <span className="material-icons-outlined text-2xl">trip_origin</span>
  501. )}
  502. <span className="text-xs">Perimeter</span>
  503. </Button>
  504. </TooltipTrigger>
  505. <TooltipContent>Move ball to edge</TooltipContent>
  506. </Tooltip>
  507. <Dialog>
  508. <Tooltip>
  509. <TooltipTrigger asChild>
  510. <DialogTrigger asChild>
  511. <Button
  512. variant="secondary"
  513. className="h-16 gap-1 flex-col items-center justify-center"
  514. >
  515. <span className="material-icons-outlined text-2xl">screen_rotation</span>
  516. <span className="text-xs">Align</span>
  517. </Button>
  518. </DialogTrigger>
  519. </TooltipTrigger>
  520. <TooltipContent>Align pattern orientation</TooltipContent>
  521. </Tooltip>
  522. <DialogContent className="sm:max-w-md">
  523. <DialogHeader>
  524. <DialogTitle>Pattern Orientation Alignment</DialogTitle>
  525. <DialogDescription>
  526. Follow these steps to align your patterns with their previews
  527. </DialogDescription>
  528. </DialogHeader>
  529. <div className="space-y-4 py-4">
  530. <ol className="space-y-3 text-sm">
  531. {[
  532. 'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
  533. 'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
  534. 'Click the "Home" button to establish this as the reference position.',
  535. 'All patterns will now be oriented according to their previews!',
  536. ].map((step, i) => (
  537. <li key={i} className="flex gap-3">
  538. <Badge
  539. variant="secondary"
  540. className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
  541. >
  542. {i + 1}
  543. </Badge>
  544. <span className="text-muted-foreground">{step}</span>
  545. </li>
  546. ))}
  547. </ol>
  548. <Separator />
  549. <Alert className="flex items-start border-amber-500/50">
  550. <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
  551. warning
  552. </span>
  553. <AlertDescription className="text-amber-600 dark:text-amber-400">
  554. Only perform this when you want to change the orientation reference.
  555. </AlertDescription>
  556. </Alert>
  557. <div className="space-y-3">
  558. <p className="text-sm font-medium text-center">Fine Adjustment</p>
  559. <div className="flex justify-center gap-2">
  560. <Button
  561. variant="secondary"
  562. onClick={() => handleRotate(-10)}
  563. disabled={isLoading === 'rotate'}
  564. >
  565. <span className="material-icons text-lg mr-1">rotate_left</span>
  566. CCW 10°
  567. </Button>
  568. <Button
  569. variant="secondary"
  570. onClick={() => handleRotate(10)}
  571. disabled={isLoading === 'rotate'}
  572. >
  573. CW 10°
  574. <span className="material-icons text-lg ml-1">rotate_right</span>
  575. </Button>
  576. </div>
  577. <p className="text-xs text-muted-foreground text-center">
  578. Each click rotates 10 degrees
  579. </p>
  580. </div>
  581. </div>
  582. <DialogFooter>
  583. <DialogTrigger asChild>
  584. <Button>Got it</Button>
  585. </DialogTrigger>
  586. </DialogFooter>
  587. </DialogContent>
  588. </Dialog>
  589. </div>
  590. </CardContent>
  591. </Card>
  592. {/* Clear Patterns */}
  593. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  594. <CardHeader className="pb-3">
  595. <CardTitle className="text-lg">Clear Sand</CardTitle>
  596. <CardDescription>Erase current pattern from the table</CardDescription>
  597. </CardHeader>
  598. <CardContent>
  599. <div className="grid grid-cols-3 gap-3">
  600. <Tooltip>
  601. <TooltipTrigger asChild>
  602. <Button
  603. onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
  604. disabled={isLoading === 'clear_from_in.thr'}
  605. variant="secondary"
  606. className="h-16 gap-1 flex-col items-center justify-center"
  607. >
  608. {isLoading === 'clear_from_in.thr' ? (
  609. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  610. ) : (
  611. <span className="material-icons-outlined text-2xl">center_focus_strong</span>
  612. )}
  613. <span className="text-xs">Clear Center</span>
  614. </Button>
  615. </TooltipTrigger>
  616. <TooltipContent>Spiral outward from center</TooltipContent>
  617. </Tooltip>
  618. <Tooltip>
  619. <TooltipTrigger asChild>
  620. <Button
  621. onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
  622. disabled={isLoading === 'clear_from_out.thr'}
  623. variant="secondary"
  624. className="h-16 gap-1 flex-col items-center justify-center"
  625. >
  626. {isLoading === 'clear_from_out.thr' ? (
  627. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  628. ) : (
  629. <span className="material-icons-outlined text-2xl">all_out</span>
  630. )}
  631. <span className="text-xs">Clear Edge</span>
  632. </Button>
  633. </TooltipTrigger>
  634. <TooltipContent>Spiral inward from edge</TooltipContent>
  635. </Tooltip>
  636. <Tooltip>
  637. <TooltipTrigger asChild>
  638. <Button
  639. onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
  640. disabled={isLoading === 'clear_sideway.thr'}
  641. variant="secondary"
  642. className="h-16 gap-1 flex-col items-center justify-center"
  643. >
  644. {isLoading === 'clear_sideway.thr' ? (
  645. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  646. ) : (
  647. <span className="material-icons-outlined text-2xl">swap_horiz</span>
  648. )}
  649. <span className="text-xs">Clear Sideway</span>
  650. </Button>
  651. </TooltipTrigger>
  652. <TooltipContent>Clear with side-to-side motion</TooltipContent>
  653. </Tooltip>
  654. </div>
  655. </CardContent>
  656. </Card>
  657. </div>
  658. {/* Serial Terminal */}
  659. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  660. <CardHeader className="pb-3 space-y-3">
  661. <div className="flex items-start justify-between gap-2">
  662. <div className="min-w-0">
  663. <CardTitle className="text-lg flex items-center gap-2">
  664. <span className="material-icons-outlined text-xl">terminal</span>
  665. Serial Terminal
  666. </CardTitle>
  667. <CardDescription className="hidden sm:block">Send raw commands to the table controller</CardDescription>
  668. </div>
  669. {/* Clear button - only show on desktop in header */}
  670. <div className="hidden sm:flex items-center gap-1">
  671. {serialHistory.length > 0 && (
  672. <Button
  673. variant="ghost"
  674. size="icon"
  675. onClick={() => setSerialHistory([])}
  676. title="Clear history"
  677. >
  678. <span className="material-icons-outlined">delete_sweep</span>
  679. </Button>
  680. )}
  681. </div>
  682. </div>
  683. {/* Controls row - stacks better on mobile */}
  684. <div className="flex flex-wrap items-center gap-2">
  685. {/* Port selector - auto-refreshes on focus */}
  686. <select
  687. className="h-9 flex-1 min-w-[140px] max-w-[200px] rounded-full border border-input bg-background px-4 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring"
  688. value={selectedSerialPort}
  689. onChange={(e) => setSelectedSerialPort(e.target.value)}
  690. onFocus={fetchSerialPorts}
  691. disabled={serialConnected || serialLoading}
  692. >
  693. <option value="">Select port...</option>
  694. {serialPorts.map((port) => (
  695. <option key={port} value={port}>{port}</option>
  696. ))}
  697. </select>
  698. {!serialConnected ? (
  699. <Button
  700. size="sm"
  701. onClick={handleSerialConnect}
  702. disabled={!selectedSerialPort || serialLoading}
  703. title="Connect"
  704. >
  705. {serialLoading ? (
  706. <span className="material-icons-outlined animate-spin sm:mr-1">sync</span>
  707. ) : (
  708. <span className="material-icons-outlined sm:mr-1">power</span>
  709. )}
  710. <span className="hidden sm:inline">Connect</span>
  711. </Button>
  712. ) : (
  713. <>
  714. <Button
  715. size="sm"
  716. variant="destructive"
  717. onClick={handleSerialDisconnect}
  718. disabled={serialLoading}
  719. title="Disconnect"
  720. >
  721. <span className="material-icons-outlined sm:mr-1">power_off</span>
  722. <span className="hidden sm:inline">Disconnect</span>
  723. </Button>
  724. <Button
  725. size="sm"
  726. variant="secondary"
  727. onClick={handleSerialReset}
  728. disabled={serialLoading}
  729. title="Send Ctrl+X soft reset"
  730. >
  731. <span className="material-icons-outlined sm:mr-1">restart_alt</span>
  732. <span className="hidden sm:inline">Reset</span>
  733. </Button>
  734. </>
  735. )}
  736. {/* Clear button - show on mobile in controls row */}
  737. {serialHistory.length > 0 && (
  738. <Button
  739. variant="ghost"
  740. size="icon"
  741. className="sm:hidden"
  742. onClick={() => setSerialHistory([])}
  743. title="Clear history"
  744. >
  745. <span className="material-icons-outlined">delete</span>
  746. </Button>
  747. )}
  748. </div>
  749. </CardHeader>
  750. <CardContent>
  751. {/* Output area */}
  752. <div
  753. ref={serialOutputRef}
  754. className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
  755. >
  756. {serialHistory.length > 0 ? (
  757. serialHistory.map((entry, i) => (
  758. <div
  759. key={i}
  760. className={`${
  761. entry.type === 'cmd'
  762. ? 'text-cyan-400'
  763. : entry.type === 'error'
  764. ? 'text-red-400'
  765. : 'text-green-400'
  766. }`}
  767. >
  768. <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
  769. {entry.type === 'cmd' ? '> ' : ''}
  770. {entry.text}
  771. </div>
  772. ))
  773. ) : (
  774. <div className="text-gray-500 italic">
  775. {serialConnected
  776. ? 'Ready. Enter a command below (e.g., $, $$, ?, $H)'
  777. : 'Connect to a serial port to send commands'}
  778. </div>
  779. )}
  780. </div>
  781. {/* Input area */}
  782. <div className="flex gap-2">
  783. <Input
  784. ref={serialInputRef}
  785. value={serialCommand}
  786. onChange={(e) => setSerialCommand(e.target.value)}
  787. onKeyDown={handleSerialKeyDown}
  788. disabled={!serialConnected}
  789. readOnly={serialLoading}
  790. placeholder={serialConnected ? 'Enter command (e.g., $, $$, ?, $H)' : 'Connect to send commands'}
  791. className="font-mono text-base h-11"
  792. />
  793. <Button
  794. onClick={handleSerialSend}
  795. disabled={!serialConnected || !serialCommand.trim() || serialLoading}
  796. className="h-11 px-6"
  797. >
  798. {serialLoading ? (
  799. <span className="material-icons-outlined animate-spin">sync</span>
  800. ) : (
  801. <span className="material-icons-outlined">send</span>
  802. )}
  803. </Button>
  804. </div>
  805. </CardContent>
  806. </Card>
  807. </div>
  808. </TooltipProvider>
  809. )
  810. }