| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914 |
- 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>
- )
- }
|