|
|
@@ -0,0 +1,914 @@
|
|
|
+import { useState, useCallback } from 'react'
|
|
|
+import { useNavigate } from 'react-router-dom'
|
|
|
+import { toast } from 'sonner'
|
|
|
+import { apiClient } from '@/lib/apiClient'
|
|
|
+import { useStatusStore } from '@/stores/useStatusStore'
|
|
|
+import { Button } from '@/components/ui/button'
|
|
|
+import { Input } from '@/components/ui/input'
|
|
|
+import { Label } from '@/components/ui/label'
|
|
|
+import { Switch } from '@/components/ui/switch'
|
|
|
+import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
|
+import { Badge } from '@/components/ui/badge'
|
|
|
+import { Separator } from '@/components/ui/separator'
|
|
|
+import {
|
|
|
+ Accordion,
|
|
|
+ AccordionContent,
|
|
|
+ AccordionItem,
|
|
|
+ AccordionTrigger,
|
|
|
+} from '@/components/ui/accordion'
|
|
|
+
|
|
|
+// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface AxisConfig {
|
|
|
+ steps_per_mm: number | null
|
|
|
+ max_rate_mm_per_min: number | null
|
|
|
+ acceleration_mm_per_sec2: number | null
|
|
|
+ direction_pin: string | null
|
|
|
+ direction_inverted: boolean | null
|
|
|
+ homing_cycle: number | null
|
|
|
+ homing_positive_direction: boolean | null
|
|
|
+ homing_mpos_mm: number | null
|
|
|
+ homing_feed_mm_per_min: number | null
|
|
|
+ homing_seek_mm_per_min: number | null
|
|
|
+ homing_settle_ms: number | null
|
|
|
+ homing_seek_scaler: number | null
|
|
|
+ homing_feed_scaler: number | null
|
|
|
+ hard_limits: boolean | null
|
|
|
+ pulloff_mm: number | null
|
|
|
+}
|
|
|
+
|
|
|
+interface FluidNCConfig {
|
|
|
+ axes: { x: AxisConfig; y: AxisConfig }
|
|
|
+ start: { must_home: boolean | null }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Calibration Wizard ──────────────────────────────────────────────────────
|
|
|
+
|
|
|
+type WizardStep =
|
|
|
+ | 'precheck'
|
|
|
+ | 'home'
|
|
|
+ | 'test-y'
|
|
|
+ | 'test-x'
|
|
|
+ | 'fix'
|
|
|
+ | 'sanity-y'
|
|
|
+ | 'sanity-x'
|
|
|
+ | 'dip-check'
|
|
|
+ | 'complete'
|
|
|
+
|
|
|
+interface WizardState {
|
|
|
+ step: WizardStep
|
|
|
+ yCorrect: boolean | null
|
|
|
+ xCorrect: boolean | null
|
|
|
+ sending: boolean
|
|
|
+ fixing: boolean
|
|
|
+}
|
|
|
+
|
|
|
+const WIZARD_STEPS: { key: WizardStep; label: string }[] = [
|
|
|
+ { key: 'precheck', label: 'Pre-check' },
|
|
|
+ { key: 'home', label: 'Home' },
|
|
|
+ { key: 'test-y', label: 'Test Y' },
|
|
|
+ { key: 'test-x', label: 'Test X' },
|
|
|
+ { key: 'fix', label: 'Fix' },
|
|
|
+ { key: 'sanity-y', label: 'Verify Y' },
|
|
|
+ { key: 'sanity-x', label: 'Verify X' },
|
|
|
+ { key: 'complete', label: 'Done' },
|
|
|
+]
|
|
|
+
|
|
|
+function getStepIndex(step: WizardStep): number {
|
|
|
+ return WIZARD_STEPS.findIndex((s) => s.key === step)
|
|
|
+}
|
|
|
+
|
|
|
+function CalibrationWizard() {
|
|
|
+ const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
|
|
|
+ const isRunning = useStatusStore((s) => s.status?.is_running ?? false)
|
|
|
+
|
|
|
+ const [wizard, setWizard] = useState<WizardState>({
|
|
|
+ step: 'precheck',
|
|
|
+ yCorrect: null,
|
|
|
+ xCorrect: null,
|
|
|
+ sending: false,
|
|
|
+ fixing: false,
|
|
|
+ })
|
|
|
+
|
|
|
+ const sendCommand = useCallback(async (command: string, manageSending = true) => {
|
|
|
+ if (manageSending) setWizard((w) => ({ ...w, sending: true }))
|
|
|
+ try {
|
|
|
+ const res = await apiClient.post<{ success: boolean; responses: string[] }>(
|
|
|
+ '/api/fluidnc/command',
|
|
|
+ { command }
|
|
|
+ )
|
|
|
+ if (!res.success) {
|
|
|
+ toast.error('Command failed')
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ toast.error(`Error: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
|
+ } finally {
|
|
|
+ if (manageSending) setWizard((w) => ({ ...w, sending: false }))
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ const fixDirection = useCallback(
|
|
|
+ async (axis: 'x' | 'y') => {
|
|
|
+ setWizard((w) => ({ ...w, fixing: true }))
|
|
|
+ try {
|
|
|
+ // Toggle direction, save config
|
|
|
+ await apiClient.patch('/api/fluidnc/config', {
|
|
|
+ axes: { [axis]: { direction_inverted: true } },
|
|
|
+ })
|
|
|
+ toast.success(`${axis.toUpperCase()} direction toggled and saved`)
|
|
|
+ } catch (err) {
|
|
|
+ toast.error(`Fix failed: ${err instanceof Error ? err.message : 'Unknown'}`)
|
|
|
+ } finally {
|
|
|
+ setWizard((w) => ({ ...w, fixing: false }))
|
|
|
+ }
|
|
|
+ },
|
|
|
+ []
|
|
|
+ )
|
|
|
+
|
|
|
+ const restartController = useCallback(async () => {
|
|
|
+ setWizard((w) => ({ ...w, fixing: true }))
|
|
|
+ try {
|
|
|
+ await apiClient.post('/api/fluidnc/command', { command: '$Bye', timeout: 2.0 })
|
|
|
+ toast.success('Restart command sent. Reconnect when ready.')
|
|
|
+ } catch {
|
|
|
+ // $Bye causes disconnect, so errors are expected
|
|
|
+ toast.info('Restart command sent. The controller will reboot.')
|
|
|
+ } finally {
|
|
|
+ setWizard((w) => ({ ...w, fixing: false }))
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ const waitForIdle = useCallback(async (timeoutMs = 30000) => {
|
|
|
+ const start = Date.now()
|
|
|
+ while (Date.now() - start < timeoutMs) {
|
|
|
+ await new Promise((r) => setTimeout(r, 1000))
|
|
|
+ try {
|
|
|
+ const res = await apiClient.post<{ success: boolean; responses: string[] }>(
|
|
|
+ '/api/fluidnc/command',
|
|
|
+ { command: '?', timeout: 2.0 }
|
|
|
+ )
|
|
|
+ if (res.responses?.some((r) => r.includes('Idle'))) return true
|
|
|
+ } catch {
|
|
|
+ // Ignore poll errors
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ const reset = () =>
|
|
|
+ setWizard({ step: 'precheck', yCorrect: null, xCorrect: null, sending: false, fixing: false })
|
|
|
+
|
|
|
+ const currentIndex = getStepIndex(wizard.step)
|
|
|
+
|
|
|
+ const canProceedFromPrecheck = isConnected && !isRunning
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* Step indicator */}
|
|
|
+ <div className="flex items-center gap-1 overflow-x-auto pb-2">
|
|
|
+ {WIZARD_STEPS.map((s, i) => (
|
|
|
+ <div key={s.key} className="flex items-center gap-1">
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ title={s.label}
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: s.key }))}
|
|
|
+ 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 ${
|
|
|
+ i < currentIndex
|
|
|
+ ? 'bg-primary text-primary-foreground'
|
|
|
+ : i === currentIndex
|
|
|
+ ? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
|
|
|
+ : 'bg-muted text-muted-foreground'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {i < currentIndex ? (
|
|
|
+ <span className="material-icons-outlined text-sm">check</span>
|
|
|
+ ) : (
|
|
|
+ i + 1
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ {i < WIZARD_STEPS.length - 1 && (
|
|
|
+ <div
|
|
|
+ className={`w-4 h-0.5 ${i < currentIndex ? 'bg-primary' : 'bg-muted'}`}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Step content */}
|
|
|
+ {wizard.step === 'precheck' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Pre-check</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ Verify the table is connected and no pattern is running before calibrating motor directions.
|
|
|
+ </p>
|
|
|
+ <div className="space-y-2">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span
|
|
|
+ className={`material-icons-outlined text-base ${isConnected ? 'text-green-500' : 'text-red-500'}`}
|
|
|
+ >
|
|
|
+ {isConnected ? 'check_circle' : 'cancel'}
|
|
|
+ </span>
|
|
|
+ <span className="text-sm">Controller connected</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span
|
|
|
+ className={`material-icons-outlined text-base ${!isRunning ? 'text-green-500' : 'text-red-500'}`}
|
|
|
+ >
|
|
|
+ {!isRunning ? 'check_circle' : 'cancel'}
|
|
|
+ </span>
|
|
|
+ <span className="text-sm">No pattern running</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'home' }))}
|
|
|
+ disabled={!canProceedFromPrecheck}
|
|
|
+ >
|
|
|
+ Begin Calibration
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'home' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Home Table</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ Home the table to establish a known position before testing motor directions.
|
|
|
+ The ball will move to the home position.
|
|
|
+ </p>
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <Button
|
|
|
+ onClick={async () => {
|
|
|
+ setWizard((w) => ({ ...w, sending: true }))
|
|
|
+ try {
|
|
|
+ await apiClient.post('/send_home')
|
|
|
+ toast.success('Homing complete')
|
|
|
+ setWizard((w) => ({ ...w, sending: false, step: 'test-y' }))
|
|
|
+ } catch (err) {
|
|
|
+ toast.error(`Homing failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
|
+ setWizard((w) => ({ ...w, sending: false }))
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ {wizard.sending ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">home</span>
|
|
|
+ )}
|
|
|
+ {wizard.sending ? 'Homing...' : 'Home Table'}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'test-y' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ Skip
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'test-y' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Test Y Axis (Radial)</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ This sends a small radial movement. Watch the ball and answer whether it moved <strong>outward</strong> (toward the perimeter).
|
|
|
+ </p>
|
|
|
+ <Button onClick={() => sendCommand('$J=G91 G21 Y5 F100.0')} disabled={wizard.sending}>
|
|
|
+ {wizard.sending ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
|
|
|
+ )}
|
|
|
+ Send Test Command
|
|
|
+ </Button>
|
|
|
+ <div className="flex gap-3 pt-2">
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, yCorrect: true, step: 'test-x' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
|
|
|
+ Yes, moved outward
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, yCorrect: false, step: 'test-x' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
|
|
|
+ No, moved inward
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'test-x' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Test X Axis (Angular)</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ This sends a small angular movement. Watch the ball and answer whether it moved <strong>clockwise</strong> when viewed from above.
|
|
|
+ </p>
|
|
|
+ <Button onClick={() => sendCommand('$J=G91 G21 X5 F100.0')} disabled={wizard.sending}>
|
|
|
+ {wizard.sending ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
|
|
|
+ )}
|
|
|
+ Send Test Command
|
|
|
+ </Button>
|
|
|
+ <div className="flex gap-3 pt-2">
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => {
|
|
|
+ setWizard((w) => ({
|
|
|
+ ...w,
|
|
|
+ xCorrect: true,
|
|
|
+ step: w.yCorrect === false ? 'fix' : 'sanity-y',
|
|
|
+ }))
|
|
|
+ }}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-green-500">thumb_up</span>
|
|
|
+ Yes, moved clockwise
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, xCorrect: false, step: 'fix' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-red-500">thumb_down</span>
|
|
|
+ No, moved counter-clockwise
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'fix' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Fix Motor Directions</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ The following axes need their direction inverted. Click to auto-fix by toggling the <code>:low</code> flag on the direction pin.
|
|
|
+ </p>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {wizard.yCorrect === false && (
|
|
|
+ <div className="flex items-center justify-between p-3 rounded-lg border">
|
|
|
+ <div>
|
|
|
+ <p className="font-medium text-sm">Y Axis (Radial)</p>
|
|
|
+ <p className="text-xs text-muted-foreground">Direction is inverted</p>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ onClick={() => fixDirection('y')}
|
|
|
+ disabled={wizard.fixing}
|
|
|
+ >
|
|
|
+ {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {wizard.xCorrect === false && (
|
|
|
+ <div className="flex items-center justify-between p-3 rounded-lg border">
|
|
|
+ <div>
|
|
|
+ <p className="font-medium text-sm">X Axis (Angular)</p>
|
|
|
+ <p className="text-xs text-muted-foreground">Direction is inverted</p>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ onClick={() => fixDirection('x')}
|
|
|
+ disabled={wizard.fixing}
|
|
|
+ >
|
|
|
+ {wizard.fixing ? 'Fixing...' : 'Fix Automatically'}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <Alert>
|
|
|
+ <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
|
|
|
+ <AlertDescription>
|
|
|
+ Direction pin changes require a controller restart to take effect. After fixing, restart the controller below, then re-run the wizard to verify.
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <Button variant="outline" onClick={restartController} disabled={wizard.fixing}>
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">restart_alt</span>
|
|
|
+ Restart Controller
|
|
|
+ </Button>
|
|
|
+ <Button onClick={() => setWizard((w) => ({ ...w, step: 'sanity-y' }))}>
|
|
|
+ Continue to Verification
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'sanity-y' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Verify Y Axis (Larger Movement)</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ 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>.
|
|
|
+ </p>
|
|
|
+ <Button
|
|
|
+ onClick={async () => {
|
|
|
+ setWizard((w) => ({ ...w, sending: true }))
|
|
|
+ try {
|
|
|
+ await apiClient.post('/move_to_center')
|
|
|
+ await waitForIdle(60000)
|
|
|
+ await sendCommand('$J=G91 G21 Y20 F100.0', false)
|
|
|
+ await waitForIdle()
|
|
|
+ } finally {
|
|
|
+ setWizard((w) => ({ ...w, sending: false }))
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ {wizard.sending ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
|
|
|
+ )}
|
|
|
+ {wizard.sending ? 'Moving...' : 'Send Verification Command'}
|
|
|
+ </Button>
|
|
|
+ <div className="flex flex-wrap gap-3 pt-2">
|
|
|
+ <Button
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'sanity-x' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
|
|
|
+ Looks Correct
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
|
|
|
+ Only Moved Halfway
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={reset}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ Start Over
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'sanity-x' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Verify X Axis (Larger Movement)</h3>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ This sends a larger angular movement. The ball should make roughly a <strong>full clockwise rotation</strong>.
|
|
|
+ It will also spiral inward slightly — this is normal due to the mechanical coupling between the angular and radial axes.
|
|
|
+ </p>
|
|
|
+ <Button
|
|
|
+ onClick={async () => {
|
|
|
+ setWizard((w) => ({ ...w, sending: true }))
|
|
|
+ try {
|
|
|
+ await sendCommand('$J=G91 G21 X50 F100.0', false)
|
|
|
+ await waitForIdle()
|
|
|
+ } finally {
|
|
|
+ setWizard((w) => ({ ...w, sending: false }))
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ {wizard.sending ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">play_arrow</span>
|
|
|
+ )}
|
|
|
+ {wizard.sending ? 'Moving...' : 'Send Verification Command'}
|
|
|
+ </Button>
|
|
|
+ <div className="flex flex-wrap gap-3 pt-2">
|
|
|
+ <Button
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'complete' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-green-500">check</span>
|
|
|
+ Looks Correct
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={() => setWizard((w) => ({ ...w, step: 'dip-check' }))}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ <span className="material-icons-outlined mr-1 text-base text-amber-500">warning</span>
|
|
|
+ Only Half a Revolution
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ onClick={reset}
|
|
|
+ disabled={wizard.sending}
|
|
|
+ >
|
|
|
+ Start Over
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'dip-check' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <h3 className="font-semibold">Check DIP Switches</h3>
|
|
|
+ <Alert variant="destructive">
|
|
|
+ <span className="material-icons-outlined text-base mr-2 shrink-0">power_off</span>
|
|
|
+ <AlertDescription>
|
|
|
+ <strong>Turn off the table completely</strong> before touching any hardware. Disconnect power before checking DIP switches.
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ If the ball only moved <strong>half the expected distance</strong>, the stepper driver microstepping DIP switches are likely misconfigured.
|
|
|
+ </p>
|
|
|
+ <div className="rounded-lg border p-4 space-y-3 bg-muted/30">
|
|
|
+ <p className="text-sm font-medium">How to fix:</p>
|
|
|
+ <ol className="text-sm text-muted-foreground list-decimal list-inside space-y-2">
|
|
|
+ <li>Power off the table completely</li>
|
|
|
+ <li>Locate the DIP switches underneath each stepper driver</li>
|
|
|
+ <li>Set <strong>all DIP switches to OFF</strong> (this selects full-step or the driver's default microstepping)</li>
|
|
|
+ <li>Power the table back on and re-run the calibration wizard</li>
|
|
|
+ </ol>
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <Button variant="outline" onClick={reset}>
|
|
|
+ Restart Wizard
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {wizard.step === 'complete' && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="flex items-center gap-2 text-green-600">
|
|
|
+ <span className="material-icons-outlined text-2xl">check_circle</span>
|
|
|
+ <h3 className="font-semibold text-lg">Calibration Complete</h3>
|
|
|
+ </div>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ Both axes are moving in the correct directions. Your table is ready to use.
|
|
|
+ </p>
|
|
|
+ <Button variant="outline" onClick={reset}>
|
|
|
+ Run Again
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── FluidNC Config Editor ───────────────────────────────────────────────────
|
|
|
+
|
|
|
+const MOVEMENT_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
|
|
|
+ { key: 'steps_per_mm', label: 'Steps per mm', unit: 'steps' },
|
|
|
+ { key: 'max_rate_mm_per_min', label: 'Max Rate', unit: 'mm/min' },
|
|
|
+ { key: 'acceleration_mm_per_sec2', label: 'Acceleration', unit: 'mm/s²' },
|
|
|
+]
|
|
|
+
|
|
|
+const HOMING_NUMBER_FIELDS: { key: keyof AxisConfig; label: string; unit: string }[] = [
|
|
|
+ { key: 'homing_cycle', label: 'Homing Cycle', unit: '-1 = disabled' },
|
|
|
+ { key: 'homing_mpos_mm', label: 'Homing MPos', unit: 'mm' },
|
|
|
+ { key: 'homing_feed_mm_per_min', label: 'Homing Feed Rate', unit: 'mm/min' },
|
|
|
+ { key: 'homing_seek_mm_per_min', label: 'Homing Seek Rate', unit: 'mm/min' },
|
|
|
+ { key: 'homing_settle_ms', label: 'Homing Settle Time', unit: 'ms' },
|
|
|
+ { key: 'homing_seek_scaler', label: 'Homing Seek Scaler', unit: '' },
|
|
|
+ { key: 'homing_feed_scaler', label: 'Homing Feed Scaler', unit: '' },
|
|
|
+ { key: 'pulloff_mm', label: 'Pulloff Distance', unit: 'mm' },
|
|
|
+]
|
|
|
+
|
|
|
+const HOMING_BOOL_FIELDS: { key: keyof AxisConfig; label: string }[] = [
|
|
|
+ { key: 'homing_positive_direction', label: 'Positive Direction' },
|
|
|
+]
|
|
|
+
|
|
|
+function ConfigEditor() {
|
|
|
+ const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
|
|
|
+
|
|
|
+ const [config, setConfig] = useState<FluidNCConfig | null>(null)
|
|
|
+ const [original, setOriginal] = useState<FluidNCConfig | null>(null)
|
|
|
+ const [loading, setLoading] = useState(false)
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
+
|
|
|
+ const readConfig = useCallback(async () => {
|
|
|
+ setLoading(true)
|
|
|
+ try {
|
|
|
+ const res = await apiClient.get<{ success: boolean; settings: FluidNCConfig }>(
|
|
|
+ '/api/fluidnc/config'
|
|
|
+ )
|
|
|
+ if (res.success) {
|
|
|
+ setConfig(res.settings)
|
|
|
+ setOriginal(structuredClone(res.settings))
|
|
|
+ toast.success('Configuration loaded from controller')
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ toast.error(`Failed to read config: ${err instanceof Error ? err.message : 'Unknown'}`)
|
|
|
+ } finally {
|
|
|
+ setLoading(false)
|
|
|
+ }
|
|
|
+ }, [])
|
|
|
+
|
|
|
+ const saveConfig = useCallback(async () => {
|
|
|
+ if (!config || !original) return
|
|
|
+ setSaving(true)
|
|
|
+ try {
|
|
|
+ // Build diff: only send changed fields
|
|
|
+ const update: { axes?: Record<string, Record<string, unknown>>; start?: Record<string, unknown> } = {}
|
|
|
+
|
|
|
+ for (const axis of ['x', 'y'] as const) {
|
|
|
+ const changes: Record<string, unknown> = {}
|
|
|
+ const curr = config.axes[axis]
|
|
|
+ const orig = original.axes[axis]
|
|
|
+ for (const key of Object.keys(curr) as (keyof AxisConfig)[]) {
|
|
|
+ if (key === 'direction_pin') continue // raw pin, skip
|
|
|
+ if (curr[key] !== orig[key] && curr[key] !== null) {
|
|
|
+ changes[key] = curr[key]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (Object.keys(changes).length > 0) {
|
|
|
+ if (!update.axes) update.axes = {}
|
|
|
+ update.axes[axis] = changes
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (config.start.must_home !== original.start.must_home && config.start.must_home !== null) {
|
|
|
+ update.start = { must_home: config.start.must_home }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!update.axes && !update.start) {
|
|
|
+ toast.info('No changes to save')
|
|
|
+ setSaving(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const res = await apiClient.patch<{
|
|
|
+ success: boolean
|
|
|
+ saved: boolean
|
|
|
+ changes_applied: string[]
|
|
|
+ restart_required: boolean
|
|
|
+ }>('/api/fluidnc/config', update)
|
|
|
+
|
|
|
+ if (res.success) {
|
|
|
+ setOriginal(structuredClone(config))
|
|
|
+ if (res.restart_required) {
|
|
|
+ toast.warning('Settings saved. Direction pin changes require a controller restart.')
|
|
|
+ } else if (res.saved) {
|
|
|
+ toast.success(`Saved ${res.changes_applied.length} setting(s) to controller`)
|
|
|
+ } else {
|
|
|
+ toast.warning('Settings applied but flash save may have failed')
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ toast.error(`Save failed: ${err instanceof Error ? err.message : 'Unknown'}`)
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }, [config, original])
|
|
|
+
|
|
|
+ const updateAxis = (axis: 'x' | 'y', key: keyof AxisConfig, value: unknown) => {
|
|
|
+ setConfig((prev) => {
|
|
|
+ if (!prev) return prev
|
|
|
+ return {
|
|
|
+ ...prev,
|
|
|
+ axes: {
|
|
|
+ ...prev.axes,
|
|
|
+ [axis]: { ...prev.axes[axis], [key]: value },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasChanges =
|
|
|
+ config && original ? JSON.stringify(config) !== JSON.stringify(original) : false
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <Button onClick={readConfig} disabled={loading || !isConnected}>
|
|
|
+ {loading ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">download</span>
|
|
|
+ )}
|
|
|
+ {loading ? 'Reading...' : 'Read from Controller'}
|
|
|
+ </Button>
|
|
|
+ {config && (
|
|
|
+ <Button onClick={saveConfig} disabled={saving || !hasChanges}>
|
|
|
+ {saving ? (
|
|
|
+ <span className="material-icons-outlined animate-spin mr-2 text-base">sync</span>
|
|
|
+ ) : (
|
|
|
+ <span className="material-icons-outlined mr-2 text-base">save</span>
|
|
|
+ )}
|
|
|
+ {saving ? 'Saving...' : 'Save to Controller'}
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
+ {hasChanges && (
|
|
|
+ <Badge variant="secondary">Unsaved changes</Badge>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {!isConnected && (
|
|
|
+ <Alert>
|
|
|
+ <span className="material-icons-outlined text-base mr-2 shrink-0">link_off</span>
|
|
|
+ <AlertDescription>
|
|
|
+ Connect to a controller first to read or modify FluidNC settings.
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {config && (
|
|
|
+ <Accordion type="multiple" defaultValue={['axis-x', 'axis-y']}>
|
|
|
+ {/* Per-axis sections */}
|
|
|
+ {(['x', 'y'] as const).map((axis) => (
|
|
|
+ <AccordionItem key={axis} value={`axis-${axis}`} className="border rounded-lg px-4 mt-2 bg-card">
|
|
|
+ <AccordionTrigger className="hover:no-underline">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <span className="material-icons-outlined text-muted-foreground">
|
|
|
+ {axis === 'x' ? 'rotate_right' : 'swap_vert'}
|
|
|
+ </span>
|
|
|
+ <div className="text-left">
|
|
|
+ <div className="font-semibold">
|
|
|
+ {axis.toUpperCase()} Axis — {axis === 'x' ? 'Angular' : 'Radial'}
|
|
|
+ </div>
|
|
|
+ <div className="text-sm text-muted-foreground font-normal">
|
|
|
+ Movement, direction, and homing for the {axis === 'x' ? 'angular' : 'radial'} motor
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </AccordionTrigger>
|
|
|
+ <AccordionContent className="pt-4 pb-6 space-y-6">
|
|
|
+ {/* Movement */}
|
|
|
+ <div className="space-y-3">
|
|
|
+ <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Movement</Label>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
|
+ {MOVEMENT_FIELDS.map((field) => (
|
|
|
+ <div key={field.key} className="space-y-1">
|
|
|
+ <Label className="text-xs text-muted-foreground">{field.label}</Label>
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ step="any"
|
|
|
+ value={(config.axes[axis][field.key] as number | null) ?? ''}
|
|
|
+ onChange={(e) =>
|
|
|
+ updateAxis(
|
|
|
+ axis,
|
|
|
+ field.key,
|
|
|
+ e.target.value ? parseFloat(e.target.value) : null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ placeholder={field.unit}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Separator />
|
|
|
+
|
|
|
+ {/* Direction */}
|
|
|
+ <div className="space-y-3">
|
|
|
+ <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Direction</Label>
|
|
|
+ <div className="flex items-center justify-between p-3 rounded-lg border">
|
|
|
+ <div>
|
|
|
+ {config.axes[axis].direction_pin !== null ? (
|
|
|
+ <p className="text-xs text-muted-foreground font-mono">
|
|
|
+ Pin: {config.axes[axis].direction_pin}
|
|
|
+ </p>
|
|
|
+ ) : (
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ Not available (may not be a stepstick driver)
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {config.axes[axis].direction_inverted !== null && (
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Label className="text-sm">Inverted</Label>
|
|
|
+ <Switch
|
|
|
+ checked={config.axes[axis].direction_inverted ?? false}
|
|
|
+ onCheckedChange={(checked) =>
|
|
|
+ updateAxis(axis, 'direction_inverted', checked)
|
|
|
+ }
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Separator />
|
|
|
+
|
|
|
+ {/* Homing */}
|
|
|
+ <div className="space-y-3">
|
|
|
+ <Label className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Homing</Label>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {HOMING_BOOL_FIELDS.map((field) => (
|
|
|
+ <div
|
|
|
+ key={field.key}
|
|
|
+ className="flex items-center justify-between p-2 rounded-lg border"
|
|
|
+ >
|
|
|
+ <Label className="text-sm">{field.label}</Label>
|
|
|
+ <Switch
|
|
|
+ checked={(config.axes[axis][field.key] as boolean) ?? false}
|
|
|
+ onCheckedChange={(checked) => updateAxis(axis, field.key, checked)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
|
+ {HOMING_NUMBER_FIELDS.map((field) => (
|
|
|
+ <div key={field.key} className="space-y-1">
|
|
|
+ <Label className="text-xs text-muted-foreground">{field.label}</Label>
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ step="any"
|
|
|
+ value={(config.axes[axis][field.key] as number | null) ?? ''}
|
|
|
+ onChange={(e) =>
|
|
|
+ updateAxis(
|
|
|
+ axis,
|
|
|
+ field.key,
|
|
|
+ e.target.value ? parseFloat(e.target.value) : null
|
|
|
+ )
|
|
|
+ }
|
|
|
+ placeholder={field.unit}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </AccordionContent>
|
|
|
+ </AccordionItem>
|
|
|
+ ))}
|
|
|
+ </Accordion>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Setup Page ──────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+export function SetupPage() {
|
|
|
+ const navigate = useNavigate()
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="max-w-4xl mx-auto space-y-6 p-4 pb-32">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <Button variant="ghost" size="icon" onClick={() => navigate('/settings')}>
|
|
|
+ <span className="material-icons-outlined">arrow_back</span>
|
|
|
+ </Button>
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-bold">Hardware Setup</h1>
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ Calibrate motors and configure FluidNC settings
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Info banner */}
|
|
|
+ <Alert>
|
|
|
+ <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
|
|
|
+ <AlertDescription>
|
|
|
+ This page is for <strong>FluidNC-based boards with bipolar stepper motors</strong> (DLC32, MKS boards).
|
|
|
+ Not applicable to unipolar/28BYJ-48 setups.
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+
|
|
|
+ {/* Main content */}
|
|
|
+ <Accordion type="multiple" defaultValue={['calibration', 'config']}>
|
|
|
+ <AccordionItem value="calibration" className="border rounded-lg px-4 bg-card">
|
|
|
+ <AccordionTrigger className="hover:no-underline">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <span className="material-icons-outlined text-muted-foreground">tune</span>
|
|
|
+ <div className="text-left">
|
|
|
+ <div className="font-semibold">Calibration Wizard</div>
|
|
|
+ <div className="text-sm text-muted-foreground font-normal">
|
|
|
+ Verify and fix motor directions step by step
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </AccordionTrigger>
|
|
|
+ <AccordionContent className="pt-4 pb-6">
|
|
|
+ <CalibrationWizard />
|
|
|
+ </AccordionContent>
|
|
|
+ </AccordionItem>
|
|
|
+
|
|
|
+ <AccordionItem value="config" className="border rounded-lg px-4 mt-2 bg-card">
|
|
|
+ <AccordionTrigger className="hover:no-underline">
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <span className="material-icons-outlined text-muted-foreground">settings</span>
|
|
|
+ <div className="text-left">
|
|
|
+ <div className="font-semibold">FluidNC Configuration</div>
|
|
|
+ <div className="text-sm text-muted-foreground font-normal">
|
|
|
+ Read and edit curated controller settings
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </AccordionTrigger>
|
|
|
+ <AccordionContent className="pt-4 pb-6" forceMount>
|
|
|
+ <Alert className="mb-4">
|
|
|
+ <span className="material-icons-outlined text-base mr-2 shrink-0">warning</span>
|
|
|
+ <AlertDescription>
|
|
|
+ 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.
|
|
|
+ </AlertDescription>
|
|
|
+ </Alert>
|
|
|
+ <ConfigEditor />
|
|
|
+ </AccordionContent>
|
|
|
+ </AccordionItem>
|
|
|
+ </Accordion>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|