SetupPage.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914
  1. import { useState, useCallback } from 'react'
  2. import { useNavigate } from 'react-router-dom'
  3. import { toast } from 'sonner'
  4. import { apiClient } from '@/lib/apiClient'
  5. import { useStatusStore } from '@/stores/useStatusStore'
  6. import { Button } from '@/components/ui/button'
  7. import { Input } from '@/components/ui/input'
  8. import { Label } from '@/components/ui/label'
  9. import { Switch } from '@/components/ui/switch'
  10. import { Alert, AlertDescription } from '@/components/ui/alert'
  11. import { Badge } from '@/components/ui/badge'
  12. import { Separator } from '@/components/ui/separator'
  13. import {
  14. Accordion,
  15. AccordionContent,
  16. AccordionItem,
  17. AccordionTrigger,
  18. } from '@/components/ui/accordion'
  19. // ─── Types ───────────────────────────────────────────────────────────────────
  20. interface AxisConfig {
  21. steps_per_mm: number | null
  22. max_rate_mm_per_min: number | null
  23. acceleration_mm_per_sec2: number | null
  24. direction_pin: string | null
  25. direction_inverted: boolean | null
  26. homing_cycle: number | null
  27. homing_positive_direction: boolean | null
  28. homing_mpos_mm: number | null
  29. homing_feed_mm_per_min: number | null
  30. homing_seek_mm_per_min: number | null
  31. homing_settle_ms: number | null
  32. homing_seek_scaler: number | null
  33. homing_feed_scaler: number | null
  34. hard_limits: boolean | null
  35. pulloff_mm: number | null
  36. }
  37. interface FluidNCConfig {
  38. axes: { x: AxisConfig; y: AxisConfig }
  39. start: { must_home: boolean | null }
  40. }
  41. // ─── Calibration Wizard ──────────────────────────────────────────────────────
  42. type WizardStep =
  43. | 'precheck'
  44. | 'home'
  45. | 'test-y'
  46. | 'test-x'
  47. | 'fix'
  48. | 'sanity-y'
  49. | 'sanity-x'
  50. | 'dip-check'
  51. | 'complete'
  52. interface WizardState {
  53. step: WizardStep
  54. yCorrect: boolean | null
  55. xCorrect: boolean | null
  56. sending: boolean
  57. fixing: boolean
  58. }
  59. const WIZARD_STEPS: { key: WizardStep; label: string }[] = [
  60. { key: 'precheck', label: 'Pre-check' },
  61. { key: 'home', label: 'Home' },
  62. { key: 'test-y', label: 'Test Y' },
  63. { key: 'test-x', label: 'Test X' },
  64. { key: 'fix', label: 'Fix' },
  65. { key: 'sanity-y', label: 'Verify Y' },
  66. { key: 'sanity-x', label: 'Verify X' },
  67. { key: 'complete', label: 'Done' },
  68. ]
  69. function getStepIndex(step: WizardStep): number {
  70. return WIZARD_STEPS.findIndex((s) => s.key === step)
  71. }
  72. function CalibrationWizard() {
  73. const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
  74. const isRunning = useStatusStore((s) => s.status?.is_running ?? false)
  75. const [wizard, setWizard] = useState<WizardState>({
  76. step: 'precheck',
  77. yCorrect: null,
  78. xCorrect: null,
  79. sending: false,
  80. fixing: false,
  81. })
  82. const sendCommand = useCallback(async (command: string, manageSending = true) => {
  83. if (manageSending) setWizard((w) => ({ ...w, sending: true }))
  84. try {
  85. const res = await apiClient.post<{ success: boolean; responses: string[] }>(
  86. '/api/fluidnc/command',
  87. { command }
  88. )
  89. if (!res.success) {
  90. toast.error('Command failed')
  91. }
  92. } catch (err) {
  93. toast.error(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`)
  94. } finally {
  95. if (manageSending) setWizard((w) => ({ ...w, sending: false }))
  96. }
  97. }, [])
  98. const fixDirection = useCallback(
  99. async (axis: 'x' | 'y') => {
  100. setWizard((w) => ({ ...w, fixing: true }))
  101. try {
  102. // Toggle direction, save config
  103. await apiClient.patch('/api/fluidnc/config', {
  104. axes: { [axis]: { direction_inverted: true } },
  105. })
  106. toast.success(`${axis.toUpperCase()} direction toggled and saved`)
  107. } catch (err) {
  108. toast.error(`Fix failed: ${err instanceof Error ? err.message : 'Unknown'}`)
  109. } finally {
  110. setWizard((w) => ({ ...w, fixing: false }))
  111. }
  112. },
  113. []
  114. )
  115. const restartController = useCallback(async () => {
  116. setWizard((w) => ({ ...w, fixing: true }))
  117. try {
  118. await apiClient.post('/api/fluidnc/command', { command: '$Bye', timeout: 2.0 })
  119. toast.success('Restart command sent. Reconnect when ready.')
  120. } catch {
  121. // $Bye causes disconnect, so errors are expected
  122. toast.info('Restart command sent. The controller will reboot.')
  123. } finally {
  124. setWizard((w) => ({ ...w, fixing: false }))
  125. }
  126. }, [])
  127. const waitForIdle = useCallback(async (timeoutMs = 30000) => {
  128. const start = Date.now()
  129. while (Date.now() - start < timeoutMs) {
  130. await new Promise((r) => setTimeout(r, 1000))
  131. try {
  132. const res = await apiClient.post<{ success: boolean; responses: string[] }>(
  133. '/api/fluidnc/command',
  134. { command: '?', timeout: 2.0 }
  135. )
  136. if (res.responses?.some((r) => r.includes('Idle'))) return true
  137. } catch {
  138. // Ignore poll errors
  139. }
  140. }
  141. return false
  142. }, [])
  143. const reset = () =>
  144. setWizard({ step: 'precheck', yCorrect: null, xCorrect: null, sending: false, fixing: false })
  145. const currentIndex = getStepIndex(wizard.step)
  146. const canProceedFromPrecheck = isConnected && !isRunning
  147. return (
  148. <div className="space-y-6">
  149. {/* Step indicator */}
  150. <div className="flex items-center gap-1 overflow-x-auto pb-2">
  151. {WIZARD_STEPS.map((s, i) => (
  152. <div key={s.key} className="flex items-center gap-1">
  153. <button
  154. type="button"
  155. title={s.label}
  156. onClick={() => setWizard((w) => ({ ...w, step: s.key }))}
  157. className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium shrink-0 cursor-pointer transition-opacity hover:opacity-80 ${
  158. i < currentIndex
  159. ? 'bg-primary text-primary-foreground'
  160. : i === currentIndex
  161. ? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
  162. : 'bg-muted text-muted-foreground'
  163. }`}
  164. >
  165. {i < currentIndex ? (
  166. <span className="material-icons-outlined text-sm">check</span>
  167. ) : (
  168. i + 1
  169. )}
  170. </button>
  171. {i < WIZARD_STEPS.length - 1 && (
  172. <div
  173. className={`w-4 h-0.5 ${i < currentIndex ? 'bg-primary' : 'bg-muted'}`}
  174. />
  175. )}
  176. </div>
  177. ))}
  178. </div>
  179. {/* Step content */}
  180. {wizard.step === 'precheck' && (
  181. <div className="space-y-4">
  182. <h3 className="font-semibold">Pre-check</h3>
  183. <p className="text-sm text-muted-foreground">
  184. Verify the table is connected and no pattern is running before calibrating motor directions.
  185. </p>
  186. <div className="space-y-2">
  187. <div className="flex items-center gap-2">
  188. <span
  189. className={`material-icons-outlined text-base ${isConnected ? 'text-green-500' : 'text-red-500'}`}
  190. >
  191. {isConnected ? 'check_circle' : 'cancel'}
  192. </span>
  193. <span className="text-sm">Controller connected</span>
  194. </div>
  195. <div className="flex items-center gap-2">
  196. <span
  197. className={`material-icons-outlined text-base ${!isRunning ? 'text-green-500' : 'text-red-500'}`}
  198. >
  199. {!isRunning ? 'check_circle' : 'cancel'}
  200. </span>
  201. <span className="text-sm">No pattern running</span>
  202. </div>
  203. </div>
  204. <Button
  205. onClick={() => setWizard((w) => ({ ...w, step: 'home' }))}
  206. disabled={!canProceedFromPrecheck}
  207. >
  208. Begin Calibration
  209. </Button>
  210. </div>
  211. )}
  212. {wizard.step === 'home' && (
  213. <div className="space-y-4">
  214. <h3 className="font-semibold">Home Table</h3>
  215. <p className="text-sm text-muted-foreground">
  216. Home the table to establish a known position before testing motor directions.
  217. The ball will move to the home position.
  218. </p>
  219. <div className="flex gap-3">
  220. <Button
  221. onClick={async () => {
  222. setWizard((w) => ({ ...w, sending: true }))
  223. try {
  224. await apiClient.post('/send_home')
  225. toast.success('Homing complete')
  226. setWizard((w) => ({ ...w, sending: false, step: 'test-y' }))
  227. } catch (err) {
  228. toast.error(`Homing failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
  229. setWizard((w) => ({ ...w, sending: false }))
  230. }
  231. }}
  232. disabled={wizard.sending}
  233. >
  234. {wizard.sending ? (
  235. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  236. ) : (
  237. <span className="material-icons-outlined mr-2 text-base">home</span>
  238. )}
  239. {wizard.sending ? 'Homing...' : 'Home Table'}
  240. </Button>
  241. <Button
  242. variant="outline"
  243. onClick={() => setWizard((w) => ({ ...w, step: 'test-y' }))}
  244. disabled={wizard.sending}
  245. >
  246. Skip
  247. </Button>
  248. </div>
  249. </div>
  250. )}
  251. {wizard.step === 'test-y' && (
  252. <div className="space-y-4">
  253. <h3 className="font-semibold">Test Y Axis (Radial)</h3>
  254. <p className="text-sm text-muted-foreground">
  255. This sends a small radial movement. Watch the ball and answer whether it moved <strong>outward</strong> (toward the perimeter).
  256. </p>
  257. <Button onClick={() => sendCommand('$J=G91 G21 Y5 F100.0')} disabled={wizard.sending}>
  258. {wizard.sending ? (
  259. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  260. ) : (
  261. <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
  262. )}
  263. Send Test Command
  264. </Button>
  265. <div className="flex gap-3 pt-2">
  266. <Button
  267. variant="outline"
  268. onClick={() => setWizard((w) => ({ ...w, yCorrect: true, step: 'test-x' }))}
  269. disabled={wizard.sending}
  270. >
  271. <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
  272. Yes, moved outward
  273. </Button>
  274. <Button
  275. variant="outline"
  276. onClick={() => setWizard((w) => ({ ...w, yCorrect: false, step: 'test-x' }))}
  277. disabled={wizard.sending}
  278. >
  279. <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
  280. No, moved inward
  281. </Button>
  282. </div>
  283. </div>
  284. )}
  285. {wizard.step === 'test-x' && (
  286. <div className="space-y-4">
  287. <h3 className="font-semibold">Test X Axis (Angular)</h3>
  288. <p className="text-sm text-muted-foreground">
  289. This sends a small angular movement. Watch the ball and answer whether it moved <strong>clockwise</strong> when viewed from above.
  290. </p>
  291. <Button onClick={() => sendCommand('$J=G91 G21 X5 F100.0')} disabled={wizard.sending}>
  292. {wizard.sending ? (
  293. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  294. ) : (
  295. <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
  296. )}
  297. Send Test Command
  298. </Button>
  299. <div className="flex gap-3 pt-2">
  300. <Button
  301. variant="outline"
  302. onClick={() => {
  303. setWizard((w) => ({
  304. ...w,
  305. xCorrect: true,
  306. step: w.yCorrect === false ? 'fix' : 'sanity-y',
  307. }))
  308. }}
  309. disabled={wizard.sending}
  310. >
  311. <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
  312. Yes, moved clockwise
  313. </Button>
  314. <Button
  315. variant="outline"
  316. onClick={() => setWizard((w) => ({ ...w, xCorrect: false, step: 'fix' }))}
  317. disabled={wizard.sending}
  318. >
  319. <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
  320. No, moved counter-clockwise
  321. </Button>
  322. </div>
  323. </div>
  324. )}
  325. {wizard.step === 'fix' && (
  326. <div className="space-y-4">
  327. <h3 className="font-semibold">Fix Motor Directions</h3>
  328. <p className="text-sm text-muted-foreground">
  329. The following axes need their direction inverted. Click to auto-fix by toggling the <code>:low</code> flag on the direction pin.
  330. </p>
  331. <div className="space-y-3">
  332. {wizard.yCorrect === false && (
  333. <div className="flex items-center justify-between p-3 rounded-lg border">
  334. <div>
  335. <p className="font-medium text-sm">Y Axis (Radial)</p>
  336. <p className="text-xs text-muted-foreground">Direction is inverted</p>
  337. </div>
  338. <Button
  339. size="sm"
  340. onClick={() => fixDirection('y')}
  341. disabled={wizard.fixing}
  342. >
  343. {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
  344. </Button>
  345. </div>
  346. )}
  347. {wizard.xCorrect === false && (
  348. <div className="flex items-center justify-between p-3 rounded-lg border">
  349. <div>
  350. <p className="font-medium text-sm">X Axis (Angular)</p>
  351. <p className="text-xs text-muted-foreground">Direction is inverted</p>
  352. </div>
  353. <Button
  354. size="sm"
  355. onClick={() => fixDirection('x')}
  356. disabled={wizard.fixing}
  357. >
  358. {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
  359. </Button>
  360. </div>
  361. )}
  362. </div>
  363. <Alert>
  364. <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
  365. <AlertDescription>
  366. Direction pin changes require a controller restart to take effect. After fixing, restart the controller below, then re-run the wizard to verify.
  367. </AlertDescription>
  368. </Alert>
  369. <div className="flex gap-3">
  370. <Button variant="outline" onClick={restartController} disabled={wizard.fixing}>
  371. <span className="material-icons-outlined mr-2 text-base">restart_alt</span>
  372. Restart Controller
  373. </Button>
  374. <Button onClick={() => setWizard((w) => ({ ...w, step: 'sanity-y' }))}>
  375. Continue to Verification
  376. </Button>
  377. </div>
  378. </div>
  379. )}
  380. {wizard.step === 'sanity-y' && (
  381. <div className="space-y-4">
  382. <h3 className="font-semibold">Verify Y Axis (Larger Movement)</h3>
  383. <p className="text-sm text-muted-foreground">
  384. This moves the ball to center first, then sends a longer radial movement. The ball should move clearly from <strong>center toward the perimeter</strong>.
  385. </p>
  386. <Button
  387. onClick={async () => {
  388. setWizard((w) => ({ ...w, sending: true }))
  389. try {
  390. await apiClient.post('/move_to_center')
  391. await waitForIdle(60000)
  392. await sendCommand('$J=G91 G21 Y20 F100.0', false)
  393. await waitForIdle()
  394. } finally {
  395. setWizard((w) => ({ ...w, sending: false }))
  396. }
  397. }}
  398. disabled={wizard.sending}
  399. >
  400. {wizard.sending ? (
  401. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  402. ) : (
  403. <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
  404. )}
  405. {wizard.sending ? 'Moving...' : 'Send Verification Command'}
  406. </Button>
  407. <div className="flex flex-wrap gap-3 pt-2">
  408. <Button
  409. onClick={() => setWizard((w) => ({ ...w, step: 'sanity-x' }))}
  410. disabled={wizard.sending}
  411. >
  412. <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
  413. Looks Correct
  414. </Button>
  415. <Button
  416. variant="outline"
  417. onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
  418. disabled={wizard.sending}
  419. >
  420. <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
  421. Only Moved Halfway
  422. </Button>
  423. <Button
  424. variant="outline"
  425. onClick={reset}
  426. disabled={wizard.sending}
  427. >
  428. Start Over
  429. </Button>
  430. </div>
  431. </div>
  432. )}
  433. {wizard.step === 'sanity-x' && (
  434. <div className="space-y-4">
  435. <h3 className="font-semibold">Verify X Axis (Larger Movement)</h3>
  436. <p className="text-sm text-muted-foreground">
  437. This sends a larger angular movement. The ball should make roughly a <strong>full clockwise rotation</strong>.
  438. It will also spiral inward slightly — this is normal due to the mechanical coupling between the angular and radial axes.
  439. </p>
  440. <Button
  441. onClick={async () => {
  442. setWizard((w) => ({ ...w, sending: true }))
  443. try {
  444. await sendCommand('$J=G91 G21 X50 F100.0', false)
  445. await waitForIdle()
  446. } finally {
  447. setWizard((w) => ({ ...w, sending: false }))
  448. }
  449. }}
  450. disabled={wizard.sending}
  451. >
  452. {wizard.sending ? (
  453. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  454. ) : (
  455. <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
  456. )}
  457. {wizard.sending ? 'Moving...' : 'Send Verification Command'}
  458. </Button>
  459. <div className="flex flex-wrap gap-3 pt-2">
  460. <Button
  461. onClick={() => setWizard((w) => ({ ...w, step: 'complete' }))}
  462. disabled={wizard.sending}
  463. >
  464. <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
  465. Looks Correct
  466. </Button>
  467. <Button
  468. variant="outline"
  469. onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
  470. disabled={wizard.sending}
  471. >
  472. <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
  473. Only Half a Revolution
  474. </Button>
  475. <Button
  476. variant="outline"
  477. onClick={reset}
  478. disabled={wizard.sending}
  479. >
  480. Start Over
  481. </Button>
  482. </div>
  483. </div>
  484. )}
  485. {wizard.step === 'dip-check' && (
  486. <div className="space-y-4">
  487. <h3 className="font-semibold">Check DIP Switches</h3>
  488. <Alert variant="destructive">
  489. <span className="material-icons-outlined text-base mr-2 shrink-0">power_off</span>
  490. <AlertDescription>
  491. <strong>Turn off the table completely</strong> before touching any hardware. Disconnect power before checking DIP switches.
  492. </AlertDescription>
  493. </Alert>
  494. <p className="text-sm text-muted-foreground">
  495. If the ball only moved <strong>half the expected distance</strong>, the stepper driver microstepping DIP switches are likely misconfigured.
  496. </p>
  497. <div className="rounded-lg border p-4 space-y-3 bg-muted/30">
  498. <p className="text-sm font-medium">How to fix:</p>
  499. <ol className="text-sm text-muted-foreground list-decimal list-inside space-y-2">
  500. <li>Power off the table completely</li>
  501. <li>Locate the DIP switches underneath each stepper driver</li>
  502. <li>Set <strong>all DIP switches to OFF</strong> (this selects full-step or the driver's default microstepping)</li>
  503. <li>Power the table back on and re-run the calibration wizard</li>
  504. </ol>
  505. </div>
  506. <div className="flex gap-3">
  507. <Button variant="outline" onClick={reset}>
  508. Restart Wizard
  509. </Button>
  510. </div>
  511. </div>
  512. )}
  513. {wizard.step === 'complete' && (
  514. <div className="space-y-4">
  515. <div className="flex items-center gap-2 text-green-600">
  516. <span className="material-icons-outlined text-2xl">check_circle</span>
  517. <h3 className="font-semibold text-lg">Calibration Complete</h3>
  518. </div>
  519. <p className="text-sm text-muted-foreground">
  520. Both axes are moving in the correct directions. Your table is ready to use.
  521. </p>
  522. <Button variant="outline" onClick={reset}>
  523. Run Again
  524. </Button>
  525. </div>
  526. )}
  527. </div>
  528. )
  529. }
  530. // ─── FluidNC Config Editor ───────────────────────────────────────────────────
  531. const MOVEMENT_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
  532. { key: 'steps_per_mm', label: 'Steps per mm', unit: 'steps' },
  533. { key: 'max_rate_mm_per_min', label: 'Max Rate', unit: 'mm/min' },
  534. { key: 'acceleration_mm_per_sec2', label: 'Acceleration', unit: 'mm/s²' },
  535. ]
  536. const HOMING_NUMBER_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
  537. { key: 'homing_cycle', label: 'Homing Cycle', unit: '-1 = disabled' },
  538. { key: 'homing_mpos_mm', label: 'Homing MPos', unit: 'mm' },
  539. { key: 'homing_feed_mm_per_min', label: 'Homing Feed Rate', unit: 'mm/min' },
  540. { key: 'homing_seek_mm_per_min', label: 'Homing Seek Rate', unit: 'mm/min' },
  541. { key: 'homing_settle_ms', label: 'Homing Settle Time', unit: 'ms' },
  542. { key: 'homing_seek_scaler', label: 'Homing Seek Scaler', unit: '' },
  543. { key: 'homing_feed_scaler', label: 'Homing Feed Scaler', unit: '' },
  544. { key: 'pulloff_mm', label: 'Pulloff Distance', unit: 'mm' },
  545. ]
  546. const HOMING_BOOL_FIELDS: { key: keyof AxisConfig; label: string }[] = [
  547. { key: 'homing_positive_direction', label: 'Positive Direction' },
  548. ]
  549. function ConfigEditor() {
  550. const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
  551. const [config, setConfig] = useState<FluidNCConfig | null>(null)
  552. const [original, setOriginal] = useState<FluidNCConfig | null>(null)
  553. const [loading, setLoading] = useState(false)
  554. const [saving, setSaving] = useState(false)
  555. const readConfig = useCallback(async () => {
  556. setLoading(true)
  557. try {
  558. const res = await apiClient.get<{ success: boolean; settings: FluidNCConfig }>(
  559. '/api/fluidnc/config'
  560. )
  561. if (res.success) {
  562. setConfig(res.settings)
  563. setOriginal(structuredClone(res.settings))
  564. toast.success('Configuration loaded from controller')
  565. }
  566. } catch (err) {
  567. toast.error(`Failed to read config: ${err instanceof Error ? err.message : 'Unknown'}`)
  568. } finally {
  569. setLoading(false)
  570. }
  571. }, [])
  572. const saveConfig = useCallback(async () => {
  573. if (!config || !original) return
  574. setSaving(true)
  575. try {
  576. // Build diff: only send changed fields
  577. const update: { axes?: Record<string, Record<string, unknown>>; start?: Record<string, unknown> } = {}
  578. for (const axis of ['x', 'y'] as const) {
  579. const changes: Record<string, unknown> = {}
  580. const curr = config.axes[axis]
  581. const orig = original.axes[axis]
  582. for (const key of Object.keys(curr) as (keyof AxisConfig)[]) {
  583. if (key === 'direction_pin') continue // raw pin, skip
  584. if (curr[key] !== orig[key] && curr[key] !== null) {
  585. changes[key] = curr[key]
  586. }
  587. }
  588. if (Object.keys(changes).length > 0) {
  589. if (!update.axes) update.axes = {}
  590. update.axes[axis] = changes
  591. }
  592. }
  593. if (config.start.must_home !== original.start.must_home && config.start.must_home !== null) {
  594. update.start = { must_home: config.start.must_home }
  595. }
  596. if (!update.axes && !update.start) {
  597. toast.info('No changes to save')
  598. setSaving(false)
  599. return
  600. }
  601. const res = await apiClient.patch<{
  602. success: boolean
  603. saved: boolean
  604. changes_applied: string[]
  605. restart_required: boolean
  606. }>('/api/fluidnc/config', update)
  607. if (res.success) {
  608. setOriginal(structuredClone(config))
  609. if (res.restart_required) {
  610. toast.warning('Settings saved. Direction pin changes require a controller restart.')
  611. } else if (res.saved) {
  612. toast.success(`Saved ${res.changes_applied.length} setting(s) to controller`)
  613. } else {
  614. toast.warning('Settings applied but flash save may have failed')
  615. }
  616. }
  617. } catch (err) {
  618. toast.error(`Save failed: ${err instanceof Error ? err.message : 'Unknown'}`)
  619. } finally {
  620. setSaving(false)
  621. }
  622. }, [config, original])
  623. const updateAxis = (axis: 'x' | 'y', key: keyof AxisConfig, value: unknown) => {
  624. setConfig((prev) => {
  625. if (!prev) return prev
  626. return {
  627. ...prev,
  628. axes: {
  629. ...prev.axes,
  630. [axis]: { ...prev.axes[axis], [key]: value },
  631. },
  632. }
  633. })
  634. }
  635. const hasChanges =
  636. config && original ? JSON.stringify(config) !== JSON.stringify(original) : false
  637. return (
  638. <div className="space-y-6">
  639. <div className="flex items-center gap-3">
  640. <Button onClick={readConfig} disabled={loading || !isConnected}>
  641. {loading ? (
  642. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  643. ) : (
  644. <span className="material-icons-outlined mr-2 text-base">download</span>
  645. )}
  646. {loading ? 'Reading...' : 'Read from Controller'}
  647. </Button>
  648. {config && (
  649. <Button onClick={saveConfig} disabled={saving || !hasChanges}>
  650. {saving ? (
  651. <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
  652. ) : (
  653. <span className="material-icons-outlined mr-2 text-base">save</span>
  654. )}
  655. {saving ? 'Saving...' : 'Save to Controller'}
  656. </Button>
  657. )}
  658. {hasChanges && (
  659. <Badge variant="secondary">Unsaved changes</Badge>
  660. )}
  661. </div>
  662. {!isConnected && (
  663. <Alert>
  664. <span className="material-icons-outlined text-base mr-2 shrink-0">link_off</span>
  665. <AlertDescription>
  666. Connect to a controller first to read or modify FluidNC settings.
  667. </AlertDescription>
  668. </Alert>
  669. )}
  670. {config && (
  671. <Accordion type="multiple" defaultValue={['axis-x', 'axis-y']}>
  672. {/* Per-axis sections */}
  673. {(['x', 'y'] as const).map((axis) => (
  674. <AccordionItem key={axis} value={`axis-${axis}`} className="border rounded-lg px-4 mt-2 bg-card">
  675. <AccordionTrigger className="hover:no-underline">
  676. <div className="flex items-center gap-3">
  677. <span className="material-icons-outlined text-muted-foreground">
  678. {axis === 'x' ? 'rotate_right' : 'swap_vert'}
  679. </span>
  680. <div className="text-left">
  681. <div className="font-semibold">
  682. {axis.toUpperCase()} Axis — {axis === 'x' ? 'Angular' : 'Radial'}
  683. </div>
  684. <div className="text-sm text-muted-foreground font-normal">
  685. Movement, direction, and homing for the {axis === 'x' ? 'angular' : 'radial'} motor
  686. </div>
  687. </div>
  688. </div>
  689. </AccordionTrigger>
  690. <AccordionContent className="pt-4 pb-6 space-y-6">
  691. {/* Movement */}
  692. <div className="space-y-3">
  693. <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Movement</Label>
  694. <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
  695. {MOVEMENT_FIELDS.map((field) => (
  696. <div key={field.key} className="space-y-1">
  697. <Label className="text-xs text-muted-foreground">{field.label}</Label>
  698. <Input
  699. type="number"
  700. step="any"
  701. value={(config.axes[axis][field.key] as number | null) ?? ''}
  702. onChange={(e) =>
  703. updateAxis(
  704. axis,
  705. field.key,
  706. e.target.value ? parseFloat(e.target.value) : null
  707. )
  708. }
  709. placeholder={field.unit}
  710. />
  711. </div>
  712. ))}
  713. </div>
  714. </div>
  715. <Separator />
  716. {/* Direction */}
  717. <div className="space-y-3">
  718. <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Direction</Label>
  719. <div className="flex items-center justify-between p-3 rounded-lg border">
  720. <div>
  721. {config.axes[axis].direction_pin !== null ? (
  722. <p className="text-xs text-muted-foreground font-mono">
  723. Pin: {config.axes[axis].direction_pin}
  724. </p>
  725. ) : (
  726. <p className="text-xs text-muted-foreground">
  727. Not available (may not be a stepstick driver)
  728. </p>
  729. )}
  730. </div>
  731. {config.axes[axis].direction_inverted !== null && (
  732. <div className="flex items-center gap-2">
  733. <Label className="text-sm">Inverted</Label>
  734. <Switch
  735. checked={config.axes[axis].direction_inverted ?? false}
  736. onCheckedChange={(checked) =>
  737. updateAxis(axis, 'direction_inverted', checked)
  738. }
  739. />
  740. </div>
  741. )}
  742. </div>
  743. </div>
  744. <Separator />
  745. {/* Homing */}
  746. <div className="space-y-3">
  747. <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Homing</Label>
  748. <div className="space-y-3">
  749. {HOMING_BOOL_FIELDS.map((field) => (
  750. <div
  751. key={field.key}
  752. className="flex items-center justify-between p-2 rounded-lg border"
  753. >
  754. <Label className="text-sm">{field.label}</Label>
  755. <Switch
  756. checked={(config.axes[axis][field.key] as boolean) ?? false}
  757. onCheckedChange={(checked) => updateAxis(axis, field.key, checked)}
  758. />
  759. </div>
  760. ))}
  761. </div>
  762. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
  763. {HOMING_NUMBER_FIELDS.map((field) => (
  764. <div key={field.key} className="space-y-1">
  765. <Label className="text-xs text-muted-foreground">{field.label}</Label>
  766. <Input
  767. type="number"
  768. step="any"
  769. value={(config.axes[axis][field.key] as number | null) ?? ''}
  770. onChange={(e) =>
  771. updateAxis(
  772. axis,
  773. field.key,
  774. e.target.value ? parseFloat(e.target.value) : null
  775. )
  776. }
  777. placeholder={field.unit}
  778. />
  779. </div>
  780. ))}
  781. </div>
  782. </div>
  783. </AccordionContent>
  784. </AccordionItem>
  785. ))}
  786. </Accordion>
  787. )}
  788. </div>
  789. )
  790. }
  791. // ─── Setup Page ──────────────────────────────────────────────────────────────
  792. export function SetupPage() {
  793. const navigate = useNavigate()
  794. return (
  795. <div className="max-w-4xl mx-auto space-y-6 p-4 pb-32">
  796. {/* Header */}
  797. <div className="flex items-center gap-3">
  798. <Button variant="ghost" size="icon" onClick={() => navigate('/settings')}>
  799. <span className="material-icons-outlined">arrow_back</span>
  800. </Button>
  801. <div>
  802. <h1 className="text-2xl font-bold">Hardware Setup</h1>
  803. <p className="text-sm text-muted-foreground">
  804. Calibrate motors and configure FluidNC settings
  805. </p>
  806. </div>
  807. </div>
  808. {/* Info banner */}
  809. <Alert>
  810. <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
  811. <AlertDescription>
  812. This page is for <strong>FluidNC-based boards with bipolar stepper motors</strong> (DLC32, MKS boards).
  813. Not applicable to unipolar/28BYJ-48 setups.
  814. </AlertDescription>
  815. </Alert>
  816. {/* Main content */}
  817. <Accordion type="multiple" defaultValue={['calibration', 'config']}>
  818. <AccordionItem value="calibration" className="border rounded-lg px-4 bg-card">
  819. <AccordionTrigger className="hover:no-underline">
  820. <div className="flex items-center gap-3">
  821. <span className="material-icons-outlined text-muted-foreground">tune</span>
  822. <div className="text-left">
  823. <div className="font-semibold">Calibration Wizard</div>
  824. <div className="text-sm text-muted-foreground font-normal">
  825. Verify and fix motor directions step by step
  826. </div>
  827. </div>
  828. </div>
  829. </AccordionTrigger>
  830. <AccordionContent className="pt-4 pb-6">
  831. <CalibrationWizard />
  832. </AccordionContent>
  833. </AccordionItem>
  834. <AccordionItem value="config" className="border rounded-lg px-4 mt-2 bg-card">
  835. <AccordionTrigger className="hover:no-underline">
  836. <div className="flex items-center gap-3">
  837. <span className="material-icons-outlined text-muted-foreground">settings</span>
  838. <div className="text-left">
  839. <div className="font-semibold">FluidNC Configuration</div>
  840. <div className="text-sm text-muted-foreground font-normal">
  841. Read and edit curated controller settings
  842. </div>
  843. </div>
  844. </div>
  845. </AccordionTrigger>
  846. <AccordionContent className="pt-4 pb-6" forceMount>
  847. <Alert className="mb-4">
  848. <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
  849. <AlertDescription>
  850. These are low-level FluidNC firmware settings. Only modify them if you understand what each parameter does — incorrect values can cause erratic movement or prevent the table from working.
  851. </AlertDescription>
  852. </Alert>
  853. <ConfigEditor />
  854. </AccordionContent>
  855. </AccordionItem>
  856. </Accordion>
  857. </div>
  858. )
  859. }