소스 검색

Add Setup page, FluidNC config editor, gear ratio override, firmware version warning

- Add SetupPage with calibration wizard and FluidNC config editor
- Add FluidNC config read/write/command API endpoints
- Add gear ratio override setting (persisted, highest priority)
- Add firmware version detection in status WebSocket payload
- Add persistent header warning when FluidNC version doesn't match expected
  (v3.8.3 for mini, v3.9.5 for other tables)
- Clean version parsing with regex to strip framing characters
- Move Home on Connect to Connection section, add hardware setup link

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 4 달 전
부모
커밋
253ecf6d21

+ 2 - 0
frontend/src/App.tsx

@@ -6,6 +6,7 @@ import { TableControlPage } from '@/pages/TableControlPage'
 import { LEDPage } from '@/pages/LEDPage'
 import { SettingsPage } from '@/pages/SettingsPage'
 import { WiFiSetupPage } from '@/pages/WiFiSetupPage'
+import { SetupPage } from '@/pages/SetupPage'
 import { Toaster } from '@/components/ui/sonner'
 import { TableProvider } from '@/contexts/TableContext'
 
@@ -20,6 +21,7 @@ function App() {
           <Route path="led" element={<LEDPage />} />
           <Route path="settings" element={<SettingsPage />} />
           <Route path="wifi-setup" element={<WiFiSetupPage />} />
+          <Route path="setup" element={<SetupPage />} />
         </Route>
       </Routes>
       <Toaster position="top-center" richColors closeButton />

+ 26 - 3
frontend/src/components/layout/Layout.tsx

@@ -88,6 +88,8 @@ export function Layout() {
   const isConnected = useStatusStore((s) => s.status?.connection_status ?? false)
   const isHoming = useStatusStore((s) => s.status?.is_homing ?? false)
   const sensorHomingFailed = useStatusStore((s) => s.status?.sensor_homing_failed ?? false)
+  const firmwareVersion = useStatusStore((s) => s.status?.firmware_version ?? null)
+  const tableType = useStatusStore((s) => s.status?.table_type ?? null)
   const statusCurrentFile = useStatusStore((s) => s.status?.current_file ?? null)
   const statusIsRunning = useStatusStore((s) => s.status?.is_running ?? false)
   const statusIsPaused = useStatusStore((s) => s.status?.is_paused ?? false)
@@ -116,6 +118,13 @@ export function Layout() {
   const [passwordInput, setPasswordInput] = useState('')
   const [passwordError, setPasswordError] = useState(false)
 
+  // FluidNC version warning — each table type has an expected version
+  const expectedFirmwareVersion = tableType === 'dune_weaver_mini' ? 'v3.8.3' : 'v3.9.5'
+  const showFirmwareWarning = useMemo(() => {
+    if (!firmwareVersion) return false
+    return firmwareVersion !== expectedFirmwareVersion
+  }, [firmwareVersion, expectedFirmwareVersion])
+
   // Fetch app settings
   const fetchAppSettings = () => {
     apiClient.get<{ app?: { name?: string; custom_logo?: string }; security?: { mode?: string; has_password?: boolean } }>('/api/settings')
@@ -741,8 +750,9 @@ export function Layout() {
     const showOverlay = !isBackendConnected || isHoming || homingJustCompleted
 
     if (!showOverlay) {
-      setConnectionLogs([])
-      // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen)
+      // Don't clear logs here — they'll be cleared when the next session starts.
+      // Clearing here races with the homingJustCompleted setState, wiping logs
+      // before the completion overlay renders.
       if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
         blockingLogsWsRef.current.close()
       }
@@ -776,6 +786,7 @@ export function Layout() {
 
     // If homing, connect to logs WebSocket to stream real logs
     if (isHoming && isBackendConnected) {
+      setConnectionLogs([])
       addLog('INFO', 'Homing started...')
 
       let shouldConnect = true
@@ -848,6 +859,7 @@ export function Layout() {
 
     // If backend disconnected, show connection retry logs
     if (!isBackendConnected) {
+      setConnectionLogs([])
       addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
 
       const interval = setInterval(() => {
@@ -1504,7 +1516,8 @@ export function Layout() {
           <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
         )}
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
-          <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="rounded-full bg-card shadow-lg border border-border pointer-events-auto">
+          <div className="flex h-12 items-center justify-between px-4">
           <div className="flex items-center gap-2">
             <Link to="/">
               <img
@@ -1546,6 +1559,15 @@ export function Layout() {
             </TableSelector>
           </div>
 
+          {showFirmwareWarning && (
+            <div className="flex items-center gap-1.5 min-w-0 mx-2 text-amber-500">
+              <span className="material-icons-outlined text-base shrink-0">warning</span>
+              <span className="text-xs truncate">
+                FluidNC {firmwareVersion} — change to {expectedFirmwareVersion}
+              </span>
+            </div>
+          )}
+
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-0 ml-2">
             {isSecurityUnlocked && (
@@ -1720,6 +1742,7 @@ export function Layout() {
             </Popover>
             </div>
           </div>
+          </div>
         </div>
       </header>
 

+ 85 - 22
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { useSearchParams, useNavigate } from 'react-router-dom'
+import { useSearchParams, useNavigate, Link } from 'react-router-dom'
 import { toast } from 'sonner'
 import { apiClient } from '@/lib/apiClient'
 import { useOnBackendConnected } from '@/hooks/useBackendConnection'
@@ -38,6 +38,7 @@ interface Settings {
   detected_table_type?: string
   effective_table_type?: string
   gear_ratio?: number
+  gear_ratio_override?: number | null
   x_steps_per_mm?: number
   y_steps_per_mm?: number
   available_table_types?: { value: string; label: string }[]
@@ -352,6 +353,7 @@ export function SettingsPage() {
         detected_table_type: data.machine?.detected_table_type,
         effective_table_type: data.machine?.effective_table_type,
         gear_ratio: data.machine?.gear_ratio,
+        gear_ratio_override: data.machine?.gear_ratio_override ?? null,
         x_steps_per_mm: data.machine?.x_steps_per_mm,
         y_steps_per_mm: data.machine?.y_steps_per_mm,
         available_table_types: data.machine?.available_table_types,
@@ -644,6 +646,7 @@ export function SettingsPage() {
       await apiClient.patch('/api/settings', {
         machine: {
           table_type_override: settings.table_type_override || '',
+          gear_ratio_override: settings.gear_ratio_override ?? 0,
         },
       })
       toast.success('Machine settings saved')
@@ -898,6 +901,35 @@ export function SettingsPage() {
                 Choose how the system connects on startup: Auto picks the first available port, Disabled requires manual connection, or select a specific port.
               </p>
             </div>
+
+            {/* Home on Connect */}
+            <div className="p-4 rounded-lg border space-y-3">
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="font-medium flex items-center gap-2">
+                    <span className="material-icons-outlined text-base">power</span>
+                    Home on Connect
+                  </p>
+                  <p className="text-xs text-muted-foreground mt-1">
+                    Automatically home when connecting on startup. Disable to connect without homing and home manually later.
+                  </p>
+                </div>
+                <Switch
+                  checked={settings.home_on_connect !== false}
+                  onCheckedChange={async (checked) => {
+                    setSettings({ ...settings, home_on_connect: checked })
+                    try {
+                      await apiClient.patch('/api/settings', {
+                        homing: { home_on_connect: checked },
+                      })
+                      toast.success(checked ? 'Home on connect enabled' : 'Home on connect disabled')
+                    } catch {
+                      toast.error('Failed to save setting')
+                    }
+                  }}
+                />
+              </div>
+            </div>
           </AccordionContent>
         </AccordionItem>
 
@@ -977,6 +1009,42 @@ export function SettingsPage() {
               </p>
             </div>
 
+            {/* Gear Ratio Override */}
+            <div className="space-y-3">
+              <Label>Gear Ratio Override</Label>
+              <div className="flex gap-3">
+                <Input
+                  type="number"
+                  step={0.25}
+                  min={0}
+                  placeholder="Auto-detect"
+                  value={settings.gear_ratio_override ?? ''}
+                  onChange={(e) =>
+                    setSettings({
+                      ...settings,
+                      gear_ratio_override: e.target.value ? parseFloat(e.target.value) : null,
+                    })
+                  }
+                  className="flex-1"
+                />
+                <Button
+                  onClick={handleSaveMachineSettings}
+                  disabled={isLoading === 'machine'}
+                  className="gap-2"
+                >
+                  {isLoading === 'machine' ? (
+                    <span className="material-icons-outlined animate-spin">sync</span>
+                  ) : (
+                    <span className="material-icons-outlined">save</span>
+                  )}
+                  Save
+                </Button>
+              </div>
+              <p className="text-xs text-muted-foreground">
+                Override the gear ratio used for angular/radial coupling compensation. Leave empty to auto-detect from table type (6.25 for mini, 10 for standard).
+              </p>
+            </div>
+
             <Alert className="flex items-start">
               <span className="material-icons-outlined text-base mr-2 shrink-0">info</span>
               <AlertDescription>
@@ -984,6 +1052,22 @@ export function SettingsPage() {
               </AlertDescription>
             </Alert>
 
+            <Link
+              to="/setup"
+              className="flex items-center justify-between w-full p-3 rounded-lg border hover:bg-muted/50 transition-colors"
+            >
+              <div className="flex items-center gap-3">
+                <span className="material-icons-outlined text-muted-foreground">build</span>
+                <div className="text-left">
+                  <p className="font-medium text-sm">Hardware Setup & Calibration</p>
+                  <p className="text-xs text-muted-foreground">
+                    Calibrate motor directions and edit FluidNC settings
+                  </p>
+                </div>
+              </div>
+              <span className="material-icons-outlined text-muted-foreground">arrow_forward</span>
+            </Link>
+
           </AccordionContent>
         </AccordionItem>
 
@@ -1063,27 +1147,6 @@ export function SettingsPage() {
               </div>
             )}
 
-            {/* Home on Connect */}
-            <div className="p-4 rounded-lg border space-y-3">
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="font-medium flex items-center gap-2">
-                    <span className="material-icons-outlined text-base">power</span>
-                    Home on Connect
-                  </p>
-                  <p className="text-xs text-muted-foreground mt-1">
-                    Automatically home when connecting on startup. Disable to connect without homing and home manually later.
-                  </p>
-                </div>
-                <Switch
-                  checked={settings.home_on_connect !== false}
-                  onCheckedChange={(checked) =>
-                    setSettings({ ...settings, home_on_connect: checked })
-                  }
-                />
-              </div>
-            </div>
-
             {/* Auto-Home During Playlists */}
             <div className="p-4 rounded-lg border space-y-3">
               <div className="flex items-center justify-between">

+ 914 - 0
frontend/src/pages/SetupPage.tsx

@@ -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>
+  )
+}

+ 2 - 0
frontend/src/stores/useStatusStore.ts

@@ -36,6 +36,8 @@ export interface StatusData {
   connection_status: boolean
   current_theta: number
   current_rho: number
+  firmware_version: string | null
+  table_type: string | null
 }
 
 interface StatusStore {

+ 169 - 0
main.py

@@ -517,6 +517,7 @@ class MqttSettingsUpdate(BaseModel):
 
 class MachineSettingsUpdate(BaseModel):
     table_type_override: Optional[str] = None  # Override detected table type, or empty string/"auto" to clear
+    gear_ratio_override: Optional[float] = None  # Override gear ratio, or 0/negative to clear
     timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York", "UTC")
 
 class SecuritySettingsUpdate(BaseModel):
@@ -792,6 +793,7 @@ async def get_all_settings():
             "table_type_override": state.table_type_override,
             "effective_table_type": state.table_type_override or state.table_type,
             "gear_ratio": state.gear_ratio,
+            "gear_ratio_override": state.gear_ratio_override,
             "x_steps_per_mm": state.x_steps_per_mm,
             "y_steps_per_mm": state.y_steps_per_mm,
             "timezone": state.timezone,
@@ -1027,6 +1029,23 @@ async def update_settings(settings_update: SettingsUpdate):
         if m.table_type_override is not None:
             # Empty string or "auto" clears the override
             state.table_type_override = None if m.table_type_override in ("", "auto") else m.table_type_override
+        if m.gear_ratio_override is not None:
+            # Zero or negative clears the override; positive value sets it
+            state.gear_ratio_override = None if m.gear_ratio_override <= 0 else m.gear_ratio_override
+            # Apply immediately to current gear_ratio
+            if state.gear_ratio_override is not None:
+                state.gear_ratio = state.gear_ratio_override
+            else:
+                # Cleared — revert to auto-detected value (table type, then env var)
+                effective_table_type = state.table_type_override or state.table_type
+                mini_types = ['dune_weaver_mini', 'dune_weaver_mini_pro', 'dune_weaver_mini_pro_byj', 'dune_weaver_gold']
+                state.gear_ratio = 6.25 if effective_table_type in mini_types else 10
+                env_gr = os.getenv('GEAR_RATIO')
+                if env_gr is not None:
+                    try:
+                        state.gear_ratio = float(env_gr)
+                    except ValueError:
+                        pass
         if m.timezone is not None:
             # Validate timezone by trying to create a ZoneInfo object
             try:
@@ -4011,6 +4030,156 @@ async def restart_system():
             status_code=500
         )
 
+###############################################################################
+# FluidNC Config Endpoints
+###############################################################################
+
+class FluidNCCommandRequest(BaseModel):
+    command: str
+    timeout: Optional[float] = 3.0
+
+class FluidNCConfigUpdate(BaseModel):
+    axes: Optional[dict] = None   # { "x": { "steps_per_mm": 320, ... }, "y": {...} }
+    start: Optional[dict] = None  # { "must_home": false }
+
+
+@app.post("/api/fluidnc/command")
+async def fluidnc_command(request: FluidNCCommandRequest):
+    """Send a raw command to FluidNC and return the response lines."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+    if state.current_playing_file and not state.pause_requested:
+        raise HTTPException(status_code=409, detail="Cannot send commands while a pattern is running")
+
+    from modules.connection import fluidnc_config
+
+    try:
+        responses = await asyncio.to_thread(
+            fluidnc_config.send_command, request.command, request.timeout or 3.0
+        )
+        return {"success": True, "command": request.command, "responses": responses}
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC command error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.get("/api/fluidnc/config")
+async def fluidnc_config_read():
+    """Read all curated FluidNC settings from the controller."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+
+    from modules.connection import fluidnc_config
+
+    try:
+        settings = await asyncio.to_thread(fluidnc_config.read_all_settings)
+        return {"success": True, "settings": settings}
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC config read error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+# Mapping from UI flat keys back to FluidNC config tree paths.
+# direction_inverted is handled specially (toggles :low on the raw pin).
+_AXIS_KEY_TO_PATH = {
+    "steps_per_mm": "axes/{axis}/steps_per_mm",
+    "max_rate_mm_per_min": "axes/{axis}/max_rate_mm_per_min",
+    "acceleration_mm_per_sec2": "axes/{axis}/acceleration_mm_per_sec2",
+    "homing_cycle": "axes/{axis}/homing/cycle",
+    "homing_positive_direction": "axes/{axis}/homing/positive_direction",
+    "homing_mpos_mm": "axes/{axis}/homing/mpos_mm",
+    "homing_feed_mm_per_min": "axes/{axis}/homing/feed_mm_per_min",
+    "homing_seek_mm_per_min": "axes/{axis}/homing/seek_mm_per_min",
+    "homing_settle_ms": "axes/{axis}/homing/settle_ms",
+    "homing_seek_scaler": "axes/{axis}/homing/seek_scaler",
+    "homing_feed_scaler": "axes/{axis}/homing/feed_scaler",
+    "pulloff_mm": "axes/{axis}/motor0/pulloff_mm",
+}
+
+
+@app.patch("/api/fluidnc/config")
+async def fluidnc_config_write(update: FluidNCConfigUpdate):
+    """Write changed FluidNC settings and persist to flash."""
+    if not state.conn or not state.conn.is_connected():
+        raise HTTPException(status_code=400, detail="Not connected to controller")
+    if state.current_playing_file and not state.pause_requested:
+        raise HTTPException(status_code=409, detail="Cannot modify config while a pattern is running")
+
+    from modules.connection import fluidnc_config
+
+    changes_applied: list[str] = []
+    restart_required = False
+
+    def apply_changes():
+        nonlocal restart_required
+        # Apply axis settings
+        if update.axes:
+            for axis in ("x", "y"):
+                axis_data = update.axes.get(axis)
+                if not axis_data:
+                    continue
+                for key, value in axis_data.items():
+                    if key in ("direction_pin", "direction_inverted_raw"):
+                        # Skip derived/raw keys handled below
+                        continue
+
+                    if key == "direction_inverted":
+                        # Toggle :low on direction pin
+                        success, new_state = fluidnc_config.toggle_direction_pin(axis)
+                        if success:
+                            changes_applied.append(f"{axis}/direction_pin")
+                            restart_required = True
+                        continue
+
+                    path_template = _AXIS_KEY_TO_PATH.get(key)
+                    if path_template:
+                        path = path_template.format(axis=axis)
+                        str_value = str(value).lower() if isinstance(value, bool) else str(value)
+                        if fluidnc_config.write_setting(path, str_value):
+                            changes_applied.append(path)
+
+        # Apply global settings
+        if update.start:
+            for key, value in update.start.items():
+                path = f"start/{key}"
+                str_value = str(value).lower() if isinstance(value, bool) else str(value)
+                if fluidnc_config.write_setting(path, str_value):
+                    changes_applied.append(path)
+
+        # Sync steps_per_mm into app state so Machine Settings reflects changes
+        if update.axes:
+            x_steps = update.axes.get("x", {}).get("steps_per_mm")
+            y_steps = update.axes.get("y", {}).get("steps_per_mm")
+            if x_steps is not None:
+                state.x_steps_per_mm = float(x_steps)
+            if y_steps is not None:
+                state.y_steps_per_mm = float(y_steps)
+            if x_steps is not None or y_steps is not None:
+                state.save()
+
+        # Persist to flash
+        saved = fluidnc_config.save_config() if changes_applied else False
+        return saved
+
+    try:
+        saved = await asyncio.to_thread(apply_changes)
+        return {
+            "success": True,
+            "saved": saved,
+            "changes_applied": changes_applied,
+            "restart_required": restart_required,
+        }
+    except ConnectionError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+    except Exception as e:
+        logger.error(f"FluidNC config write error: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 def entrypoint():
     import uvicorn
     logger.info("Starting FastAPI server on port 8080...")

+ 12 - 7
modules/connection/connection_manager.py

@@ -1,6 +1,7 @@
 import threading
 import time
 import logging
+import re
 import serial
 import serial.tools.list_ports
 import websocket
@@ -579,13 +580,10 @@ def _detect_firmware():
 
                     if 'fluidnc' in response_lower:
                         firmware_type = 'fluidnc'
-                        # Try to extract version from response like "FluidNC v3.7.2"
-                        if 'v' in response_lower:
-                            parts = response.split()
-                            for part in parts:
-                                if part.lower().startswith('v') and any(c.isdigit() for c in part):
-                                    version = part
-                                    break
+                        # Extract version like "v3.7.2" from response
+                        ver_match = re.search(r'v(\d+\.\d+\.\d+)', response_lower)
+                        if ver_match:
+                            version = f"v{ver_match.group(1)}"
                         break
                     elif 'grbl' in response_lower and 'fluidnc' not in response_lower:
                         firmware_type = 'grbl'
@@ -876,6 +874,8 @@ def get_machine_steps(timeout=10):
 
     # Detect firmware type
     firmware_type, firmware_version = _detect_firmware()
+    state.firmware_type = firmware_type
+    state.firmware_version = firmware_version
 
     if firmware_type == 'fluidnc':
         if firmware_version:
@@ -947,6 +947,11 @@ def get_machine_steps(timeout=10):
         else:
             logger.info(f"Machine type detected: {state.table_type}, gear ratio: {state.gear_ratio} (hardcoded)")
 
+        # User UI override takes highest priority
+        if state.gear_ratio_override is not None:
+            state.gear_ratio = state.gear_ratio_override
+            logger.info(f"Gear ratio overridden to: {state.gear_ratio} (user override)")
+
         return True
     else:
         missing = []

+ 337 - 0
modules/connection/fluidnc_config.py

@@ -0,0 +1,337 @@
+"""FluidNC configuration utilities.
+
+Provides functions to read/write FluidNC settings via the main serial/WebSocket
+connection. Uses $Config/Dump for bulk reads (single round-trip) and individual
+$/path=value writes.
+
+Targets FluidNC-based boards with bipolar stepper motors (DLC32, MKS boards).
+"""
+
+import time
+import logging
+import yaml
+from modules.core.state import state
+
+logger = logging.getLogger(__name__)
+
+# Curated settings exposed in the Setup UI.
+# Keys are FluidNC config tree paths queried via $/path.
+CURATED_SETTINGS = {
+    "x": [
+        "axes/x/steps_per_mm",
+        "axes/x/max_rate_mm_per_min",
+        "axes/x/acceleration_mm_per_sec2",
+        "axes/x/motor0/stepstick/direction_pin",
+        "axes/x/homing/cycle",
+        "axes/x/homing/positive_direction",
+        "axes/x/homing/mpos_mm",
+        "axes/x/homing/feed_mm_per_min",
+        "axes/x/homing/seek_mm_per_min",
+        "axes/x/homing/settle_ms",
+        "axes/x/homing/seek_scaler",
+        "axes/x/homing/feed_scaler",
+        "axes/x/motor0/pulloff_mm",
+    ],
+    "y": [
+        "axes/y/steps_per_mm",
+        "axes/y/max_rate_mm_per_min",
+        "axes/y/acceleration_mm_per_sec2",
+        "axes/y/motor0/stepstick/direction_pin",
+        "axes/y/homing/cycle",
+        "axes/y/homing/positive_direction",
+        "axes/y/homing/mpos_mm",
+        "axes/y/homing/feed_mm_per_min",
+        "axes/y/homing/seek_mm_per_min",
+        "axes/y/homing/settle_ms",
+        "axes/y/homing/seek_scaler",
+        "axes/y/homing/feed_scaler",
+        "axes/y/motor0/pulloff_mm",
+    ],
+    "global": [],
+}
+
+
+def send_command(command: str, timeout: float = 3.0, silence: float = 1.0) -> list[str]:
+    """Send a command via the main connection and return response lines.
+
+    Clears the input buffer, sends the command, then reads lines until
+    'ok', 'error', silence gap, or timeout is reached.
+
+    Args:
+        command: The FluidNC command string.
+        timeout: Absolute max wait time in seconds.
+        silence: After receiving data, if no new data arrives for this many
+                 seconds, consider the response complete. Handles commands
+                 like $CD that may not end with 'ok'.
+    """
+    if not state.conn or not state.conn.is_connected():
+        raise ConnectionError("Not connected to controller")
+
+    # Clear input buffer
+    try:
+        while state.conn.in_waiting() > 0:
+            state.conn.readline()
+    except Exception:
+        pass
+
+    # Send command
+    state.conn.send(command + "\n")
+    time.sleep(0.2)
+
+    # Read response lines
+    lines: list[str] = []
+    start_time = time.time()
+    last_data_time = start_time
+    got_data = False
+    while time.time() - start_time < timeout:
+        try:
+            if state.conn.in_waiting() > 0:
+                response = state.conn.readline()
+                if response:
+                    line = response.strip() if isinstance(response, str) else response.decode("utf-8", errors="replace").strip()
+                    if line:
+                        lines.append(line)
+                        got_data = True
+                        last_data_time = time.time()
+                        if line.lower() == "ok" or line.lower().startswith("error"):
+                            break
+            else:
+                # If data was flowing but has gone silent, we're done
+                if got_data and (time.time() - last_data_time > silence):
+                    break
+                time.sleep(0.05)
+        except Exception as e:
+            logger.warning(f"Error reading response: {e}")
+            break
+
+    return lines
+
+
+def read_setting(path: str) -> str | None:
+    """Query a single FluidNC setting by config-tree path.
+
+    Returns the value string, or None if the setting doesn't exist
+    (e.g. stepstick path on a unipolar board).
+    """
+    try:
+        responses = send_command(f"$/{path}")
+    except ConnectionError:
+        return None
+
+    # Response format: "/axes/x/steps_per_mm=200.000" or similar
+    leaf = path.split("/")[-1]
+    for line in responses:
+        if "=" in line and leaf in line:
+            return line.split("=", 1)[1].strip()
+        if line.lower().startswith("error"):
+            return None
+    return None
+
+
+def _parse_value(raw: str | None, path: str) -> object:
+    """Parse a raw FluidNC value string into a typed Python value."""
+    if raw is None:
+        return None
+
+    # Direction pin — keep raw string but also derive inverted flag
+    if "direction_pin" in path:
+        return raw
+
+    # Boolean-ish
+    lower = raw.lower()
+    if lower in ("true", "false"):
+        return lower == "true"
+
+    # Numeric
+    try:
+        if "." in raw:
+            return float(raw)
+        return int(raw)
+    except ValueError:
+        return raw
+
+
+def _resolve_yaml_path(data: dict, path: str):
+    """Walk a nested dict by slash-separated path. Returns None if any key is missing."""
+    node = data
+    for key in path.split("/"):
+        if not isinstance(node, dict) or key not in node:
+            return None
+        node = node[key]
+    return node
+
+
+def _make_key(path: str) -> str:
+    """Convert a FluidNC config path to a flat UI key.
+
+    e.g. "axes/x/motor0/stepstick/direction_pin" → "direction_pin"
+         "axes/x/homing/feed_mm_per_min"         → "homing_feed_mm_per_min"
+         "axes/x/motor0/hard_limits"              → "hard_limits"
+    """
+    parts = path.split("/")
+    remaining = parts[2:]  # drop "axes/x" prefix
+    remaining = [p for p in remaining if p not in ("motor0", "stepstick")]
+    return "_".join(remaining)
+
+
+def read_all_settings() -> dict:
+    """Read all curated settings from the controller via $Config/Dump.
+
+    Issues a single $Config/Dump command, parses the YAML response, and
+    extracts curated settings. Much faster than individual queries
+    (~1s vs ~9s for 30 settings).
+
+    Returns a structured dict:
+    {
+        "axes": {
+            "x": { "steps_per_mm": 320.0, "direction_inverted": True, "direction_pin": "i2so.2:low", ... },
+            "y": { ... }
+        },
+        "start": { "must_home": False }
+    }
+    """
+    lines = send_command("$CD", timeout=10.0, silence=1.5)
+    logger.info(f"$CD returned {len(lines)} lines")
+
+    # Filter out non-YAML lines (status messages, 'ok', etc.)
+    yaml_lines = []
+    for line in lines:
+        if line.lower() == "ok" or line.lower().startswith("error"):
+            continue
+        # Skip FluidNC status messages like [MSG:...]
+        if line.startswith("["):
+            continue
+        yaml_lines.append(line)
+
+    yaml_text = "\n".join(yaml_lines)
+    if not yaml_text.strip():
+        logger.warning("$CD returned no YAML content, falling back to individual queries")
+        return _read_all_settings_individual()
+
+    try:
+        config = yaml.safe_load(yaml_text)
+    except yaml.YAMLError as e:
+        logger.error(f"Failed to parse $CD YAML ({len(yaml_lines)} lines): {e}")
+        return _read_all_settings_individual()
+
+    if not isinstance(config, dict):
+        logger.warning(f"$CD parsed as {type(config).__name__}, falling back to individual queries")
+        return _read_all_settings_individual()
+
+    logger.info(f"$CD YAML top-level keys: {list(config.keys())}")
+
+    result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
+    resolved_count = 0
+
+    for axis in ("x", "y"):
+        for path in CURATED_SETTINGS[axis]:
+            raw = _resolve_yaml_path(config, path)
+            key = _make_key(path)
+
+            if raw is not None:
+                resolved_count += 1
+
+            if "direction_pin" in path:
+                raw_str = str(raw) if raw is not None else None
+                result["axes"][axis][key] = raw_str
+                result["axes"][axis]["direction_inverted"] = (
+                    ":low" in raw_str if raw_str else None
+                )
+            else:
+                result["axes"][axis][key] = raw
+
+    for path in CURATED_SETTINGS["global"]:
+        raw = _resolve_yaml_path(config, path)
+        if raw is not None:
+            resolved_count += 1
+        parts = path.split("/")
+        key = "_".join(parts[1:])
+        result["start"][key] = raw
+
+    # If $CD parsed but we couldn't resolve any of our settings,
+    # the YAML structure doesn't match — fall back to individual queries
+    if resolved_count == 0:
+        logger.warning(
+            f"$CD YAML parsed ({len(config)} keys) but no curated settings resolved. "
+            f"Falling back to individual queries."
+        )
+        return _read_all_settings_individual()
+
+    logger.info(f"$CD resolved {resolved_count}/{len(CURATED_SETTINGS['x']) * 2 + len(CURATED_SETTINGS['global'])} settings")
+    return result
+
+
+def _read_all_settings_individual() -> dict:
+    """Fallback: read curated settings one by one via $/path queries."""
+    result: dict = {"axes": {"x": {}, "y": {}}, "start": {}}
+
+    for axis in ("x", "y"):
+        for path in CURATED_SETTINGS[axis]:
+            raw = read_setting(path)
+            key = _make_key(path)
+            parsed = _parse_value(raw, path)
+            result["axes"][axis][key] = parsed
+
+            if "direction_pin" in path:
+                result["axes"][axis]["direction_inverted"] = (
+                    ":low" in raw if raw else None
+                )
+
+    for path in CURATED_SETTINGS["global"]:
+        raw = read_setting(path)
+        parts = path.split("/")
+        key = "_".join(parts[1:])
+        result["start"][key] = _parse_value(raw, path)
+
+    return result
+
+
+def write_setting(path: str, value: str) -> bool:
+    """Write a single FluidNC setting. Returns True on success."""
+    try:
+        responses = send_command(f"$/{path}={value}")
+    except ConnectionError:
+        return False
+    return any("ok" in r.lower() for r in responses)
+
+
+def get_config_filename() -> str:
+    """Get the active config filename from FluidNC."""
+    try:
+        responses = send_command("$Config/Filename")
+    except ConnectionError:
+        return "config.yaml"
+
+    for line in responses:
+        if "=" in line and "Filename" in line:
+            return line.split("=", 1)[1].strip()
+    return "config.yaml"
+
+
+def save_config() -> bool:
+    """Persist current in-RAM config to flash using the active config filename."""
+    filename = get_config_filename()
+    try:
+        responses = send_command(f"$CD=/littlefs/{filename}", timeout=5.0)
+    except ConnectionError:
+        return False
+    return any("ok" in r.lower() for r in responses)
+
+
+def toggle_direction_pin(axis: str) -> tuple[bool, bool]:
+    """Toggle :low on the direction pin for the given axis.
+
+    Returns (success, new_inverted_state).
+    """
+    path = f"axes/{axis}/motor0/stepstick/direction_pin"
+    current = read_setting(path)
+    if current is None:
+        return (False, False)
+
+    if ":low" in current:
+        new_val = current.replace(":low", "")
+    else:
+        new_val = current + ":low"
+
+    success = write_setting(path, new_val)
+    return (success, ":low" in new_val)

+ 3 - 1
modules/core/pattern_manager.py

@@ -1987,7 +1987,9 @@ def get_status():
         "original_pause_time": getattr(state, 'original_pause_time', None),
         "connection_status": state.conn.is_connected() if state.conn else False,
         "current_theta": state.current_theta,
-        "current_rho": state.current_rho
+        "current_rho": state.current_rho,
+        "firmware_version": state.firmware_version,
+        "table_type": state.table_type_override or state.table_type
     }
     
     # Add playlist information if available

+ 7 - 0
modules/core/state.py

@@ -68,6 +68,10 @@ class AppState:
         # This indicates to the UI that sensor homing failed and user action is needed
         self.sensor_homing_failed = False
 
+        # Firmware info (runtime only, detected on connect, not persisted)
+        self.firmware_type = None  # 'fluidnc', 'grbl', or 'unknown'
+        self.firmware_version = None  # e.g., "v3.7.2"
+
         # Angular homing compass reference point
         # This is the angular offset in degrees where the sensor is placed
         # After homing, theta will be set to this value
@@ -119,6 +123,7 @@ class AppState:
         self.dw_led_last_activity_time = None  # Last activity timestamp (runtime only, not persisted)
         self.table_type = None
         self.table_type_override = None  # User override for table type detection
+        self.gear_ratio_override = None  # User override for gear ratio (highest priority)
         self._playlist_mode = "loop"
         self._pause_time = 0
         self._clear_pattern = "none"
@@ -542,6 +547,7 @@ class AppState:
             "mqtt_device_id": self.mqtt_device_id,
             "mqtt_device_name": self.mqtt_device_name,
             "table_type_override": self.table_type_override,
+            "gear_ratio_override": self.gear_ratio_override,
             "security_mode": self.security_mode,
             "security_password_hash": self.security_password_hash,
         }
@@ -662,6 +668,7 @@ class AppState:
         self.mqtt_device_id = data.get("mqtt_device_id", "dune_weaver")
         self.mqtt_device_name = data.get("mqtt_device_name", "Dune Weaver")
         self.table_type_override = data.get("table_type_override", None)
+        self.gear_ratio_override = data.get("gear_ratio_override", None)
         self.security_mode = data.get("security_mode", "off")
         self.security_password_hash = data.get("security_password_hash", "")